The lab raises a mesh, draws it, and now places tier 0 inside it #1
@@ -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<string>([name]);
|
||||
|
||||
+29
-7
@@ -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<string, RouterPlan>();
|
||||
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. */
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user