From bef5a0873d4a3d7e9b354fe3cf5b4ef54595020c Mon Sep 17 00:00:00 2001 From: Stephen Stack Date: Sun, 7 Jun 2026 09:30:09 +0100 Subject: [PATCH] feat: add Ciena GNE/RNE topology (ciena-6500-tl1-gne, RTRV-NBR) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Model a TL1 Gateway NE (GNE) that fronts Remote NEs (RNEs) reachable only through it — you SSH into the GNE and address an RNE by putting its TID in the command, and the GNE returns that RNE's response. Driver (ciena_tl1, TID-aware): - parseTL1 now returns (verb, tid, ctag) and accepts both the strict VERB::AID:CTAG form and the short VERB:TID:CTAG form operators use to address an NE directly. - At session start the mmap'd config is indexed (zero-copy) into the GNE-own inventory plus a per-RNE-TID map, split on ";;RNE " marker lines. A file with no marker is a legacy single-NE config (back-compat). - RTRV-* with an RNE TID streams that RNE's inventory with the RNE's TID as the response SID; unknown/unreachable TID returns DENY/IIAC. New RTRV-NBR verb (CmdTL1RtrvNbr) lists the RNEs. TID is never a metric label. Generator: - New model ciena-6500-tl1-gne renders the GNE shelf plus 2-5 RNE shelves (RNE- TIDs) into delimited sections, deterministic via deviceRand. The shared inventory body is now a single template define reused by both Ciena templates; the standalone ciena-6500-tl1 output is byte-identical (verified). Tests: TID/ctag parsing (both forms), tidIsLocal, indexSections (back-compat + sub-slice boundaries), GNE routing (local/RNE/IIAC), RTRV-NBR; GNE determinism; over-the-wire example session (ACT-USER -> RTRV-NBR -> RTRV-EQPT:RNE -> RTRV-EQPT::ALL -> RTRV-ALM-ALL:RNE -> unknown-TID DENY). No breaking change: existing model/output/manifests unchanged; additive model name; one new command-label value within the asserted cardinality bound. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 16 +- README.md | 25 ++- internal/configs/ciena.go | 64 ++++++- internal/configs/ciena_test.go | 37 ++++ internal/configs/generator.go | 3 +- .../configs/templates/ciena_tl1_body.tmpl | 19 +++ .../configs/templates/ciena_tl1_eqpt.tmpl | 18 +- internal/configs/templates/ciena_tl1_gne.tmpl | 8 + internal/sshsrv/ciena_integration_test.go | 71 +++++++- internal/sshsrv/dispatch.go | 3 + internal/sshsrv/driver_ciena.go | 161 +++++++++++++++--- internal/sshsrv/driver_ciena_test.go | 139 ++++++++++++++- 12 files changed, 507 insertions(+), 57 deletions(-) create mode 100644 internal/configs/templates/ciena_tl1_body.tmpl create mode 100644 internal/configs/templates/ciena_tl1_gne.tmpl diff --git a/CHANGELOG.md b/CHANGELOG.md index 79aa3ec..da032a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/README.md b/README.md index 8ff25e5..2a54c17 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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 diff --git a/internal/configs/ciena.go b/internal/configs/ciena.go index fae79ec..568c3aa 100644 --- a/internal/configs/ciena.go +++ b/internal/configs/ciena.go @@ -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. @@ -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 "" } @@ -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- 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 @@ -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)) diff --git a/internal/configs/ciena_test.go b/internal/configs/ciena_test.go index f658a46..915ac67 100644 --- a/internal/configs/ciena_test.go +++ b/internal/configs/ciena_test.go @@ -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) } diff --git a/internal/configs/generator.go b/internal/configs/generator.go index 5580227..877b1bb 100644 --- a/internal/configs/generator.go +++ b/internal/configs/generator.go @@ -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 { diff --git a/internal/configs/templates/ciena_tl1_body.tmpl b/internal/configs/templates/ciena_tl1_body.tmpl new file mode 100644 index 0000000..6745733 --- /dev/null +++ b/internal/configs/templates/ciena_tl1_body.tmpl @@ -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 -}} diff --git a/internal/configs/templates/ciena_tl1_eqpt.tmpl b/internal/configs/templates/ciena_tl1_eqpt.tmpl index fcb25a6..2bc8bb2 100644 --- a/internal/configs/templates/ciena_tl1_eqpt.tmpl +++ b/internal/configs/templates/ciena_tl1_eqpt.tmpl @@ -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" . -}} diff --git a/internal/configs/templates/ciena_tl1_gne.tmpl b/internal/configs/templates/ciena_tl1_gne.tmpl new file mode 100644 index 0000000..61a1e4a --- /dev/null +++ b/internal/configs/templates/ciena_tl1_gne.tmpl @@ -0,0 +1,8 @@ +{{- /* Gateway NE inventory followed by one ";;RNE " 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 -}} diff --git a/internal/sshsrv/ciena_integration_test.go b/internal/sshsrv/ciena_integration_test.go index 9494958..0dee4b8 100644 --- a/internal/sshsrv/ciena_integration_test.go +++ b/internal/sshsrv/ciena_integration_test.go @@ -10,6 +10,7 @@ import ( "fmt" "io" "path/filepath" + "regexp" "strings" "testing" "time" @@ -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") @@ -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) @@ -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. diff --git a/internal/sshsrv/dispatch.go b/internal/sshsrv/dispatch.go index bf0a831..d939056 100644 --- a/internal/sshsrv/dispatch.go +++ b/internal/sshsrv/dispatch.go @@ -40,6 +40,7 @@ const ( CmdTL1RtrvActiveUser CmdTL1RtrvSwVer CmdTL1RtrvSys + CmdTL1RtrvNbr ) // String returns the Go identifier form of the command. Used as a bounded @@ -86,6 +87,8 @@ func (c Command) String() string { return "CmdTL1RtrvSwVer" case CmdTL1RtrvSys: return "CmdTL1RtrvSys" + case CmdTL1RtrvNbr: + return "CmdTL1RtrvNbr" default: return "CmdUnknown" } diff --git a/internal/sshsrv/driver_ciena.go b/internal/sshsrv/driver_ciena.go index 46a165d..13b4784 100644 --- a/internal/sshsrv/driver_ciena.go +++ b/internal/sshsrv/driver_ciena.go @@ -1,6 +1,7 @@ package sshsrv import ( + "bytes" "fmt" "io" "strings" @@ -32,19 +33,31 @@ func (cienaTL1) Commands() []string { CmdTL1Unknown.String(), CmdTL1Deny.String(), CmdTL1ActUser.String(), CmdTL1RtrvEqpt.String(), CmdTL1RtrvAlmAll.String(), CmdTL1RtrvCondAll.String(), CmdTL1RtrvActiveUser.String(), CmdTL1RtrvSwVer.String(), CmdTL1RtrvSys.String(), + CmdTL1RtrvNbr.String(), } } // tl1Session is the per-channel TL1 state. Driver-local — kept off the Cisco // State struct so vendor concepts stay disjoint. +// +// A device may be a Gateway NE (GNE) fronting Remote NEs (RNEs) reachable only +// through it. The GNE's own EQPT inventory and each RNE's are carved out of the +// mmap'd config as zero-copy sub-slices at session start; commands addressed to +// an RNE TID stream that RNE's slice. type tl1Session struct { - loggedIn bool - sid string // system identifier / TID, shown in every response header - serial string // shelf serial, substituted into synthesized payloads + loggedIn bool + sid string // GNE system identifier / TID, shown in local response headers + serial string // GNE shelf serial, substituted into synthesized payloads + localEQPT []byte // GNE-own RTRV-EQPT inventory (sub-slice of dev.Data) + rneEQPT map[string][]byte // RNE TID (upper-case) -> its RTRV-EQPT inventory (sub-slice) + rneOrder []string // RNE TIDs in file order, for RTRV-NBR } func (cienaTL1) Serve(ctx *sessionCtx) { s := &tl1Session{sid: ctx.dev.Hostname, serial: ctx.dev.SerialNumber} + if len(ctx.dev.Data) > 0 { + s.localEQPT, s.rneEQPT, s.rneOrder = indexSections(ctx.dev.Data) + } for { // The "<" prompt is both greeting and per-command prompt in TL1. if _, err := writeAndCount(ctx, []byte("\r\n< ")); err != nil { @@ -71,9 +84,11 @@ func (cienaTL1) Serve(ctx *sessionCtx) { // 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. +// returns DENY. Commands carrying an RNE TID are routed to that RNE's data; the +// response header SID becomes the RNE's TID, mirroring a real GNE forwarding the +// reply on the RNE's behalf. func (ctx *sessionCtx) dispatchTL1(raw string, s *tl1Session) (Command, Response) { - verb, ctag := parseTL1(raw) + verb, tid, ctag := parseTL1(raw) if verb == "" { // Bare ";" or whitespace — silently re-prompt. return CmdTL1Unknown, Response{} @@ -93,33 +108,111 @@ func (ctx *sessionCtx) dispatchTL1(raw string, s *tl1Session) (Command, Response return CmdTL1Deny, Response{Output: tl1Deny(s.sid, ctag, "PLNA")} } + // RTRV-NBR is always a GNE-local op: list the RNEs reachable through this GNE. + if verb == "RTRV-NBR" { + return CmdTL1RtrvNbr, Response{Output: tl1Compld(s.sid, ctag, nbrPayload(s))} + } + + // Resolve the target NE. Local (empty/ALL/own-SID) responds as the GNE; an + // RNE TID routes to that RNE's data and responds AS the RNE. + respSID := s.sid + rneEQPT := s.localEQPT + if !tidIsLocal(tid, s.sid) { + body, ok := s.rneEQPT[strings.ToUpper(tid)] + if !ok { + // IIAC = input, invalid access identifier (unknown/unreachable TID). + return CmdTL1Deny, Response{Output: tl1Deny(s.sid, ctag, "IIAC")} + } + respSID, rneEQPT = strings.ToUpper(tid), body + } + 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 { + if len(rneEQPT) > 0 { return CmdTL1RtrvEqpt, Response{ - Output: tl1CompldHeader(s.sid, ctag), - ConfigOutput: ctx.dev.Data, + Output: tl1CompldHeader(respSID, ctag), + ConfigOutput: rneEQPT, Trailer: []byte(";\r\n"), } } - return CmdTL1RtrvEqpt, Response{Output: tl1Compld(s.sid, ctag, eqptPayload(s))} + return CmdTL1RtrvEqpt, Response{Output: tl1Compld(respSID, ctag, eqptPayload(s))} case "RTRV-ALM-ALL": - return CmdTL1RtrvAlmAll, Response{Output: tl1Compld(s.sid, ctag, almPayload(s))} + return CmdTL1RtrvAlmAll, Response{Output: tl1Compld(respSID, ctag, almPayload(respSID))} case "RTRV-COND-ALL": - return CmdTL1RtrvCondAll, Response{Output: tl1Compld(s.sid, ctag, condPayload(s))} + return CmdTL1RtrvCondAll, Response{Output: tl1Compld(respSID, ctag, condPayload(respSID))} case "RTRV-ACTIVE-USER": - return CmdTL1RtrvActiveUser, Response{Output: tl1Compld(s.sid, ctag, activeUserPayload(ctx.username))} + return CmdTL1RtrvActiveUser, Response{Output: tl1Compld(respSID, ctag, activeUserPayload(ctx.username))} case "RTRV-SW-VER": - return CmdTL1RtrvSwVer, Response{Output: tl1Compld(s.sid, ctag, swVerPayload(s))} + return CmdTL1RtrvSwVer, Response{Output: tl1Compld(respSID, ctag, swVerPayload(s))} case "RTRV-SYS": - return CmdTL1RtrvSys, Response{Output: tl1Compld(s.sid, ctag, sysPayload(s))} + return CmdTL1RtrvSys, Response{Output: tl1Compld(respSID, ctag, sysPayload(respSID, s.serial))} default: // ICNV = input, command not valid. - return CmdTL1Unknown, Response{Output: tl1Deny(s.sid, ctag, "ICNV")} + return CmdTL1Unknown, Response{Output: tl1Deny(respSID, ctag, "ICNV")} + } +} + +// tidIsLocal reports whether a TID addresses the GNE itself rather than an RNE. +// Empty, "ALL", or the GNE's own SID (case-insensitive) all mean local. +func tidIsLocal(tid, ownSID string) bool { + t := strings.ToUpper(strings.TrimSpace(tid)) + return t == "" || t == "ALL" || t == strings.ToUpper(ownSID) +} + +// indexSections splits an mmap'd Ciena config into the GNE-own EQPT inventory +// and the per-RNE inventories, using lines that begin with ";;RNE " as +// section separators. Every returned []byte is a zero-copy sub-slice of data. +// +// A file with no ";;RNE " marker is a legacy single-NE config: localEQPT is the +// whole file and there are no RNEs. This keeps the standalone ciena-6500-tl1 +// model byte-identical and driver behaviour unchanged for it. +func indexSections(data []byte) (localEQPT []byte, rne map[string][]byte, order []string) { + marker := []byte(";;RNE ") + // Find section boundaries: the start of each ";;RNE " line (at column 0, + // i.e. either at offset 0 or immediately after a newline). + type bound struct { + tid string + bodyStart, line int + } + var bounds []bound + for i := 0; i < len(data); i++ { + atLineStart := i == 0 || data[i-1] == '\n' + if !atLineStart || !bytes.HasPrefix(data[i:], marker) { + continue + } + // TID runs to end of this marker line. + nl := bytes.IndexByte(data[i:], '\n') + lineEnd := len(data) + bodyStart := len(data) + if nl >= 0 { + lineEnd = i + nl + bodyStart = lineEnd + 1 + } + tid := strings.ToUpper(strings.TrimSpace(string(data[i+len(marker) : lineEnd]))) + bounds = append(bounds, bound{tid: tid, bodyStart: bodyStart, line: i}) + } + + if len(bounds) == 0 { + return data, nil, nil } + + localEQPT = data[:bounds[0].line] + rne = make(map[string][]byte, len(bounds)) + for i, b := range bounds { + end := len(data) + if i+1 < len(bounds) { + end = bounds[i+1].line + } + if b.tid == "" { + continue + } + rne[b.tid] = data[b.bodyStart:end] + order = append(order, b.tid) + } + return localEQPT, rne, order } // validTL1Login mirrors the SSH PasswordCallback semantics: empty configured @@ -134,20 +227,30 @@ func (ctx *sessionCtx) validTL1Login(user, pass string) bool { return true } -// parseTL1 splits a TL1 command into its verb and CTAG. Grammar: +// parseTL1 splits a TL1 command into its verb, target TID, 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) { +// The verb is field 0 and the TID is field 1. CTAG is the 4th field (index 3) +// in the strict form (VERB::AID:CTAG, e.g. "RTRV-EQPT::ALL:100"), but operators +// commonly omit the empty AID placeholder when addressing an NE directly +// (VERB:TID:CTAG, e.g. "RTRV-EQPT:RNE-LIMERICK:3" or "RTRV-NBR:ALL:2"). So CTAG +// is field 3 when the command has 4+ fields, otherwise the last field. (The ";" +// terminator is stripped by readTL1 before this is called, so it is never a field.) +func parseTL1(raw string) (verb, tid, ctag string) { fields := strings.Split(strings.TrimSpace(raw), ":") if len(fields) > 0 { verb = strings.ToUpper(strings.TrimSpace(fields[0])) } - if len(fields) > 3 { + if len(fields) > 1 { + tid = strings.TrimSpace(fields[1]) + } + if len(fields) >= 4 { ctag = strings.TrimSpace(fields[3]) + } else if len(fields) > 0 { + ctag = strings.TrimSpace(fields[len(fields)-1]) } - return verb, ctag + return verb, tid, ctag } // parseActUser extracts the username and password from an ACT-USER command: @@ -268,19 +371,29 @@ func eqptPayload(s *tl1Session) string { return b.String() } -func almPayload(s *tl1Session) string { +func almPayload(sid string) 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 { +func condPayload(sid string) string { var b strings.Builder b.WriteString(" \"SLOT-1:T-OPR-OCH,NEND,,,,,:\\\"Optical power received\\\"\"\r\n") return b.String() } +// nbrPayload lists the RNEs reachable through this GNE (one quoted line each). +// Empty for a standalone node / legacy single-NE config — a valid empty COMPLD. +func nbrPayload(s *tl1Session) string { + var b strings.Builder + for _, tid := range s.rneOrder { + fmt.Fprintf(&b, " \"%s:PROTOCOL=OSC,REACHABLE=YES,STATE=IS-NR\"\r\n", tid) + } + return b.String() +} + func activeUserPayload(user string) string { if user == "" { user = "ADMIN" @@ -294,8 +407,8 @@ func swVerPayload(s *tl1Session) string { return " \"SWVER=12.4,LOAD=12.4-GA,STATUS=ACTIVE\"\r\n" } -func sysPayload(s *tl1Session) string { +func sysPayload(sid, serial string) string { var b strings.Builder - fmt.Fprintf(&b, " \"SID=%s,TYPE=6500-7SLOT,SHELFSN=%s\"\r\n", s.sid, s.serial) + fmt.Fprintf(&b, " \"SID=%s,TYPE=6500-7SLOT,SHELFSN=%s\"\r\n", sid, serial) return b.String() } diff --git a/internal/sshsrv/driver_ciena_test.go b/internal/sshsrv/driver_ciena_test.go index 0bc00a2..9036a19 100644 --- a/internal/sshsrv/driver_ciena_test.go +++ b/internal/sshsrv/driver_ciena_test.go @@ -23,19 +23,26 @@ func TestParseTL1(t *testing.T) { cases := []struct { raw string wantVerb string + wantTID 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"}, + // Strict form (VERB::AID:CTAG) — CTAG is field 3. + {"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"}, + // Short form (VERB:TID:CTAG) — CTAG is the last field; TID addresses an RNE. + {"RTRV-EQPT:RNE-LIMERICK:3", "RTRV-EQPT", "RNE-LIMERICK", "3"}, + {"RTRV-ALM-ALL:RNE-LIMERICK:4", "RTRV-ALM-ALL", "RNE-LIMERICK", "4"}, + {"RTRV-NBR:ALL:2", "RTRV-NBR", "ALL", "2"}, } 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) + verb, tid, ctag := parseTL1(c.raw) + if verb != c.wantVerb || tid != c.wantTID || ctag != c.wantCtag { + t.Errorf("parseTL1(%q) = (%q,%q,%q), want (%q,%q,%q)", + c.raw, verb, tid, ctag, c.wantVerb, c.wantTID, c.wantCtag) } } } @@ -144,6 +151,120 @@ func TestRequireSSHAuth(t *testing.T) { } } +func TestTidIsLocal(t *testing.T) { + own := "CIENA-LAB-0001" + local := []string{"", "ALL", "all", "CIENA-LAB-0001", "ciena-lab-0001"} + rne := []string{"RNE-CORK", "RNE-LIMERICK", "SOMETHING"} + for _, tid := range local { + if !tidIsLocal(tid, own) { + t.Errorf("tidIsLocal(%q) = false, want true", tid) + } + } + for _, tid := range rne { + if tidIsLocal(tid, own) { + t.Errorf("tidIsLocal(%q) = true, want false", tid) + } + } +} + +func TestIndexSections(t *testing.T) { + // No marker -> legacy single-NE: whole data is local, no RNEs. + plain := []byte(" \"SHELF-1::X\"\n \"SLOT-1:Y\"\n") + local, rne, order := indexSections(plain) + if string(local) != string(plain) || len(rne) != 0 || len(order) != 0 { + t.Errorf("no-marker: local=%q rne=%v order=%v", local, rne, order) + } + + // GNE + two RNEs. + data := []byte("GNE-A\nGNE-B\n;;RNE RNE-CORK\nCORK-1\n;;RNE RNE-GALWAY\nGAL-1\nGAL-2\n") + local, rne, order = indexSections(data) + if string(local) != "GNE-A\nGNE-B\n" { + t.Errorf("local section = %q", local) + } + if got := []string{"RNE-CORK", "RNE-GALWAY"}; order[0] != got[0] || order[1] != got[1] { + t.Errorf("order = %v, want %v", order, got) + } + if string(rne["RNE-CORK"]) != "CORK-1\n" { + t.Errorf("RNE-CORK = %q", rne["RNE-CORK"]) + } + if string(rne["RNE-GALWAY"]) != "GAL-1\nGAL-2\n" { + t.Errorf("RNE-GALWAY = %q", rne["RNE-GALWAY"]) + } + // Sections must be zero-copy sub-slices of the input (same backing array). + if &rne["RNE-CORK"][0] != &data[len("GNE-A\nGNE-B\n;;RNE RNE-CORK\n")] { + t.Error("RNE-CORK section is not a sub-slice of the input (copy detected)") + } +} + +// gneSession builds a logged-in GNE session over a sectioned config blob. +func gneSession(t *testing.T) (*sessionCtx, *tl1Session) { + t.Helper() + ctx := tl1Ctx("admin", "admin") + data := []byte(" \"SHELF-1::GNE\"\n;;RNE RNE-CORK\n \"SHELF-1::CORK\"\n;;RNE RNE-GALWAY\n \"SHELF-1::GAL\"\n") + ctx.dev.Data = data + s := newTL1Session(ctx) + s.localEQPT, s.rneEQPT, s.rneOrder = indexSections(data) + s.loggedIn = true + return ctx, s +} + +func TestTL1GNERouting(t *testing.T) { + ctx, s := gneSession(t) + + // Local RTRV-EQPT streams the GNE section; header SID is the GNE. + cmd, resp := ctx.dispatchTL1("RTRV-EQPT::ALL:100", s) + if cmd != CmdTL1RtrvEqpt || string(resp.ConfigOutput) != " \"SHELF-1::GNE\"\n" { + t.Errorf("local EQPT: cmd=%v body=%q", cmd, resp.ConfigOutput) + } + if !strings.Contains(string(resp.Output), "CIENA-LAB-0001") { + t.Errorf("local EQPT header should carry GNE SID: %q", resp.Output) + } + + // RTRV-EQPT to an RNE streams that RNE's section; header SID is the RNE TID. + cmd, resp = ctx.dispatchTL1("RTRV-EQPT:RNE-CORK:3", s) + if cmd != CmdTL1RtrvEqpt || string(resp.ConfigOutput) != " \"SHELF-1::CORK\"\n" { + t.Errorf("RNE EQPT: cmd=%v body=%q", cmd, resp.ConfigOutput) + } + if !strings.Contains(string(resp.Output), "M 3 COMPLD") || !strings.Contains(string(resp.Output), "RNE-CORK") { + t.Errorf("RNE EQPT header should carry RNE TID + ctag: %q", resp.Output) + } + + // Unknown TID -> DENY IIAC. + cmd, resp = ctx.dispatchTL1("RTRV-EQPT:RNE-NOPE:9", s) + if cmd != CmdTL1Deny || !strings.Contains(string(resp.Output), "IIAC") { + t.Errorf("unknown TID: cmd=%v out=%q, want DENY/IIAC", cmd, resp.Output) + } + + // RNE-targeted alarm: header SID is the RNE. + cmd, resp = ctx.dispatchTL1("RTRV-ALM-ALL:RNE-GALWAY:4", s) + if cmd != CmdTL1RtrvAlmAll || !strings.Contains(string(resp.Output), "RNE-GALWAY") { + t.Errorf("RNE alarm: cmd=%v out=%q", cmd, resp.Output) + } +} + +func TestTL1RtrvNbr(t *testing.T) { + ctx, s := gneSession(t) + cmd, resp := ctx.dispatchTL1("RTRV-NBR:ALL:2", s) + if cmd != CmdTL1RtrvNbr { + t.Fatalf("RTRV-NBR: cmd=%v, want CmdTL1RtrvNbr", cmd) + } + out := string(resp.Output) + for _, tid := range []string{"RNE-CORK", "RNE-GALWAY"} { + if !strings.Contains(out, tid) { + t.Errorf("RTRV-NBR list missing %q: %q", tid, out) + } + } + + // A standalone (no-RNE) session answers RTRV-NBR with an empty COMPLD. + ctxS := tl1Ctx("admin", "admin") + sStandalone := newTL1Session(ctxS) + sStandalone.loggedIn = true + cmd, resp = ctxS.dispatchTL1("RTRV-NBR:ALL:2", sStandalone) + if cmd != CmdTL1RtrvNbr || !strings.Contains(string(resp.Output), "M 2 COMPLD") { + t.Errorf("standalone RTRV-NBR: cmd=%v out=%q", cmd, resp.Output) + } +} + func TestTL1UnknownVerb(t *testing.T) { ctx := tl1Ctx("admin", "admin") s := newTL1Session(ctx)