Transit, host firewalls, and the whole topology raising

The full topology now raises: four machines, three routers, a transit
router, six segments, in 35 seconds. Everything the declaration model can
express except `place`, which is refused because the node host it would
place does not exist yet.

Transit was a real gap, not a bug. The design says public networks are
unrelated and routed to each other, never bridged — and I built the
segments and never built the thing that routes between them, so three
public networks were islands and nothing crossed. A transit router now
holds an interface on every public segment, forwarding and no translation:
the closest thing the lab has to the internet, deliberately dumb.

Proven rather than asserted, by ping TTL across the raised topology:

  within one segment                     ttl=64   no hops
  across two unrelated public networks   ttl=62   gateway + transit
  multicast between public networks      0 replies

A flat internet would have shown ttl=64 and answered multicast — which
would let a node discover a peer it could never reach in production, and
report success. That is the fault the as-is layer records the mesh already
hitting with multicast name resolution.

inbound: deny is implemented as a host firewall on the machine, read back
after applying. A declared refusal that silently did not load leaves the
machine wide open, which looks exactly like a machine that is working.
Established and related traffic is accepted, so a defended machine can
still dial out rather than being a disconnected one.

Verified by running, all of it:

  home -> devices (policy allow)               reachable
  devices -> home (policy deny)                blocked
  behind unforwardable NAT -> out              reachable
  in -> behind unforwardable NAT               unreachable
  inbound: deny, dialling out                  reachable
  reaching a machine that denies inbound       refused

The two routers differ exactly as declared: the forwardable one carries the
policy rule and no inbound drop, the unforwardable one carries `ct state
new drop` and no DNAT.
This commit is contained in:
2026-08-24 01:49:30 +02:00
parent a270cd5b02
commit 5d01006eab
9 changed files with 295 additions and 31 deletions
+59
View File
@@ -0,0 +1,59 @@
/**
* `inbound: deny` — a host firewall on the machine itself.
*
* Distinct from NAT and behaving differently: a machine can be perfectly routable and
* still refuse everything unsolicited, which is the normal state of a v6-addressed
* machine. Without this, v6 addressing would silently imply reachability, and a scenario
* that said a machine refuses traffic would produce one that accepts it.
*
* Established and related traffic is accepted, so the machine can still dial out. That is
* what a host firewall does; a machine that could not reach anything would be reproducing
* a disconnected machine rather than a defended one.
*/
import type { Scenario } from "../declaration/types.ts";
import { incus } from "../incus/client.ts";
const RULESET = `flush ruleset
table inet mlab {
chain input {
type filter hook input priority filter; policy drop;
ct state established,related accept
iif lo accept
ct state invalid drop
}
}
`;
export async function applyHostFirewalls(
scenario: Scenario,
machineNames: Map<string, string>,
log: (message: string) => void = () => {},
): Promise<void> {
for (const [machine, spec] of Object.entries(scenario.machines)) {
if (spec.inbound !== "deny") continue;
const name = machineNames.get(machine);
if (!name) continue;
await incus(
["exec", name, "--", "sh", "-c",
`cat > /tmp/mlab-host.nft <<'MLABNFT'\n${RULESET}MLABNFT\nnft -f /tmp/mlab-host.nft`],
60_000,
);
// Read back. A declared refusal that silently did not apply is the fault this lab
// exists to catch, and a ruleset that failed to load leaves the machine wide open —
// which looks exactly like a machine that is working.
const check = await incus(
["exec", name, "--", "sh", "-c", "nft list table inet mlab >/dev/null 2>&1 && echo present || echo absent"],
20_000,
);
if (check.stdout.trim() !== "present") {
throw new Error(
`${machine}: inbound: deny was declared but the ruleset is not loaded — the machine ` +
`would accept traffic the scenario says it refuses`,
);
}
log(` ${machine} refuses unsolicited inbound`);
}
}