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.
246 lines
8.0 KiB
TypeScript
246 lines
8.0 KiB
TypeScript
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 {
|
|
assert.throws(() => parseScenario(yaml), (err: Error) => {
|
|
assert.match(err.message, pattern);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
test("the shipped scenarios are valid", () => {
|
|
for (const file of ["scenarios/bootstrap-single.yml", "scenarios/the-ordinary-shape.yml"]) {
|
|
assert.doesNotThrow(() => loadScenario(file));
|
|
}
|
|
});
|
|
|
|
test("a public segment on a private range is refused — the mesh would silently never form", () => {
|
|
refuses(
|
|
`scenario: x
|
|
segments: { net: { kind: public, cidr: [192.168.1.0/24] } }
|
|
machines: { a: { at: { segment: net, address: [192.168.1.1] } } }`,
|
|
/not documentation space/,
|
|
);
|
|
});
|
|
|
|
test("a public segment on a real routable range is refused", () => {
|
|
refuses(
|
|
`scenario: x
|
|
segments: { net: { kind: public, cidr: [8.8.8.0/24] } }
|
|
machines: { a: { at: { segment: net, address: [8.8.8.8] } } }`,
|
|
/not documentation space/,
|
|
);
|
|
});
|
|
|
|
test("a private segment may use any range, including someone else's RFC 1918", () => {
|
|
assert.doesNotThrow(() =>
|
|
parseScenario(`scenario: x
|
|
segments:
|
|
pub: { kind: public, cidr: [192.0.2.0/24] }
|
|
cafe: { kind: private, cidr: [10.50.0.0/16], gateway: { to: pub, address: [192.0.2.5], nat: [v4], forwardable: false } }
|
|
machines: { a: { at: { segment: cafe, address: [10.50.0.9] } } }`),
|
|
);
|
|
});
|
|
|
|
test("publishing through an unforwardable gateway is refused — that is the constraint", () => {
|
|
refuses(
|
|
`scenario: x
|
|
segments:
|
|
pub: { kind: public, cidr: [192.0.2.0/24] }
|
|
cafe: { kind: private, cidr: [10.50.0.0/16], gateway: { to: pub, address: [192.0.2.5], nat: [v4], forwardable: false } }
|
|
machines:
|
|
a:
|
|
at: { segment: cafe, address: [10.50.0.9] }
|
|
published: [{ port: 443, on: cafe }]`,
|
|
/not forwardable/,
|
|
);
|
|
});
|
|
|
|
test("a gateway address must be on the PARENT segment, not the one behind it", () => {
|
|
refuses(
|
|
`scenario: x
|
|
segments:
|
|
pub: { kind: public, cidr: [192.0.2.0/24] }
|
|
home: { kind: private, cidr: [192.168.1.0/24], gateway: { to: pub, address: [192.168.1.1], nat: [v4] } }
|
|
machines: { a: { at: { segment: home, address: [192.168.1.9] } } }`,
|
|
/is not within 'pub'/,
|
|
);
|
|
});
|
|
|
|
test("a machine address outside its segment is refused", () => {
|
|
refuses(
|
|
`scenario: x
|
|
segments: { net: { kind: public, cidr: [192.0.2.0/24] } }
|
|
machines: { a: { at: { segment: net, address: [203.0.113.9] } } }`,
|
|
/is not within segment 'net'/,
|
|
);
|
|
});
|
|
|
|
test("an unknown segment reference is refused", () => {
|
|
refuses(
|
|
`scenario: x
|
|
segments: { net: { kind: public, cidr: [192.0.2.0/24] } }
|
|
machines: { a: { at: { segment: nope, address: [192.0.2.1] } } }`,
|
|
/unknown segment 'nope'/,
|
|
);
|
|
});
|
|
|
|
test("a gateway loop is refused rather than raised forever", () => {
|
|
refuses(
|
|
`scenario: x
|
|
segments:
|
|
a: { kind: private, cidr: [10.0.0.0/24], gateway: { to: b, address: [10.0.1.1], nat: [] } }
|
|
b: { kind: private, cidr: [10.0.1.0/24], gateway: { to: a, address: [10.0.0.1], nat: [] } }
|
|
machines: { m: { at: { segment: a, address: [10.0.0.9] } } }`,
|
|
/loops through/,
|
|
);
|
|
});
|
|
|
|
test("a detached machine cannot publish", () => {
|
|
refuses(
|
|
`scenario: x
|
|
segments: { net: { kind: public, cidr: [192.0.2.0/24] } }
|
|
machines: { a: { at: detached, published: [{ port: 443, on: net }] } }`,
|
|
/detached but declares published/,
|
|
);
|
|
});
|
|
|
|
test("publishing on a segment the machine is not attached to is refused", () => {
|
|
refuses(
|
|
`scenario: x
|
|
segments:
|
|
pub: { kind: public, cidr: [192.0.2.0/24] }
|
|
home: { kind: private, cidr: [192.168.1.0/24], gateway: { to: pub, address: [192.0.2.5], nat: [v4] } }
|
|
machines:
|
|
a:
|
|
at: { segment: pub, address: [192.0.2.10] }
|
|
published: [{ port: 443, on: home }]`,
|
|
/not attached to it/,
|
|
);
|
|
});
|
|
|
|
test("two addresses of one family on one attachment is refused", () => {
|
|
refuses(
|
|
`scenario: x
|
|
segments: { net: { kind: public, cidr: [192.0.2.0/24] } }
|
|
machines: { a: { at: { segment: net, address: [192.0.2.1, 192.0.2.2] } } }`,
|
|
/two v4 addresses/,
|
|
);
|
|
});
|
|
|
|
test("place naming a machine that does not exist is refused", () => {
|
|
refuses(
|
|
`scenario: x
|
|
segments: { net: { kind: public, cidr: [192.0.2.0/24] } }
|
|
machines: { a: { at: { segment: net, address: [192.0.2.1] } } }
|
|
place: { ghost: [host] }`,
|
|
/'ghost' is not a machine/,
|
|
);
|
|
});
|
|
|
|
test("every problem is reported, not just the first", () => {
|
|
try {
|
|
parseScenario(`scenario: x
|
|
segments: { net: { kind: public, cidr: [192.168.0.0/24] } }
|
|
machines: { a: { at: { segment: nope, address: [1.2.3.4] } } }
|
|
place: { ghost: [host] }`);
|
|
assert.fail("should have thrown");
|
|
} catch (err) {
|
|
const problems = (err as { problems: string[] }).problems;
|
|
assert.ok(problems.length >= 3, `expected several problems, got ${problems.length}`);
|
|
}
|
|
});
|
|
|
|
test("a detached machine is valid", () => {
|
|
assert.doesNotThrow(() =>
|
|
parseScenario(`scenario: x
|
|
segments: { net: { kind: public, cidr: [192.0.2.0/24] } }
|
|
machines:
|
|
a: { at: { segment: net, address: [192.0.2.1] } }
|
|
roamer: { at: detached }`),
|
|
);
|
|
});
|
|
|
|
test("a multi-homed machine is valid", () => {
|
|
assert.doesNotThrow(() =>
|
|
parseScenario(`scenario: x
|
|
segments:
|
|
pub: { kind: public, cidr: [192.0.2.0/24] }
|
|
home: { kind: private, cidr: [192.168.1.0/24], gateway: { to: pub, address: [192.0.2.5], nat: [v4] } }
|
|
machines:
|
|
border:
|
|
at:
|
|
- { segment: pub, address: [192.0.2.60] }
|
|
- { segment: home, address: [192.168.1.2] }`),
|
|
);
|
|
});
|
|
|
|
test("an isolated private segment with no gateway is valid — a site with no internet", () => {
|
|
assert.doesNotThrow(() =>
|
|
parseScenario(`scenario: x
|
|
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,
|
|
);
|
|
});
|