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 523d231..79aa3ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,41 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ## [Unreleased] +## [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 + 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 +119,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..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)]() @@ -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 @@ -187,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 ``` @@ -223,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 ``` @@ -291,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) @@ -1155,6 +1207,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 +1305,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 +1484,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) @@ -1419,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 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) {