feat: add Cisco IOS shell with mode state machine and abbreviation matching (PLAN.md 3.2)

Implements a Cisco IOS CLI emulator with four modes (user exec, privileged exec,
global config, interface config), Cisco-style command abbreviation (e.g. sh run,
conf t), enable password flow, and realistic show command output including
running-config, interfaces, IP routes, and VLANs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 14:58:26 +01:00
parent 058da51f86
commit 5ba62afec3
9 changed files with 1506 additions and 3 deletions

View File

@@ -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).

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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<<uint(i)) != 0 {
bits++
} else {
return bits
}
}
}
return bits
}
func parseIPv4(s string) []int {
var a, b, c, d int
n, _ := fmt.Sscanf(s, "%d.%d.%d.%d", &a, &b, &c, &d)
if n != 4 {
return nil
}
return []int{a, b, c, d}
}

View File

@@ -0,0 +1,109 @@
package cisco
import "fmt"
// iosMode represents the current CLI mode of the IOS state machine.
type iosMode int
const (
modeUserExec iosMode = iota // Router>
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
}

View File

@@ -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.01.0, sessions above this trigger notifications