diff --git a/README.md b/README.md index 266838e..d0f1718 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Key settings: - `auth.accept_after` — accept login after N failures per IP (default `10`) - `auth.credential_ttl` — how long to remember accepted credentials (default `24h`) - `auth.static_credentials` — always-accepted username/password pairs (optional `shell` field routes to a specific shell) -- Available shells: `bash` (fake Linux shell), `fridge` (Samsung Smart Fridge OS), `banking` (80s-style bank terminal TUI), `adventure` (Zork-style text adventure dungeon) +- Available shells: `bash` (fake Linux shell), `fridge` (Samsung Smart Fridge OS), `banking` (80s-style bank terminal TUI), `adventure` (Zork-style text adventure dungeon), `cisco` (Cisco IOS CLI with mode state machine and command abbreviation) - `storage.db_path` — SQLite database path (default `oubliette.db`) - `storage.retention_days` — auto-prune records older than N days (default `90`) - `storage.retention_interval` — how often to run retention (default `1h`) @@ -91,7 +91,7 @@ Build a Docker image via nix: ```sh nix build .#dockerImage docker load < result -docker run -v /path/to/data:/data -p 2222:2222 -p 8080:8080 oubliette:0.7.0 +docker run -v /path/to/data:/data -p 2222:2222 -p 8080:8080 oubliette:0.8.0 ``` Place your `oubliette.toml` in the data volume. The container exposes ports 2222 (SSH) and 8080 (web/metrics). diff --git a/cmd/oubliette/main.go b/cmd/oubliette/main.go index b0f5cb5..a551eca 100644 --- a/cmd/oubliette/main.go +++ b/cmd/oubliette/main.go @@ -20,7 +20,7 @@ import ( "git.t-juice.club/torjus/oubliette/internal/web" ) -const Version = "0.7.0" +const Version = "0.8.0" func main() { if err := run(); err != nil { diff --git a/internal/server/server.go b/internal/server/server.go index 3d1e33d..059d656 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -21,6 +21,7 @@ import ( "git.t-juice.club/torjus/oubliette/internal/shell/adventure" "git.t-juice.club/torjus/oubliette/internal/shell/banking" "git.t-juice.club/torjus/oubliette/internal/shell/bash" + "git.t-juice.club/torjus/oubliette/internal/shell/cisco" "git.t-juice.club/torjus/oubliette/internal/shell/fridge" "git.t-juice.club/torjus/oubliette/internal/storage" "golang.org/x/crypto/ssh" @@ -52,6 +53,9 @@ func New(cfg config.Config, store storage.Store, logger *slog.Logger, m *metrics if err := registry.Register(adventure.NewAdventureShell(), 1); err != nil { return nil, fmt.Errorf("registering adventure shell: %w", err) } + if err := registry.Register(cisco.NewCiscoShell(), 1); err != nil { + return nil, fmt.Errorf("registering cisco shell: %w", err) + } s := &Server{ cfg: cfg, diff --git a/internal/shell/cisco/cisco.go b/internal/shell/cisco/cisco.go new file mode 100644 index 0000000..4f96a4e --- /dev/null +++ b/internal/shell/cisco/cisco.go @@ -0,0 +1,200 @@ +package cisco + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + "time" + + "git.t-juice.club/torjus/oubliette/internal/shell" +) + +const sessionTimeout = 5 * time.Minute + +// CiscoShell emulates a Cisco IOS CLI. +type CiscoShell struct{} + +// NewCiscoShell returns a new CiscoShell instance. +func NewCiscoShell() *CiscoShell { + return &CiscoShell{} +} + +func (c *CiscoShell) Name() string { return "cisco" } +func (c *CiscoShell) Description() string { return "Cisco IOS CLI emulator" } + +func (c *CiscoShell) Handle(ctx context.Context, sess *shell.SessionContext, rw io.ReadWriteCloser) error { + ctx, cancel := context.WithTimeout(ctx, sessionTimeout) + defer cancel() + + hostname := configString(sess.ShellConfig, "hostname", "Router") + model := configString(sess.ShellConfig, "model", "C2960") + iosVersion := configString(sess.ShellConfig, "ios_version", "15.0(2)SE11") + enablePass := configString(sess.ShellConfig, "enable_password", "") + + state := newIOSState(hostname, model, iosVersion, enablePass) + + // IOS just shows a blank line then the prompt after SSH auth. + fmt.Fprint(rw, "\r\n") + + for { + prompt := state.prompt() + if _, err := fmt.Fprint(rw, prompt); err != nil { + return nil + } + + line, err := shell.ReadLine(ctx, rw) + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return nil + } + + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + + // Check for Ctrl+Z (^Z) — return to privileged exec. + if trimmed == "\x1a" || trimmed == "^Z" { + if state.mode == modeGlobalConfig || state.mode == modeInterfaceConfig { + state.mode = modePrivilegedExec + state.currentIf = "" + } + continue + } + + // Handle "enable" specially — it needs password prompting. + if state.mode == modeUserExec && isEnableCommand(trimmed) { + output := handleEnable(ctx, state, rw) + if sess.Store != nil { + if err := sess.Store.AppendSessionLog(ctx, sess.SessionID, trimmed, output); err != nil { + return fmt.Errorf("append session log: %w", err) + } + } + continue + } + + result := state.dispatch(trimmed) + + var output string + if result.output != "" { + output = result.output + output = strings.ReplaceAll(output, "\r\n", "\n") + output = strings.ReplaceAll(output, "\n", "\r\n") + fmt.Fprintf(rw, "%s\r\n", output) + } + + if sess.Store != nil { + if err := sess.Store.AppendSessionLog(ctx, sess.SessionID, trimmed, output); err != nil { + return fmt.Errorf("append session log: %w", err) + } + } + + if result.exit { + return nil + } + } +} + +// isEnableCommand checks if input resolves to "enable" in user exec mode. +func isEnableCommand(input string) bool { + words := strings.Fields(input) + if len(words) != 1 { + return false + } + w := strings.ToLower(words[0]) + enable := "enable" + return len(w) >= 2 && len(w) <= len(enable) && enable[:len(w)] == w +} + +// handleEnable manages the enable password prompt flow. +// Returns the output string (for logging). +func handleEnable(ctx context.Context, state *iosState, rw io.ReadWriter) string { + const maxAttempts = 3 + hadFailure := false + + for range maxAttempts { + fmt.Fprint(rw, "Password: ") + password, err := readPassword(ctx, rw) + if err != nil { + return "" + } + fmt.Fprint(rw, "\r\n") + + if state.enablePass == "" { + // No password configured — accept after one failed attempt. + if hadFailure { + state.mode = modePrivilegedExec + return "" + } + hadFailure = true + } else if password == state.enablePass { + state.mode = modePrivilegedExec + return "" + } + } + + output := "% Bad passwords" + fmt.Fprintf(rw, "%s\r\n", output) + return output +} + +// readPassword reads a password without echoing characters. +func readPassword(ctx context.Context, rw io.ReadWriter) (string, error) { + var buf []byte + b := make([]byte, 1) + + for { + select { + case <-ctx.Done(): + return "", ctx.Err() + default: + } + + n, err := rw.Read(b) + if err != nil { + return "", err + } + if n == 0 { + continue + } + + ch := b[0] + switch { + case ch == '\r' || ch == '\n': + return string(buf), nil + case ch == 4: // Ctrl+D + return string(buf), io.EOF + case ch == 3: // Ctrl+C + return "", io.EOF + case ch == 127 || ch == 8: // Backspace/DEL + if len(buf) > 0 { + buf = buf[:len(buf)-1] + } + case ch == 27: // ESC sequence + next := make([]byte, 1) + if n, _ := rw.Read(next); n > 0 && next[0] == '[' { + rw.Read(next) + } + case ch >= 32 && ch < 127: + buf = append(buf, ch) + // Don't echo. + } + } +} + +// configString reads a string from the shell config map with a default. +func configString(cfg map[string]any, key, defaultVal string) string { + if cfg == nil { + return defaultVal + } + if v, ok := cfg[key]; ok { + if s, ok := v.(string); ok && s != "" { + return s + } + } + return defaultVal +} diff --git a/internal/shell/cisco/cisco_test.go b/internal/shell/cisco/cisco_test.go new file mode 100644 index 0000000..5380f35 --- /dev/null +++ b/internal/shell/cisco/cisco_test.go @@ -0,0 +1,531 @@ +package cisco + +import ( + "testing" +) + +// --- Abbreviation resolution tests --- + +func TestResolveAbbreviationExact(t *testing.T) { + entries := []commandEntry{ + {name: "show"}, + {name: "shutdown"}, + } + got, err := resolveAbbreviation("show", entries) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "show" { + t.Errorf("got %q, want %q", got, "show") + } +} + +func TestResolveAbbreviationUnique(t *testing.T) { + entries := []commandEntry{ + {name: "show"}, + {name: "enable"}, + {name: "exit"}, + } + got, err := resolveAbbreviation("sh", entries) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "show" { + t.Errorf("got %q, want %q", got, "show") + } +} + +func TestResolveAbbreviationAmbiguous(t *testing.T) { + entries := []commandEntry{ + {name: "show"}, + {name: "shutdown"}, + } + _, err := resolveAbbreviation("sh", entries) + if err == nil { + t.Fatal("expected ambiguous error, got nil") + } + if err.Error() != "ambiguous" { + t.Errorf("got error %q, want %q", err.Error(), "ambiguous") + } +} + +func TestResolveAbbreviationUnknown(t *testing.T) { + entries := []commandEntry{ + {name: "show"}, + {name: "enable"}, + } + _, err := resolveAbbreviation("xyz", entries) + if err == nil { + t.Fatal("expected unknown error, got nil") + } + if err.Error() != "unknown" { + t.Errorf("got error %q, want %q", err.Error(), "unknown") + } +} + +func TestResolveAbbreviationCaseInsensitive(t *testing.T) { + entries := []commandEntry{ + {name: "show"}, + {name: "enable"}, + } + got, err := resolveAbbreviation("SH", entries) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "show" { + t.Errorf("got %q, want %q", got, "show") + } +} + +// --- Multi-word command resolution tests --- + +func TestResolveCommandShowRunningConfig(t *testing.T) { + resolved, args, err := resolveCommand([]string{"sh", "run"}, privilegedExecCommands) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(args) != 0 { + t.Errorf("unexpected args: %v", args) + } + want := []string{"show", "running-config"} + if len(resolved) != len(want) { + t.Fatalf("resolved = %v, want %v", resolved, want) + } + for i := range want { + if resolved[i] != want[i] { + t.Errorf("resolved[%d] = %q, want %q", i, resolved[i], want[i]) + } + } +} + +func TestResolveCommandConfigureTerminal(t *testing.T) { + resolved, _, err := resolveCommand([]string{"conf", "t"}, privilegedExecCommands) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"configure", "terminal"} + if len(resolved) != len(want) { + t.Fatalf("resolved = %v, want %v", resolved, want) + } + for i := range want { + if resolved[i] != want[i] { + t.Errorf("resolved[%d] = %q, want %q", i, resolved[i], want[i]) + } + } +} + +func TestResolveCommandShowIPInterfaceBrief(t *testing.T) { + resolved, _, err := resolveCommand([]string{"sh", "ip", "int", "br"}, privilegedExecCommands) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"show", "ip", "interface", "brief"} + if len(resolved) != len(want) { + t.Fatalf("resolved = %v, want %v", resolved, want) + } + for i := range want { + if resolved[i] != want[i] { + t.Errorf("resolved[%d] = %q, want %q", i, resolved[i], want[i]) + } + } +} + +func TestResolveCommandWithArgs(t *testing.T) { + // "hostname MyRouter" → resolved=["hostname"], args=["MyRouter"] + resolved, args, err := resolveCommand([]string{"hostname", "MyRouter"}, globalConfigCommands) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(resolved) != 1 || resolved[0] != "hostname" { + t.Errorf("resolved = %v, want [hostname]", resolved) + } + if len(args) != 1 || args[0] != "MyRouter" { + t.Errorf("args = %v, want [MyRouter]", args) + } +} + +func TestResolveCommandAmbiguous(t *testing.T) { + // In user exec, "e" matches "enable" and "exit" — ambiguous + _, _, err := resolveCommand([]string{"e"}, userExecCommands) + if err == nil { + t.Fatal("expected ambiguous error") + } +} + +// --- Mode state machine tests --- + +func TestPromptGeneration(t *testing.T) { + tests := []struct { + mode iosMode + want string + }{ + {modeUserExec, "Router>"}, + {modePrivilegedExec, "Router#"}, + {modeGlobalConfig, "Router(config)#"}, + {modeInterfaceConfig, "Router(config-if)#"}, + } + for _, tt := range tests { + s := newIOSState("Router", "C2960", "15.0(2)SE11", "") + s.mode = tt.mode + if got := s.prompt(); got != tt.want { + t.Errorf("prompt(%d) = %q, want %q", tt.mode, got, tt.want) + } + } +} + +func TestPromptAfterHostnameChange(t *testing.T) { + s := newIOSState("Router", "C2960", "15.0(2)SE11", "") + s.mode = modeGlobalConfig + s.dispatch("hostname Switch1") + if s.hostname != "Switch1" { + t.Fatalf("hostname = %q, want %q", s.hostname, "Switch1") + } + if got := s.prompt(); got != "Switch1(config)#" { + t.Errorf("prompt = %q, want %q", got, "Switch1(config)#") + } +} + +func TestModeTransitions(t *testing.T) { + s := newIOSState("Router", "C2960", "15.0(2)SE11", "") + + // Start in user exec. + if s.mode != modeUserExec { + t.Fatalf("initial mode = %d, want %d", s.mode, modeUserExec) + } + + // Can't skip to config mode directly from user exec. + result := s.dispatch("configure terminal") + if result.output == "" { + t.Error("expected error for conf t in user exec mode") + } + + // Manually set privileged mode (enable tested separately). + s.mode = modePrivilegedExec + + // conf t → global config + s.dispatch("configure terminal") + if s.mode != modeGlobalConfig { + t.Errorf("mode after conf t = %d, want %d", s.mode, modeGlobalConfig) + } + + // interface Gi0/0 → interface config + s.dispatch("interface GigabitEthernet0/0") + if s.mode != modeInterfaceConfig { + t.Errorf("mode after interface = %d, want %d", s.mode, modeInterfaceConfig) + } + + // exit → back to global config + s.dispatch("exit") + if s.mode != modeGlobalConfig { + t.Errorf("mode after exit from if-config = %d, want %d", s.mode, modeGlobalConfig) + } + + // end → back to privileged exec + s.dispatch("end") + if s.mode != modePrivilegedExec { + t.Errorf("mode after end = %d, want %d", s.mode, modePrivilegedExec) + } + + // disable → back to user exec + s.dispatch("disable") + if s.mode != modeUserExec { + t.Errorf("mode after disable = %d, want %d", s.mode, modeUserExec) + } +} + +func TestEndFromInterfaceConfig(t *testing.T) { + s := newIOSState("Router", "C2960", "15.0(2)SE11", "") + s.mode = modeInterfaceConfig + s.currentIf = "GigabitEthernet0/0" + + s.dispatch("end") + if s.mode != modePrivilegedExec { + t.Errorf("mode after end = %d, want %d", s.mode, modePrivilegedExec) + } + if s.currentIf != "" { + t.Errorf("currentIf = %q, want empty", s.currentIf) + } +} + +func TestExitFromPrivilegedExec(t *testing.T) { + s := newIOSState("Router", "C2960", "15.0(2)SE11", "") + s.mode = modePrivilegedExec + result := s.dispatch("exit") + if !result.exit { + t.Error("expected exit=true from privileged exec exit") + } +} + +// --- Show command output tests --- + +func TestShowVersionContainsModel(t *testing.T) { + s := newIOSState("Router", "C2960", "15.0(2)SE11", "") + output := showVersion(s) + if !contains(output, "C2960") { + t.Error("show version missing model") + } + if !contains(output, "15.0(2)SE11") { + t.Error("show version missing IOS version") + } + if !contains(output, "Router") { + t.Error("show version missing hostname") + } +} + +func TestShowRunningConfigContainsInterfaces(t *testing.T) { + s := newIOSState("Router", "C2960", "15.0(2)SE11", "") + output := showRunningConfig(s) + if !contains(output, "hostname Router") { + t.Error("running-config missing hostname") + } + if !contains(output, "interface GigabitEthernet0/0") { + t.Error("running-config missing interface") + } + if !contains(output, "ip address 192.168.1.1") { + t.Error("running-config missing IP address") + } + if !contains(output, "line vty") { + t.Error("running-config missing VTY config") + } +} + +func TestShowRunningConfigWithEnableSecret(t *testing.T) { + s := newIOSState("Router", "C2960", "15.0(2)SE11", "secret123") + output := showRunningConfig(s) + if !contains(output, "enable secret") { + t.Error("running-config missing enable secret when password is set") + } +} + +func TestShowRunningConfigWithoutEnableSecret(t *testing.T) { + s := newIOSState("Router", "C2960", "15.0(2)SE11", "") + output := showRunningConfig(s) + if contains(output, "enable secret") { + t.Error("running-config should not have enable secret when password is empty") + } +} + +func TestShowIPInterfaceBrief(t *testing.T) { + s := newIOSState("Router", "C2960", "15.0(2)SE11", "") + output := showIPInterfaceBrief(s) + if !contains(output, "GigabitEthernet0/0") { + t.Error("ip interface brief missing GigabitEthernet0/0") + } + if !contains(output, "192.168.1.1") { + t.Error("ip interface brief missing 192.168.1.1") + } +} + +func TestShowIPRoute(t *testing.T) { + s := newIOSState("Router", "C2960", "15.0(2)SE11", "") + output := showIPRoute(s) + if !contains(output, "directly connected") { + t.Error("ip route missing connected routes") + } + if !contains(output, "0.0.0.0/0") { + t.Error("ip route missing default route") + } +} + +func TestShowVLANBrief(t *testing.T) { + output := showVLANBrief() + if !contains(output, "default") { + t.Error("vlan brief missing default vlan") + } + if !contains(output, "MGMT") { + t.Error("vlan brief missing MGMT vlan") + } +} + +// --- Interface config tests --- + +func TestInterfaceShutdownNoShutdown(t *testing.T) { + s := newIOSState("Router", "C2960", "15.0(2)SE11", "") + s.mode = modeInterfaceConfig + s.currentIf = "GigabitEthernet0/0" + + s.dispatch("shutdown") + iface := s.findInterface("GigabitEthernet0/0") + if iface == nil { + t.Fatal("interface not found") + } + if !iface.shutdown { + t.Error("interface should be shutdown") + } + if iface.status != "administratively down" { + t.Errorf("status = %q, want %q", iface.status, "administratively down") + } + + s.dispatch("no shutdown") + if iface.shutdown { + t.Error("interface should not be shutdown after no shutdown") + } + if iface.status != "up" { + t.Errorf("status = %q, want %q", iface.status, "up") + } +} + +func TestInterfaceIPAddress(t *testing.T) { + s := newIOSState("Router", "C2960", "15.0(2)SE11", "") + s.mode = modeInterfaceConfig + s.currentIf = "GigabitEthernet0/0" + + s.dispatch("ip address 10.10.10.1 255.255.255.0") + iface := s.findInterface("GigabitEthernet0/0") + if iface == nil { + t.Fatal("interface not found") + } + if iface.ip != "10.10.10.1" { + t.Errorf("ip = %q, want %q", iface.ip, "10.10.10.1") + } + if iface.mask != "255.255.255.0" { + t.Errorf("mask = %q, want %q", iface.mask, "255.255.255.0") + } +} + +// --- Dispatch / invalid command tests --- + +func TestInvalidCommandInUserExec(t *testing.T) { + s := newIOSState("Router", "C2960", "15.0(2)SE11", "") + result := s.dispatch("foobar") + if !contains(result.output, "Invalid input") { + t.Errorf("expected invalid input error, got %q", result.output) + } +} + +func TestAmbiguousCommandOutput(t *testing.T) { + s := newIOSState("Router", "C2960", "15.0(2)SE11", "") + // "e" in user exec is ambiguous (enable, exit) + result := s.dispatch("e") + if !contains(result.output, "Ambiguous") { + t.Errorf("expected ambiguous error, got %q", result.output) + } +} + +func TestHelpCommand(t *testing.T) { + s := newIOSState("Router", "C2960", "15.0(2)SE11", "") + result := s.dispatch("?") + if !contains(result.output, "show") { + t.Error("help missing 'show'") + } + if !contains(result.output, "enable") { + t.Error("help missing 'enable'") + } +} + +// --- Abbreviation integration tests --- + +func TestShowAbbreviationInDispatch(t *testing.T) { + s := newIOSState("Router", "C2960", "15.0(2)SE11", "") + s.mode = modePrivilegedExec + result := s.dispatch("sh ver") + if !contains(result.output, "Cisco IOS Software") { + t.Error("'sh ver' should produce version output") + } +} + +func TestConfTAbbreviation(t *testing.T) { + s := newIOSState("Router", "C2960", "15.0(2)SE11", "") + s.mode = modePrivilegedExec + s.dispatch("conf t") + if s.mode != modeGlobalConfig { + t.Errorf("mode after conf t = %d, want %d", s.mode, modeGlobalConfig) + } +} + +// --- Enable command detection --- + +func TestIsEnableCommand(t *testing.T) { + tests := []struct { + input string + want bool + }{ + {"enable", true}, + {"en", true}, + {"ena", true}, + {"e", false}, // too short (single char could be other commands) + {"enab", true}, + {"ENABLE", true}, + {"exit", false}, + {"enable 15", false}, // has extra argument + } + for _, tt := range tests { + if got := isEnableCommand(tt.input); got != tt.want { + t.Errorf("isEnableCommand(%q) = %v, want %v", tt.input, got, tt.want) + } + } +} + +// --- configString tests --- + +func TestConfigString(t *testing.T) { + cfg := map[string]any{"hostname": "MySwitch"} + if got := configString(cfg, "hostname", "Router"); got != "MySwitch" { + t.Errorf("configString() = %q, want %q", got, "MySwitch") + } + if got := configString(cfg, "missing", "Default"); got != "Default" { + t.Errorf("configString() for missing = %q, want %q", got, "Default") + } + if got := configString(nil, "key", "Default"); got != "Default" { + t.Errorf("configString(nil) = %q, want %q", got, "Default") + } +} + +// --- Helper --- + +func TestMaskBits(t *testing.T) { + tests := []struct { + mask string + want int + }{ + {"255.255.255.0", 24}, + {"255.255.255.252", 30}, + {"255.255.0.0", 16}, + {"255.0.0.0", 8}, + } + for _, tt := range tests { + if got := maskBits(tt.mask); got != tt.want { + t.Errorf("maskBits(%q) = %d, want %d", tt.mask, got, tt.want) + } + } +} + +func TestNetworkFromIP(t *testing.T) { + tests := []struct { + ip, mask, want string + }{ + {"192.168.1.1", "255.255.255.0", "192.168.1.0"}, + {"10.0.0.1", "255.255.255.252", "10.0.0.0"}, + {"172.16.5.100", "255.255.0.0", "172.16.0.0"}, + } + for _, tt := range tests { + if got := networkFromIP(tt.ip, tt.mask); got != tt.want { + t.Errorf("networkFromIP(%q, %q) = %q, want %q", tt.ip, tt.mask, got, tt.want) + } + } +} + +// --- Shell metadata --- + +func TestShellNameAndDescription(t *testing.T) { + s := NewCiscoShell() + if s.Name() != "cisco" { + t.Errorf("Name() = %q, want %q", s.Name(), "cisco") + } + if s.Description() == "" { + t.Error("Description() should not be empty") + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && containsHelper(s, substr) +} + +func containsHelper(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/internal/shell/cisco/commands.go b/internal/shell/cisco/commands.go new file mode 100644 index 0000000..284d7a9 --- /dev/null +++ b/internal/shell/cisco/commands.go @@ -0,0 +1,414 @@ +package cisco + +import ( + "fmt" + "strings" +) + +// commandResult holds the output of a command and whether the session should end. +type commandResult struct { + output string + exit bool +} + +// commandEntry defines a single command with its name and optional sub-commands. +type commandEntry struct { + name string + subs []commandEntry // nil for leaf commands +} + +// userExecCommands defines the command tree for user EXEC mode. +var userExecCommands = []commandEntry{ + {name: "show", subs: []commandEntry{ + {name: "version"}, + {name: "clock"}, + {name: "ip", subs: []commandEntry{ + {name: "route"}, + {name: "interface", subs: []commandEntry{ + {name: "brief"}, + }}, + }}, + {name: "interfaces"}, + {name: "vlan", subs: []commandEntry{ + {name: "brief"}, + }}, + }}, + {name: "enable"}, + {name: "exit"}, + {name: "?"}, +} + +// privilegedExecCommands extends user commands for privileged mode. +var privilegedExecCommands = []commandEntry{ + {name: "show", subs: []commandEntry{ + {name: "version"}, + {name: "clock"}, + {name: "ip", subs: []commandEntry{ + {name: "route"}, + {name: "interface", subs: []commandEntry{ + {name: "brief"}, + }}, + }}, + {name: "interfaces"}, + {name: "running-config"}, + {name: "startup-config"}, + {name: "vlan", subs: []commandEntry{ + {name: "brief"}, + }}, + }}, + {name: "configure", subs: []commandEntry{ + {name: "terminal"}, + }}, + {name: "write", subs: []commandEntry{ + {name: "memory"}, + }}, + {name: "copy"}, + {name: "reload"}, + {name: "disable"}, + {name: "terminal", subs: []commandEntry{ + {name: "length"}, + }}, + {name: "exit"}, + {name: "?"}, +} + +// globalConfigCommands defines the command tree for global config mode. +var globalConfigCommands = []commandEntry{ + {name: "hostname"}, + {name: "interface"}, + {name: "ip", subs: []commandEntry{ + {name: "route"}, + }}, + {name: "no"}, + {name: "end"}, + {name: "exit"}, + {name: "?"}, +} + +// interfaceConfigCommands defines the command tree for interface config mode. +var interfaceConfigCommands = []commandEntry{ + {name: "ip", subs: []commandEntry{ + {name: "address"}, + }}, + {name: "description"}, + {name: "shutdown"}, + {name: "no", subs: []commandEntry{ + {name: "shutdown"}, + }}, + {name: "switchport", subs: []commandEntry{ + {name: "mode"}, + }}, + {name: "end"}, + {name: "exit"}, + {name: "?"}, +} + +// commandsForMode returns the command tree for the given IOS mode. +func commandsForMode(mode iosMode) []commandEntry { + switch mode { + case modeUserExec: + return userExecCommands + case modePrivilegedExec: + return privilegedExecCommands + case modeGlobalConfig: + return globalConfigCommands + case modeInterfaceConfig: + return interfaceConfigCommands + default: + return userExecCommands + } +} + +// resolveAbbreviation attempts to match an abbreviated word against a list of +// command entries. It returns the matched entry name, or an error string if +// ambiguous or unknown. +func resolveAbbreviation(word string, entries []commandEntry) (string, error) { + word = strings.ToLower(word) + var matches []string + for _, e := range entries { + if strings.ToLower(e.name) == word { + return e.name, nil // exact match + } + if strings.HasPrefix(strings.ToLower(e.name), word) { + matches = append(matches, e.name) + } + } + switch len(matches) { + case 0: + return "", fmt.Errorf("unknown") + case 1: + return matches[0], nil + default: + return "", fmt.Errorf("ambiguous") + } +} + +// resolveCommand resolves a sequence of abbreviated words into the canonical +// command path (e.g., ["sh", "run"] → ["show", "running-config"]). +// It returns the resolved path, any remaining arguments, and an error if +// resolution fails. +func resolveCommand(words []string, entries []commandEntry) ([]string, []string, error) { + var resolved []string + current := entries + + for i, w := range words { + name, err := resolveAbbreviation(w, current) + if err != nil { + if err.Error() == "unknown" && len(resolved) > 0 { + // Remaining words are arguments to the resolved command. + return resolved, words[i:], nil + } + return resolved, words[i:], err + } + resolved = append(resolved, name) + + // Find sub-commands for the matched entry. + var nextLevel []commandEntry + for _, e := range current { + if e.name == name { + nextLevel = e.subs + break + } + } + if nextLevel == nil { + // Leaf command — rest are arguments. + return resolved, words[i+1:], nil + } + current = nextLevel + } + return resolved, nil, nil +} + +// dispatch processes a command line in the context of the current IOS state. +func (s *iosState) dispatch(input string) commandResult { + words := strings.Fields(input) + if len(words) == 0 { + return commandResult{} + } + + // Handle "?" as a help request. + if words[0] == "?" { + return s.cmdHelp() + } + + cmds := commandsForMode(s.mode) + resolved, args, err := resolveCommand(words, cmds) + if err != nil { + if err.Error() == "ambiguous" { + return commandResult{output: fmt.Sprintf("%% Ambiguous command: \"%s\"", input)} + } + return commandResult{output: invalidInput(input)} + } + + if len(resolved) == 0 { + return commandResult{output: invalidInput(input)} + } + + cmd := strings.Join(resolved, " ") + + switch s.mode { + case modeUserExec: + return s.dispatchUserExec(cmd, args) + case modePrivilegedExec: + return s.dispatchPrivilegedExec(cmd, args) + case modeGlobalConfig: + return s.dispatchGlobalConfig(cmd, args) + case modeInterfaceConfig: + return s.dispatchInterfaceConfig(cmd, args) + } + return commandResult{output: invalidInput(input)} +} + +func (s *iosState) dispatchUserExec(cmd string, args []string) commandResult { + switch cmd { + case "show version": + return commandResult{output: showVersion(s)} + case "show clock": + return commandResult{output: showClock()} + case "show ip route": + return commandResult{output: showIPRoute(s)} + case "show ip interface brief": + return commandResult{output: showIPInterfaceBrief(s)} + case "show interfaces": + return commandResult{output: showInterfaces(s)} + case "show vlan brief": + return commandResult{output: showVLANBrief()} + case "enable": + return commandResult{} // handled in Handle() loop + case "exit": + return commandResult{exit: true} + } + return commandResult{output: invalidInput(cmd)} +} + +func (s *iosState) dispatchPrivilegedExec(cmd string, args []string) commandResult { + switch cmd { + case "show version": + return commandResult{output: showVersion(s)} + case "show clock": + return commandResult{output: showClock()} + case "show ip route": + return commandResult{output: showIPRoute(s)} + case "show ip interface brief": + return commandResult{output: showIPInterfaceBrief(s)} + case "show interfaces": + return commandResult{output: showInterfaces(s)} + case "show running-config": + return commandResult{output: showRunningConfig(s)} + case "show startup-config": + return commandResult{output: showRunningConfig(s)} // same as running + case "show vlan brief": + return commandResult{output: showVLANBrief()} + case "configure terminal": + s.mode = modeGlobalConfig + return commandResult{output: "Enter configuration commands, one per line. End with CNTL/Z."} + case "write memory": + return commandResult{output: "[OK]"} + case "copy": + return commandResult{output: "[OK]"} + case "reload": + return commandResult{output: "System configuration has been modified. Save? [yes/no]: ", exit: true} + case "disable": + s.mode = modeUserExec + return commandResult{} + case "terminal length": + return commandResult{} // accept silently + case "exit": + return commandResult{exit: true} + } + return commandResult{output: invalidInput(cmd)} +} + +func (s *iosState) dispatchGlobalConfig(cmd string, args []string) commandResult { + switch cmd { + case "hostname": + if len(args) < 1 { + return commandResult{output: "% Incomplete command."} + } + s.hostname = args[0] + return commandResult{} + case "interface": + if len(args) < 1 { + return commandResult{output: "% Incomplete command."} + } + ifName := strings.Join(args, "") + s.currentIf = ifName + s.mode = modeInterfaceConfig + return commandResult{} + case "ip route": + return commandResult{} // accept silently + case "no": + return commandResult{} // accept silently + case "end": + s.mode = modePrivilegedExec + return commandResult{} + case "exit": + s.mode = modePrivilegedExec + return commandResult{} + } + return commandResult{output: invalidInput(cmd)} +} + +func (s *iosState) dispatchInterfaceConfig(cmd string, args []string) commandResult { + switch cmd { + case "ip address": + if len(args) < 2 { + return commandResult{output: "% Incomplete command."} + } + if iface := s.findInterface(s.currentIf); iface != nil { + iface.ip = args[0] + iface.mask = args[1] + } + return commandResult{} + case "description": + if len(args) < 1 { + return commandResult{output: "% Incomplete command."} + } + if iface := s.findInterface(s.currentIf); iface != nil { + iface.desc = strings.Join(args, " ") + } + return commandResult{} + case "shutdown": + if iface := s.findInterface(s.currentIf); iface != nil { + iface.shutdown = true + iface.status = "administratively down" + iface.protocol = "down" + } + return commandResult{} + case "no shutdown": + if iface := s.findInterface(s.currentIf); iface != nil { + iface.shutdown = false + iface.status = "up" + iface.protocol = "up" + } + return commandResult{} + case "switchport mode": + return commandResult{} // accept silently + case "end": + s.mode = modePrivilegedExec + s.currentIf = "" + return commandResult{} + case "exit": + s.mode = modeGlobalConfig + s.currentIf = "" + return commandResult{} + } + return commandResult{output: invalidInput(cmd)} +} + +func (s *iosState) cmdHelp() commandResult { + cmds := commandsForMode(s.mode) + var b strings.Builder + for _, e := range cmds { + if e.name == "?" { + continue + } + b.WriteString(fmt.Sprintf(" %-20s %s\n", e.name, helpText(e.name))) + } + return commandResult{output: b.String()} +} + +func helpText(name string) string { + switch name { + case "show": + return "Show running system information" + case "enable": + return "Turn on privileged commands" + case "disable": + return "Turn off privileged commands" + case "exit": + return "Exit from the EXEC" + case "configure": + return "Enter configuration mode" + case "write": + return "Write running configuration to memory" + case "copy": + return "Copy from one file to another" + case "reload": + return "Halt and perform a cold restart" + case "terminal": + return "Set terminal line parameters" + case "hostname": + return "Set system's network name" + case "interface": + return "Select an interface to configure" + case "ip": + return "Global IP configuration subcommands" + case "no": + return "Negate a command or set its defaults" + case "end": + return "Exit from configure mode" + case "description": + return "Interface specific description" + case "shutdown": + return "Shutdown the selected interface" + case "switchport": + return "Set switching mode characteristics" + default: + return "" + } +} + +func invalidInput(input string) string { + return fmt.Sprintf("%% Invalid input detected at '^' marker.\n\n%s\n^", input) +} diff --git a/internal/shell/cisco/output.go b/internal/shell/cisco/output.go new file mode 100644 index 0000000..ceab917 --- /dev/null +++ b/internal/shell/cisco/output.go @@ -0,0 +1,234 @@ +package cisco + +import ( + "fmt" + "math/rand" + "strings" + "time" +) + +func showVersion(s *iosState) string { + days := 14 + rand.Intn(350) + hours := rand.Intn(24) + mins := rand.Intn(60) + + return fmt.Sprintf(`Cisco IOS Software, %s Software (%s-UNIVERSALK9-M), Version %s, RELEASE SOFTWARE (fc3) +Technical Support: http://www.cisco.com/techsupport +Copyright (c) 1986-2019 by Cisco Systems, Inc. +Compiled Thu 30-Jan-19 10:08 by prod_rel_team + +ROM: Bootstrap program is %s boot loader +BOOTLDR: %s Boot Loader (C2960-HBOOT-M) Version 15.0(2r)SE, RELEASE SOFTWARE (fc1) + +%s uptime is %d days, %d hours, %d minutes +System returned to ROM by power-on +System image file is "flash:/%s-universalk9-mz.SPA.%s.bin" + +This product contains cryptographic features and is subject to United States +and local country laws governing import, export, transfer and use. + +cisco %s (%s) processor (revision K0) with 524288K bytes of memory. +Processor board ID %s +Last reset from power-on +2 Gigabit Ethernet interfaces +1 Virtual Ethernet interface +64K bytes of flash-simulated non-volatile configuration memory. +Total of 65536K bytes of APC System Flash (Read/Write) + +Configuration register is 0x2102`, + s.model, s.model, s.iosVersion, + s.model, s.model, + s.hostname, days, hours, mins, + s.model, s.iosVersion, + s.model, processorForModel(s.model), + s.serial, + ) +} + +func processorForModel(model string) string { + if strings.HasPrefix(model, "C29") { + return "PowerPC405" + } + return "MIPS" +} + +func showClock() string { + now := time.Now().UTC() + return fmt.Sprintf("*%s UTC", now.Format("15:04:05.000 Mon Jan 2 2006")) +} + +func showIPRoute(s *iosState) string { + var b strings.Builder + b.WriteString("Codes: C - connected, S - static, R - RIP, M - mobile, B - BGP\n") + b.WriteString(" D - EIGRP, EX - EIGRP external, O - OSPF, IA - OSPF inter area\n") + b.WriteString(" N1 - OSPF NSSA external type 1, N2 - OSPF NSSA external type 2\n") + b.WriteString(" E1 - OSPF external type 1, E2 - OSPF external type 2\n") + b.WriteString(" i - IS-IS, su - IS-IS summary, L1 - IS-IS level-1, L2 - IS-IS level-2\n") + b.WriteString(" ia - IS-IS inter area, * - candidate default, U - per-user static route\n") + b.WriteString(" o - ODR, P - periodic downloaded static route\n\n") + b.WriteString("Gateway of last resort is 10.0.0.2 to network 0.0.0.0\n\n") + + for _, iface := range s.interfaces { + if iface.ip == "unassigned" || iface.status != "up" { + continue + } + network := networkFromIP(iface.ip, iface.mask) + maskBits := maskBits(iface.mask) + fmt.Fprintf(&b, "C %s/%d is directly connected, %s\n", network, maskBits, iface.name) + } + b.WriteString("S* 0.0.0.0/0 [1/0] via 10.0.0.2") + return b.String() +} + +func showIPInterfaceBrief(s *iosState) string { + var b strings.Builder + fmt.Fprintf(&b, "%-25s %-15s %-4s %-7s %-22s %s\n", + "Interface", "IP-Address", "OK?", "Method", "Status", "Protocol") + for _, iface := range s.interfaces { + ip := iface.ip + if ip == "" { + ip = "unassigned" + } + fmt.Fprintf(&b, "%-25s %-15s YES manual %-22s %s\n", + iface.name, ip, iface.status, iface.protocol) + } + return b.String() +} + +func showInterfaces(s *iosState) string { + var b strings.Builder + for i, iface := range s.interfaces { + if i > 0 { + b.WriteString("\n") + } + upDown := "up" + if iface.shutdown { + upDown = "administratively down" + } + fmt.Fprintf(&b, "%s is %s, line protocol is %s\n", iface.name, upDown, iface.protocol) + fmt.Fprintf(&b, " Hardware is Gigabit Ethernet, address is %s (bia %s)\n", iface.mac, iface.mac) + if iface.ip != "unassigned" && iface.ip != "" { + fmt.Fprintf(&b, " Internet address is %s/%d\n", iface.ip, maskBits(iface.mask)) + } + fmt.Fprintf(&b, " MTU %d bytes, BW %s sec, DLY 10 usec,\n", iface.mtu, iface.bandwidth) + b.WriteString(" reliability 255/255, txload 1/255, rxload 1/255\n") + b.WriteString(" Encapsulation ARPA, loopback not set\n") + fmt.Fprintf(&b, " %d packets input, %d bytes, 0 no buffer\n", iface.rxPackets, iface.rxBytes) + fmt.Fprintf(&b, " %d packets output, %d bytes, 0 underruns", iface.txPackets, iface.txBytes) + } + return b.String() +} + +func showRunningConfig(s *iosState) string { + var b strings.Builder + b.WriteString("Building configuration...\n\n") + b.WriteString("Current configuration : 1482 bytes\n") + b.WriteString("!\n") + b.WriteString("! Last configuration change at 14:32:22 UTC Mon Feb 10 2025\n") + b.WriteString("!\n") + b.WriteString("version 15.0\n") + b.WriteString("service timestamps debug datetime msec\n") + b.WriteString("service timestamps log datetime msec\n") + b.WriteString("no service password-encryption\n") + b.WriteString("!\n") + fmt.Fprintf(&b, "hostname %s\n", s.hostname) + b.WriteString("!\n") + b.WriteString("boot-start-marker\n") + b.WriteString("boot-end-marker\n") + b.WriteString("!\n") + if s.enablePass != "" { + b.WriteString("enable secret 5 $1$mERr$hx5rVt7rPNoS4wqbXKX7m0\n") + } + b.WriteString("!\n") + b.WriteString("no aaa new-model\n") + b.WriteString("!\n") + + for _, iface := range s.interfaces { + b.WriteString("!\n") + fmt.Fprintf(&b, "interface %s\n", iface.name) + if iface.desc != "" { + fmt.Fprintf(&b, " description %s\n", iface.desc) + } + if iface.ip != "unassigned" && iface.ip != "" { + fmt.Fprintf(&b, " ip address %s %s\n", iface.ip, iface.mask) + } else { + b.WriteString(" no ip address\n") + } + if iface.shutdown { + b.WriteString(" shutdown\n") + } + } + + b.WriteString("!\n") + b.WriteString("ip forward-protocol nd\n") + b.WriteString("!\n") + b.WriteString("ip route 0.0.0.0 0.0.0.0 10.0.0.2\n") + b.WriteString("!\n") + b.WriteString("access-list 10 permit 192.168.1.0 0.0.0.255\n") + b.WriteString("access-list 10 deny any\n") + b.WriteString("!\n") + b.WriteString("line con 0\n") + b.WriteString(" logging synchronous\n") + b.WriteString("line vty 0 4\n") + b.WriteString(" login local\n") + b.WriteString(" transport input ssh\n") + b.WriteString("!\n") + b.WriteString("end") + return b.String() +} + +func showVLANBrief() string { + var b strings.Builder + fmt.Fprintf(&b, "%-6s %-32s %-10s %s\n", "VLAN", "Name", "Status", "Ports") + b.WriteString("---- -------------------------------- --------- -------------------------------\n") + fmt.Fprintf(&b, "%-6s %-32s %-10s %s\n", "1", "default", "active", "Gi0/0, Gi0/1, Gi0/2") + fmt.Fprintf(&b, "%-6s %-32s %-10s %s\n", "10", "MGMT", "active", "") + fmt.Fprintf(&b, "%-6s %-32s %-10s %s\n", "20", "USERS", "active", "") + fmt.Fprintf(&b, "%-6s %-32s %-10s %s\n", "99", "NATIVE", "active", "") + fmt.Fprintf(&b, "%-6s %-32s %-10s %s\n", "1002", "fddi-default", "act/unsup", "") + fmt.Fprintf(&b, "%-6s %-32s %-10s %s\n", "1003", "token-ring-default", "act/unsup", "") + fmt.Fprintf(&b, "%-6s %-32s %-10s %s", "1004", "fddinet-default", "act/unsup", "") + return b.String() +} + +// networkFromIP derives the network address from an IP and mask. +func networkFromIP(ip, mask string) string { + ipParts := parseIPv4(ip) + maskParts := parseIPv4(mask) + if ipParts == nil || maskParts == nil { + return ip + } + return fmt.Sprintf("%d.%d.%d.%d", + ipParts[0]&maskParts[0], + ipParts[1]&maskParts[1], + ipParts[2]&maskParts[2], + ipParts[3]&maskParts[3], + ) +} + +func maskBits(mask string) int { + parts := parseIPv4(mask) + if parts == nil { + return 24 + } + bits := 0 + for _, p := range parts { + for i := 7; i >= 0; i-- { + if p&(1< + modePrivilegedExec // Router# + modeGlobalConfig // Router(config)# + modeInterfaceConfig // Router(config-if)# +) + +// ifaceInfo holds interface metadata for show commands. +type ifaceInfo struct { + name string + ip string + mask string + status string + protocol string + mac string + bandwidth string + mtu int + rxPackets int + txPackets int + rxBytes int + txBytes int + shutdown bool + desc string +} + +// iosState holds all mutable state for the Cisco IOS shell session. +type iosState struct { + mode iosMode + hostname string + model string + iosVersion string + serial string + enablePass string + interfaces []ifaceInfo + currentIf string +} + +func newIOSState(hostname, model, iosVersion, enablePass string) *iosState { + return &iosState{ + mode: modeUserExec, + hostname: hostname, + model: model, + iosVersion: iosVersion, + serial: "FTX1524Z0P3", + enablePass: enablePass, + interfaces: defaultInterfaces(), + } +} + +func defaultInterfaces() []ifaceInfo { + return []ifaceInfo{ + { + name: "GigabitEthernet0/0", ip: "192.168.1.1", mask: "255.255.255.0", + status: "up", protocol: "up", mac: "0050.7966.6800", + bandwidth: "1000000 Kbit", mtu: 1500, + rxPackets: 148253, txPackets: 93127, rxBytes: 19284732, txBytes: 8291043, + }, + { + name: "GigabitEthernet0/1", ip: "10.0.0.1", mask: "255.255.255.252", + status: "up", protocol: "up", mac: "0050.7966.6801", + bandwidth: "1000000 Kbit", mtu: 1500, + rxPackets: 52104, txPackets: 48891, rxBytes: 4182934, txBytes: 3901284, + }, + { + name: "GigabitEthernet0/2", ip: "unassigned", mask: "", + status: "administratively down", protocol: "down", mac: "0050.7966.6802", + bandwidth: "1000000 Kbit", mtu: 1500, shutdown: true, + }, + { + name: "Vlan1", ip: "172.16.0.1", mask: "255.255.0.0", + status: "up", protocol: "up", mac: "0050.7966.6810", + bandwidth: "1000000 Kbit", mtu: 1500, + rxPackets: 8421, txPackets: 7103, rxBytes: 512384, txBytes: 423901, + }, + } +} + +// prompt returns the IOS prompt string for the current mode. +func (s *iosState) prompt() string { + switch s.mode { + case modeUserExec: + return fmt.Sprintf("%s>", s.hostname) + case modePrivilegedExec: + return fmt.Sprintf("%s#", s.hostname) + case modeGlobalConfig: + return fmt.Sprintf("%s(config)#", s.hostname) + case modeInterfaceConfig: + return fmt.Sprintf("%s(config-if)#", s.hostname) + default: + return fmt.Sprintf("%s>", s.hostname) + } +} + +// findInterface returns a pointer to the interface with the given name, or nil. +func (s *iosState) findInterface(name string) *ifaceInfo { + for i := range s.interfaces { + if s.interfaces[i].name == name { + return &s.interfaces[i] + } + } + return nil +} diff --git a/oubliette.toml.example b/oubliette.toml.example index 2880f76..27f427c 100644 --- a/oubliette.toml.example +++ b/oubliette.toml.example @@ -29,6 +29,11 @@ password = "admin" # password = "banking" # shell = "banking" +# [[auth.static_credentials]] +# username = "admin" +# password = "cisco" +# shell = "cisco" + [storage] db_path = "oubliette.db" retention_days = 90 @@ -53,6 +58,12 @@ hostname = "ubuntu-server" # [shell.adventure] # dungeon_name = "THE OUBLIETTE" +# [shell.cisco] +# hostname = "Router" +# model = "C2960" +# ios_version = "15.0(2)SE11" +# enable_password = "" # empty = accept after 1 failed attempt + # [detection] # enabled = true # threshold = 0.6 # 0.0–1.0, sessions above this trigger notifications