Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 37 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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::<user>:<ctag>::<pass>;` 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.
Expand Down Expand Up @@ -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
16 changes: 10 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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@<IP>.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

Expand Down Expand Up @@ -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/<name>.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/<name>.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/<name>.tmpl`; on the runtime side add one `Driver` implementation registered via `init()` in `internal/sshsrv/driver_<vendor>.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

Expand Down
105 changes: 100 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)]()
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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_<vendor>.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/<name>.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

```
Expand Down Expand Up @@ -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 <ctag> 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

```
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading