/** * 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(); 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([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(); 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(); 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); }