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 }