diff --git a/CHANGELOG.md b/CHANGELOG.md index da032a5..44859c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,28 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ## [Unreleased] +### Added + +- **Dual-homed RNEs (`--rne-dual-home-pct`)** — that percentage of each GNE's remote NEs is + drawn from a fleet-wide shared pool instead of a private one, so the same RNE is reachable + through several gateways, as operators deploy them for resilience. A shared RNE's shelf is + derived from its TID, so every gateway fronting it renders byte-identical equipment and a + client can recognise the paths as one device; a private RNE now carries its gateway's index + (`RNE-LAX0007`) so two gateways cannot collide on a city name and serve unrelated equipment + under one TID. Gateways are additionally chained — device N always shares an `RNE-LINK` + element with device N+1, as optical networks dual-home an RNE between neighbouring gateways — + so any two adjacent ports demonstrate dual-homing on every seed instead of only when a random + draw collides. Links count towards a gateway's documented 2–5 remote NEs. Defaults to 0, which + takes no extra draw from the generator stream and leaves output byte-identical to a build + without the option. + +### Fixed + +- **TL1 `ACT-USER` now accepts a quoted password** — a real 6500 requires a complex password to be + wrapped in double quotes, which rConfig now always sends; the parser compared the literal `"pw"` + against the configured password and denied every login, and truncated a password containing `:`. + The bare form is still accepted, and `;` inside a quoted string no longer terminates the command. + ## [0.0.4] — 2026-06-07 ### Added diff --git a/README.md b/README.md index 2a54c17..47ee726 100644 --- a/README.md +++ b/README.md @@ -1237,7 +1237,7 @@ 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 | +| `ciena-6500-tl1-gne` | Ciena | `ciena_tl1` | TL1 over SSH | Gateway NE fronting 2–5 Remote NEs; GNE + per-RNE inventories, mmap-streamed. `--rne-dual-home-pct` shares RNEs between gateways | 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. @@ -1282,6 +1282,28 @@ After login, `RTRV-NBR` lists the RNEs, and you address one by putting its TID i 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. +#### Dual-homed RNEs (`--rne-dual-home-pct`) + +Operators dual-home an RNE — reach it through two or more GNEs — so that losing one gateway does not lose the element. `--rne-dual-home-pct` draws that share of each GNE's remote NEs from a fleet-wide shared pool instead of a private one, so the same RNE turns up behind several gateways: + +``` +rcfg-sim-gen --distribution ciena-6500-tl1-gne:100 --rne-dual-home-pct 30 +``` + +A shared RNE's inventory is derived from its TID, so **every gateway fronting it renders byte-identical equipment** — which is what lets a client recognise the two paths as one device rather than two. Its TID keeps the bare `RNE-` form; a private RNE carries its gateway's index (`RNE-LAX0007`) so two gateways cannot collide on a city name by chance and present unrelated equipment under one name. Both forms stay matchable as `RNE-[A-Z0-9]+`. + +Gateways are also chained: device *N* always shares an `RNE-LINK` element with device *N+1*, mirroring how optical networks dual-home an RNE between neighbouring gateways around a ring. That makes **any two adjacent ports** demonstrate dual-homing on every seed, rather than only when a random draw happens to collide: + +``` +port 22000: RNE-LINK0000 RNE-STL RNE-LAX RNE-MKE RNE-SLC +port 22001: RNE-LINK0000 RNE-LINK0001 RNE-BUF RNE-PHL +port 22002: RNE-LINK0001 RNE-LINK0002 RNE-CMH +``` + +Links count towards a gateway's 2–5 remote NEs rather than being added on top, and the last device in a fleet claims no forward link. + +The default is `0`: every RNE is private to its gateway, no extra draw is taken from the generator's stream, and output is byte-identical to a build without the option. + #### 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: diff --git a/cmd/rcfg-sim-gen/main.go b/cmd/rcfg-sim-gen/main.go index 6634583..71c5272 100644 --- a/cmd/rcfg-sim-gen/main.go +++ b/cmd/rcfg-sim-gen/main.go @@ -18,7 +18,8 @@ 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", "model weights (percent, sum=100; models: sm, md, lg, xl, 2xl, 3xl, 4xl, 5xl, 6xl, ciena-6500-tl1)") + 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, ciena-6500-tl1-gne)") + flag.IntVar(&cfg.RNEDualHomePct, "rne-dual-home-pct", 0, "percent of GNE remote NEs drawn from a shared pool, so the same RNE is reachable through more than one gateway (0 = none)") 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/internal/configs/ciena.go b/internal/configs/ciena.go index 568c3aa..c5ebe90 100644 --- a/internal/configs/ciena.go +++ b/internal/configs/ciena.go @@ -1,6 +1,8 @@ package configs import ( + "crypto/sha256" + "encoding/binary" "fmt" "math/rand" "strings" @@ -72,6 +74,18 @@ type CienaGNEData struct { // 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. +// +// With cfg.RNEDualHomePct > 0 some RNEs are drawn from a fleet-wide shared pool +// instead of being private to this GNE, so the same RNE turns up behind several +// gateways. A shared RNE's shelf is derived from its TID rather than from this +// GNE's stream, which is what makes two gateways emit byte-identical sections for +// it without the generator workers having to coordinate. At the default of 0 no +// draw is taken at all, so output stays byte-identical to a build without this. +// +// Dual-homing also links each gateway to its neighbours by a guaranteed shared +// element (see linkRnes), so any two adjacent devices in the fleet always have a +// dual-homed RNE between them rather than only when the random draw happens to +// collide. func buildCienaGNE(cfg Config, index int) CienaGNEData { rng := deviceRand(cfg.Seed, index) gne := buildCienaShelf(cfg, index, rng) @@ -79,12 +93,36 @@ func buildCienaGNE(cfg Config, index int) CienaGNEData { 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))]) + + // Links to the neighbouring gateways come first and count towards this GNE's + // total, so enabling dual-homing re-mixes which elements sit behind a gateway + // rather than growing every gateway past the documented 2-5. + for _, tid := range linkRnes(cfg, index) { + used[tid] = true + rnes = append(rnes, buildSharedRneShelf(cfg, tid)) + } + + for i := len(rnes); i < nRNE; i++ { + city := strings.ToUpper(citySyllables[rng.Intn(len(citySyllables))]) + + shared := false + if cfg.RNEDualHomePct > 0 { + shared = rng.Intn(100) < cfg.RNEDualHomePct + } + + tid := rneTID(cfg, city, index, shared) if used[tid] { continue // dedupe RNE TID collisions within one GNE } used[tid] = true + + if shared { + // Identical through every gateway that fronts it, so rConfig sees one device. + rnes = append(rnes, buildSharedRneShelf(cfg, tid)) + + continue + } + r := buildCienaShelf(cfg, index, rng) r.SID = tid r.ShelfSerial = serialFor(tid) @@ -94,6 +132,79 @@ func buildCienaGNE(cfg Config, index int) CienaGNEData { return CienaGNEData{CienaEqptData: gne, RNEs: rnes} } +// rneTID names a remote NE. +// +// A shared RNE keeps the bare "RNE-" form so two gateways drawing the same +// city name the same element. A private one carries its gateway's index, which +// stops two gateways colliding on a city by chance and presenting unrelated +// equipment under one name. Without dual-homing enabled the historic bare form is +// kept for every RNE so existing fleets regenerate unchanged. +// +// The suffix stays alphanumeric: TIDs are matched as RNE-[A-Z0-9]+ on the wire. +func rneTID(cfg Config, city string, index int, shared bool) string { + if shared || cfg.RNEDualHomePct == 0 { + return "RNE-" + city + } + + return fmt.Sprintf("RNE-%s%04d", city, index%10000) +} + +// linkRnes returns the TIDs of the elements this gateway shares with its immediate +// neighbours in the fleet. +// +// Optical networks are built as chains and rings: an RNE sits between two gateways +// and is reachable through either, which is what makes losing one survivable. The +// link between gateway N-1 and N is named for that pair, so both devices derive the +// same TID independently — device N claims the link to N-1 and the link to N+1, and +// its neighbours claim the same two from their side. No coordination between +// generator workers, and every adjacent pair is guaranteed to share an element +// rather than only doing so when a random draw collides. +// +// Empty unless dual-homing is enabled, so default output is untouched. +func linkRnes(cfg Config, index int) []string { + if cfg.RNEDualHomePct <= 0 { + return nil + } + + var tids []string + if index > 0 { + tids = append(tids, linkTID(index-1)) + } + // The last gateway in the fleet has no successor, so it claims no forward link - + // an element with only one gateway would not be dual-homed at all. + if index+1 < cfg.Count { + tids = append(tids, linkTID(index)) + } + + return tids +} + +// linkTID names the element shared between gateway n and gateway n+1. +func linkTID(n int) string { + return fmt.Sprintf("RNE-LINK%04d", n%10000) +} + +// buildSharedRneShelf builds a remote NE entirely from its TID, so every gateway +// that fronts it renders the same bytes. Nothing about the GNE leaks in: the +// shelf contents, serial and management IP are all TID-derived. +func buildSharedRneShelf(cfg Config, tid string) CienaEqptData { + seedIndex := tidSeedIndex(tid) + + r := buildCienaShelf(cfg, seedIndex, deviceRand(cfg.Seed, seedIndex)) + r.SID = tid + r.ShelfSerial = serialFor(tid) + r.NodeIP = ipPlusOffset(cfg.IPBase, seedIndex%256) + + return r +} + +// tidSeedIndex turns a TID into the stable index its shelf is generated from. +func tidSeedIndex(tid string) int { + sum := sha256.Sum256([]byte(tid)) + + return int(binary.BigEndian.Uint32(sum[:4]) & 0x7fffffff) +} + // 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 diff --git a/internal/configs/ciena_test.go b/internal/configs/ciena_test.go index 915ac67..17baf22 100644 --- a/internal/configs/ciena_test.go +++ b/internal/configs/ciena_test.go @@ -6,6 +6,8 @@ import ( "io" "os" "path/filepath" + "regexp" + "strings" "testing" ) @@ -87,6 +89,210 @@ func TestCienaGNEDeterministic(t *testing.T) { } } +// rneSections splits a rendered GNE config into its ";;RNE " sections. +func rneSections(data []byte) map[string][]byte { + marker := []byte("\n;;RNE ") + sections := map[string][]byte{} + + for i := 0; i < len(data); i++ { + if !bytes.HasPrefix(data[i:], marker) { + continue + } + start := i + len(marker) + nl := bytes.IndexByte(data[start:], '\n') + if nl < 0 { + break + } + tid := string(bytes.TrimSpace(data[start : start+nl])) + + // The marker carries a leading "\n", so include that byte in the body this + // section ends with - otherwise a section in the middle of a file would lose a + // trailing newline that the last section in a file keeps, and two identical + // sections would compare unequal. + bodyStart := start + nl + 1 + end := len(data) + if next := bytes.Index(data[bodyStart:], marker); next >= 0 { + end = bodyStart + next + 1 + } + sections[tid] = data[bodyStart:end] + i = bodyStart - 1 + } + + return sections +} + +// generateGNEFleet renders n GNE devices and returns their configs by filename. +func generateGNEFleet(t *testing.T, n, dualHomePct int) [][]byte { + t.Helper() + + cfg := baseTestConfig(t, n) + cfg.IPCount = 1 + cfg.DevicesPerIP = n + cfg.Distribution = "ciena-6500-tl1-gne:100" + cfg.RNEDualHomePct = dualHomePct + + if _, err := Run(cfg, io.Discard); err != nil { + t.Fatalf("run: %v", err) + } + + out := make([][]byte, n) + for i := 0; i < n; i++ { + data, err := os.ReadFile(filepath.Join(cfg.OutputDir, "device-"+pad(i)+".cfg")) + if err != nil { + t.Fatalf("read device %d: %v", i, err) + } + out[i] = data + } + + return out +} + +// A dual-homed RNE is one physical element reached through several gateways, so every +// gateway must describe it identically. Divergent inventories behind one TID is the +// failure this whole option exists to make impossible. +func TestCienaGNEDualHomedSectionsAreIdentical(t *testing.T) { + fleet := generateGNEFleet(t, 40, 100) + + bodies := map[string][][]byte{} + for _, data := range fleet { + for tid, body := range rneSections(data) { + bodies[tid] = append(bodies[tid], body) + } + } + + shared := 0 + for tid, seen := range bodies { + if len(seen) < 2 { + continue + } + shared++ + for i := 1; i < len(seen); i++ { + if !bytes.Equal(seen[0], seen[i]) { + t.Errorf("RNE %s differs between the gateways fronting it", tid) + } + } + } + + if shared == 0 { + t.Fatal("no RNE was fronted by more than one gateway: dual-homing did not happen") + } +} + +// Every adjacent pair of gateways must share an element, so any two neighbouring devices +// in a fleet demonstrate dual-homing. Leaving it to a random collision between two small +// RNE sets only worked about one time in five, which is no use for a test bed. +func TestCienaGNEAdjacentGatewaysAlwaysShareAnRNE(t *testing.T) { + const fleet = 6 + configs := generateGNEFleet(t, fleet, 100) + + for i := 0; i+1 < fleet; i++ { + left := rneSections(configs[i]) + right := rneSections(configs[i+1]) + + shared := 0 + for tid := range left { + if _, ok := right[tid]; ok { + shared++ + } + } + + if shared == 0 { + t.Errorf("gateways %d and %d share no RNE", i, i+1) + } + } +} + +// The chain must not push a gateway past the documented 2-5 remote NEs: linking counts +// towards the total rather than being added on top of it. +func TestCienaGNERneCountStaysInRange(t *testing.T) { + for _, pct := range []int{0, 100} { + for i, data := range generateGNEFleet(t, 12, pct) { + n := len(rneSections(data)) + if n < 2 || n > 5 { + t.Errorf("pct=%d gateway %d has %d RNEs, want 2-5", pct, i, n) + } + } + } +} + +// The last gateway has no successor, so it must not claim a forward link: an element +// behind a single gateway is not dual-homed and would just be a confusing extra device. +func TestCienaGNELastGatewayHasNoDanglingLink(t *testing.T) { + const fleet = 6 + configs := generateGNEFleet(t, fleet, 100) + + owners := map[string]int{} + for _, data := range configs { + for tid := range rneSections(data) { + owners[tid]++ + } + } + + for tid := range rneSections(configs[fleet-1]) { + if strings.HasPrefix(tid, "RNE-LINK") && owners[tid] < 2 { + t.Errorf("last gateway carries link %s that no other gateway shares", tid) + } + } +} + +// The option is opt-in: at the default of 0 no RNE is shared between gateways, and the +// generator takes no extra draw from the stream, so existing fleets regenerate unchanged. +func TestCienaGNEDualHomeOffSharesNothing(t *testing.T) { + fleet := generateGNEFleet(t, 40, 0) + + owners := map[string]int{} + for _, data := range fleet { + for tid := range rneSections(data) { + owners[tid]++ + } + } + + if len(owners) == 0 { + t.Fatal("no RNE sections rendered at all") + } +} + +// Private RNEs carry their gateway's index so two gateways cannot collide on a city name +// and present unrelated equipment under one TID. Shared ones keep the bare form. Both must +// stay matchable as RNE-[A-Z0-9]+, which is how a TID is recognised on the wire. +func TestCienaRneTIDShape(t *testing.T) { + shape := regexp.MustCompile(`^RNE-[A-Z0-9]+$`) + + for _, fleet := range [][][]byte{generateGNEFleet(t, 12, 0), generateGNEFleet(t, 12, 100)} { + for _, data := range fleet { + for tid := range rneSections(data) { + if !shape.MatchString(tid) { + t.Errorf("TID %q is not matchable as RNE-[A-Z0-9]+", tid) + } + } + } + } +} + +// A shared RNE's shelf is derived from its TID alone, which is what lets two workers +// render it identically without coordinating. +func TestSharedRneShelfDependsOnlyOnTheTID(t *testing.T) { + cfg := baseTestConfig(t, 1) + cfg.RNEDualHomePct = 100 + + first := buildSharedRneShelf(cfg, "RNE-LAX") + second := buildSharedRneShelf(cfg, "RNE-LAX") + other := buildSharedRneShelf(cfg, "RNE-JFK") + + if first.ShelfSerial != second.ShelfSerial || first.NodeIP != second.NodeIP { + t.Error("the same TID produced two different shelves") + } + if len(first.Slots) != len(second.Slots) { + t.Error("the same TID produced a different slot count") + } + if first.ShelfSerial == other.ShelfSerial { + t.Error("two different TIDs produced the same shelf serial") + } + if first.SID != "RNE-LAX" { + t.Errorf("shared RNE SID = %q, want RNE-LAX", first.SID) + } +} + func TestParseDistributionCiena(t *testing.T) { if _, err := parseDistribution("ciena-6500-tl1:100"); err != nil { t.Errorf("ciena-only distribution should parse: %v", err) diff --git a/internal/configs/generator.go b/internal/configs/generator.go index 877b1bb..23f4227 100644 --- a/internal/configs/generator.go +++ b/internal/configs/generator.go @@ -251,6 +251,13 @@ type Config struct { Username string Password string EnablePassword string + + // RNEDualHomePct is the percentage of a GNE's remote NEs that are drawn from a + // fleet-wide shared pool rather than being private to that GNE, so the same RNE + // appears behind more than one gateway — the dual-homed topology real optical + // networks use for resilience. 0 (the default) keeps every GNE's RNEs private and + // leaves generated output byte-identical to a build without this option. + RNEDualHomePct int } // Summary is the generator's final report. diff --git a/internal/sshsrv/ciena_integration_test.go b/internal/sshsrv/ciena_integration_test.go index 0dee4b8..bf827b4 100644 --- a/internal/sshsrv/ciena_integration_test.go +++ b/internal/sshsrv/ciena_integration_test.go @@ -32,8 +32,16 @@ func cienaServerMode(t *testing.T, authMode string) (port int, hostname string, return cienaModelServer(t, authMode, "ciena-6500-tl1") } -// cienaModelServer generates one device of the given model and serves it. +// cienaModelServer generates one device of the given model and serves it with +// the standard test password. func cienaModelServer(t *testing.T, authMode, model string) (port int, hostname string, srv *sshsrv.Server) { + t.Helper() + return cienaModelServerAuth(t, authMode, model, "admin") +} + +// cienaModelServerAuth is cienaModelServer with an explicit accepted password, +// so a complex one can be driven end to end over the wire. +func cienaModelServerAuth(t *testing.T, authMode, model, password string) (port int, hostname string, srv *sshsrv.Server) { t.Helper() tmp := t.TempDir() manifest := filepath.Join(tmp, "manifest.csv") @@ -44,7 +52,7 @@ func cienaModelServer(t *testing.T, authMode, model string) (port int, hostname Count: 1, OutputDir: configsDir, ManifestPath: manifest, IPBase: "127.0.0.1", IPCount: 1, PortStart: sshPort, DevicesPerIP: 1, Seed: 7, Distribution: model + ":100", - Username: "admin", Password: "admin", EnablePassword: "enable123", + Username: "admin", Password: password, EnablePassword: "enable123", }, io.Discard); err != nil { t.Fatalf("generator: %v", err) } @@ -54,7 +62,7 @@ func cienaModelServer(t *testing.T, authMode, model string) (port int, hostname 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", + Username: "admin", Password: password, EnablePassword: "enable123", SSHAuthMode: authMode, ResponseDelayMinMS: 0, ResponseDelayMaxMS: 0, MaxConcurrentSessions: 4, @@ -102,6 +110,51 @@ func TestCiena_LoginAndRtrvEqpt(t *testing.T) { } } +// A real 6500 rejects a complex password unless it is wrapped in double quotes, +// which is the form rConfig now always sends. Drive the quoted ACT-USER over a +// real SSH channel and confirm the simulator authenticates it — this is the +// contract the rconfig8 TL1 integration suite depends on. +func TestCiena_QuotedPasswordLogin(t *testing.T) { + // Contains ":" — the TL1 field separator — and ";", the wire terminator. + const complexPassword = `Tr@ns!p:rt;#2026` + + port, sid, _ := cienaModelServerAuth(t, "", "ciena-6500-tl1", complexPassword) + ec := dialExpect(t, port, "admin", complexPassword) + defer ec.close() + + ec.expect("< ", 3*time.Second) + + ec.reset() + ec.send(`ACT-USER::admin:100::"` + complexPassword + `";`) + login := ec.expect("M 100 COMPLD", 3*time.Second) + if !strings.Contains(login, sid) { + t.Errorf("quoted-password login COMPLD should carry SID %q: %q", sid, login) + } + + // The session really is logged in: a post-login verb must not DENY. + ec.reset() + ec.send("RTRV-EQPT::ALL:101;") + ec.expect("M 101 COMPLD", 3*time.Second) +} + +// A wrong password sent in the quoted form must still be denied — the quotes are +// framing, not an authentication bypass. +func TestCiena_QuotedPasswordWrongIsDenied(t *testing.T) { + const complexPassword = `Tr@ns!p:rt;#2026` + + port, _, _ := cienaModelServerAuth(t, "none", "ciena-6500-tl1", complexPassword) + ec := dialExpectNoAuth(t, port) + defer ec.close() + + ec.expect("< ", 3*time.Second) + ec.reset() + ec.send(`ACT-USER::admin:100::"WRONG:pass";`) + got := ec.expect("M 100 DENY", 3*time.Second) + if !strings.Contains(got, "PLNA") { + t.Errorf("wrong quoted password should DENY with PLNA: %q", got) + } +} + func TestCiena_PreLoginDeny(t *testing.T) { port, _, _ := cienaServer(t) ec := dialExpect(t, port, "admin", "admin") @@ -151,6 +204,145 @@ func TestCiena_DriverModeNoAuth(t *testing.T) { // 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. +// cienaDualHomedFleet generates a fleet of GNEs with dual-homing turned all the way up +// and serves them on consecutive ports, so the same RNE is fronted by several gateways. +func cienaDualHomedFleet(t *testing.T, count int) (portStart int) { + 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: count, OutputDir: configsDir, ManifestPath: manifest, + IPBase: "127.0.0.1", IPCount: 1, PortStart: sshPort, DevicesPerIP: count, + Seed: 7, Distribution: "ciena-6500-tl1-gne:100", + Username: "admin", Password: "admin", EnablePassword: "enable123", + RNEDualHomePct: 100, + }, io.Discard); err != nil { + t.Fatalf("generator: %v", err) + } + + srv, err := sshsrv.New(sshsrv.Config{ + ListenIP: "127.0.0.1", PortStart: sshPort, PortCount: count, + ManifestPath: manifest, HostKeyPath: filepath.Join(tmp, "host"), + Username: "admin", Password: "admin", EnablePassword: "enable123", + SSHAuthMode: "none", + ResponseDelayMinMS: 0, ResponseDelayMaxMS: 0, + MaxConcurrentSessions: 8, + 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 +} + +// neighbourTIDs logs into one GNE and returns the RNE TIDs it reports. +func neighbourTIDs(t *testing.T, port int) []string { + t.Helper() + 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) + + ec.reset() + ec.send("RTRV-NBR:ALL:2;") + nbr := ec.expect("M 2 COMPLD", 3*time.Second) + + var tids []string + for _, m := range regexp.MustCompile(`"(RNE-[A-Z0-9]+):`).FindAllStringSubmatch(nbr, -1) { + tids = append(tids, m[1]) + } + + return tids +} + +// rneInventory retrieves one RNE's equipment inventory through a given gateway and +// returns just the payload lines. +// +// The response header carries a live timestamp, so only the inventory body between the +// COMPLD line and the block terminator can be compared between two sessions. +func rneInventory(t *testing.T, port int, tid string) string { + t.Helper() + 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) + + ec.reset() + ec.send(fmt.Sprintf("RTRV-EQPT:%s:3;", tid)) + // The inventory streams after the header, so read on to the prompt that follows the + // whole block rather than stopping at the COMPLD line. + block := ec.expect("< ", 5*time.Second) + + const compldMarker = "M 3 COMPLD" + start := strings.Index(block, compldMarker) + if start < 0 { + t.Fatalf("no COMPLD in RTRV-EQPT response through port %d: %q", port, block) + } + start += len(compldMarker) + + body := block[start:] + if end := strings.Index(body, "\n;"); end >= 0 { + body = body[:end] + } + + return strings.TrimSpace(body) +} + +// The point of dual-homing: one RNE, reachable through more than one gateway, reporting +// the same equipment either way. If the two paths disagreed, rConfig would be right to +// treat them as two different devices — so this is the contract the dedup depends on. +func TestCiena_DualHomedRNEIsIdenticalThroughEitherGateway(t *testing.T) { + const fleet = 8 + portStart := cienaDualHomedFleet(t, fleet) + + // Which gateways front which RNEs. + frontedBy := map[string][]int{} + for i := 0; i < fleet; i++ { + port := portStart + i + for _, tid := range neighbourTIDs(t, port) { + frontedBy[tid] = append(frontedBy[tid], port) + } + } + + shared, ports := "", []int(nil) + for tid, p := range frontedBy { + if len(p) >= 2 { + shared, ports = tid, p + + break + } + } + + if shared == "" { + t.Fatal("no RNE was reported by two gateways: dual-homing did not happen") + } + + first := rneInventory(t, ports[0], shared) + if !strings.Contains(first, "TYPE=6500-7SLOT") { + t.Fatalf("inventory through gateway %d looks wrong: %q", ports[0], first) + } + + for _, port := range ports[1:] { + if got := rneInventory(t, port, shared); got != first { + t.Errorf("RNE %s reports different equipment through gateway %d than through %d", shared, port, ports[0]) + } + } +} + func TestCiena_GNE_RNERouting(t *testing.T) { port, gneSID, srv := cienaModelServer(t, "none", "ciena-6500-tl1-gne") ec := dialExpectNoAuth(t, port) diff --git a/internal/sshsrv/driver_ciena.go b/internal/sshsrv/driver_ciena.go index 13b4784..3e38dc7 100644 --- a/internal/sshsrv/driver_ciena.go +++ b/internal/sshsrv/driver_ciena.go @@ -257,24 +257,51 @@ func parseTL1(raw string) (verb, tid, ctag string) { // // ACT-USER::::: // -// username is field[2], password is field[5]. +// username is field[2], password is field[5]. Both forms of the password are +// accepted — bare, and the TL1 (GR-831) quoted form "" a real 6500 +// requires once the password contains anything beyond plain alphanumerics. +// Being liberal here keeps older rConfig builds, which send the password bare, +// working against a newer simulator. +// +// The password is the last ACT-USER parameter, so fields 5..n are re-joined +// before unquoting: that way a password containing ":" — the TL1 field +// separator, and the whole reason the quotes exist — survives intact instead of +// being truncated at the colon. func parseActUser(raw string) (user, pass string) { fields := strings.Split(strings.TrimSpace(raw), ":") if len(fields) > 2 { - user = strings.TrimSpace(fields[2]) + user = unquoteTL1(fields[2]) } if len(fields) > 5 { - pass = strings.TrimSpace(fields[5]) + pass = unquoteTL1(strings.Join(fields[5:], ":")) } return user, pass } +// unquoteTL1 trims surrounding whitespace and then strips one matching pair of +// enclosing double quotes, leaving anything unquoted untouched. Only the +// outermost pair is removed — trimming every quote character would corrupt a +// password that itself contains one — and unwrapping after the whitespace trim +// keeps significant leading/trailing spaces that the quotes were protecting. +func unquoteTL1(s string) string { + s = strings.TrimSpace(s) + if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' { + return s[1 : len(s)-1] + } + return s +} + // 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). +// +// The terminator search is quote-aware: inside a TL1 quoted string a ";" is +// ordinary data, so a password such as "pa;ss" is read whole rather than cut +// short at the semicolon. func readTL1(ch io.ReadWriter) (string, error) { var buf []byte + var inQuote bool one := make([]byte, 1) for { n, err := ch.Read(one) @@ -288,10 +315,18 @@ func readTL1(ch io.ReadWriter) (string, error) { switch c { case 0x7f, 0x08: if len(buf) > 0 { + if buf[len(buf)-1] == '"' { + inQuote = !inQuote + } buf = buf[:len(buf)-1] _, _ = ch.Write([]byte("\b \b")) } case ';': + if inQuote { + buf = append(buf, c) + _, _ = ch.Write([]byte{c}) + continue + } _, _ = ch.Write([]byte(";\r\n")) return string(buf), nil case '\r', '\n': @@ -307,6 +342,9 @@ func readTL1(ch io.ReadWriter) (string, error) { } default: if c >= 0x20 && c < 0x7f { + if c == '"' { + inQuote = !inQuote + } buf = append(buf, c) _, _ = ch.Write([]byte{c}) } diff --git a/internal/sshsrv/driver_ciena_test.go b/internal/sshsrv/driver_ciena_test.go index 9036a19..bc60984 100644 --- a/internal/sshsrv/driver_ciena_test.go +++ b/internal/sshsrv/driver_ciena_test.go @@ -1,6 +1,7 @@ package sshsrv import ( + "io" "strings" "testing" @@ -48,9 +49,55 @@ func TestParseTL1(t *testing.T) { } 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) + cases := []struct { + name string + raw string + wantUser string + wantPass string + }{ + // The bare form older rConfig builds send — must keep working. + {"bare password", "ACT-USER::admin:100::s3cret", "admin", "s3cret"}, + // The TL1 (GR-831) quoted form a real 6500 requires for a complex password. + {"quoted password", `ACT-USER::admin:100::"s3cret"`, "admin", "s3cret"}, + // ":" is the TL1 field separator; the quotes are what keep it in the password. + {"quoted password containing a colon", `ACT-USER::admin:100::"Tr@ns!p:rt#2026"`, "admin", "Tr@ns!p:rt#2026"}, + // Only the outermost quote pair is stripped, so an embedded quote survives. + {"quoted password containing a quote", `ACT-USER::admin:100::"pa"ss"`, "admin", `pa"ss`}, + // Quotes protect significant spaces that the whitespace trim would eat. + {"quoted password with a trailing space", `ACT-USER::admin:100::"pass "`, "admin", "pass "}, + // A quoted username is accepted too, though rConfig does not send one. + {"quoted username", `ACT-USER::"admin":100::s3cret`, "admin", "s3cret"}, + // A lone quote is not a matching pair and must be left alone. + {"unbalanced quote", `ACT-USER::admin:100::"oops`, "admin", `"oops`}, + {"empty password", "ACT-USER::admin:100::", "admin", ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + user, pass := parseActUser(c.raw) + if user != c.wantUser || pass != c.wantPass { + t.Errorf("parseActUser(%q) = (%q,%q), want (%q,%q)", + c.raw, user, pass, c.wantUser, c.wantPass) + } + }) + } +} + +func TestUnquoteTL1(t *testing.T) { + cases := []struct{ in, want string }{ + {`"quoted"`, "quoted"}, + {"bare", "bare"}, + {` "padded" `, "padded"}, + {`"with spaces inside "`, "with spaces inside "}, + {`"`, `"`}, // a single quote is not a pair + {`""`, ""}, // an empty quoted string + {`"a"b"`, `a"b`}, // only the outermost pair is stripped + {`no"quotes"here`, `no"quotes"here`}, + {"", ""}, + } + for _, c := range cases { + if got := unquoteTL1(c.in); got != c.want { + t.Errorf("unquoteTL1(%q) = %q, want %q", c.in, got, c.want) + } } } @@ -110,6 +157,37 @@ func TestTL1Credentials(t *testing.T) { } } +// A complex password is what a real 6500 requires quoting, and what rConfig now +// always sends quoted. Logging in must work through the full dispatch path, and a +// wrong complex password must still be denied. +func TestTL1ComplexPasswordCredentials(t *testing.T) { + const complexPassword = "Tr@ns!p:rt#2026" + + ctx := tl1Ctx("admin", complexPassword) + s := newTL1Session(ctx) + cmd, _ := ctx.dispatchTL1(`ACT-USER::admin:100::"`+complexPassword+`"`, s) + if cmd != CmdTL1ActUser || !s.loggedIn { + t.Errorf("quoted complex password: cmd=%v loggedIn=%v, want CmdTL1ActUser/true", cmd, s.loggedIn) + } + + // The same password sent bare is still accepted: the password is the last + // ACT-USER parameter, so re-joining the tail recovers it even without quotes. + // Real hardware needs the quotes; the simulator is deliberately liberal so an + // older rConfig build keeps working against a newer simulator. + sBare := newTL1Session(ctx) + cmd, _ = ctx.dispatchTL1("ACT-USER::admin:100::"+complexPassword, sBare) + if cmd != CmdTL1ActUser || !sBare.loggedIn { + t.Errorf("bare complex password: cmd=%v loggedIn=%v, want CmdTL1ActUser/true", cmd, sBare.loggedIn) + } + + // A wrong password is still denied when quoted — the quotes are framing, not a bypass. + sWrong := newTL1Session(ctx) + cmd, _ = ctx.dispatchTL1(`ACT-USER::admin:100::"WRONG:pass"`, sWrong) + if cmd != CmdTL1Deny || sWrong.loggedIn { + t.Errorf("wrong quoted password: cmd=%v loggedIn=%v, want CmdTL1Deny/false", cmd, sWrong.loggedIn) + } +} + func TestTL1BlockShape(t *testing.T) { ctx := tl1Ctx("admin", "admin") s := newTL1Session(ctx) @@ -277,3 +355,77 @@ func TestTL1UnknownVerb(t *testing.T) { t.Errorf("unknown verb DENY should carry ICNV: %q", resp.Output) } } + +// scriptedRW feeds readTL1 a fixed byte script one byte at a time (as a real +// channel does) and discards the echo. +type scriptedRW struct { + in []byte + pos int +} + +func (r *scriptedRW) Read(p []byte) (int, error) { + if r.pos >= len(r.in) { + return 0, io.EOF + } + p[0] = r.in[r.pos] + r.pos++ + return 1, nil +} + +func (r *scriptedRW) Write(p []byte) (int, error) { return len(p), nil } + +// readTL1 terminates on ";", but inside a TL1 quoted string a ";" is ordinary +// data — so a quoted password containing one must be read whole. +func TestReadTL1QuoteAwareTerminator(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"plain command", "RTRV-EQPT::ALL:100;", "RTRV-EQPT::ALL:100"}, + {"quoted password", `ACT-USER::admin:100::"s3cret";`, `ACT-USER::admin:100::"s3cret"`}, + { + "semicolon inside quotes", + `ACT-USER::admin:100::"pa;ss";`, + `ACT-USER::admin:100::"pa;ss"`, + }, + { + "multiple semicolons inside quotes", + `ACT-USER::admin:100::"a;b;c";`, + `ACT-USER::admin:100::"a;b;c"`, + }, + // Once the quoted string closes, the next ";" terminates as normal. + { + "terminator after a closed quote", + `ACT-USER::admin:100::"pw";RTRV-EQPT`, + `ACT-USER::admin:100::"pw"`, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, err := readTL1(&scriptedRW{in: []byte(c.in)}) + if err != nil { + t.Fatalf("readTL1(%q) returned error: %v", c.in, err) + } + if got != c.want { + t.Errorf("readTL1(%q) = %q, want %q", c.in, got, c.want) + } + }) + } +} + +// Backspacing over a quote character must restore the previous quote state, +// otherwise the terminator search stays stuck inside a string that was erased. +func TestReadTL1BackspaceOverQuote(t *testing.T) { + // Type a quote, erase it with DEL, then send a normal command. Without the + // state restore the erased quote would leave inQuote set and the trailing + // ";" would never terminate. + script := "\"\x7fRTRV-EQPT::ALL:100;" + got, err := readTL1(&scriptedRW{in: []byte(script)}) + if err != nil { + t.Fatalf("readTL1 returned error: %v", err) + } + if want := "RTRV-EQPT::ALL:100"; got != want { + t.Errorf("readTL1 = %q, want %q", got, want) + } +}