This repository has been archived on 2026-03-09. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
oubliette/internal/shell/cisco/cisco.go
Torjus Håkestad 5ba62afec3 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>
2026-02-15 14:58:26 +01:00

201 lines
4.6 KiB
Go

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
}