A segment named "uplink" is refused. The lab claims that name for the NAT bridge behind `egress: true`, and a scenario wearing it first would have its egress machines silently attached to an isolated bridge — a declared key doing nothing, which is the fault this repo exists to refuse, in the repo that refuses it. settled() parses inside the try. A truncated status from a struggling machine was the one shape of bad answer that still threw out of the wait, and the likeliest moment for one is exactly the machine the poll is watching. Malformed now counts as "could not ask", like the exec that times out. And a sentence on the uplink's UseDNS saying its inertness is load-bearing: it matters only where systemd-resolved runs, and on a machine whose modules own resolv.conf the uplink must not outvote the resolver a scenario is testing.
344 lines
14 KiB
TypeScript
344 lines
14 KiB
TypeScript
/**
|
|
* Every rule the design states about a scenario, checked before anything is raised.
|
|
*
|
|
* The reason validation is this strict is that the failures it prevents are silent. A
|
|
* private range on a segment meant to be public does not produce an error — the mesh
|
|
* simply never forms, because its own code decides public-versus-private by matching the
|
|
* address. Publishing through a gateway that cannot forward does not produce an error
|
|
* either; it produces a machine that looks reachable and is not.
|
|
*
|
|
* So: refuse the declaration, loudly, before spending a minute raising something that
|
|
* would have taught us the wrong thing.
|
|
*
|
|
* See novox/hq 03-DESIGN/01-to-be/02-scenario-declaration.md
|
|
*/
|
|
|
|
import type { Scenario, Segment } from "./types.ts";
|
|
import { contains, familyOf, parseAddress, parseCidr, type Cidr } from "./net.ts";
|
|
|
|
/** RFC 5737 and RFC 3849. The only addresses guaranteed never to route on the real internet. */
|
|
const DOCUMENTATION_RANGES = [
|
|
"192.0.2.0/24",
|
|
"198.51.100.0/24",
|
|
"203.0.113.0/24",
|
|
"2001:db8::/32",
|
|
].map(parseCidr);
|
|
|
|
export class DeclarationError extends Error {
|
|
readonly problems: string[];
|
|
|
|
constructor(problems: string[]) {
|
|
super(`scenario declaration is not valid:\n - ${problems.join("\n - ")}`);
|
|
this.name = "DeclarationError";
|
|
this.problems = problems;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Whether a range sits inside documentation space. Compared as ranges rather than as a
|
|
* sample address so that a range wider than the reserved block — `203.0.0.0/8`, say — is
|
|
* correctly refused instead of passing because its first address happens to fall inside.
|
|
*/
|
|
function isDocumentationRange(range: Cidr): boolean {
|
|
return DOCUMENTATION_RANGES.some(
|
|
(allowed) =>
|
|
allowed.family === range.family &&
|
|
range.prefix >= allowed.prefix &&
|
|
containsRange(allowed, range),
|
|
);
|
|
}
|
|
|
|
/** Is `inner` entirely within `outer`? Both already parsed, so no address parsing can throw. */
|
|
function containsRange(outer: Cidr, inner: Cidr): boolean {
|
|
const bits = outer.family === "v4" ? 32n : 128n;
|
|
const hostBits = bits - BigInt(outer.prefix);
|
|
return ((inner.base >> hostBits) << hostBits) === outer.base;
|
|
}
|
|
|
|
/** Ranges of a segment, parsed once, with malformed entries reported rather than thrown. */
|
|
function rangesOf(name: string, segment: Segment, problems: string[]): Cidr[] {
|
|
const ranges: Cidr[] = [];
|
|
for (const text of segment.cidr) {
|
|
try {
|
|
ranges.push(parseCidr(text));
|
|
} catch (err) {
|
|
problems.push(`segment '${name}': ${(err as Error).message}`);
|
|
}
|
|
}
|
|
return ranges;
|
|
}
|
|
|
|
function inAnyRange(ranges: Cidr[], address: string): boolean {
|
|
return ranges.some((range) => contains(range, address));
|
|
}
|
|
|
|
export function validate(scenario: Scenario): void {
|
|
const problems: string[] = [];
|
|
const segmentNames = Object.keys(scenario.segments);
|
|
|
|
if (!scenario.scenario) problems.push("scenario has no name");
|
|
if (segmentNames.length === 0) problems.push("scenario declares no segments");
|
|
if (Object.keys(scenario.machines).length === 0) {
|
|
problems.push("scenario declares no machines");
|
|
}
|
|
|
|
const ranges = new Map<string, Cidr[]>();
|
|
for (const [name, segment] of Object.entries(scenario.segments)) {
|
|
ranges.set(name, rangesOf(name, segment, problems));
|
|
}
|
|
|
|
for (const [name, segment] of Object.entries(scenario.segments)) {
|
|
// A public segment stands in for the internet. Anything that is not documentation
|
|
// space could route somewhere real, and — far more likely — a private range here
|
|
// makes the mesh's own public-versus-private test fail silently.
|
|
if (segment.kind === "public") {
|
|
// Only ranges that parsed — a malformed one is already reported, and re-parsing it
|
|
// here would throw out of validation with a single cryptic message instead of the
|
|
// full list.
|
|
for (const range of ranges.get(name) ?? []) {
|
|
if (!isDocumentationRange(range)) {
|
|
problems.push(
|
|
`segment '${name}' is public but '${range.text}' is not documentation space ` +
|
|
`(RFC 5737 / RFC 3849). A non-documentation range here either routes somewhere ` +
|
|
`real, or — if private — makes the mesh silently never form.`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (segment.mtu !== undefined && (segment.mtu < 576 || segment.mtu > 9000)) {
|
|
problems.push(`segment '${name}': mtu ${segment.mtu} is outside any plausible range`);
|
|
}
|
|
|
|
const gateway = segment.gateway;
|
|
if (!gateway) continue;
|
|
|
|
if (!segmentNames.includes(gateway.to)) {
|
|
problems.push(`segment '${name}': gateway points at unknown segment '${gateway.to}'`);
|
|
continue;
|
|
}
|
|
if (gateway.to === name) {
|
|
problems.push(`segment '${name}': gateway points at itself`);
|
|
continue;
|
|
}
|
|
|
|
// The gateway's address is what the world sees this network as, so it belongs to the
|
|
// PARENT segment, not this one. Getting this backwards is easy and produces a topology
|
|
// that raises fine and reproduces nothing.
|
|
const parentRanges = ranges.get(gateway.to) ?? [];
|
|
for (const address of gateway.address) {
|
|
try {
|
|
parseAddress(address);
|
|
} catch (err) {
|
|
problems.push(`segment '${name}' gateway: ${(err as Error).message}`);
|
|
continue;
|
|
}
|
|
if (parentRanges.length > 0 && !inAnyRange(parentRanges, address)) {
|
|
problems.push(
|
|
`segment '${name}' gateway: address '${address}' is not within '${gateway.to}' ` +
|
|
`(${scenario.segments[gateway.to]?.cidr.join(", ")}). A gateway's address is the ` +
|
|
`one the parent network sees, not one on the segment behind it.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const family of gateway.nat) {
|
|
if (family !== "v4" && family !== "v6") {
|
|
problems.push(`segment '${name}' gateway: '${family}' is not an address family`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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]);
|
|
let current = scenario.segments[name]?.gateway?.to;
|
|
while (current) {
|
|
if (seen.has(current)) {
|
|
problems.push(`segment '${name}': gateway chain loops through '${current}'`);
|
|
break;
|
|
}
|
|
seen.add(current);
|
|
current = scenario.segments[current]?.gateway?.to;
|
|
}
|
|
}
|
|
|
|
// "uplink" is the one network name the lab itself claims, for the NAT bridge behind
|
|
// `egress: true`. A segment wearing it would be created first, as an isolated bridge — and the
|
|
// uplink code, finding a network by that name, would attach egress machines to it. No error
|
|
// anywhere, no route anywhere: a scenario key silently ignored, which is the fault this file
|
|
// exists to refuse.
|
|
if (scenario.segments["uplink"]) {
|
|
problems.push(`segment 'uplink': the name is reserved for the lab's own NAT bridge — ` +
|
|
`an egress machine would be silently attached to this segment instead of the world`);
|
|
}
|
|
|
|
for (const [name, machine] of Object.entries(scenario.machines)) {
|
|
if (machine.at === "detached") {
|
|
if (machine.published?.length) {
|
|
problems.push(`machine '${name}' is detached but declares published ports`);
|
|
}
|
|
// The same fault as publishing from nowhere: raising it would drop the key on the floor,
|
|
// and a scenario key the runtime silently ignores is the thing this lab exists to catch.
|
|
if (machine.egress) {
|
|
problems.push(`machine '${name}' is detached but declares egress — a machine on no ` +
|
|
`segment reaches nothing, the world included`);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (!Array.isArray(machine.at) || machine.at.length === 0) {
|
|
problems.push(`machine '${name}': 'at' must be an attachment, a list of them, or "detached"`);
|
|
continue;
|
|
}
|
|
|
|
const attachedTo = new Set<string>();
|
|
for (const attachment of machine.at) {
|
|
if (!segmentNames.includes(attachment.segment)) {
|
|
problems.push(`machine '${name}': unknown segment '${attachment.segment}'`);
|
|
continue;
|
|
}
|
|
if (attachedTo.has(attachment.segment)) {
|
|
problems.push(`machine '${name}': attached to '${attachment.segment}' more than once`);
|
|
}
|
|
attachedTo.add(attachment.segment);
|
|
|
|
const segmentRanges = ranges.get(attachment.segment) ?? [];
|
|
const seenFamilies = new Set<string>();
|
|
for (const address of attachment.address) {
|
|
try {
|
|
parseAddress(address);
|
|
} catch (err) {
|
|
problems.push(`machine '${name}': ${(err as Error).message}`);
|
|
continue;
|
|
}
|
|
const family = familyOf(address);
|
|
if (seenFamilies.has(family)) {
|
|
problems.push(`machine '${name}': two ${family} addresses on '${attachment.segment}'`);
|
|
}
|
|
seenFamilies.add(family);
|
|
|
|
if (segmentRanges.length > 0 && !inAnyRange(segmentRanges, address)) {
|
|
problems.push(
|
|
`machine '${name}': address '${address}' is not within segment ` +
|
|
`'${attachment.segment}' (${scenario.segments[attachment.segment]?.cidr.join(", ")})`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const publication of machine.published ?? []) {
|
|
if (!Number.isInteger(publication.port) || publication.port < 1 || publication.port > 65535) {
|
|
problems.push(`machine '${name}': port ${publication.port} is not a port`);
|
|
}
|
|
const via = scenario.segments[publication.on];
|
|
if (!via) {
|
|
problems.push(`machine '${name}': publishes on unknown segment '${publication.on}'`);
|
|
continue;
|
|
}
|
|
if (!attachedTo.has(publication.on)) {
|
|
problems.push(
|
|
`machine '${name}': publishes on '${publication.on}' but is not attached to it`,
|
|
);
|
|
continue;
|
|
}
|
|
if (!via.gateway) {
|
|
problems.push(
|
|
`machine '${name}': publishes on '${publication.on}', which has no gateway to ` +
|
|
`forward through`,
|
|
);
|
|
continue;
|
|
}
|
|
// The constraint being reproduced, not an implementation limit: a machine behind a
|
|
// gateway it does not control cannot be published, and pretending otherwise would
|
|
// make the lab certify something production cannot do.
|
|
if (!via.gateway.forwardable) {
|
|
problems.push(
|
|
`machine '${name}': cannot publish through '${publication.on}' — its gateway is ` +
|
|
`not forwardable. That is the constraint being reproduced, not a limitation.`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const rule of scenario.policy ?? []) {
|
|
for (const side of [rule.from, rule.to]) {
|
|
if (!segmentNames.includes(side)) {
|
|
problems.push(`policy: unknown segment '${side}'`);
|
|
}
|
|
}
|
|
if (rule.from === rule.to) {
|
|
problems.push(`policy: '${rule.from}' to itself is not a rule`);
|
|
}
|
|
}
|
|
|
|
for (const machine of Object.keys(scenario.place ?? {})) {
|
|
if (machine === "all") continue;
|
|
if (!(machine in scenario.machines)) {
|
|
problems.push(`place: '${machine}' is not a machine in this scenario`);
|
|
}
|
|
}
|
|
|
|
for (const image of scenario.images ?? []) {
|
|
if (!image.trim()) {
|
|
problems.push("images: an empty entry names nothing");
|
|
} else if (image.includes("@sha256:")) {
|
|
// The digest a declaration pins is the one the LAB's registry assigns, which is not
|
|
// knowable before the scenario is raised. Naming an upstream digest here would pin
|
|
// something this registry will never serve.
|
|
problems.push(
|
|
`images: '${image}' is pinned by digest. Name it by tag — the lab's registry assigns ` +
|
|
`its own digest and reports it when the scenario is raised`,
|
|
);
|
|
}
|
|
}
|
|
if ((scenario.images ?? []).length > 0) {
|
|
const hasPublicV4 = Object.values(scenario.segments)
|
|
.some((s) => s.kind === "public" && s.cidr.some((c) => !c.includes(":")));
|
|
if (!hasPublicV4) {
|
|
problems.push(
|
|
"images: this scenario declares images and has no public IPv4 segment to serve them " +
|
|
"from. The registry stands in for the outside world, so it sits on a public segment",
|
|
);
|
|
}
|
|
}
|
|
|
|
if (problems.length > 0) throw new DeclarationError(problems);
|
|
}
|