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/commands.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

415 lines
11 KiB
Go

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