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.
|
// A gateway chain must terminate. A cycle would raise forever rather than fail.
|
||||||
for (const name of segmentNames) {
|
for (const name of segmentNames) {
|
||||||
const seen = new Set<string>([name]);
|
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
|
* router, not several — that is what a VLAN-capable router is, and two routers sharing an
|
||||||
* external address would not work anyway.
|
* 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[] {
|
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)) {
|
for (const [segmentName, segment] of Object.entries(scenario.segments)) {
|
||||||
const gateway = segment.gateway;
|
const gateway = segment.gateway;
|
||||||
if (!gateway) continue;
|
if (!gateway) continue;
|
||||||
|
|
||||||
const key = `${gateway.to}|${[...gateway.address].sort().join(",")}`;
|
const existing = plans.find(
|
||||||
const existing = byGateway.get(key);
|
(plan) =>
|
||||||
|
plan.outside === gateway.to &&
|
||||||
|
plan.outsideAddresses.some((address) => gateway.address.includes(address)),
|
||||||
|
);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.inside.push(segmentName);
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
byGateway.set(key, {
|
plans.push({
|
||||||
name: `mlab-${instanceId}-gw${byGateway.size}`,
|
name: `mlab-${instanceId}-gw${plans.length}`,
|
||||||
inside: [segmentName],
|
inside: [segmentName],
|
||||||
outside: gateway.to,
|
outside: gateway.to,
|
||||||
outsideAddresses: gateway.address,
|
outsideAddresses: [...gateway.address],
|
||||||
nat: gateway.nat,
|
nat: gateway.nat,
|
||||||
forwardable: gateway.forwardable,
|
forwardable: gateway.forwardable,
|
||||||
mappingTtl: gateway.mappingTtl,
|
mappingTtl: gateway.mappingTtl,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...byGateway.values()];
|
return plans;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "120s" / "2m" / "90" → seconds. */
|
/** "120s" / "2m" / "90" → seconds. */
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { test } from "node:test";
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { parseScenario } from "../src/declaration/parse.ts";
|
import { parseScenario } from "../src/declaration/parse.ts";
|
||||||
import { loadScenario } 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. */
|
/** Every rejection below is a fault that would otherwise be silent at runtime. */
|
||||||
function refuses(yaml: string, pattern: RegExp): void {
|
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] } } }`),
|
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