Add 6 new Prometheus metrics for richer observability: - auth_attempts_by_country_total (counter by country) - commands_executed_total (counter by shell via OnCommand callback) - human_score (histogram of final detection scores) - storage_login_attempts_total, storage_unique_ips, storage_sessions_total (gauges via custom collector querying GetDashboardStats on each scrape) Add optional bearer token authentication for the /metrics endpoint via web.metrics_token config option. Uses crypto/subtle.ConstantTimeCompare. Empty token (default) means no auth for backwards compatibility. Also adds "cisco" to pre-initialized session/command metric labels. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
207 lines
4.7 KiB
Go
207 lines
4.7 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)
|
|
}
|
|
}
|
|
if sess.OnCommand != nil {
|
|
sess.OnCommand("cisco")
|
|
}
|
|
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 sess.OnCommand != nil {
|
|
sess.OnCommand("cisco")
|
|
}
|
|
|
|
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
|
|
}
|