Files
mesh-lab/test/validate.test.ts
T
jschoubben 94e617915c The home segment moves off 192.168.1.0/24
It is the commonest home LAN range there is, so on an ordinary workstation the
lab's private segment and the machine's own network are the same addresses. The
scenario routes an egress machine explicitly and marks the rest unreachable, so
nothing leaked — but that guard was carrying the whole weight of a collision
nobody chose, and a guard is a bad place for that.

10.99.1.0/24 is still RFC 1918, so the bed still models a home LAN behind an
access point. It is simply far from what this kind of machine already has:
192.168.1 is the LAN, 172.16-31 and 192.168.16-95 are container bridges, and
10.10/10.42/10.208 are a tunnel, the mesh overlay and the virtualisation daemon.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-11 00:00:19 +02:00

317 lines
11 KiB
TypeScript

import { test } from "node:test";
import assert from "node:assert/strict";
import { readdirSync } from "node:fs";
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", () => {
// **Every one of them**, not a chosen two. Thirty-odd scenarios were rewritten in one pass when
// the lab's registry was removed, and a scenario nobody loads is a scenario nobody validates —
// which is how a bed goes unraisable for weeks and is only found when somebody wants it.
const files = readdirSync("scenarios").filter((f) => f.endsWith(".yml"));
assert.ok(files.length > 20, `only ${files.length} scenarios found — is the path right?`);
for (const file of files) {
assert.doesNotThrow(() => loadScenario(`scenarios/${file}`), `scenarios/${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: [10.99.1.0/24] } }
machines: { a: { at: { segment: net, address: [10.99.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: [10.99.1.0/24], gateway: { to: pub, address: [10.99.1.1], nat: [v4] } }
machines: { a: { at: { segment: home, address: [10.99.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: [10.99.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: [10.99.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: [10.99.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: [10.99.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: [10.99.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,
);
});
test("a detached machine cannot declare egress", () => {
refuses(`scenario: x
segments: { net: { kind: public, cidr: [192.0.2.0/24] } }
machines: { a: { at: detached, egress: true } }`,
/detached but declares egress/);
});
/**
* `images:` is the mesh's own images and nothing else.
*
* **The rule that replaced the lab's registry.** Anything with somewhere to be fetched from is
* fetched from there, by the machine, over its uplink. Serving it from inside the scenario instead
* is what hid the bootstrap faults this lab exists to find — so it is refused rather than quietly
* done, or the fiction comes back one convenient line at a time.
*/
test("a third-party image in images: is refused, because nothing loads it", () => {
for (const image of ["postgres:17-alpine", "gitea/gitea:1.22", "ghcr.io/mailu/admin:1.9"]) {
refuses(`scenario: x
segments: { net: { kind: public, cidr: [192.0.2.0/24] } }
machines: { a: { at: { segment: net, address: [192.0.2.1] }, egress: true } }
images: ["${image}"]
place: { all: [runtime] }`,
/is not one of the mesh's own images/);
}
});
test("one of ours in images: is accepted", () => {
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] }, egress: true } }
images: [mesh-control:development, mesh-route-proxy:development]
place: { all: [runtime] }`));
});
test("images: is named by tag — an image ID is not knowable until the image is built", () => {
refuses(`scenario: x
segments: { net: { kind: public, cidr: [192.0.2.0/24] } }
machines: { a: { at: { segment: net, address: [192.0.2.1] }, egress: true } }
images: ["mesh-control@sha256:${"0".repeat(64)}"]
place: { all: [runtime] }`,
/is pinned by digest/);
});
test("a machine cannot be handed an image the scenario does not have", () => {
// Ignoring it silently would be a machine missing a runtime, failing several minutes later
// inside an apply, as a container that will not start.
refuses(`scenario: x
segments: { net: { kind: public, cidr: [192.0.2.0/24] } }
machines:
a:
at: { segment: net, address: [192.0.2.1] }
egress: true
images: [mesh-runtime-redis:development]
images: [mesh-control:development]
place: { all: [runtime] }`,
/is not in this scenario's images/);
});
test("a segment may not be named 'uplink' — the lab claims that name for egress", () => {
refuses(`scenario: x
segments: { uplink: { kind: public, cidr: [192.0.2.0/24] } }
machines: { a: { at: { segment: uplink, address: [192.0.2.1] }, egress: true } }`,
/reserved for the lab's own NAT bridge/);
});