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:
@@ -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).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
200
internal/shell/cisco/cisco.go
Normal file
200
internal/shell/cisco/cisco.go
Normal 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
|
||||
}
|
||||
531
internal/shell/cisco/cisco_test.go
Normal file
531
internal/shell/cisco/cisco_test.go
Normal 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
|
||||
}
|
||||
414
internal/shell/cisco/commands.go
Normal file
414
internal/shell/cisco/commands.go
Normal 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)
|
||||
}
|
||||
234
internal/shell/cisco/output.go
Normal file
234
internal/shell/cisco/output.go
Normal 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}
|
||||
}
|
||||
109
internal/shell/cisco/state.go
Normal file
109
internal/shell/cisco/state.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user