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
16 changes: 15 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and

## [Unreleased]

## [0.0.4] — 2026-06-07

### Added

- **Ciena GNE/RNE topology** — new model `ciena-6500-tl1-gne`: a 6500 acting as a Gateway NE
(GNE) that fronts 2–5 Remote NEs (RNEs) reachable only through it. The generator writes each
RNE's shelf inventory into the device's config (delimited sections); the `ciena_tl1` driver
indexes them at session start and routes `RTRV-*` commands to an RNE by its TID
(`RTRV-EQPT:RNE-LIMERICK:3;`), streaming that RNE's inventory zero-copy with the RNE's TID as
the response SID. New verb **`RTRV-NBR`** lists the RNEs behind a GNE. Unknown/unreachable TID
returns TL1 `DENY`/`IIAC`. The existing `ciena-6500-tl1` model is unchanged (standalone node,
byte-identical output); the TL1 command parser now also accepts the short `VERB:TID:CTAG` form.

## [0.0.3] — 2026-06-06

### Changed
Expand Down Expand Up @@ -119,7 +132,8 @@ 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.3...HEAD
[Unreleased]: https://github.com/rconfig/rconfig-sim/compare/v0.0.4...HEAD
[0.0.4]: https://github.com/rconfig/rconfig-sim/releases/tag/v0.0.4
[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
25 changes: 23 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1237,8 +1237,9 @@ The generator is driven by a **model registry**, of which the nine Cisco size bu
| Model | Vendor | Driver | Protocol | Payload |
|---|---|---|---|---|
| `ciena-6500-tl1` | Ciena | `ciena_tl1` | TL1 over SSH | `RTRV-EQPT::ALL` shelf inventory (7-slot 6500), mmap-streamed |
| `ciena-6500-tl1-gne` | Ciena | `ciena_tl1` | TL1 over SSH | Gateway NE fronting 2–5 Remote NEs; GNE + per-RNE inventories, 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.
Mix them 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:

Expand All @@ -1259,7 +1260,27 @@ M 100 COMPLD
;
```

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.
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`, `RTRV-NBR`. Anything before a valid `ACT-USER`, or any unrecognised verb, returns a TL1 `DENY` block.

#### GNE / RNE topology (`ciena-6500-tl1-gne`)

Real optical networks are reached through a **Gateway NE (GNE)** — the node you SSH into — which fronts several **Remote NEs (RNEs)** that have no direct management access of their own. The `ciena-6500-tl1-gne` model emulates this: each generated device is a GNE whose config also carries the inventories of 2–5 RNEs behind it.

After login, `RTRV-NBR` lists the RNEs, and you address one by putting its TID in the command's TID field; the GNE returns that RNE's response (with the RNE's TID as the response SID). The same verb set works against any RNE.

```
< ACT-USER::admin:1::admin; ← log into the GNE
< RTRV-NBR:ALL:2; ← list the RNEs reachable through it
...
"RNE-LIMERICK:PROTOCOL=OSC,REACHABLE=YES,STATE=IS-NR"
"RNE-GALWAY:PROTOCOL=OSC,REACHABLE=YES,STATE=IS-NR"
;
< RTRV-EQPT:RNE-LIMERICK:3; ← inventory of a specific RNE (TID in field 2)
< RTRV-ALM-ALL:RNE-LIMERICK:4; ← alarms for that RNE
< RTRV-EQPT::ALL:100; ← GNE-own inventory (empty TID, as before)
```

A TID that empty/`ALL`/the GNE's own SID is treated as local; an unknown or unreachable RNE TID returns `DENY`/`IIAC`. The TID-addressed short form `VERB:TID:CTAG;` and the strict `VERB::AID:CTAG;` form are both accepted. The standalone `ciena-6500-tl1` model is a GNE with no RNEs — `RTRV-NBR` returns an empty list.

#### SSH-layer auth vs in-band TL1 auth

Expand Down
64 changes: 63 additions & 1 deletion internal/configs/ciena.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ import (
// so future 6500 form factors (ciena-6500-2slot, …) slot in alongside it.
const cienaModelName = "ciena-6500-tl1"

// cienaGNEModelName is a Ciena 6500 acting as a Gateway NE (GNE) that fronts
// several Remote NEs (RNEs). The generated config carries the GNE's own shelf
// inventory plus each RNE's, which the ciena_tl1 driver routes to by TID.
const cienaGNEModelName = "ciena-6500-tl1-gne"

// 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.
Expand All @@ -20,6 +25,8 @@ func modelHostname(data any) string {
return d.Hostname
case CienaEqptData:
return d.SID
case CienaGNEData:
return d.SID // the GNE is the SSH-addressable node; RNEs are not manifest rows
default:
return ""
}
Expand All @@ -39,6 +46,54 @@ func cienaModel() model {
}
}

// cienaGNEModel returns the registry entry for a Ciena 6500 GNE. Same runtime
// driver (ciena_tl1) as the standalone model; the GNE template additionally
// emits per-RNE inventory sections that the driver routes to by TID.
func cienaGNEModel() model {
return model{
name: cienaGNEModelName,
vendor: "Ciena",
template: "ciena_tl1",
tmplFile: "ciena_tl1_gne.tmpl",
build: func(cfg Config, index int, m model) any { return buildCienaGNE(cfg, index) },
}
}

// CienaGNEData is the payload for templates/ciena_tl1_gne.tmpl: the GNE's own
// shelf (the embedded CienaEqptData) plus the shelves of the RNEs reachable
// through it (each a CienaEqptData whose SID is the RNE's TID). Deterministic
// via the same deviceRand stream as the standalone builder.
type CienaGNEData struct {
CienaEqptData
RNEs []CienaEqptData
}

// buildCienaGNE builds a GNE with 2–5 RNEs behind it. The GNE shelf is the same
// shape as a standalone node; each RNE is a full shelf re-identified with an
// RNE-<CITY> TID. All randomness flows from deviceRand(seed, index) so output is
// byte-reproducible.
func buildCienaGNE(cfg Config, index int) CienaGNEData {
rng := deviceRand(cfg.Seed, index)
gne := buildCienaShelf(cfg, index, rng)

nRNE := 2 + rng.Intn(4) // 2..5
used := map[string]bool{}
var rnes []CienaEqptData
for i := 0; i < nRNE; i++ {
tid := "RNE-" + strings.ToUpper(citySyllables[rng.Intn(len(citySyllables))])
if used[tid] {
continue // dedupe RNE TID collisions within one GNE
}
used[tid] = true
r := buildCienaShelf(cfg, index, rng)
r.SID = tid
r.ShelfSerial = serialFor(tid)
r.NodeIP = ipPlusOffset(cfg.IPBase, index/cfg.DevicesPerIP)
rnes = append(rnes, r)
}
return CienaGNEData{CienaEqptData: gne, RNEs: rnes}
}

// 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
Expand Down Expand Up @@ -78,8 +133,15 @@ var (
cienaOptics = []string{"SFP+", "QSFP28", "CFP2"}
)

// buildCienaEqpt builds one standalone shelf with its own deterministic rng.
func buildCienaEqpt(cfg Config, index int) CienaEqptData {
rng := deviceRand(cfg.Seed, index)
return buildCienaShelf(cfg, index, deviceRand(cfg.Seed, index))
}

// buildCienaShelf builds one 6500 shelf, drawing from the supplied rng. Taking
// the rng as a parameter lets a GNE build its own shelf plus several RNE shelves
// off a single deterministic stream.
func buildCienaShelf(cfg Config, index int, rng *rand.Rand) CienaEqptData {
city := citySyllables[rng.Intn(len(citySyllables))]
sid := fmt.Sprintf("CIENA-%s-%04d", strings.ToUpper(city), 1000+(index%9000))

Expand Down
37 changes: 37 additions & 0 deletions internal/configs/ciena_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,47 @@ func pad(i int) string {
return string(s)
}

// TestCienaGNEDeterministic: a GNE config (GNE shelf + RNE sections) is
// byte-reproducible across runs with the same seed.
func TestCienaGNEDeterministic(t *testing.T) {
mk := func() string {
cfg := baseTestConfig(t, 12)
cfg.IPCount = 1
cfg.DevicesPerIP = 12
cfg.Distribution = "ciena-6500-tl1-gne:100"
if _, err := Run(cfg, io.Discard); err != nil {
t.Fatalf("run: %v", err)
}
return cfg.OutputDir
}
dirA, dirB := mk(), mk()
for i := 0; i < 12; 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("GNE device %d not deterministic across runs", i)
}
if !bytes.Contains(a, []byte("\n;;RNE ")) {
t.Errorf("GNE device %d has no RNE section: %q", i, a)
}
}
}

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("ciena-6500-tl1-gne:100"); err != nil {
t.Errorf("ciena GNE 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)
}
Expand Down
3 changes: 2 additions & 1 deletion internal/configs/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,13 +91,14 @@ var registry = func() map[string]model {
}
}
r[cienaModelName] = cienaModel()
r[cienaGNEModelName] = cienaGNEModel()
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)
var modelOrder = append(append([]string{}, bucketOrder...), cienaModelName, cienaGNEModelName)

