Routers: NAT, port forwarding, policy and mapping expiry

A gateway is the one implicit machine in a declaration — a scenario says a
segment sits behind one and never names the thing that serves it. This
materialises it.

A router is a container, not a virtual machine, because it is scenery
rather than something under test (hq ADR 0033). Verified before building
that a plain unprivileged container can do all of it: ip_forward and ipv6
forwarding settable, nftables masquerade accepted, and the conntrack
timeouts mapping_ttl depends on both writable. No privileged mode.

Verified by running, on a machine behind a household gateway reached from
one on a routable address:

  home-server -> anchor                      0% loss, through masquerade
  anchor -> 192.168.1.135 (private, direct)  unreachable
  anchor -> 192.0.2.50:8080 (the GATEWAY)    HTTP 200

The last line is the published-but-behind-NAT case research 004 says only
exists in production. It is now a 32-second scenario on a workstation.

Segments sharing a gateway declaration share ONE router — that is what a
VLAN-capable router is, and two routers sharing an external address would
not work anyway.

mapping_ttl is read back after setting rather than assumed. Those sysctls
are not on every kernel, and a scenario that declared an expiring mapping
and silently got a permanent one would be exactly the fault being built
against.

Four bugs found by running it, three of them the same fault — a failure
made invisible.

The router had no route to a package repository, by design, so installing
nftables at raise time could not work. The image is now built once with
temporary connectivity and cached; every scenario after that needs no
network. That failure was hidden behind `|| true`, which is why it took a
raise to find.

The builder then failed on DNS: exec works before a container has an
address, and I had treated usable as ready. It now waits for the thing
actually needed.

The stock Alpine image ships `auto eth0 / inet dhcp` and its boot-time
networking service flushed the static address the scenario set — on eth0
only, so the outside interface came up bare while inside ones were fine.
The image build now neutralises it: a router reconfiguring itself from an
image default is the lab overriding the declaration. `ip addr add … || true`
had hidden this too, and is now `ip addr replace` with no swallow.

And routers were orphaned by destroy, holding their networks open so
destroy reported removing zero segments. They now carry the same machine
tag as everything else, so one query finds an instance's resources.
This commit is contained in:
2026-08-24 01:37:19 +02:00
parent a27d861d3b
commit a270cd5b02
8 changed files with 590 additions and 52 deletions
+24 -8
View File
@@ -107,9 +107,11 @@ than ignored:
| declared addresses, both families | **works** |
| segment MTU | **works** |
| raise · exec · snapshot · restore · destroy · list | **works** |
| gateways, NAT, forwarding | **refused at raise** |
| `published:` ports | **refused at raise** |
| `policy:` between segments | **refused at raise** |
| gateways, NAT, masquerade | **works** |
| `published:` ports (DNAT through the gateway's address) | **works** |
| `mapping_ttl:` (conntrack timeout) | **works**, and verified after setting — a declared expiry that silently did not apply would be the fault this catches |
| `forwardable: false` | implemented, **not yet verified by running** |
| `policy:` between segments | implemented, **not yet verified by running** |
| `inbound: deny` | **refused at raise** |
| `place:` | **refused at raise** |
@@ -123,11 +125,25 @@ it is the topology being built toward, and the tool says exactly what is missing
## Measured on a workstation
| | one machine | two machines |
|---|---|---|
| raise, to usable | 12.5 s | 14.6 s |
| snapshot | 0.14 s | 0.28 s |
| restore, to usable again | 10.5 s | 11.6 s |
| | one machine | two machines | two machines + a router |
|---|---|---|---|
| raise, to usable | 12.5 s | 14.6 s | 32 s |
| snapshot | 0.14 s | 0.28 s | — |
| restore, to usable again | 10.5 s | 11.6 s | — |
A router adds seconds, not a boot: it is a container, because it is scenery rather than
something under test (`novox/hq` ADR 0033).
**Verified by running**, not asserted — a machine at `192.168.1.135` behind a household
gateway, reached from a machine on a routable address:
```
home-server -> anchor 0% loss, through masquerade
anchor -> 192.168.1.135 (private, direct) unreachable ✓
anchor -> 192.0.2.50:8080 (the GATEWAY) HTTP 200
```
The last line is the case research 004 says only exists in production.
Machines boot concurrently, so a second machine costs seconds rather than doubling the wait.
Nearly all of the remaining time is boot, which cannot be avoided.