From a6b7d67e190871ed15e16b7c6b7f725fd980f353 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 24 Aug 2026 23:43:23 +0200 Subject: [PATCH] Gateways sharing an address are one gateway MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by asking what gw-devices and gw-home actually were, in a picture that finally made them easy to see side by side. planRouters grouped on the exact address list, so `home` declaring a v4 and a v6 address and `devices` declaring only the v4 became two router containers — both holding 198.51.100.7 on the same segment. The lab raised it without complaint. Not theoretical. On the raised instance the transit router resolved that one address to two different MACs across a cache flush: 198.51.100.7 -> 02:c9:16:70:23:29 (gw0, which HAS the :443 dnat) 198.51.100.7 -> 02:bd:75:0b:b0:75 (gw1, which has none) So home-server's published port worked or did not depending on which container answered ARP last — intermittent, and it would have presented as a flaky test rather than as a broken scenario. One public address is one box. Checked against the thing this models rather than argued from the model: a bridged modem, a single gateway holding the public address, one network behind it, and every port forward landing on one host at that address. Two routers on one address is not a topology, it is a collision. Gateways to the same segment sharing any address are now one router and their address lists union, so a v6 address declared on only one of the segments it serves is still carried. Where such declarations disagree on nat, forwardable or mapping_ttl, validate refuses — one box cannot behave two ways. the-ordinary-shape now raises 7 machines instead of 8, and gw0 holds the public address on eth0 while serving home on eth1 and devices on eth2. --- src/declaration/validate.ts | 36 +++++++++++++++++++++++ src/lifecycle/router.ts | 36 ++++++++++++++++++----- test/validate.test.ts | 58 +++++++++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 7 deletions(-) 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, + ); +});