// profile holds the per-size-bucket generation counts.
type profile struct {
Expand Down
19 changes: 19 additions & 0 deletions internal/configs/templates/ciena_tl1_body.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{{- /* Shared Ciena 6500 RTRV-EQPT inventory body. Rendered for the local shelf
(standalone node or GNE) and for each RNE behind a GNE. Kept in one place
so the standalone and GNE templates can never drift. */ -}}
{{- define "ciena_eqpt_body" -}}
"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 -}}
{{- end -}}
18 changes: 1 addition & 17 deletions internal/configs/templates/ciena_tl1_eqpt.tmpl
Original file line number Diff line number Diff line change
@@ -1,17 +1 @@
{{- /* 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 -}}
{{- template "ciena_eqpt_body" . -}}
8 changes: 8 additions & 0 deletions internal/configs/templates/ciena_tl1_gne.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{{- /* Gateway NE inventory followed by one ";;RNE <TID>" section per remote NE.
The ciena_tl1 driver splits on the ";;RNE " marker lines (column 0) at
session start and streams the GNE's own or a targeted RNE's section. */ -}}
{{- template "ciena_eqpt_body" . -}}
{{- range .RNEs -}}
;;RNE {{ .SID }}
{{ template "ciena_eqpt_body" . -}}
{{- end -}}
71 changes: 69 additions & 2 deletions internal/sshsrv/ciena_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"fmt"
"io"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
Expand All @@ -23,11 +24,16 @@ import (
// 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, "")
return cienaModelServer(t, "", "ciena-6500-tl1")
}

// cienaServerMode is cienaServer with an explicit --ssh-auth mode.
func cienaServerMode(t *testing.T, authMode string) (port int, hostname string, srv *sshsrv.Server) {
return cienaModelServer(t, authMode, "ciena-6500-tl1")
}

// cienaModelServer generates one device of the given model and serves it.
func cienaModelServer(t *testing.T, authMode, model string) (port int, hostname string, srv *sshsrv.Server) {
t.Helper()
tmp := t.TempDir()
manifest := filepath.Join(tmp, "manifest.csv")
Expand All @@ -37,7 +43,7 @@ func cienaServerMode(t *testing.T, authMode string) (port int, hostname string,
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",
Seed: 7, Distribution: model + ":100",
Username: "admin", Password: "admin", EnablePassword: "enable123",
}, io.Discard); err != nil {
t.Fatalf("generator: %v", err)
Expand Down Expand Up @@ -141,6 +147,67 @@ func TestCiena_DriverModeNoAuth(t *testing.T) {
ec.expect("M 1 COMPLD", 3*time.Second)
}

// TestCiena_GNE_RNERouting drives the full GNE/RNE example session over the wire:
// log in to the GNE, list RNEs via RTRV-NBR, address an RNE by TID (EQPT streamed
// with the RNE's SID in the header), confirm GNE-local commands still work, and
// that an unknown TID is denied with IIAC.
func TestCiena_GNE_RNERouting(t *testing.T) {
port, gneSID, srv := cienaModelServer(t, "none", "ciena-6500-tl1-gne")
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)

// RTRV-NBR lists the RNEs behind this GNE; pull one TID out of the response.
ec.reset()
ec.send("RTRV-NBR:ALL:2;")
nbr := ec.expect("M 2 COMPLD", 3*time.Second)
m := regexp.MustCompile(`"(RNE-[A-Z0-9]+):`).FindStringSubmatch(nbr)
if m == nil {
t.Fatalf("RTRV-NBR returned no RNE TID: %q", nbr)
}
rne := m[1]

// RTRV-EQPT to that RNE: COMPLD, header SID is the RNE TID (3-space SID line,
// distinct from the command echo), inventory streamed.
ec.reset()
ec.send(fmt.Sprintf("RTRV-EQPT:%s:3;", rne))
eqpt := ec.expect("M 3 COMPLD", 3*time.Second)
if !strings.Contains(eqpt, " "+rne+" ") {
t.Errorf("RNE EQPT header should carry RNE TID %q as SID: %q", rne, eqpt)
}
ec.expect("TYPE=6500-7SLOT", 3*time.Second)

// GNE-local EQPT still works; header SID is the GNE.
ec.reset()
ec.send("RTRV-EQPT::ALL:100;")
local := ec.expect("M 100 COMPLD", 3*time.Second)
if !strings.Contains(local, " "+gneSID+" ") {
t.Errorf("local EQPT header should carry GNE SID %q: %q", gneSID, local)
}

// RNE-targeted alarm completes.
ec.reset()
ec.send(fmt.Sprintf("RTRV-ALM-ALL:%s:4;", rne))
ec.expect("M 4 COMPLD", 3*time.Second)

// Unknown / unreachable TID -> DENY IIAC.
ec.reset()
ec.send("RTRV-EQPT:RNE-NOPE:9;")
deny := ec.expect("M 9 DENY", 3*time.Second)
if !strings.Contains(deny, "IIAC") {
t.Errorf("unknown TID should DENY/IIAC: %q", deny)
}

if n := histogramLabelSampleCount(t, srv.Metrics().Gatherer(),
"rcfgsim_command_duration_seconds", "command", "CmdTL1RtrvNbr"); n < 1 {
t.Errorf("rcfgsim_command_duration_seconds{command=CmdTL1RtrvNbr}: want >=1 sample, got %d", n)
}
}

// 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.
Expand Down
3 changes: 3 additions & 0 deletions internal/sshsrv/dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const (
CmdTL1RtrvActiveUser
CmdTL1RtrvSwVer
CmdTL1RtrvSys
CmdTL1RtrvNbr
)

// String returns the Go identifier form of the command. Used as a bounded
Expand Down Expand Up @@ -86,6 +87,8 @@ func (c Command) String() string {
return "CmdTL1RtrvSwVer"
case CmdTL1RtrvSys:
return "CmdTL1RtrvSys"
case CmdTL1RtrvNbr:
return "CmdTL1RtrvNbr"
default:
return "CmdUnknown"
}
Expand Down
Loading
Loading