From 4fdb6a82e5d1d359f26c5b0629d3676389172eed Mon Sep 17 00:00:00 2001 From: Stephen Stack Date: Sat, 6 Jun 2026 18:58:39 +0100 Subject: [PATCH 1/3] feat: add multi-vendor driver framework and Ciena 6500 TL1 model Introduce a per-device "driver" abstraction so the SSH server can present different vendor personalities. The driver is selected at session start from the manifest `template` column (previously written but unused at runtime), and each vendor is one self-contained file. Cisco IOS behaviour is preserved byte-for-byte (extracted verbatim into the cisco_ios driver; the shared response-delay, fault-injection, and metrics machinery factored into helpers both drivers call). Add the first non-Cisco model, ciena-6500-tl1: a TL1 personality over SSH with a bare `<` prompt, an in-band ACT-USER login gate, and `;`-terminated RTRV-* verbs returning COMPLD/DENY blocks. The generator emits a deterministic RTRV-EQPT::ALL shelf inventory per device, mmap-streamed zero-copy at runtime. The generator's size buckets are generalised into a model registry so new vendors/models are one registry entry plus a template. Add --ssh-auth (password|driver|none) to model both real-world TL1 access patterns: TL1-only (no SSH challenge; ACT-USER is the only gate) and SSH login then TL1. Each driver declares RequiresSSHAuth() (Cisco yes, Ciena no), which the driver mode consults so mixed fleets behave correctly. No breaking changes: no CSV schema change (vendor/template columns already existed), no renamed flags/buckets/metric keys, and Cisco generated bytes and wire output are identical. New TL1 command label values are additive and stay within the asserted cardinality bound. Tests: characterization tests pinning the Cisco greeting/enable/close before the refactor; TL1 unit tests (parsing, login gate, block rendering); the ssh-auth mode matrix; and integration tests covering ACT-USER login, RTRV-EQPT streaming, pre-login DENY, multi-line commands, and the no-auth scenarios. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 32 +- CLAUDE.md | 16 +- README.md | 48 ++- cmd/rcfg-sim-gen/main.go | 2 +- cmd/rcfg-sim/main.go | 1 + internal/configs/ciena.go | 147 +++++++++ internal/configs/ciena_test.go | 119 +++++++ internal/configs/generator.go | 84 +++-- internal/configs/loader.go | 4 + .../configs/templates/ciena_tl1_eqpt.tmpl | 17 + internal/sshsrv/characterization_test.go | 293 +++++++++++++++++ internal/sshsrv/ciena_integration_test.go | 182 +++++++++++ internal/sshsrv/dispatch.go | 32 ++ internal/sshsrv/driver.go | 172 ++++++++++ internal/sshsrv/driver_ciena.go | 301 ++++++++++++++++++ internal/sshsrv/driver_ciena_test.go | 158 +++++++++ internal/sshsrv/driver_cisco.go | 104 ++++++ internal/sshsrv/server.go | 70 +++- internal/sshsrv/session.go | 148 +-------- 19 files changed, 1743 insertions(+), 187 deletions(-) create mode 100644 internal/configs/ciena.go create mode 100644 internal/configs/ciena_test.go create mode 100644 internal/configs/templates/ciena_tl1_eqpt.tmpl create mode 100644 internal/sshsrv/characterization_test.go create mode 100644 internal/sshsrv/ciena_integration_test.go create mode 100644 internal/sshsrv/driver.go create mode 100644 internal/sshsrv/driver_ciena.go create mode 100644 internal/sshsrv/driver_ciena_test.go create mode 100644 internal/sshsrv/driver_cisco.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 523d231..d7b119a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,35 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ## [Unreleased] +## [0.0.3] — 2026-06-06 + +### Added + +- **Multi-vendor device-driver framework.** The SSH server now selects a per-device + personality ("driver") from the manifest `template` column at session start, so new + vendors/models are added as one self-contained driver file plus one generator model + entry. The Cisco IOS behaviour is preserved byte-for-byte (extracted verbatim into the + `cisco_ios` driver); the manifest `vendor`/`template` columns — previously written but + unused at runtime — are now the live wiring. No CSV schema change. +- **Ciena 6500 7-slot optical model (`ciena-6500-tl1`).** A TL1 personality reached over + SSH: a bare `<` prompt, an in-band `ACT-USER:::::;` login gate (commands + before login return TL1 `DENY`), and `;`-terminated `RTRV-*` verbs returning `COMPLD` + blocks. Supported verbs: `RTRV-EQPT`, `RTRV-ALM-ALL`, `RTRV-COND-ALL`, `RTRV-ACTIVE-USER`, + `RTRV-SW-VER`, `RTRV-SYS`. The generator emits a deterministic `RTRV-EQPT::ALL` shelf + inventory per device, mmap-streamed zero-copy at runtime (and subject to the same fault + injection as Cisco config streams). Select it via `--distribution`, e.g. + `--distribution sm:50,ciena-6500-tl1:50`. +- New `command` label values on `rcfgsim_command_duration_seconds` for the TL1 verbs + (`CmdTL1ActUser`, `CmdTL1RtrvEqpt`, …), pre-registered at zero. No new metric names or + label keys; cardinality stays within the asserted bound. +- **`--ssh-auth` server flag** to model both real-world TL1 access patterns: + `password` (default — SSH password auth for every device, unchanged), `driver` (per-driver: + Cisco authenticates at the SSH layer, Ciena TL1 does not — `ACT-USER` is the only gate), and + `none` (no SSH auth for any device). Each driver declares `RequiresSSHAuth()`; `driver` mode + honours it so mixed Cisco/Ciena fleets behave correctly. In a no-auth mode the SSH client + connects unchallenged and authenticates in-band; the `auth_fail` fault and `auth_attempts` + metric do not apply. + ## [0.0.2] — 2026-05-19 Bucket-label rename and five new stress-test size tiers. Breaking change: every `--distribution` string and every `size_bucket` value in existing manifests is invalidated. Migration is a mechanical rename — see below. @@ -84,6 +113,7 @@ Initial public release. High-density Cisco IOS SSH simulator for load testing [r See [README § Known limitations](README.md#known-limitations) for the full list. -[Unreleased]: https://github.com/rconfig/rconfig-sim/compare/v0.0.2...HEAD +[Unreleased]: https://github.com/rconfig/rconfig-sim/compare/v0.0.3...HEAD +[0.0.3]: https://github.com/rconfig/rconfig-sim/releases/tag/v0.0.3 [0.0.2]: https://github.com/rconfig/rconfig-sim/releases/tag/v0.0.2 [0.0.1]: https://github.com/rconfig/rconfig-sim/releases/tag/v0.0.1 diff --git a/CLAUDE.md b/CLAUDE.md index 43a61e2..9a7df7e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,14 +32,15 @@ The project follows [Semantic Versioning 2.0.0](https://semver.org/) and [Keep a ### Breaking-change surface (assume external users depend on these) -- Bucket labels: `sm`, `md`, `lg`, `xl`, `2xl`, `3xl`, `4xl`, `5xl`, `6xl` (defined in [internal/configs/generator.go](internal/configs/generator.go)) -- `--distribution` string syntax (`bucket:weight,...`) +- Model names (the `--distribution` / `size_bucket` keys in the `registry`, [internal/configs/generator.go](internal/configs/generator.go)): the Cisco size labels `sm`, `md`, `lg`, `xl`, `2xl`, `3xl`, `4xl`, `5xl`, `6xl`, plus `ciena-6500-tl1` +- Driver/template ids in the manifest `template` column (`cisco_ios`, `ciena_tl1`) — the runtime resolves the per-device driver from these (see `driverFor`, [internal/sshsrv/driver.go](internal/sshsrv/driver.go)) +- `--distribution` string syntax (`model:weight,...`) - All CLI flag names and defaults on both binaries - Manifest CSV header order - Prometheus metric names and label keys (cardinality is asserted by test — don't add new labels casually) - Systemd unit name `rcfg-sim@.service` and the env-file variable names it consumes - Default paths: `/etc/rcfg-sim/`, `/opt/rcfg-sim/`, host-key path -- The set of recognised `show ...` commands and their abbreviations +- The set of recognised commands per driver and their abbreviations (Cisco `show ...`; Ciena TL1 `RTRV-*` / `ACT-USER`) ## Commit style — Conventional Commits @@ -111,9 +112,12 @@ Integration tests live behind build tag `integration` and run separately — the ## Working with the generator -- Adding a bucket: append to `bucketOrder`, add a profile entry in `profiles`, and either create a `templates/.tmpl` or register an alias in `templateAliases` so a bigger tier can reuse a smaller template. Bucket names are user-facing — see [README § Configuration templates](README.md#configuration-templates). -- Increasing a profile's counts is non-breaking (file sizes drift); renaming or removing a bucket is breaking. -- The deterministic test (`TestRunDeterministic`) hashes outputs across two runs with the same seed. Any change that alters template output for a given seed will fail this test — bump the test fixture only when the diff is intentional. +- The generator is driven by a `registry` of `model` entries ([internal/configs/generator.go](internal/configs/generator.go)). Each model carries its manifest `vendor`/`template` strings, the template file to render, and a per-vendor data-builder. The Cisco size buckets are derived mechanically from `profiles` + `templateAliases`; `modelOrder` is the canonical iteration order (Cisco buckets first, unchanged, then non-Cisco models appended). +- Adding a Cisco size bucket: append to `bucketOrder`, add a `profiles` entry, and either create a `templates/.tmpl` or register an alias in `templateAliases`. The registry picks it up automatically. +- Adding a new vendor/model: add one `registry` entry (vendor, driver/template id, template file, builder) and a `templates/.tmpl`; on the runtime side add one `Driver` implementation registered via `init()` in `internal/sshsrv/driver_.go`. Model names and driver ids are user-facing — see [README § Configuration templates](README.md#configuration-templates). +- Increasing a profile's counts is non-breaking (file sizes drift); renaming or removing a model is breaking. +- The deterministic test (`TestRunDeterministic`, and `TestCienaDeterministic` for Ciena) hashes outputs across two runs with the same seed. Any change that alters template output for a given seed will fail this test — bump the test fixture only when the diff is intentional. +- Cisco output must stay byte-identical when refactoring shared machinery. The integration characterization tests ([internal/sshsrv/characterization_test.go](internal/sshsrv/characterization_test.go)) pin the greeting, enable-mode flow, and session close — run `go test -tags integration ./...` before and after. ## When in doubt diff --git a/README.md b/README.md index ba345ff..e04c684 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Stand up 50,000 fake network devices on a single Linux host. Each one speaks rea ## What this is and isn't -**It is:** a purpose-built Go SSH server that emulates Cisco IOS devices well enough to satisfy rConfig's standard collection flow. It is designed to run at extreme density — tens of thousands of listeners on a single host — with bounded memory, zero-copy config delivery, and realistic timing characteristics. It emits Prometheus metrics covering session lifecycle, throughput, and fault activity. It supports deliberate fault injection to exercise rConfig's error handling paths. +**It is:** a purpose-built Go SSH server that emulates network devices well enough to satisfy rConfig's standard collection flow — Cisco IOS by default, with a pluggable per-device driver framework that also ships a Ciena 6500 TL1 personality. It is designed to run at extreme density — tens of thousands of listeners on a single host — with bounded memory, zero-copy config delivery, and realistic timing characteristics. It emits Prometheus metrics covering session lifecycle, throughput, and fault activity. It supports deliberate fault injection to exercise rConfig's error handling paths. **It isn't:** a full Cisco IOS emulator, a network topology simulator (no routing, no data plane, no control plane), or a replacement for GNS3/EVE-NG/Containerlab. It doesn't do SSH key auth, VRF separation, or anything past the ten-or-so commands rConfig-sim actually issues. The point is to load-test an NMS, not to run virtual labs. @@ -85,6 +85,7 @@ You cannot answer any of these with unit tests or a lab of ten devices. You need - **Fault injection** — four independent fault types (auth_fail, disconnect_mid, slow_response, malformed) with per-session RNG and verified zero overhead when disabled - **Systemd-native operation** — one service instance per IP, independent restart, drain, and log streams - **Cisco-style command parsing** — prefix matching (`sh run` → `show running-config`), ambiguity detection, enable mode, deterministic serial numbers +- **Pluggable multi-vendor drivers** — per-device personality selected from the manifest; ships Cisco IOS and a Ciena 6500 TL1 model (`<` prompt, in-band `ACT-USER` login, `;`-terminated `RTRV-*` verbs). New vendors are one driver file plus one generator model entry. - **Fully static binaries** — `CGO_ENABLED=0`, no runtime dependencies beyond glibc 2.34 - **36 runnable manual test samples** covering every feature path @@ -1155,6 +1156,49 @@ Generated configs span nine size buckets. The first four match typical enterpris Default distribution (40/40/15/5) approximates a typical enterprise network. Override with `--distribution "sm:N,md:N,lg:N,xl:N,..."` where values sum to 100; any subset of the nine buckets may be specified. +### Non-Cisco models + +The generator is driven by a **model registry**, of which the nine Cisco size buckets above are the initial entries. Each model carries its own vendor, runtime driver, and template, so the `--distribution` syntax doubles as a vendor selector: a model name that isn't a Cisco bucket simply selects a different personality. + +| Model | Vendor | Driver | Protocol | Payload | +|---|---|---|---|---| +| `ciena-6500-tl1` | Ciena | `ciena_tl1` | TL1 over SSH | `RTRV-EQPT::ALL` shelf inventory (7-slot 6500), mmap-streamed | + +Mix it into any run, e.g. `--distribution "sm:50,ciena-6500-tl1:50"`. Ciena rows in the manifest carry `vendor=Ciena, template=ciena_tl1`; Cisco rows are unchanged. + +The Ciena 6500 personality is **not** Cisco IOS. After SSH connects it presents a bare `<` prompt and requires an in-band TL1 login before any command works: + +``` +< ACT-USER::admin:CTAG1::admin; + + CIENA-LAX-1001 26-02-17 14:27:08 +M CTAG1 COMPLD + /*AUTHTYPE=LOCAL*/ +; +< RTRV-EQPT::ALL:100; + + CIENA-LAX-1001 26-02-17 14:27:10 +M 100 COMPLD + "SHELF-1::PROVISIONED,TYPE=6500-7SLOT,...:IS-NR" + "SLOT-1:OTR2,...:IS-NR" + ... +; +``` + +Commands are terminated by `;` (and may span lines). Recognised verbs: `ACT-USER`, `RTRV-EQPT`, `RTRV-ALM-ALL`, `RTRV-COND-ALL`, `RTRV-ACTIVE-USER`, `RTRV-SW-VER`, `RTRV-SYS`. Anything before a valid `ACT-USER`, or any unrecognised verb, returns a TL1 `DENY` block. + +#### SSH-layer auth vs in-band TL1 auth + +Real 6500 deployments differ in whether the SSH transport itself challenges for a password. Both patterns are supported via the server's `--ssh-auth` flag: + +| `--ssh-auth` | SSH transport | Then | Models | +|---|---|---|---| +| `password` (default) | password auth required | `<` prompt → `ACT-USER` | **Scenario B**: interactive SSH login *and* TL1 login | +| `driver` | per-driver: Cisco requires it, Ciena does not | `<` prompt → `ACT-USER` | **Scenario A** for Ciena, normal auth for Cisco — correct for mixed fleets | +| `none` | no auth (any/none accepted) | `<` prompt → `ACT-USER` | **Scenario A**: TL1 `ACT-USER` is the only gate | + +In a no-auth mode (`none`, or `driver` for a Ciena device) the SSH client connects without a password prompt and lands directly on `<`; `ACT-USER` is the sole authentication. In `password` mode the client authenticates at the SSH layer first, then again in-band via `ACT-USER`. Each driver declares its requirement through `RequiresSSHAuth()` (Cisco IOS `true`, Ciena TL1 `false`), which is what `driver` mode consults. + ### Per-device parameterisation Each config has unique: @@ -1210,6 +1254,7 @@ SSH server. One instance per IP alias. --username string Accepted username; empty = any (default "admin") --password string Accepted password; empty = any (default "admin") --enable-password string Enable mode password (default "enable123") +--ssh-auth string SSH transport auth: password (all) | driver (Cisco yes, Ciena TL1 no) | none (default "password") --metrics-addr string HTTP addr for /metrics and /healthz (default "0.0.0.0:9100") --response-delay-ms-min int Minimum response delay (default 50) --response-delay-ms-max int Maximum response delay (default 500) @@ -1388,7 +1433,6 @@ sudo modprobe -r nf_conntrack 2>/dev/null || true **v1 scope deliberately excludes:** - SSH public key authentication (password only) -- Multiple vendors (Cisco IOS only) - IPv6 listening addresses - TLS (SSH is cleartext-protocol-over-TCP by nature; no TLS wrapper) - SCP/SFTP file transfer (rConfig uses `show running-config`, not file copy) diff --git a/cmd/rcfg-sim-gen/main.go b/cmd/rcfg-sim-gen/main.go index 6eb8899..6634583 100644 --- a/cmd/rcfg-sim-gen/main.go +++ b/cmd/rcfg-sim-gen/main.go @@ -18,7 +18,7 @@ func main() { flag.IntVar(&cfg.PortStart, "port-start", 10000, "first port in range") flag.IntVar(&cfg.DevicesPerIP, "devices-per-ip", 2500, "devices mapped to each IP") flag.Int64Var(&cfg.Seed, "seed", 42, "PRNG seed for deterministic output") - flag.StringVar(&cfg.Distribution, "distribution", "sm:40,md:40,lg:15,xl:5", "size-bucket weights (percent, sum=100; buckets: sm, md, lg, xl, 2xl, 3xl, 4xl, 5xl, 6xl)") + flag.StringVar(&cfg.Distribution, "distribution", "sm:40,md:40,lg:15,xl:5", "model weights (percent, sum=100; models: sm, md, lg, xl, 2xl, 3xl, 4xl, 5xl, 6xl, ciena-6500-tl1)") flag.StringVar(&cfg.Username, "username", "admin", "username written into manifest") flag.StringVar(&cfg.Password, "password", "admin", "password written into manifest") flag.StringVar(&cfg.EnablePassword, "enable-password", "enable123", "enable password written into manifest") diff --git a/cmd/rcfg-sim/main.go b/cmd/rcfg-sim/main.go index 0a8be6c..a47ce93 100644 --- a/cmd/rcfg-sim/main.go +++ b/cmd/rcfg-sim/main.go @@ -30,6 +30,7 @@ func main() { flag.StringVar(&cfg.Username, "username", "admin", "accepted SSH username (currently informational; auth is password-only)") flag.StringVar(&cfg.Password, "password", "admin", "accepted SSH password (empty = accept any)") flag.StringVar(&cfg.EnablePassword, "enable-password", "enable123", "enable-mode password") + flag.StringVar(&cfg.SSHAuthMode, "ssh-auth", "password", "SSH transport auth: password (all devices) | driver (per-driver: Cisco yes, Ciena TL1 no) | none (in-band auth only)") flag.IntVar(&cfg.ResponseDelayMinMS, "response-delay-ms-min", 50, "min per-command response delay (ms)") flag.IntVar(&cfg.ResponseDelayMaxMS, "response-delay-ms-max", 500, "max per-command response delay (ms)") flag.IntVar(&cfg.MaxConcurrentSessions, "max-concurrent-sessions", 5000, "semaphore cap on concurrent sessions") diff --git a/internal/configs/ciena.go b/internal/configs/ciena.go new file mode 100644 index 0000000..fae79ec --- /dev/null +++ b/internal/configs/ciena.go @@ -0,0 +1,147 @@ +package configs + +import ( + "fmt" + "math/rand" + "strings" +) + +// cienaModelName is the public model name: used in --distribution, written to +// the manifest size_bucket column, and documented as API. Vendor/model/protocol +// so future 6500 form factors (ciena-6500-2slot, …) slot in alongside it. +const cienaModelName = "ciena-6500-tl1" + +// modelHostname extracts the manifest hostname from a model's rendered data. +// Each vendor's data struct names this field differently (Cisco Hostname, +// Ciena SID); the manifest hostname becomes the device's runtime SID/TID. +func modelHostname(data any) string { + switch d := data.(type) { + case TemplateData: + return d.Hostname + case CienaEqptData: + return d.SID + default: + return "" + } +} + +// cienaModel returns the registry entry for the Ciena 6500 7-slot optical. Its +// rendered config file is a TL1 RTRV-EQPT::ALL inventory payload, mmap-streamed +// at runtime by the ciena_tl1 driver; the other RTRV-* responses are synthesized +// live by that driver. +func cienaModel() model { + return model{ + name: cienaModelName, + vendor: "Ciena", + template: "ciena_tl1", + tmplFile: "ciena_tl1_eqpt.tmpl", + build: func(cfg Config, index int, m model) any { return buildCienaEqpt(cfg, index) }, + } +} + +// CienaEqptData is the payload for templates/ciena_tl1_eqpt.tmpl: the equipment +// inventory of a 6500 7-slot shelf. Fully determined by (seed, index) via +// deviceRand, so a fixed seed yields byte-identical output. Disjoint from the +// Cisco TemplateData so vendor data shapes never cross-couple. +type CienaEqptData struct { + SID string + NodeIP string + ShelfSerial string + SwVersion string + Slots []CienaSlot +} + +type CienaSlot struct { + Slot int + Equipped bool + CardType string + CLEI string + Serial string + PartNum string + State string + Ports []CienaPort +} + +type CienaPort struct { + Port int + Equipped bool + OpticType string + Wavelength string + Serial string + State string +} + +// Fixed catalogs — a 6500 7-slot draws cards/optics from a bounded set, so the +// inventory looks realistic without unbounded cardinality. +var ( + cienaCards = []string{"WL3N", "OTR2", "EDFA", "10X10G", "OSC", "SP2", "XCIF"} + cienaOptics = []string{"SFP+", "QSFP28", "CFP2"} +) + +func buildCienaEqpt(cfg Config, index int) CienaEqptData { + rng := deviceRand(cfg.Seed, index) + city := citySyllables[rng.Intn(len(citySyllables))] + sid := fmt.Sprintf("CIENA-%s-%04d", strings.ToUpper(city), 1000+(index%9000)) + + d := CienaEqptData{ + SID: sid, + NodeIP: ipPlusOffset(cfg.IPBase, index/cfg.DevicesPerIP), + ShelfSerial: serialFor(sid), + SwVersion: "12.4", + } + + nEquipped := 4 + rng.Intn(4) // 4..7 of the 7 payload slots populated + for s := 1; s <= 7; s++ { + slot := CienaSlot{Slot: s, Equipped: s <= nEquipped} + if slot.Equipped { + slot.CardType = cienaCards[rng.Intn(len(cienaCards))] + slot.CLEI = cienaCLEI(rng) + slot.Serial = cienaCardSerial(rng, s) + slot.PartNum = fmt.Sprintf("NTK%03d%s", 500+rng.Intn(99), string(rune('A'+rng.Intn(26)))) + slot.State = "IS-NR" + if rng.Intn(20) == 0 { + slot.State = "OOS-AU" + } + nPorts := 2 + rng.Intn(7) // 2..8 ports per card + for p := 1; p <= nPorts; p++ { + port := CienaPort{Port: p, Equipped: rng.Intn(4) != 0} + if port.Equipped { + port.OpticType = cienaOptics[rng.Intn(len(cienaOptics))] + port.Wavelength = cienaLambda(rng) + port.Serial = cienaCardSerial(rng, s*100+p) + port.State = "IS-NR" + } + slot.Ports = append(slot.Ports, port) + } + } + d.Slots = append(d.Slots, slot) + } + return d +} + +func cienaCLEI(rng *rand.Rand) string { + const alpha = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + var b strings.Builder + b.WriteString("WMOT") + for i := 0; i < 6; i++ { + b.WriteByte(alpha[rng.Intn(len(alpha))]) + } + return b.String() +} + +func cienaCardSerial(rng *rand.Rand, slot int) string { + const alpha = "ABCDEFGHJKLMNPQRSTUVWXYZ0123456789" + var b strings.Builder + b.WriteString("LBC") + for i := 0; i < 7; i++ { + b.WriteByte(alpha[rng.Intn(len(alpha))]) + } + return fmt.Sprintf("%s%02d", b.String(), slot%100) +} + +// cienaLambda returns an ITU C-band channel centre frequency / wavelength. +func cienaLambda(rng *rand.Rand) string { + // C-band ~191.0..196.0 THz; render as nm for readability. + nm := 1528.0 + float64(rng.Intn(400))*0.05 + return fmt.Sprintf("%.2f", nm) +} diff --git a/internal/configs/ciena_test.go b/internal/configs/ciena_test.go new file mode 100644 index 0000000..f658a46 --- /dev/null +++ b/internal/configs/ciena_test.go @@ -0,0 +1,119 @@ +package configs + +import ( + "bytes" + "encoding/csv" + "io" + "os" + "path/filepath" + "testing" +) + +// TestCienaDeterministic mirrors TestRunDeterministic for the Ciena model: the +// same seed must produce byte-identical TL1 inventory payloads. +func TestCienaDeterministic(t *testing.T) { + mk := func() string { + cfg := baseTestConfig(t, 20) + cfg.IPCount = 1 + cfg.DevicesPerIP = 20 + cfg.Distribution = "ciena-6500-tl1:100" + if _, err := Run(cfg, io.Discard); err != nil { + t.Fatalf("run: %v", err) + } + return cfg.OutputDir + } + dirA := mk() + dirB := mk() + for i := 0; i < 20; i++ { + fa := filepath.Join(dirA, "device-"+pad(i)+".cfg") + fb := filepath.Join(dirB, "device-"+pad(i)+".cfg") + a, err := os.ReadFile(fa) + if err != nil { + t.Fatalf("read %s: %v", fa, err) + } + b, err := os.ReadFile(fb) + if err != nil { + t.Fatalf("read %s: %v", fb, err) + } + if !bytes.Equal(a, b) { + t.Fatalf("device %d not deterministic across runs", i) + } + if !bytes.Contains(a, []byte("TYPE=6500-7SLOT")) { + t.Errorf("device %d payload missing 6500 shelf line: %q", i, a) + } + } +} + +func pad(i int) string { + s := []byte("00000") + for p := len(s) - 1; i > 0 && p >= 0; p-- { + s[p] = byte('0' + i%10) + i /= 10 + } + return string(s) +} + +func TestParseDistributionCiena(t *testing.T) { + if _, err := parseDistribution("ciena-6500-tl1:100"); err != nil { + t.Errorf("ciena-only distribution should parse: %v", err) + } + if _, err := parseDistribution("sm:50,ciena-6500-tl1:50"); err != nil { + t.Errorf("mixed Cisco/Ciena distribution should parse: %v", err) + } + if _, err := parseDistribution("tiny:100"); err == nil { + t.Error("unknown model name should error") + } +} + +// TestManifestVendorColumns asserts the vendor/template columns now reflect the +// per-device model: Ciena rows carry Ciena/ciena_tl1, Cisco rows still carry +// Cisco/cisco_ios, and the header is unchanged (10 columns, same order). +func TestManifestVendorColumns(t *testing.T) { + cfg := baseTestConfig(t, 40) + cfg.IPCount = 1 + cfg.DevicesPerIP = 40 + cfg.Distribution = "sm:50,ciena-6500-tl1:50" + if _, err := Run(cfg, io.Discard); err != nil { + t.Fatalf("run: %v", err) + } + + f, err := os.Open(cfg.ManifestPath) + if err != nil { + t.Fatalf("open manifest: %v", err) + } + defer f.Close() + rows, err := csv.NewReader(f).ReadAll() + if err != nil { + t.Fatalf("read manifest: %v", err) + } + + wantHeader := []string{"hostname", "ip", "port", "vendor", "template", "username", "password", "enable_password", "config_file", "size_bucket"} + if len(rows) == 0 || len(rows[0]) != len(wantHeader) { + t.Fatalf("header shape changed: %v", rows[0]) + } + for i, h := range wantHeader { + if rows[0][i] != h { + t.Fatalf("header[%d] = %q, want %q", i, rows[0][i], h) + } + } + + var sawCisco, sawCiena bool + for _, row := range rows[1:] { + vendor, template, bucket := row[3], row[4], row[9] + switch bucket { + case "ciena-6500-tl1": + sawCiena = true + if vendor != "Ciena" || template != "ciena_tl1" { + t.Errorf("ciena row: vendor=%q template=%q, want Ciena/ciena_tl1", vendor, template) + } + case "sm": + sawCisco = true + if vendor != "Cisco" || template != "cisco_ios" { + t.Errorf("cisco row: vendor=%q template=%q, want Cisco/cisco_ios", vendor, template) + } + } + } + if !sawCisco || !sawCiena { + t.Fatalf("expected both Cisco and Ciena rows; sawCisco=%v sawCiena=%v", sawCisco, sawCiena) + } +} diff --git a/internal/configs/generator.go b/internal/configs/generator.go index 85c7c2a..5580227 100644 --- a/internal/configs/generator.go +++ b/internal/configs/generator.go @@ -54,6 +54,51 @@ func init() { compiledOnce = template.Must(template.New("root").Funcs(templateFuncMap).ParseFS(templateFS, "templates/*.tmpl")) } +// model is one fully-qualified, generatable device model. The registry key is +// the model name used in --distribution and written to the manifest size_bucket +// column. Each model carries its manifest vendor/template strings, the embedded +// template to render, and a per-vendor data-builder. +// +// Adding a vendor/model is one registry entry plus a template file — the +// generator never special-cases a vendor beyond this struct. +type model struct { + name string + vendor string // manifest "vendor" column + template string // manifest "template" column = runtime driver id + tmplFile string // embedded template name to ExecuteTemplate + build func(cfg Config, index int, m model) any +} + +// registry maps a model name to its model. The Cisco size buckets are derived +// mechanically from the existing profiles + templateAliases so their resolved +// template, builder, and manifest output are byte-identical to before the +// registry existed. The Ciena 6500 is appended as the first non-Cisco model. +var registry = func() map[string]model { + r := make(map[string]model, len(bucketOrder)+1) + for _, name := range bucketOrder { + tmplFile := name + ".tmpl" + if alias, ok := templateAliases[name]; ok { + tmplFile = alias + } + r[name] = model{ + name: name, + vendor: "Cisco", + template: "cisco_ios", + tmplFile: tmplFile, + build: func(cfg Config, index int, m model) any { + return buildDeviceData(cfg, index, m.name) + }, + } + } + r[cienaModelName] = cienaModel() + return r +}() + +// modelOrder is the canonical iteration order: the Cisco buckets in their +// existing order, then non-Cisco models appended. Keeping Cisco first and +// unchanged is what preserves deterministic assignment for legacy invocations. +var modelOrder = append(append([]string{}, bucketOrder...), cienaModelName) + // profile holds the per-size-bucket generation counts. type profile struct { deviceKind string @@ -227,9 +272,14 @@ func (s Summary) String() string { fmt.Fprintf(&sb, "generator summary: count=%d elapsed=%s total_bytes=%d (%.2f MB)\n", s.Count, s.Elapsed.Round(time.Millisecond), s.TotalBytes, float64(s.TotalBytes)/(1024*1024)) fmt.Fprintln(&sb, "per-bucket distribution:") - for _, b := range bucketOrder { + for _, b := range modelOrder { bs := s.PerBucket[b] w := s.Weights[b] + // Suppress models that are absent from this run (no target, none + // realised) so legacy Cisco-only output is unchanged. + if w == 0 && bs.Target == 0 && bs.Realised == 0 { + continue + } targetPct := float64(w) realisedPct := 0.0 if s.Count > 0 { @@ -262,8 +312,8 @@ func parseDistribution(s string) (map[string]int, error) { return nil, fmt.Errorf("distribution token %q: want bucket:weight", tok) } name := strings.TrimSpace(parts[0]) - if _, ok := profiles[name]; !ok { - return nil, fmt.Errorf("unknown bucket %q (valid: %s)", name, strings.Join(bucketOrder, ", ")) + if _, ok := registry[name]; !ok { + return nil, fmt.Errorf("unknown model %q (valid: %s)", name, strings.Join(modelOrder, ", ")) } w, err := strconv.Atoi(strings.TrimSpace(parts[1])) if err != nil { @@ -281,7 +331,7 @@ func parseDistribution(s string) (map[string]int, error) { if total != 100 { return nil, fmt.Errorf("distribution weights must sum to 100, got %d", total) } - for _, b := range bucketOrder { + for _, b := range modelOrder { if _, ok := weights[b]; !ok { weights[b] = 0 } @@ -294,17 +344,17 @@ func parseDistribution(s string) (map[string]int, error) { func stratifiedCounts(total int, weights map[string]int) map[string]int { counts := map[string]int{} allocated := 0 - for _, b := range bucketOrder { + for _, b := range modelOrder { c := total * weights[b] / 100 counts[b] = c allocated += c } - // Give leftover to the bucket with the highest weight (stable: tie broken by order). + // Give leftover to the model with the highest weight (stable: tie broken by order). rem := total - allocated if rem > 0 { var top string topW := -1 - for _, b := range bucketOrder { + for _, b := range modelOrder { if weights[b] > topW { top = b topW = weights[b] @@ -323,7 +373,7 @@ func buildAssignments(counts map[string]int, rng *rand.Rand) []string { total += c } out := make([]string, 0, total) - for _, b := range bucketOrder { + for _, b := range modelOrder { for i := 0; i < counts[b]; i++ { out = append(out, b) } @@ -394,7 +444,8 @@ func Run(cfg Config, stdout io.Writer) (Summary, error) { defer wg.Done() for i := range jobs { bucket := assignments[i] - data := buildDeviceData(cfg, i, bucket) + m := registry[bucket] + data := m.build(cfg, i, m) filename := fmt.Sprintf("device-%05d.cfg", i) path := filepath.Join(cfg.OutputDir, filename) @@ -405,11 +456,7 @@ func Run(cfg Config, stdout io.Writer) (Summary, error) { continue } counter := &byteCounter{w: f} - tmplName := bucket + ".tmpl" - if alias, ok := templateAliases[bucket]; ok { - tmplName = alias - } - if rerr := compiledOnce.ExecuteTemplate(counter, tmplName, data); rerr != nil { + if rerr := compiledOnce.ExecuteTemplate(counter, m.tmplFile, data); rerr != nil { f.Close() results[i] = result{err: fmt.Errorf("render %s (%s): %w", path, bucket, rerr)} continue @@ -420,7 +467,7 @@ func Run(cfg Config, stdout io.Writer) (Summary, error) { } results[i] = result{ - hostname: data.Hostname, + hostname: modelHostname(data), ip: ipPlusOffset(cfg.IPBase, i/cfg.DevicesPerIP), port: cfg.PortStart + (i % cfg.DevicesPerIP), bucket: bucket, @@ -462,14 +509,15 @@ func Run(cfg Config, stdout io.Writer) (Summary, error) { PerBucket: map[string]bucketStats{}, Weights: weights, } - for _, b := range bucketOrder { + for _, b := range modelOrder { summary.PerBucket[b] = bucketStats{Target: counts[b]} } for i, r := range results { + m := registry[r.bucket] if err := w.Write([]string{ r.hostname, r.ip, strconv.Itoa(r.port), - "Cisco", "cisco_ios", + m.vendor, m.template, cfg.Username, cfg.Password, cfg.EnablePassword, r.path, r.bucket, }); err != nil { @@ -492,7 +540,7 @@ func Run(cfg Config, stdout io.Writer) (Summary, error) { // Verify distribution within ±1 percentage point (tolerance widened from ±1 integer // to ±1 percentage point, since below ~100 devices any rounding blows the stricter bound). var offenders []string - for _, b := range bucketOrder { + for _, b := range modelOrder { bs := summary.PerBucket[b] target := float64(weights[b]) realised := 100 * float64(bs.Realised) / float64(summary.Count) diff --git a/internal/configs/loader.go b/internal/configs/loader.go index cb2c80b..7b49343 100644 --- a/internal/configs/loader.go +++ b/internal/configs/loader.go @@ -15,6 +15,8 @@ type Device struct { Hostname string IP string Port int + Vendor string // manifest "vendor" column, e.g. "Cisco" / "Ciena" + Driver string // manifest "template" column = runtime driver id, e.g. "cisco_ios" / "ciena_tl1" SizeBucket string ConfigPath string Data []byte @@ -64,6 +66,8 @@ func LoadForListener(manifestPath, listenIP string, portStart, portCount int) ([ Hostname: row[0], IP: row[1], Port: port, + Vendor: row[3], + Driver: row[4], ConfigPath: row[8], SizeBucket: row[9], SerialNumber: serialFor(row[0]), diff --git a/internal/configs/templates/ciena_tl1_eqpt.tmpl b/internal/configs/templates/ciena_tl1_eqpt.tmpl new file mode 100644 index 0000000..fcb25a6 --- /dev/null +++ b/internal/configs/templates/ciena_tl1_eqpt.tmpl @@ -0,0 +1,17 @@ +{{- /* TL1 RTRV-EQPT::ALL inventory payload for a Ciena 6500 7-slot shelf. + The ciena_tl1 runtime driver streams this body between a COMPLD header + and the ";" terminator, so this template emits only the quoted AID lines. */ -}} + "SHELF-1::PROVISIONED,TYPE=6500-7SLOT,SN={{ .ShelfSerial }},SWVER={{ .SwVersion }},IP={{ .NodeIP }}:IS-NR" +{{ range .Slots -}} +{{ if .Equipped -}} +{{ $slot := .Slot -}} + "SLOT-{{ .Slot }}:{{ .CardType }},CLEI={{ .CLEI }},SN={{ .Serial }},PN={{ .PartNum }}:{{ .State }}" +{{ range .Ports -}} +{{ if .Equipped -}} + "SLOT-{{ $slot }}-{{ .Port }}::OPTIC={{ .OpticType }},WL={{ .Wavelength }},SN={{ .Serial }}:{{ .State }}" +{{ end -}} +{{ end -}} +{{ else -}} + "SLOT-{{ .Slot }}::UNEQUIPPED:OOS-AUMA" +{{ end -}} +{{ end -}} diff --git a/internal/sshsrv/characterization_test.go b/internal/sshsrv/characterization_test.go new file mode 100644 index 0000000..e7b8df7 --- /dev/null +++ b/internal/sshsrv/characterization_test.go @@ -0,0 +1,293 @@ +//go:build integration + +package sshsrv_test + +// Characterization tests that pin the exact observable Cisco-IOS session +// behaviour BEFORE the driver refactor: greeting bytes, the enable-mode +// password sub-flow (happy + sad), and clean session close on `exit`. These +// must keep passing byte-for-byte after the ciscoIOS driver extraction — they +// are the regression gate for that move. + +import ( + "bytes" + "encoding/csv" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "sync" + "testing" + "time" + + "github.com/prometheus/client_golang/prometheus/testutil" + "golang.org/x/crypto/ssh" + + "github.com/rcfg-sim/rcfg-sim/internal/configs" + "github.com/rcfg-sim/rcfg-sim/internal/sshsrv" +) + +// charServer brings up a one-device Cisco server on loopback with the default +// (password) SSH auth mode and returns the SSH port, hostname, and server. +func charServer(t *testing.T) (port int, hostname string, srv *sshsrv.Server) { + return charServerMode(t, "") +} + +// charServerMode is charServer with an explicit --ssh-auth mode. +func charServerMode(t *testing.T, authMode string) (port int, hostname string, srv *sshsrv.Server) { + t.Helper() + tmp := t.TempDir() + manifest := filepath.Join(tmp, "manifest.csv") + configsDir := filepath.Join(tmp, "configs") + sshPort := freePort(t) + + if _, err := configs.Run(configs.Config{ + Count: 1, OutputDir: configsDir, ManifestPath: manifest, + IPBase: "127.0.0.1", IPCount: 1, PortStart: sshPort, DevicesPerIP: 1, + Seed: 17, Distribution: "sm:100,md:0,lg:0,xl:0", + Username: "admin", Password: "admin", EnablePassword: "enable123", + }, io.Discard); err != nil { + t.Fatalf("generator: %v", err) + } + hostname = manifestHostname(t, manifest, sshPort) + + var err error + srv, err = sshsrv.New(sshsrv.Config{ + ListenIP: "127.0.0.1", PortStart: sshPort, PortCount: 1, + ManifestPath: manifest, HostKeyPath: filepath.Join(tmp, "host"), + Username: "admin", Password: "admin", EnablePassword: "enable123", + SSHAuthMode: authMode, + ResponseDelayMinMS: 0, ResponseDelayMaxMS: 0, + MaxConcurrentSessions: 4, + }) + if err != nil { + t.Fatalf("New: %v", err) + } + if err := srv.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + t.Cleanup(func() { srv.Shutdown(5 * time.Second) }) + return sshPort, hostname, srv +} + +func manifestHostname(t *testing.T, manifest string, port int) string { + t.Helper() + f, err := os.Open(manifest) + if err != nil { + t.Fatalf("open manifest: %v", err) + } + defer f.Close() + rows, err := csv.NewReader(f).ReadAll() + if err != nil { + t.Fatalf("read manifest: %v", err) + } + for i, row := range rows { + if i == 0 { + continue + } + if p, _ := strconv.Atoi(row[2]); p == port { + return row[0] + } + } + t.Fatalf("no manifest row for port %d", port) + return "" +} + +// expectConn is an interactive SSH session that buffers everything the server +// sends so a test can wait for a sentinel and assert on the bytes seen so far. +type expectConn struct { + t *testing.T + client *ssh.Client + sess *ssh.Session + stdin io.WriteCloser + mu sync.Mutex + buf bytes.Buffer + waitMu sync.Mutex +} + +func dialExpect(t *testing.T, port int, user, pass string) *expectConn { + t.Helper() + return dialExpectCfg(t, port, &ssh.ClientConfig{ + User: user, Auth: []ssh.AuthMethod{ssh.Password(pass)}, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), Timeout: 5 * time.Second, + }) +} + +// dialExpectNoAuth connects offering no auth methods — the client succeeds only +// if the server accepts the "none" method (NoClientAuth), modelling a TL1-only +// device where the SSH transport does not challenge for a password. +func dialExpectNoAuth(t *testing.T, port int) *expectConn { + t.Helper() + return dialExpectCfg(t, port, &ssh.ClientConfig{ + User: "anyone", + HostKeyCallback: ssh.InsecureIgnoreHostKey(), Timeout: 5 * time.Second, + }) +} + +func dialExpectCfg(t *testing.T, port int, clientCfg *ssh.ClientConfig) *expectConn { + t.Helper() + c, err := ssh.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", port), clientCfg) + if err != nil { + t.Fatalf("ssh dial port %d: %v", port, err) + } + sess, err := c.NewSession() + if err != nil { + t.Fatalf("new session: %v", err) + } + // ECHO:0 so the client terminal layer does not echo; the server's own + // readLine echo is what we observe. + _ = sess.RequestPty("xterm", 80, 24, ssh.TerminalModes{ssh.ECHO: 0}) + stdin, _ := sess.StdinPipe() + stdout, _ := sess.StdoutPipe() + if err := sess.Shell(); err != nil { + t.Fatalf("shell: %v", err) + } + ec := &expectConn{t: t, client: c, sess: sess, stdin: stdin} + go func() { + b := make([]byte, 4096) + for { + n, err := stdout.Read(b) + if n > 0 { + ec.mu.Lock() + ec.buf.Write(b[:n]) + ec.mu.Unlock() + } + if err != nil { + return + } + } + }() + return ec +} + +// expect polls the buffer until it contains sub, then returns everything +// accumulated so far. Fails the test on timeout. +func (ec *expectConn) expect(sub string, timeout time.Duration) string { + ec.t.Helper() + deadline := time.Now().Add(timeout) + for { + ec.mu.Lock() + s := ec.buf.String() + ec.mu.Unlock() + if bytes.Contains([]byte(s), []byte(sub)) { + return s + } + if time.Now().After(deadline) { + ec.t.Fatalf("expect %q timed out; buffer so far: %q", sub, s) + } + time.Sleep(10 * time.Millisecond) + } +} + +func (ec *expectConn) snapshot() string { + ec.mu.Lock() + defer ec.mu.Unlock() + return ec.buf.String() +} + +func (ec *expectConn) reset() { + ec.mu.Lock() + defer ec.mu.Unlock() + ec.buf.Reset() +} + +func (ec *expectConn) send(line string) { + fmt.Fprintf(ec.stdin, "%s\n", line) +} + +func (ec *expectConn) close() { + _ = ec.stdin.Close() + _ = ec.sess.Close() + _ = ec.client.Close() +} + +// TestChar_GreetingBytes pins the exact greeting + first prompt the server +// emits before any input. No echo noise is possible here because nothing has +// been sent yet. +func TestChar_GreetingBytes(t *testing.T) { + port, host, _ := charServer(t) + ec := dialExpect(t, port, "admin", "admin") + defer ec.close() + + want := "\r\n" + host + " line 0 is now available\r\n\r\n" + host + ">" + ec.expect(host+">", 3*time.Second) + if got := ec.snapshot(); got != want { + t.Errorf("greeting bytes:\n got %q\nwant %q", got, want) + } +} + +// TestChar_EnableHappyPath pins the enable-mode sub-flow: `enable` -> the +// "Password: " prompt -> correct password -> prompt flips '>' to '#'. +func TestChar_EnableHappyPath(t *testing.T) { + port, host, _ := charServer(t) + ec := dialExpect(t, port, "admin", "admin") + defer ec.close() + + ec.expect(host+">", 3*time.Second) + ec.reset() + ec.send("enable") + ec.expect("Password: ", 3*time.Second) + ec.reset() + ec.send("enable123") + got := ec.expect(host+"#", 3*time.Second) + if bytes.Contains([]byte(got), []byte("% Access denied")) { + t.Errorf("enable happy path unexpectedly denied: %q", got) + } +} + +// TestChar_EnableSadPath pins rejection: wrong enable password yields the +// "% Access denied" line and the prompt stays at user-exec '>'. +func TestChar_EnableSadPath(t *testing.T) { + port, host, _ := charServer(t) + ec := dialExpect(t, port, "admin", "admin") + defer ec.close() + + ec.expect(host+">", 3*time.Second) + ec.reset() + ec.send("enable") + ec.expect("Password: ", 3*time.Second) + ec.reset() + ec.send("wrongpw") + got := ec.expect("% Access denied", 3*time.Second) + if bytes.Contains([]byte(got), []byte(host+"#")) { + t.Errorf("enable sad path unexpectedly entered enable mode: %q", got) + } + // Prompt returns to user-exec. + ec.expect(host+">", 3*time.Second) +} + +// TestChar_SessionCloseOnExit pins that `exit` at user-exec closes the channel +// cleanly (sess.Wait returns) and the session is counted as result=ok. +func TestChar_SessionCloseOnExit(t *testing.T) { + port, host, srv := charServer(t) + ec := dialExpect(t, port, "admin", "admin") + defer ec.close() + + ec.expect(host+">", 3*time.Second) + + before := testutil.ToFloat64(srv.Metrics().SessionsTotal.WithLabelValues("ok")) + ec.send("exit") + + done := make(chan error, 1) + go func() { done <- ec.sess.Wait() }() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("session did not close after exit") + } + + // sessions_total is recorded when the connection tears down (handleConn's + // defer), not on channel close — close the client so that fires, then poll. + _ = ec.client.Close() + after := before + for deadline := time.Now().Add(2 * time.Second); time.Now().Before(deadline); { + after = testutil.ToFloat64(srv.Metrics().SessionsTotal.WithLabelValues("ok")) + if after > before { + break + } + time.Sleep(20 * time.Millisecond) + } + if after <= before { + t.Errorf("rcfgsim_sessions_total{result=ok}: want increment, before=%v after=%v", before, after) + } +} diff --git a/internal/sshsrv/ciena_integration_test.go b/internal/sshsrv/ciena_integration_test.go new file mode 100644 index 0000000..9494958 --- /dev/null +++ b/internal/sshsrv/ciena_integration_test.go @@ -0,0 +1,182 @@ +//go:build integration + +package sshsrv_test + +// Over-the-wire tests for the Ciena 6500 TL1 driver: the in-band ACT-USER login +// gate, zero-copy RTRV-EQPT streaming of the generated inventory, pre-login +// DENY, and a ";"-terminated command split across physical lines. + +import ( + "fmt" + "io" + "path/filepath" + "strings" + "testing" + "time" + + "golang.org/x/crypto/ssh" + + "github.com/rcfg-sim/rcfg-sim/internal/configs" + "github.com/rcfg-sim/rcfg-sim/internal/sshsrv" +) + +// cienaServer generates a single Ciena 6500 device and serves it on loopback +// with the default (password) SSH auth mode. +func cienaServer(t *testing.T) (port int, hostname string, srv *sshsrv.Server) { + return cienaServerMode(t, "") +} + +// cienaServerMode is cienaServer with an explicit --ssh-auth mode. +func cienaServerMode(t *testing.T, authMode string) (port int, hostname string, srv *sshsrv.Server) { + t.Helper() + tmp := t.TempDir() + manifest := filepath.Join(tmp, "manifest.csv") + configsDir := filepath.Join(tmp, "configs") + sshPort := freePort(t) + + if _, err := configs.Run(configs.Config{ + Count: 1, OutputDir: configsDir, ManifestPath: manifest, + IPBase: "127.0.0.1", IPCount: 1, PortStart: sshPort, DevicesPerIP: 1, + Seed: 7, Distribution: "ciena-6500-tl1:100", + Username: "admin", Password: "admin", EnablePassword: "enable123", + }, io.Discard); err != nil { + t.Fatalf("generator: %v", err) + } + hostname = manifestHostname(t, manifest, sshPort) + + var err error + srv, err = sshsrv.New(sshsrv.Config{ + ListenIP: "127.0.0.1", PortStart: sshPort, PortCount: 1, + ManifestPath: manifest, HostKeyPath: filepath.Join(tmp, "host"), + Username: "admin", Password: "admin", EnablePassword: "enable123", + SSHAuthMode: authMode, + ResponseDelayMinMS: 0, ResponseDelayMaxMS: 0, + MaxConcurrentSessions: 4, + MetricsAddr: fmt.Sprintf("127.0.0.1:%d", freePort(t)), + }) + if err != nil { + t.Fatalf("New: %v", err) + } + if err := srv.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + t.Cleanup(func() { srv.Shutdown(5 * time.Second) }) + return sshPort, hostname, srv +} + +func TestCiena_LoginAndRtrvEqpt(t *testing.T) { + port, sid, srv := cienaServer(t) + ec := dialExpect(t, port, "admin", "admin") + defer ec.close() + + // Bare "<" greeting/prompt. + ec.expect("< ", 3*time.Second) + + // In-band login. + ec.reset() + ec.send("ACT-USER::admin:100::admin;") + login := ec.expect("M 100 COMPLD", 3*time.Second) + if !strings.Contains(login, sid) { + t.Errorf("login COMPLD should carry SID %q: %q", sid, login) + } + + // RTRV-EQPT streams the generated inventory (zero-copy) wrapped in COMPLD. + ec.reset() + ec.send("RTRV-EQPT::ALL:101;") + eqpt := ec.expect("M 101 COMPLD", 3*time.Second) + ec.expect("TYPE=6500-7SLOT", 3*time.Second) // streamed payload body + if !strings.Contains(eqpt, sid) { + t.Errorf("RTRV-EQPT COMPLD should carry SID: %q", eqpt) + } + + // command_duration must have a sample under the TL1 label. + if n := histogramLabelSampleCount(t, srv.Metrics().Gatherer(), + "rcfgsim_command_duration_seconds", "command", "CmdTL1RtrvEqpt"); n < 1 { + t.Errorf("rcfgsim_command_duration_seconds{command=CmdTL1RtrvEqpt}: want >=1 sample, got %d", n) + } +} + +func TestCiena_PreLoginDeny(t *testing.T) { + port, _, _ := cienaServer(t) + ec := dialExpect(t, port, "admin", "admin") + defer ec.close() + + ec.expect("< ", 3*time.Second) + ec.reset() + ec.send("RTRV-ALM-ALL::ALL:200;") + got := ec.expect("M 200 DENY", 3*time.Second) + if !strings.Contains(got, "PLNA") { + t.Errorf("pre-login DENY should carry PLNA: %q", got) + } +} + +// TestCiena_NoSSHAuth (scenario A: TL1-only) — with --ssh-auth=none the client +// connects offering no auth methods, lands on the "<" prompt unchallenged, and +// authenticates purely in-band via ACT-USER. +func TestCiena_NoSSHAuth(t *testing.T) { + port, sid, _ := cienaServerMode(t, "none") + ec := dialExpectNoAuth(t, port) + defer ec.close() + + ec.expect("< ", 3*time.Second) + ec.reset() + ec.send("ACT-USER::admin:100::admin;") + got := ec.expect("M 100 COMPLD", 3*time.Second) + if !strings.Contains(got, sid) { + t.Errorf("TL1 login COMPLD should carry SID %q: %q", sid, got) + } +} + +// TestCiena_DriverModeNoAuth (scenario A via per-driver default) — with +// --ssh-auth=driver, a Ciena device (RequiresSSHAuth=false) accepts a no-auth +// client, while Cisco devices on the same mode would still require a password. +func TestCiena_DriverModeNoAuth(t *testing.T) { + port, _, _ := cienaServerMode(t, "driver") + ec := dialExpectNoAuth(t, port) + defer ec.close() + + ec.expect("< ", 3*time.Second) + ec.reset() + ec.send("ACT-USER::admin:1::admin;") + ec.expect("M 1 COMPLD", 3*time.Second) +} + +// TestDriverMode_CiscoRejectsNoAuth is the negative of TestCiena_DriverModeNoAuth: +// under --ssh-auth=driver a Cisco device (RequiresSSHAuth=true) still requires SSH +// password auth, so a client offering no auth methods must fail the handshake. +func TestDriverMode_CiscoRejectsNoAuth(t *testing.T) { + port, _, _ := charServerMode(t, "driver") + _, err := ssh.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", port), &ssh.ClientConfig{ + User: "anyone", + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: 3 * time.Second, + }) + if err == nil { + t.Fatal("Cisco device under --ssh-auth=driver should reject a no-auth client") + } + if !strings.Contains(err.Error(), "unable to authenticate") { + t.Errorf("expected an authentication failure, got: %v", err) + } +} + +// TestCiena_MultiLineCommand sends a ";"-terminated command split across two +// physical lines and asserts it still parses (CTAG 102 echoed in COMPLD). +func TestCiena_MultiLineCommand(t *testing.T) { + port, _, _ := cienaServer(t) + ec := dialExpect(t, port, "admin", "admin") + defer ec.close() + + ec.expect("< ", 3*time.Second) + ec.reset() + ec.send("ACT-USER::admin:1::admin;") + ec.expect("M 1 COMPLD", 3*time.Second) + + // "RTRV-SYS:::" then a newline, then "102;" — one TL1 command, two lines. + ec.reset() + ec.send("RTRV-SYS:::") + ec.send("102;") + got := ec.expect("M 102 COMPLD", 3*time.Second) + if !strings.Contains(got, "TYPE=6500-7SLOT") { + t.Errorf("RTRV-SYS payload missing system line: %q", got) + } +} diff --git a/internal/sshsrv/dispatch.go b/internal/sshsrv/dispatch.go index adff60a..bf0a831 100644 --- a/internal/sshsrv/dispatch.go +++ b/internal/sshsrv/dispatch.go @@ -27,6 +27,19 @@ const ( CmdShowStartupConfig CmdShowInventory CmdExit + + // TL1 (Ciena) command labels. Appended after the Cisco set so existing iota + // values are unchanged. These are the metric label values the cienaTL1 + // driver can emit on rcfgsim_command_duration_seconds{command}. + CmdTL1Unknown + CmdTL1Deny + CmdTL1ActUser + CmdTL1RtrvEqpt + CmdTL1RtrvAlmAll + CmdTL1RtrvCondAll + CmdTL1RtrvActiveUser + CmdTL1RtrvSwVer + CmdTL1RtrvSys ) // String returns the Go identifier form of the command. Used as a bounded @@ -55,6 +68,24 @@ func (c Command) String() string { return "CmdShowInventory" case CmdExit: return "CmdExit" + case CmdTL1Unknown: + return "CmdTL1Unknown" + case CmdTL1Deny: + return "CmdTL1Deny" + case CmdTL1ActUser: + return "CmdTL1ActUser" + case CmdTL1RtrvEqpt: + return "CmdTL1RtrvEqpt" + case CmdTL1RtrvAlmAll: + return "CmdTL1RtrvAlmAll" + case CmdTL1RtrvCondAll: + return "CmdTL1RtrvCondAll" + case CmdTL1RtrvActiveUser: + return "CmdTL1RtrvActiveUser" + case CmdTL1RtrvSwVer: + return "CmdTL1RtrvSwVer" + case CmdTL1RtrvSys: + return "CmdTL1RtrvSys" default: return "CmdUnknown" } @@ -170,6 +201,7 @@ func ResolveCommand(input string) (Command, string) { type Response struct { Output []byte ConfigOutput []byte // zero-copy mmap slice; nil if not applicable + Trailer []byte // written after ConfigOutput (e.g. TL1 closing ";"); nil for Cisco RequestEnablePassword bool Close bool ExitEnable bool diff --git a/internal/sshsrv/driver.go b/internal/sshsrv/driver.go new file mode 100644 index 0000000..b32c5e6 --- /dev/null +++ b/internal/sshsrv/driver.go @@ -0,0 +1,172 @@ +package sshsrv + +import ( + "time" + + "github.com/rcfg-sim/rcfg-sim/internal/fault" +) + +// Driver renders one vendor/model's interactive SSH session. A driver owns its +// entire read/parse/respond loop via Serve. It MUST route every response byte +// back through (*sessionCtx).emit (for fault injection, command_duration, and +// byte-counting) or writeAndCount (for greeting/prompt text), so the observable +// fault and metric behaviour is uniform across vendors. +// +// Adding a new vendor is one self-contained file: implement Driver, and +// registerDriver it from an init(). Nothing else in the hot path needs to know +// the vendor exists. +type Driver interface { + // Name is the manifest `template` value this driver is registered under + // (e.g. "cisco_ios", "ciena_tl1"). Stable and user-facing. + Name() string + + // Commands returns the Command label-string values this driver can emit on + // the rcfgsim_command_duration_seconds `command` label. The server unions + // these across all registered drivers and pre-registers them, so the + // cardinality set is assembled from drivers rather than hardcoded. + Commands() []string + + // RequiresSSHAuth reports whether this device authenticates at the SSH + // transport layer. Cisco IOS does (SSH password); Ciena TL1 does not — it + // authenticates in-band via ACT-USER. Consulted only under --ssh-auth=driver. + RequiresSSHAuth() bool + + // Serve runs the full interactive loop for one channel. It returns when the + // client exits, the channel closes, or a read error occurs. Closing the + // channel afterwards remains the caller's responsibility. + Serve(ctx *sessionCtx) +} + +// driverRegistry maps a manifest template name to its Driver. Populated by the +// init() in each driver_*.go file and read-only after package initialisation, +// so no locking is required. +var driverRegistry = map[string]Driver{} + +func registerDriver(d Driver) { driverRegistry[d.Name()] = d } + +// driverFor resolves a template name to a Driver, defaulting to cisco_ios for +// empty or unknown values. This keeps any pre-existing manifest (whose template +// column was always "cisco_ios", or absent) behaving exactly as before. +func driverFor(template string) Driver { + if d, ok := driverRegistry[template]; ok { + return d + } + return driverRegistry["cisco_ios"] +} + +// registeredCommands returns the sorted-by-insertion union of every registered +// driver's Commands(), de-duplicated. The server uses it to pre-register the +// command_duration label values at zero so they appear in scrapes immediately. +func registeredCommands() []string { + seen := map[string]struct{}{} + var out []string + for _, d := range driverRegistry { + for _, c := range d.Commands() { + if _, ok := seen[c]; ok { + continue + } + seen[c] = struct{}{} + out = append(out, c) + } + } + return out +} + +// applyResponseDelay computes the base per-command jitter and, if the +// slow_response fault fires, multiplies it by uniform[10,50] capped at 60s, +// then sleeps. Drivers call this once between resolving a command and +// dispatching it (the slow_response label is attributed to the resolved +// command via the fault counter here). +// +// This is the exact delay logic previously inline in runShell; it is the only +// place a response sleep happens, so neither driver may sleep elsewhere. +func (ctx *sessionCtx) applyResponseDelay() { + delayMS := ctx.delayMinMS + if ctx.delayMaxMS > ctx.delayMinMS { + delayMS += ctx.rng.Intn(ctx.delayMaxMS - ctx.delayMinMS + 1) + } + // slow_response fault: multiply delay by uniform[10,50], cap at 60s. + // Guarantee at least 10ms of base so the multiplier is observable even when + // the operator configured --response-delay-ms-max=0. + if ctx.faults.Roll(ctx.rng, fault.TypeSlowResponse) { + multiplier := 10 + ctx.rng.Intn(41) // inclusive 10..50 + if delayMS < 10 { + delayMS = 10 + } + delayMS *= multiplier + if delayMS > 60000 { + delayMS = 60000 + } + if ctx.metrics != nil { + ctx.metrics.FaultsInjected.WithLabelValues(fault.TypeSlowResponse.String()).Inc() + } + } + if delayMS > 0 { + time.Sleep(time.Duration(delayMS) * time.Millisecond) + } +} + +// emit applies the shared response machinery to a Response: the verbatim +// Output, then the zero-copy ConfigOutput (mmap'd bytes) with disconnect_mid / +// malformed fault injection, an optional Trailer, and a trailing CRLF, and +// finally observes the command_duration metric. cmdStart is captured by the +// caller just before dispatch so the duration covers the same span as before. +// +// It returns closed=true wherever the session must terminate — a write error, +// or a disconnect_mid hard close — so the caller's loop can return. This is the +// exact post-dispatch logic previously inline in runShell. +func (ctx *sessionCtx) emit(cmd Command, cmdStart time.Time, resp Response) (closed bool) { + if len(resp.Output) > 0 { + if _, err := writeAndCount(ctx, resp.Output); err != nil { + return true + } + } + if len(resp.ConfigOutput) > 0 { + // disconnect_mid: write a 20-40% prefix then hard-RST the TCP conn. + // Happens before any malformed check because the connection is going + // away anyway. Observe the command duration first so phase-5 metrics + // still reflect work the dispatcher did. + if ctx.faults.Roll(ctx.rng, fault.TypeDisconnectMid) { + window := 20 + ctx.rng.Intn(21) // 20..40 inclusive + n := len(resp.ConfigOutput) * window / 100 + _, _ = writeAndCount(ctx, resp.ConfigOutput[:n]) + if ctx.metrics != nil { + ctx.metrics.FaultsInjected.WithLabelValues(fault.TypeDisconnectMid.String()).Inc() + } + if ctx.outcome != nil { + ctx.outcome.Set("disconnect") + } + observeCmd(ctx, cmd, cmdStart) + hardCloseTCP(ctx.rawConn) + return true + } + + // malformed: corrupt the stream in one of three ways. Preserves the + // zero-copy hot path for before/after segments. + if ctx.faults.Roll(ctx.rng, fault.TypeMalformed) { + if err := writeMalformed(ctx, resp.ConfigOutput); err != nil { + return true + } + if ctx.metrics != nil { + ctx.metrics.FaultsInjected.WithLabelValues(fault.TypeMalformed.String()).Inc() + } + } else { + // Hot path: direct write of mmap'd bytes. Zero copy. + if _, err := writeAndCount(ctx, resp.ConfigOutput); err != nil { + return true + } + } + // Optional driver-supplied trailer (e.g. the TL1 closing ";"), then a + // trailing CRLF so the next prompt lands on a fresh line. + if len(resp.Trailer) > 0 { + if _, err := writeAndCount(ctx, resp.Trailer); err != nil { + return true + } + } + if _, err := writeAndCount(ctx, []byte("\r\n")); err != nil { + return true + } + } + observeCmd(ctx, cmd, cmdStart) + return false +} diff --git a/internal/sshsrv/driver_ciena.go b/internal/sshsrv/driver_ciena.go new file mode 100644 index 0000000..46a165d --- /dev/null +++ b/internal/sshsrv/driver_ciena.go @@ -0,0 +1,301 @@ +package sshsrv + +import ( + "fmt" + "io" + "strings" + "time" +) + +func init() { registerDriver(cienaTL1{}) } + +// cienaTL1 is the Ciena 6500 optical personality: a TL1 management interface +// reached over SSH. After connect the device emits a bare "<" prompt and waits +// for an in-band ACT-USER login; only after a valid login do RTRV-* verbs +// return COMPLD blocks (otherwise DENY). Commands are terminated by ";" and may +// span multiple physical lines. +// +// Self-contained on purpose: adding a vendor should not require touching any +// other file in the hot path. The only shared machinery it borrows is +// (*sessionCtx).applyResponseDelay / emit (fault injection, metrics, +// byte-counting) and the writeAndCount/readLine-style primitives. +type cienaTL1 struct{} + +func (cienaTL1) Name() string { return "ciena_tl1" } + +// RequiresSSHAuth: Ciena TL1 authenticates in-band via ACT-USER, so the SSH +// transport can accept the connection without a password challenge. +func (cienaTL1) RequiresSSHAuth() bool { return false } + +func (cienaTL1) Commands() []string { + return []string{ + CmdTL1Unknown.String(), CmdTL1Deny.String(), CmdTL1ActUser.String(), + CmdTL1RtrvEqpt.String(), CmdTL1RtrvAlmAll.String(), CmdTL1RtrvCondAll.String(), + CmdTL1RtrvActiveUser.String(), CmdTL1RtrvSwVer.String(), CmdTL1RtrvSys.String(), + } +} + +// tl1Session is the per-channel TL1 state. Driver-local — kept off the Cisco +// State struct so vendor concepts stay disjoint. +type tl1Session struct { + loggedIn bool + sid string // system identifier / TID, shown in every response header + serial string // shelf serial, substituted into synthesized payloads +} + +func (cienaTL1) Serve(ctx *sessionCtx) { + s := &tl1Session{sid: ctx.dev.Hostname, serial: ctx.dev.SerialNumber} + for { + // The "<" prompt is both greeting and per-command prompt in TL1. + if _, err := writeAndCount(ctx, []byte("\r\n< ")); err != nil { + return + } + raw, err := readTL1(ctx.ch) + if err != nil { + if ctx.outcome != nil { + ctx.outcome.Set("disconnect") + } + return + } + cmdStart := time.Now() + ctx.applyResponseDelay() + cmd, resp := ctx.dispatchTL1(raw, s) + if ctx.emit(cmd, cmdStart, resp) { + return + } + if resp.Close { + return + } + } +} + +// dispatchTL1 parses one raw TL1 command and produces a Response. The login +// gate is enforced here: anything other than ACT-USER before a successful login +// returns DENY. +func (ctx *sessionCtx) dispatchTL1(raw string, s *tl1Session) (Command, Response) { + verb, ctag := parseTL1(raw) + if verb == "" { + // Bare ";" or whitespace — silently re-prompt. + return CmdTL1Unknown, Response{} + } + + if verb == "ACT-USER" { + user, pass := parseActUser(raw) + if ctx.validTL1Login(user, pass) { + s.loggedIn = true + return CmdTL1ActUser, Response{Output: tl1Compld(s.sid, ctag, actUserPayload(user))} + } + // PLNA = login failure (privileged login not allowed / bad credentials). + return CmdTL1Deny, Response{Output: tl1Deny(s.sid, ctag, "PLNA")} + } + + if !s.loggedIn { + return CmdTL1Deny, Response{Output: tl1Deny(s.sid, ctag, "PLNA")} + } + + switch verb { + case "RTRV-EQPT": + // Stream the generated inventory zero-copy if present; otherwise + // synthesize a small canned block so the driver works without a + // generated payload (unit tests, hand-rolled manifests). + if len(ctx.dev.Data) > 0 { + return CmdTL1RtrvEqpt, Response{ + Output: tl1CompldHeader(s.sid, ctag), + ConfigOutput: ctx.dev.Data, + Trailer: []byte(";\r\n"), + } + } + return CmdTL1RtrvEqpt, Response{Output: tl1Compld(s.sid, ctag, eqptPayload(s))} + case "RTRV-ALM-ALL": + return CmdTL1RtrvAlmAll, Response{Output: tl1Compld(s.sid, ctag, almPayload(s))} + case "RTRV-COND-ALL": + return CmdTL1RtrvCondAll, Response{Output: tl1Compld(s.sid, ctag, condPayload(s))} + case "RTRV-ACTIVE-USER": + return CmdTL1RtrvActiveUser, Response{Output: tl1Compld(s.sid, ctag, activeUserPayload(ctx.username))} + case "RTRV-SW-VER": + return CmdTL1RtrvSwVer, Response{Output: tl1Compld(s.sid, ctag, swVerPayload(s))} + case "RTRV-SYS": + return CmdTL1RtrvSys, Response{Output: tl1Compld(s.sid, ctag, sysPayload(s))} + default: + // ICNV = input, command not valid. + return CmdTL1Unknown, Response{Output: tl1Deny(s.sid, ctag, "ICNV")} + } +} + +// validTL1Login mirrors the SSH PasswordCallback semantics: empty configured +// password accepts any; a configured username/password must match. +func (ctx *sessionCtx) validTL1Login(user, pass string) bool { + if ctx.password != "" && pass != ctx.password { + return false + } + if ctx.username != "" && user != ctx.username { + return false + } + return true +} + +// parseTL1 splits a TL1 command into its verb and CTAG. Grammar: +// +// VERB:TID:AID:CTAG[:GENBLK][:payload] +// +// The verb is everything before the first ":"; the CTAG is the 4th field. +func parseTL1(raw string) (verb, ctag string) { + fields := strings.Split(strings.TrimSpace(raw), ":") + if len(fields) > 0 { + verb = strings.ToUpper(strings.TrimSpace(fields[0])) + } + if len(fields) > 3 { + ctag = strings.TrimSpace(fields[3]) + } + return verb, ctag +} + +// parseActUser extracts the username and password from an ACT-USER command: +// +// ACT-USER::::: +// +// username is field[2], password is field[5]. +func parseActUser(raw string) (user, pass string) { + fields := strings.Split(strings.TrimSpace(raw), ":") + if len(fields) > 2 { + user = strings.TrimSpace(fields[2]) + } + if len(fields) > 5 { + pass = strings.TrimSpace(fields[5]) + } + return user, pass +} + +// readTL1 reads one ";"-terminated TL1 command from the channel. Like the Cisco +// readLine it echoes printable input and handles backspace / Ctrl-C / Ctrl-D, +// but it terminates on ";" rather than newline and tolerates commands spanning +// multiple physical lines (CR/LF between tokens are echoed but not buffered). +func readTL1(ch io.ReadWriter) (string, error) { + var buf []byte + one := make([]byte, 1) + for { + n, err := ch.Read(one) + if err != nil { + return "", err + } + if n == 0 { + continue + } + c := one[0] + switch c { + case 0x7f, 0x08: + if len(buf) > 0 { + buf = buf[:len(buf)-1] + _, _ = ch.Write([]byte("\b \b")) + } + case ';': + _, _ = ch.Write([]byte(";\r\n")) + return string(buf), nil + case '\r', '\n': + // TL1 commands may span physical lines; echo a newline but keep + // accumulating until the ";" terminator. + _, _ = ch.Write([]byte("\r\n")) + case 0x03: + _, _ = ch.Write([]byte("^C\r\n")) + return "", errUserAborted + case 0x04: + if len(buf) == 0 { + return "", io.EOF + } + default: + if c >= 0x20 && c < 0x7f { + buf = append(buf, c) + _, _ = ch.Write([]byte{c}) + } + } + } +} + +// tl1Timestamp renders the response-header time in the YY-MM-DD HH:MM:SS form a +// 6500 uses. Isolated behind one function so output is easy to stub; no test +// hashes TL1 wire bytes. +func tl1Timestamp() string { + return time.Now().UTC().Format("06-01-02 15:04:05") +} + +// tl1CompldHeader renders the COMPLD block header (blank line, SID + timestamp, +// "M COMPLD"). Used standalone when the payload is streamed separately +// (zero-copy RTRV-EQPT) and the ";" terminator comes via Response.Trailer. +func tl1CompldHeader(sid, ctag string) []byte { + var b strings.Builder + b.WriteString("\r\n") + fmt.Fprintf(&b, " %s %s\r\n", sid, tl1Timestamp()) + fmt.Fprintf(&b, "M %s COMPLD\r\n", ctag) + return []byte(b.String()) +} + +// tl1Compld renders a complete COMPLD block: header, payload (which supplies its +// own trailing CRLFs), and the ";" terminator. +func tl1Compld(sid, ctag, payload string) []byte { + var b strings.Builder + b.Write(tl1CompldHeader(sid, ctag)) + b.WriteString(payload) + b.WriteString(";\r\n") + return []byte(b.String()) +} + +// tl1Deny renders a DENY block with a 4-char TL1 error code. +func tl1Deny(sid, ctag, errCode string) []byte { + var b strings.Builder + b.WriteString("\r\n") + fmt.Fprintf(&b, " %s %s\r\n", sid, tl1Timestamp()) + fmt.Fprintf(&b, "M %s DENY\r\n", ctag) + fmt.Fprintf(&b, " %s\r\n", errCode) + b.WriteString(";\r\n") + return []byte(b.String()) +} + +// --- synthesized payloads (small, identity-substituted, like showVersionFor) --- + +func actUserPayload(user string) string { + var b strings.Builder + b.WriteString(" /*AUTHTYPE=LOCAL*/\r\n") + fmt.Fprintf(&b, " /*USERID=%s*/\r\n", strings.ToUpper(user)) + return b.String() +} + +func eqptPayload(s *tl1Session) string { + var b strings.Builder + fmt.Fprintf(&b, " \"SHELF-1::PROVISIONED,SN=%s,TYPE=6500-7SLOT:IS-NR\"\r\n", s.serial) + fmt.Fprintf(&b, " \"SLOT-1:OTR2,%s-01:IS-NR\"\r\n", s.serial) + fmt.Fprintf(&b, " \"SLOT-2:WL3N,%s-02:IS-NR\"\r\n", s.serial) + fmt.Fprintf(&b, " \"SLOT-7:EDFA,%s-07:IS-NR\"\r\n", s.serial) + return b.String() +} + +func almPayload(s *tl1Session) string { + var b strings.Builder + b.WriteString(" \"SLOT-2:MN,CONTBUS,SA,,,,:\\\"Intermittent equipment communication\\\"\"\r\n") + b.WriteString(" \"SLOT-7:MJ,T-LOS,NSA,,,,:\\\"Loss of signal\\\"\"\r\n") + return b.String() +} + +func condPayload(s *tl1Session) string { + var b strings.Builder + b.WriteString(" \"SLOT-1:T-OPR-OCH,NEND,,,,,:\\\"Optical power received\\\"\"\r\n") + return b.String() +} + +func activeUserPayload(user string) string { + if user == "" { + user = "ADMIN" + } + var b strings.Builder + fmt.Fprintf(&b, " \"%s:ADMIN,ACTIVE\"\r\n", strings.ToUpper(user)) + return b.String() +} + +func swVerPayload(s *tl1Session) string { + return " \"SWVER=12.4,LOAD=12.4-GA,STATUS=ACTIVE\"\r\n" +} + +func sysPayload(s *tl1Session) string { + var b strings.Builder + fmt.Fprintf(&b, " \"SID=%s,TYPE=6500-7SLOT,SHELFSN=%s\"\r\n", s.sid, s.serial) + return b.String() +} diff --git a/internal/sshsrv/driver_ciena_test.go b/internal/sshsrv/driver_ciena_test.go new file mode 100644 index 0000000..0bc00a2 --- /dev/null +++ b/internal/sshsrv/driver_ciena_test.go @@ -0,0 +1,158 @@ +package sshsrv + +import ( + "strings" + "testing" + + "github.com/rcfg-sim/rcfg-sim/internal/configs" +) + +func tl1Ctx(user, pass string) *sessionCtx { + return &sessionCtx{ + dev: &configs.Device{Hostname: "CIENA-LAB-0001", SerialNumber: "SNTEST123"}, + username: user, + password: pass, + } +} + +func newTL1Session(ctx *sessionCtx) *tl1Session { + return &tl1Session{sid: ctx.dev.Hostname, serial: ctx.dev.SerialNumber} +} + +func TestParseTL1(t *testing.T) { + cases := []struct { + raw string + wantVerb string + wantCtag string + }{ + {"RTRV-EQPT::ALL:100", "RTRV-EQPT", "100"}, + {"RTRV-ALM-ALL::ALL:101", "RTRV-ALM-ALL", "101"}, + {"RTRV-SW-VER:::100", "RTRV-SW-VER", "100"}, + {"RTRV-SYS:::101", "RTRV-SYS", "101"}, + {"rtrv-eqpt::all:7", "RTRV-EQPT", "7"}, // case-insensitive verb + {"ACT-USER::admin:CTAG1::secret", "ACT-USER", "CTAG1"}, + } + for _, c := range cases { + verb, ctag := parseTL1(c.raw) + if verb != c.wantVerb || ctag != c.wantCtag { + t.Errorf("parseTL1(%q) = (%q,%q), want (%q,%q)", c.raw, verb, ctag, c.wantVerb, c.wantCtag) + } + } +} + +func TestParseActUser(t *testing.T) { + user, pass := parseActUser("ACT-USER::admin:100::s3cret") + if user != "admin" || pass != "s3cret" { + t.Errorf("parseActUser = (%q,%q), want (admin,s3cret)", user, pass) + } +} + +func TestTL1LoginGate(t *testing.T) { + ctx := tl1Ctx("admin", "admin") + s := newTL1Session(ctx) + + // Before login: any RTRV is denied. + cmd, resp := ctx.dispatchTL1("RTRV-EQPT::ALL:100", s) + if cmd != CmdTL1Deny { + t.Fatalf("pre-login RTRV-EQPT: cmd=%v, want CmdTL1Deny", cmd) + } + if !strings.Contains(string(resp.Output), "DENY") || !strings.Contains(string(resp.Output), "PLNA") { + t.Errorf("pre-login DENY block missing DENY/PLNA: %q", resp.Output) + } + if s.loggedIn { + t.Error("session should not be logged in after a denied RTRV") + } + + // Valid ACT-USER unlocks. + cmd, resp = ctx.dispatchTL1("ACT-USER::admin:100::admin", s) + if cmd != CmdTL1ActUser { + t.Fatalf("ACT-USER: cmd=%v, want CmdTL1ActUser", cmd) + } + if !s.loggedIn { + t.Fatal("session should be logged in after valid ACT-USER") + } + if !strings.Contains(string(resp.Output), "COMPLD") { + t.Errorf("ACT-USER success should be COMPLD: %q", resp.Output) + } + + // After login: RTRV-EQPT completes. + cmd, resp = ctx.dispatchTL1("RTRV-EQPT::ALL:101", s) + if cmd != CmdTL1RtrvEqpt { + t.Fatalf("post-login RTRV-EQPT: cmd=%v, want CmdTL1RtrvEqpt", cmd) + } + if !strings.Contains(string(resp.Output), "COMPLD") { + t.Errorf("post-login RTRV-EQPT should be COMPLD: %q", resp.Output) + } +} + +func TestTL1Credentials(t *testing.T) { + // Wrong password is denied, session stays logged out. + ctx := tl1Ctx("admin", "admin") + s := newTL1Session(ctx) + cmd, _ := ctx.dispatchTL1("ACT-USER::admin:100::WRONG", s) + if cmd != CmdTL1Deny || s.loggedIn { + t.Errorf("wrong password: cmd=%v loggedIn=%v, want CmdTL1Deny/false", cmd, s.loggedIn) + } + + // Empty configured password accepts any password (mirrors PasswordCallback). + ctxAny := tl1Ctx("admin", "") + sAny := newTL1Session(ctxAny) + cmd, _ = ctxAny.dispatchTL1("ACT-USER::admin:100::whatever", sAny) + if cmd != CmdTL1ActUser || !sAny.loggedIn { + t.Errorf("empty-password accept-any: cmd=%v loggedIn=%v, want CmdTL1ActUser/true", cmd, sAny.loggedIn) + } +} + +func TestTL1BlockShape(t *testing.T) { + ctx := tl1Ctx("admin", "admin") + s := newTL1Session(ctx) + _, resp := ctx.dispatchTL1("ACT-USER::admin:CTAG7::admin", s) + out := string(resp.Output) + + if !strings.HasPrefix(out, "\r\n") { + t.Errorf("COMPLD block should start with a blank line: %q", out) + } + if !strings.Contains(out, "CIENA-LAB-0001") { + t.Errorf("COMPLD block should contain the SID: %q", out) + } + if !strings.Contains(out, "M CTAG7 COMPLD") { + t.Errorf("COMPLD block should echo the CTAG in the response code line: %q", out) + } + if !strings.HasSuffix(out, ";\r\n") { + t.Errorf("COMPLD block should be terminated by ';': %q", out) + } +} + +func TestRequireSSHAuth(t *testing.T) { + cisco := &configs.Device{Driver: "cisco_ios"} + ciena := &configs.Device{Driver: "ciena_tl1"} + cases := []struct { + mode string + dev *configs.Device + want bool + }{ + {"", cisco, true}, {"", ciena, true}, // empty == password (back-compat) + {"password", cisco, true}, {"password", ciena, true}, + {"driver", cisco, true}, {"driver", ciena, false}, // per-driver + {"none", cisco, false}, {"none", ciena, false}, + } + for _, c := range cases { + s := &Server{cfg: Config{SSHAuthMode: c.mode}} + if got := s.requireSSHAuth(c.dev); got != c.want { + t.Errorf("mode=%q driver=%q: requireSSHAuth=%v, want %v", c.mode, c.dev.Driver, got, c.want) + } + } +} + +func TestTL1UnknownVerb(t *testing.T) { + ctx := tl1Ctx("admin", "admin") + s := newTL1Session(ctx) + ctx.dispatchTL1("ACT-USER::admin:1::admin", s) // log in first + cmd, resp := ctx.dispatchTL1("ENT-CRS-OCH::FOO:9", s) + if cmd != CmdTL1Unknown { + t.Errorf("unknown verb: cmd=%v, want CmdTL1Unknown", cmd) + } + if !strings.Contains(string(resp.Output), "ICNV") { + t.Errorf("unknown verb DENY should carry ICNV: %q", resp.Output) + } +} diff --git a/internal/sshsrv/driver_cisco.go b/internal/sshsrv/driver_cisco.go new file mode 100644 index 0000000..a72d0d7 --- /dev/null +++ b/internal/sshsrv/driver_cisco.go @@ -0,0 +1,104 @@ +package sshsrv + +import "time" + +func init() { registerDriver(ciscoIOS{}) } + +// ciscoIOS is the Cisco IOS personality: the original simulator behaviour, +// extracted verbatim from runShell. The greeting, prompts, per-token +// prefix-matched dispatch, and enable-mode password sub-flow are unchanged; the +// shared response-delay and post-dispatch machinery is delegated to +// (*sessionCtx).applyResponseDelay / emit so every vendor injects faults and +// records metrics identically. +type ciscoIOS struct{} + +func (ciscoIOS) Name() string { return "cisco_ios" } + +// RequiresSSHAuth: Cisco IOS authenticates at the SSH layer. +func (ciscoIOS) RequiresSSHAuth() bool { return true } + +func (ciscoIOS) Commands() []string { + return []string{ + CmdUnknown.String(), CmdEmpty.String(), CmdAmbiguous.String(), + CmdTerminalLength.String(), CmdTerminalPager.String(), CmdEnable.String(), + CmdShowVersion.String(), CmdShowRunningConfig.String(), + CmdShowStartupConfig.String(), CmdShowInventory.String(), CmdExit.String(), + } +} + +// Serve is the per-channel interactive loop. It does line editing with echo so +// the `ssh` CLI client is usable, reads one line at a time, resolves to a +// Command, applies the shared response delay, and emits the response. +// +// It returns when the channel closes, the client requests exit, or a read error +// occurs. Channel close is the caller's responsibility. +func (ciscoIOS) Serve(ctx *sessionCtx) { + state := &State{ + Hostname: ctx.dev.Hostname, + Serial: ctx.dev.SerialNumber, + ConfigBytes: ctx.dev.Data, + } + + writeAndCount(ctx, []byte("\r\n")) + writeAndCount(ctx, []byte(ctx.dev.Hostname+" line 0 is now available\r\n")) + writeAndCount(ctx, []byte("\r\n")) + + for { + prompt := state.Hostname + ">" + if state.EnableMode { + prompt = state.Hostname + "#" + } + if _, err := writeAndCount(ctx, []byte(prompt)); err != nil { + return + } + + line, err := readLine(ctx.ch, true) + if err != nil { + // Mid-session read error (EOF / reset) with no explicit exit + // command ⇒ classify as disconnect. Authoritative exit commands + // return via resp.Close below and leave outcome="ok". + if ctx.outcome != nil { + ctx.outcome.Set("disconnect") + } + return + } + cmdStart := time.Now() + cmd, canonical := ResolveCommand(line) + + ctx.applyResponseDelay() + + resp := Dispatch(cmd, canonical, state) + + if resp.RequestEnablePassword { + if _, err := writeAndCount(ctx, []byte("Password: ")); err != nil { + return + } + pw, err := readLine(ctx.ch, false) + if err != nil { + if ctx.outcome != nil { + ctx.outcome.Set("disconnect") + } + return + } + if pw == ctx.enablePassword { + state.EnableMode = true + } else { + writeAndCount(ctx, []byte("% Access denied\r\n")) + } + observeCmd(ctx, cmd, cmdStart) + continue + } + + if ctx.emit(cmd, cmdStart, resp) { + return + } + + if resp.ExitEnable { + state.EnableMode = false + continue + } + if resp.Close { + return + } + } +} diff --git a/internal/sshsrv/server.go b/internal/sshsrv/server.go index 12d4ff7..e212c57 100644 --- a/internal/sshsrv/server.go +++ b/internal/sshsrv/server.go @@ -27,14 +27,21 @@ import ( // Config is everything the Server needs to run. Only fields supplied by the CLI. type Config struct { - ListenIP string - PortStart int - PortCount int - ManifestPath string - HostKeyPath string - Username string - Password string - EnablePassword string + ListenIP string + PortStart int + PortCount int + ManifestPath string + HostKeyPath string + Username string + Password string + EnablePassword string + // SSHAuthMode controls whether the SSH transport authenticates the client + // before the interactive session starts: + // "password" (default) — every device requires SSH password auth. + // "driver" — the device's driver decides (Cisco yes, Ciena TL1 no). + // "none" — no SSH auth for any device; in-band auth only. + // Empty is treated as "password" for backward compatibility. + SSHAuthMode string ResponseDelayMinMS int ResponseDelayMaxMS int MaxConcurrentSessions int @@ -75,6 +82,11 @@ func New(cfg Config) (*Server, error) { if cfg.PortCount <= 0 { return nil, errors.New("port-count must be > 0") } + switch cfg.SSHAuthMode { + case "", "password", "driver", "none": + default: + return nil, fmt.Errorf("invalid ssh-auth mode %q (want password|driver|none)", cfg.SSHAuthMode) + } signer, err := loadOrGenerateHostKey(cfg.HostKeyPath) if err != nil { @@ -97,7 +109,7 @@ func New(cfg Config) (*Server, error) { } ctx, cancel := context.WithCancel(context.Background()) - return &Server{ + srv := &Server{ cfg: cfg, signer: signer, devices: devMap, @@ -108,7 +120,14 @@ func New(cfg Config) (*Server, error) { cancel: cancel, totalMap: total, metrics: metrics.New(), - }, nil + } + // Pre-register the command_duration label values every registered driver can + // emit, so vendor-specific values (e.g. TL1) appear in /metrics at zero from + // the first scrape — the same guarantee metrics.KnownCommands gives Cisco. + for _, cmd := range registeredCommands() { + srv.metrics.CommandDuration.WithLabelValues(cmd) + } + return srv, nil } // Metrics returns the metrics registry — primarily for integration tests @@ -232,6 +251,20 @@ func (s *Server) closeListeners() { } } +// requireSSHAuth decides whether the SSH transport must authenticate this +// device's client before the interactive session starts, per the configured +// SSHAuthMode. Empty mode is treated as "password". +func (s *Server) requireSSHAuth(dev *configs.Device) bool { + switch s.cfg.SSHAuthMode { + case "none": + return false + case "driver": + return driverFor(dev.Driver).RequiresSSHAuth() + default: // "" or "password" + return true + } +} + func (s *Server) handleConn(conn net.Conn, dev *configs.Device) { defer conn.Close() @@ -258,8 +291,9 @@ func (s *Server) handleConn(conn net.Conn, dev *configs.Device) { _ = conn.SetDeadline(time.Now().Add(30 * time.Second)) // handshake deadline - serverConfig := &ssh.ServerConfig{ - PasswordCallback: func(meta ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) { + serverConfig := &ssh.ServerConfig{} + if s.requireSSHAuth(dev) { + serverConfig.PasswordCallback = func(meta ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) { // auth_fail fault fires BEFORE password check so the rejection // is indistinguishable from a real wrong-password case to the // client. Increment both auth_attempts{fail} and faults_injected. @@ -274,7 +308,12 @@ func (s *Server) handleConn(conn net.Conn, dev *configs.Device) { } s.metrics.AuthAttempts.WithLabelValues("fail").Inc() return nil, errors.New("invalid password") - }, + } + } else { + // No SSH-layer auth: the client connects unchallenged and authenticates + // in-band (e.g. Ciena TL1 ACT-USER). The auth_fail fault and + // auth_attempts metric do not apply in this mode. + serverConfig.NoClientAuth = true } serverConfig.AddHostKey(s.signer) @@ -382,6 +421,9 @@ func (s *Server) handleSession(ch ssh.Channel, reqs <-chan *ssh.Request, dev *co ctx := &sessionCtx{ ch: ch, dev: dev, + driver: driverFor(dev.Driver), + username: s.cfg.Username, + password: s.cfg.Password, enablePassword: s.cfg.EnablePassword, delayMinMS: s.cfg.ResponseDelayMinMS, delayMaxMS: s.cfg.ResponseDelayMaxMS, @@ -405,7 +447,7 @@ func (s *Server) handleSession(ch ssh.Channel, reqs <-chan *ssh.Request, dev *co if !shellStarted { shellStarted = true go func() { - runShell(ctx) + ctx.driver.Serve(ctx) _ = ch.Close() }() } diff --git a/internal/sshsrv/session.go b/internal/sshsrv/session.go index 085a2ea..724d084 100644 --- a/internal/sshsrv/session.go +++ b/internal/sshsrv/session.go @@ -20,6 +20,9 @@ import ( type sessionCtx struct { ch ssh.Channel dev *configs.Device + driver Driver + username string // accepted login username (for in-band auth, e.g. TL1 ACT-USER) + password string // accepted login password (empty = accept any) enablePassword string delayMinMS int delayMaxMS int @@ -31,151 +34,6 @@ type sessionCtx struct { rawConn net.Conn // for hard-close (disconnect_mid) fault } -// runShell is the per-channel interactive loop. It does line editing with echo -// so the `ssh` CLI client is usable, reads one line at a time, resolves to a -// Command, applies a uniform response delay, and writes back the response. -// -// It returns when the channel closes, the client requests exit, or a read error -// occurs. Channel close is the caller's responsibility. Metrics hooks are -// inline so the hot path has no extra indirection. -func runShell(ctx *sessionCtx) { - state := &State{ - Hostname: ctx.dev.Hostname, - Serial: ctx.dev.SerialNumber, - ConfigBytes: ctx.dev.Data, - } - - writeAndCount(ctx, []byte("\r\n")) - writeAndCount(ctx, []byte(ctx.dev.Hostname+" line 0 is now available\r\n")) - writeAndCount(ctx, []byte("\r\n")) - - for { - prompt := state.Hostname + ">" - if state.EnableMode { - prompt = state.Hostname + "#" - } - if _, err := writeAndCount(ctx, []byte(prompt)); err != nil { - return - } - - line, err := readLine(ctx.ch, true) - if err != nil { - // Mid-session read error (EOF / reset) with no explicit exit - // command ⇒ classify as disconnect. Authoritative exit commands - // return via resp.Close below and leave outcome="ok". - if ctx.outcome != nil { - ctx.outcome.Set("disconnect") - } - return - } - cmdStart := time.Now() - cmd, canonical := ResolveCommand(line) - - delayMS := ctx.delayMinMS - if ctx.delayMaxMS > ctx.delayMinMS { - delayMS += ctx.rng.Intn(ctx.delayMaxMS - ctx.delayMinMS + 1) - } - // slow_response fault: multiply delay by uniform[10,50], cap at 60s. - // Guarantee at least 10ms of base so the multiplier is observable - // even when the operator configured --response-delay-ms-max=0. - if ctx.faults.Roll(ctx.rng, fault.TypeSlowResponse) { - multiplier := 10 + ctx.rng.Intn(41) // inclusive 10..50 - if delayMS < 10 { - delayMS = 10 - } - delayMS *= multiplier - if delayMS > 60000 { - delayMS = 60000 - } - if ctx.metrics != nil { - ctx.metrics.FaultsInjected.WithLabelValues(fault.TypeSlowResponse.String()).Inc() - } - } - if delayMS > 0 { - time.Sleep(time.Duration(delayMS) * time.Millisecond) - } - - resp := Dispatch(cmd, canonical, state) - - if resp.RequestEnablePassword { - if _, err := writeAndCount(ctx, []byte("Password: ")); err != nil { - return - } - pw, err := readLine(ctx.ch, false) - if err != nil { - if ctx.outcome != nil { - ctx.outcome.Set("disconnect") - } - return - } - if pw == ctx.enablePassword { - state.EnableMode = true - } else { - writeAndCount(ctx, []byte("% Access denied\r\n")) - } - observeCmd(ctx, cmd, cmdStart) - continue - } - - if len(resp.Output) > 0 { - if _, err := writeAndCount(ctx, resp.Output); err != nil { - return - } - } - if len(resp.ConfigOutput) > 0 { - // disconnect_mid: write a 20-40% prefix then hard-RST the TCP conn. - // Happens before any malformed check because the connection is - // going away anyway. Observe the command duration first so phase 5 - // metrics still reflect work the dispatcher did. - if ctx.faults.Roll(ctx.rng, fault.TypeDisconnectMid) { - window := 20 + ctx.rng.Intn(21) // 20..40 inclusive - n := len(resp.ConfigOutput) * window / 100 - _, _ = writeAndCount(ctx, resp.ConfigOutput[:n]) - if ctx.metrics != nil { - ctx.metrics.FaultsInjected.WithLabelValues(fault.TypeDisconnectMid.String()).Inc() - } - if ctx.outcome != nil { - ctx.outcome.Set("disconnect") - } - observeCmd(ctx, cmd, cmdStart) - hardCloseTCP(ctx.rawConn) - return - } - - // malformed: corrupt the stream in one of three ways. Preserves - // the zero-copy hot path for before/after segments; only the - // perturbation itself allocates (bit flip = 1 byte, inject = ~60 - // byte junk marker, truncate = no allocation). - if ctx.faults.Roll(ctx.rng, fault.TypeMalformed) { - if err := writeMalformed(ctx, resp.ConfigOutput); err != nil { - return - } - if ctx.metrics != nil { - ctx.metrics.FaultsInjected.WithLabelValues(fault.TypeMalformed.String()).Inc() - } - } else { - // Hot path: direct write of mmap'd bytes. Zero copy. - if _, err := writeAndCount(ctx, resp.ConfigOutput); err != nil { - return - } - } - // Trailing CRLF so the next prompt lands on a fresh line. - if _, err := writeAndCount(ctx, []byte("\r\n")); err != nil { - return - } - } - observeCmd(ctx, cmd, cmdStart) - - if resp.ExitEnable { - state.EnableMode = false - continue - } - if resp.Close { - return - } - } -} - // writeAndCount writes to the channel and increments the bytes_sent counter // by the number of bytes actually accepted. Metrics may be nil in unit tests. func writeAndCount(ctx *sessionCtx, p []byte) (int, error) { From 8b1b9143203fd2a526f0508846803203aab87e5d Mon Sep 17 00:00:00 2001 From: Stephen Stack Date: Sat, 6 Jun 2026 19:07:41 +0100 Subject: [PATCH 2/3] docs: expand README for the multi-vendor driver framework Add an Architecture subsection describing the per-device driver abstraction (shared response/fault/metrics machinery vs driver-owned greeting/prompt/ read-unit/dispatch), a driver comparison table, and how to add a vendor. Add a parallel Ciena TL1 data-flow alongside the Cisco one, and point the roadmap's "more vendors" item at the framework. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 53 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e04c684..6b173d1 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,35 @@ You cannot answer any of these with unit tests or a lab of ten devices. You need └─────────────────────────────────────────────────────────────┘ ``` +### Device drivers (multi-vendor) + +The interactive session is not hardwired to Cisco IOS. Each device is served by a **driver** — a vendor/model personality selected per connection from the manifest `template` column (`cisco_ios`, `ciena_tl1`, …). The server resolves the driver once at session start (`driverFor(template)`) and hands it the channel; everything the client sees on the wire is the driver's doing. + +What stays **shared** across every driver, so behaviour and observability are uniform: + +- response-delay jitter and the three stream faults (`slow_response`, `disconnect_mid`, `malformed`) +- zero-copy streaming of mmap'd bytes (`ConfigOutput`) +- `bytes_sent_total` accounting and the `command_duration_seconds{command}` histogram + +What each driver **owns**: + +- the greeting and prompt (`host>` / `host#` vs a bare `<`) +- how one command unit is read — a newline-terminated line vs a `;`-terminated TL1 block that may span physical lines +- the command grammar and dispatch (Cisco prefix-matching `show …` vs TL1 `RTRV-*` / `ACT-USER`) +- whether the SSH transport authenticates (`RequiresSSHAuth()` — consulted by `--ssh-auth=driver`) + +| Driver id (`template`) | Vendor | Prompt | SSH auth | Commands | +|---|---|---|---|---| +| `cisco_ios` | Cisco | `host>` / `host#` | password, then `enable` | `show …`, `terminal …`, `enable`, `exit` | +| `ciena_tl1` | Ciena | `<` | in-band `ACT-USER` (SSH auth optional) | `ACT-USER`, `RTRV-*` | + +**Adding a vendor** is two small pieces, with no change to the core loop: + +1. **Runtime** — a `Driver` implementation in `internal/sshsrv/driver_.go`, registered via `init()`. It declares its metric command labels (`Commands()`) and SSH-auth requirement (`RequiresSSHAuth()`), and implements `Serve()`, calling the shared `applyResponseDelay` / `emit` helpers for the response path. +2. **Generator** — a `model` entry in the registry (`internal/configs/generator.go`) carrying the vendor, driver id, template file, and a deterministic data-builder, plus a `templates/.tmpl`. + +The manifest's `vendor`/`template` columns are the wiring between the two halves: the generator writes them per model, the loader reads them onto each `Device`, and `driverFor` resolves the runtime driver — defaulting to `cisco_ios` for empty or unknown values, so pre-existing manifests behave exactly as before. + ### Data flow for a single collection run ``` @@ -224,6 +253,28 @@ You cannot answer any of these with unit tests or a lab of ten devices. You need 7. rConfig stores snapshot, diffs against previous, persists ``` +### Data flow for a Ciena TL1 device + +``` +1. Worker opens SSH to 10.50.0.7:22001 + │ --ssh-auth=none / driver → no password challenge (TL1-only) + │ --ssh-auth=password → SSH password auth first + ▼ +2. ciena_tl1 driver greets with "< " + ▼ +3. Worker sends "ACT-USER::admin:CTAG1::admin;" + │ in-band login validated → "M CTAG1 COMPLD" (carries the SID) + │ metric: command_duration_seconds{CmdTL1ActUser} observed + │ (any RTRV before a valid ACT-USER → "M DENY") + ▼ +4. Worker sends "RTRV-EQPT::ALL:100;" + │ COMPLD header + mmap'd shelf inventory streamed zero-copy + ";" + │ metric: command_duration_seconds{CmdTL1RtrvEqpt} observed + │ metric: bytes_sent_total += len(inventory) + ▼ +5. Worker disconnects → sessions_total{ok}, session_duration_seconds observed +``` + ### File layout on disk ``` @@ -1463,7 +1514,7 @@ sudo modprobe -r nf_conntrack 2>/dev/null || true **Possible v2 work, prioritised by likely rConfig value:** -- Additional vendors (Juniper Junos, Arista EOS, HP/Aruba ProCurve) via per-vendor dispatch maps and template sets +- More vendors on the [driver framework](#device-drivers-multi-vendor) (Juniper Junos, Arista EOS, HP/Aruba ProCurve) — each is one driver file plus a generator model entry, following the Ciena 6500 TL1 driver as the template - Config mutation support (`configure terminal`, `write memory`) for testing rConfig's push workflows - SSH public key auth - Per-device credential variation (manifest-driven) for credential rotation testing From 6608e06236c8ce4addeadc9e162818e292165501 Mon Sep 17 00:00:00 2001 From: Stephen Stack Date: Sat, 6 Jun 2026 19:19:38 +0100 Subject: [PATCH 3/3] ci: test Go 1.24 and 1.25, drop 1.22/1.23 x/crypto 0.45.0 and x/sys 0.38.0 declare go 1.24.0, and go.mod requires the same, so 1.22 and 1.23 cannot satisfy the directive (the 1.22 job failed; the 1.23 job only "passed" by auto-downloading the 1.24 toolchain). Set the matrix to the real floor plus the latest stable. Update the README badge/prerequisite and note the Go 1.24 floor in the changelog. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 3 ++- CHANGELOG.md | 6 ++++++ README.md | 4 ++-- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a12cd7..2b62470 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,8 @@ jobs: strategy: fail-fast: false matrix: - go: ['1.22', '1.23', '1.24'] + # x/crypto and x/sys (and go.mod) require Go 1.24; test the floor + latest. + go: ['1.24', '1.25'] steps: - name: Checkout uses: actions/checkout@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index d7b119a..79aa3ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ## [0.0.3] — 2026-06-06 +### Changed + +- Minimum supported Go is now **1.24** (required by `golang.org/x/crypto` and + `golang.org/x/sys`, and declared in `go.mod`). CI tests Go 1.24 and 1.25; the 1.22/1.23 + matrix entries are removed since neither toolchain satisfies the `go 1.24.0` directive. + ### Added - **Multi-vendor device-driver framework.** The SSH server now selects a per-device diff --git a/README.md b/README.md index 6b173d1..d473e2d 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Stand up 50,000 fake network devices on a single Linux host. Each one speaks rea [![CI](https://github.com/rconfig/rconfig-sim/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/rconfig/rconfig-sim/actions/workflows/ci.yml) [![Website](https://img.shields.io/badge/website-rconfig.com%2Frconfig--sim-D97757)](https://www.rconfig.com/rconfig-sim) -[![Go Version](https://img.shields.io/badge/go-1.22%2B-00ADD8?logo=go)](https://go.dev/) +[![Go Version](https://img.shields.io/badge/go-1.24%2B-00ADD8?logo=go)](https://go.dev/) [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) [![Platform](https://img.shields.io/badge/platform-Linux-lightgrey)]() [![Status](https://img.shields.io/badge/status-v1-brightgreen)]() @@ -343,7 +343,7 @@ Measured against the reference VM (12 vCPU Intel i9-9900K, 48 GB RAM, virtio-net **Software:** -- Go 1.22 or later (1.26+ recommended) +- Go 1.24 or later (1.26+ recommended) — required by `golang.org/x/crypto` and `golang.org/x/sys` - `make` - `systemd` (v250+ for the unit semantics used) - `iproute2` (for `ip` command used by alias script)