diff --git a/src/declaration/validate.ts b/src/declaration/validate.ts index 21455aa..f705a99 100644 --- a/src/declaration/validate.ts +++ b/src/declaration/validate.ts @@ -149,6 +149,42 @@ export function validate(scenario: Scenario): void { } } + // Two gateways sharing an address are the same box, and one box cannot behave two ways. + // Left unchecked this raised two routers holding one address on one segment, where the + // address resolved to whichever answered ARP last — so a published port worked or did + // not, run to run, with nothing reporting a fault. + const gateways = Object.entries(scenario.segments) + .filter(([, segment]) => segment.gateway) + .map(([name, segment]) => ({ name, gateway: segment.gateway! })); + + for (let i = 0; i < gateways.length; i++) { + for (let j = i + 1; j < gateways.length; j++) { + const a = gateways[i]!; + const b = gateways[j]!; + if (a.gateway.to !== b.gateway.to) continue; + const shared = a.gateway.address.filter((address) => b.gateway.address.includes(address)); + if (shared.length === 0) continue; + + const differences: string[] = []; + if ([...a.gateway.nat].sort().join(",") !== [...b.gateway.nat].sort().join(",")) { + differences.push(`nat (${a.gateway.nat.join("+") || "none"} vs ${b.gateway.nat.join("+") || "none"})`); + } + if (a.gateway.forwardable !== b.gateway.forwardable) { + differences.push(`forwardable (${a.gateway.forwardable} vs ${b.gateway.forwardable})`); + } + if (a.gateway.mappingTtl !== b.gateway.mappingTtl) { + differences.push(`mapping_ttl (${a.gateway.mappingTtl ?? "none"} vs ${b.gateway.mappingTtl ?? "none"})`); + } + if (differences.length > 0) { + problems.push( + `segments '${a.name}' and '${b.name}' declare gateways on '${a.gateway.to}' sharing ` + + `address '${shared[0]}', so they are one gateway — but they disagree on ` + + `${differences.join(" and ")}. One box cannot behave two ways.`, + ); + } + } + } + // A gateway chain must terminate. A cycle would raise forever rather than fail. for (const name of segmentNames) { const seen = new Set([name]); diff --git a/src/lifecycle/router.ts b/src/lifecycle/router.ts index 2beb2ea..5d28dc5 100644 --- a/src/lifecycle/router.ts +++ b/src/lifecycle/router.ts @@ -132,32 +132,54 @@ export interface RouterPlan { * router, not several — that is what a VLAN-capable router is, and two routers sharing an * external address would not work anyway. */ +/** + * Gateways that share an address are ONE gateway. + * + * Grouping on the exact address list instead split a household in two: `home` declaring a + * v4 and a v6 address and `devices` declaring only the v4 produced two router containers, + * both holding the same v4 address on the same segment. The lab raised it, and the shared + * address resolved to whichever container answered ARP last — so a published port worked or + * did not, run to run, with nothing reporting a fault. + * + * One public address is one box. Checked against the real thing this models: a bridged + * modem, a single gateway holding the public address, everything behind it on one network. + * Two routers on one address is not a topology, it is a collision. + */ export function planRouters(scenario: Scenario, instanceId: string): RouterPlan[] { - const byGateway = new Map(); + const plans: RouterPlan[] = []; for (const [segmentName, segment] of Object.entries(scenario.segments)) { const gateway = segment.gateway; if (!gateway) continue; - const key = `${gateway.to}|${[...gateway.address].sort().join(",")}`; - const existing = byGateway.get(key); + const existing = plans.find( + (plan) => + plan.outside === gateway.to && + plan.outsideAddresses.some((address) => gateway.address.includes(address)), + ); if (existing) { existing.inside.push(segmentName); + // The union, so a gateway declared with a v6 address on only one of the segments it + // serves still carries it. The declarations must otherwise agree — validate refuses + // the case where they do not, so there is nothing to reconcile here. + for (const address of gateway.address) { + if (!existing.outsideAddresses.includes(address)) existing.outsideAddresses.push(address); + } continue; } - byGateway.set(key, { - name: `mlab-${instanceId}-gw${byGateway.size}`, + plans.push({ + name: `mlab-${instanceId}-gw${plans.length}`, inside: [segmentName], outside: gateway.to, - outsideAddresses: gateway.address, + outsideAddresses: [...gateway.address], nat: gateway.nat, forwardable: gateway.forwardable, mappingTtl: gateway.mappingTtl, }); } - return [...byGateway.values()]; + return plans; } /** "120s" / "2m" / "90" → seconds. */ diff --git a/test/validate.test.ts b/test/validate.test.ts index 3117146..1256c1a 100644 --- a/test/validate.test.ts +++ b/test/validate.test.ts @@ -2,6 +2,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { parseScenario } from "../src/declaration/parse.ts"; import { loadScenario } from "../src/declaration/parse.ts"; +import { planRouters } from "../src/lifecycle/router.ts"; /** Every rejection below is a fault that would otherwise be silent at runtime. */ function refuses(yaml: string, pattern: RegExp): void { @@ -185,3 +186,60 @@ segments: { island: { kind: private, cidr: [10.9.0.0/24] } } machines: { a: { at: { segment: island, address: [10.9.0.1] } } }`), ); }); + +test("two gateways sharing an address are one gateway, not two", () => { + // Modelled on the real thing: a bridged modem, one gateway holding the public address, + // everything behind it. Two routers on one address is not a topology, it is a collision — + // and the lab raised it happily, with the address resolving to whichever container + // answered ARP last. + const scenario = parseScenario(` +scenario: shared-gateway +segments: + isp: + kind: public + cidr: [198.51.100.0/24, "2001:db8:b::/48"] + home: + kind: private + cidr: [192.168.1.0/24] + gateway: { to: isp, address: [198.51.100.7, "2001:db8:b::7"], nat: [v4], forwardable: true, mapping_ttl: 120s } + devices: + kind: private + cidr: [192.168.30.0/24] + gateway: { to: isp, address: [198.51.100.7], nat: [v4], forwardable: true, mapping_ttl: 120s } +machines: + thermostat: + at: { segment: devices, address: [192.168.30.20] } +`); + const plans = planRouters(scenario, "test"); + assert.equal(plans.length, 1, `expected one gateway, got ${plans.map((p) => p.inside.join("+")).join(" / ")}`); + assert.deepEqual([...plans[0]!.inside].sort(), ["devices", "home"]); + // The union: a v6 address declared on only one of the segments it serves is still carried. + assert.deepEqual([...plans[0]!.outsideAddresses].sort(), ["198.51.100.7", "2001:db8:b::7"]); +}); + +test("one box cannot behave two ways", () => { + // If two gateways share an address they are the same box, so a disagreement about what + // that box does is a contradiction — refused rather than silently resolved one way. + assert.throws( + () => + parseScenario(` +scenario: contradictory-gateway +segments: + isp: + kind: public + cidr: [198.51.100.0/24] + home: + kind: private + cidr: [192.168.1.0/24] + gateway: { to: isp, address: [198.51.100.7], nat: [v4], forwardable: true } + devices: + kind: private + cidr: [192.168.30.0/24] + gateway: { to: isp, address: [198.51.100.7], nat: [v4], forwardable: false } +machines: + thermostat: + at: { segment: devices, address: [192.168.30.20] } +`), + /one gateway.*disagree.*forwardable/s, + ); +});