508d3d97
docs: bridged guest networking
a73x 2026-08-06 09:12
Commit message
docs/superpowers/plans/2026-08-05-bridged-guest-networking.md
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,309 @@ | |||
| 1 | # Bridged by default: a guest is a machine on the network | ||
| 2 | |||
| 3 | **Status:** parked — demand-driven. Service exposure covers reaching a guest's | ||
| 4 | services; what this plan alone provides is LAN citizenship — broadcast-domain | ||
| 5 | discovery (mDNS/SSDP) and operator-owned addressing. It is revived by the | ||
| 6 | first workload that needs those. A UDP want alone is not that workload: | ||
| 7 | `protocol: udp` on exposures is the smaller answer. | ||
| 8 | **Branch:** `v0.0.3` | ||
| 9 | **Ethos:** the guest owns the guest, eitri owns the fleet — and the network | ||
| 10 | belongs to whoever runs it. | ||
| 11 | |||
| 12 | ## The decision | ||
| 13 | |||
| 14 | A Linux host puts its guests on the operator's network. It does not build a | ||
| 15 | network of its own, does not hand out addresses, and does not masquerade. It is | ||
| 16 | told which bridge to attach taps to, and it refuses to start without one. | ||
| 17 | |||
| 18 | This is a default, not a mode: there is no `--guest-net` flag and nothing in the | ||
| 19 | console flips it. A Linux host that has a bridge is bridged; a Linux host that | ||
| 20 | does not is a host that will not serve. | ||
| 21 | |||
| 22 | Two things it is not. It is not fleet-wide — macOS cannot bridge, so the NAT | ||
| 23 | shape survives on Macs whatever we do here (below). And it is not a licence to | ||
| 24 | rebuild the operator's uplink: eitri never enslaves an interface it did not | ||
| 25 | create. The bridge is the operator's, built by whatever manages that host's | ||
| 26 | network, and the agent's only claim on it is a tap. | ||
| 27 | |||
| 28 | ## What is there now | ||
| 29 | |||
| 30 | Every guest sits behind NAT on a host-private bridge. `netenv.Net` builds | ||
| 31 | `eitri0`, takes `.1`, enables forwarding, installs one `nft` masquerade rule | ||
| 32 | (`netenv.go:172`), and runs eitri's own DHCP responder with a per-VM | ||
| 33 | reservation keyed on a deterministic MAC (`netenv.go:240`, `state.go:94`). | ||
| 34 | |||
| 35 | The seam this all hangs off is already the right one. `cloudhv` does not import | ||
| 36 | `netenv`: it declares a five-method consumer-owned `Network` interface | ||
| 37 | (`cloudhv.go:46`) — `ReserveIP`, `Address`, `CreateTap`, `DeleteTap`, | ||
| 38 | `TapName` — and `netenv` happens to satisfy it. The VMM sees one line of the | ||
| 39 | whole arrangement, `--net tap=eit-xxxxxxxx,mac=52:54:00:…` | ||
| 40 | (`cloudhv.go:151`), and that line is identical under either regime. | ||
| 41 | |||
| 42 | So bridged is a second implementation of an interface that already exists. | ||
| 43 | `reconcile`, `buildArgs`, the pumps and the pidfiles do not move. | ||
| 44 | |||
| 45 | ## The three things that actually change | ||
| 46 | |||
| 47 | ### 1. The mode is derived from the identity, never set | ||
| 48 | |||
| 49 | `state.Identity` (`state.go:43`) gains two fields — `GuestNet string` (`nat` or | ||
| 50 | `bridged`) and `Bridge string` — and the answer comes from the record, with no | ||
| 51 | flag to disagree with: | ||
| 52 | |||
| 53 | - `GuestNet` set → that, and for `bridged`, on the bridge named in `Bridge`. | ||
| 54 | - unset, `BridgeCIDR != ""` → a host enrolled by an older agent. NAT, recorded | ||
| 55 | as `GuestNet: "nat"` on this first start and read from the record thereafter. | ||
| 56 | - unset, nothing else → a new host. `--bridge` is required; refuse to start. | ||
| 57 | |||
| 58 | The recording matters more than it looks. Deriving "this host is on NAT" from a | ||
| 59 | missing field works exactly once — the next change to what absence means would | ||
| 60 | silently re-home hosts that were never asked. Writing the answer down the first | ||
| 61 | time a host resolves it makes an existing host's NAT a decision the fleet holds, | ||
| 62 | not an inference it re-makes every start. It is the same one-time persist | ||
| 63 | `persistGuestCIDR` already does for the subnet (`guestcidr.go:51`). | ||
| 64 | |||
| 65 | This is the precedence `resolveGuestCIDR` already enforces for the subnet | ||
| 66 | (`guestcidr.go:33`), and for the reason stated there: a flag that overrides a | ||
| 67 | persisted identity moves the network under live guests and every one of them | ||
| 68 | loses its address at the next restart. Changing a host's bridge is an edit to | ||
| 69 | `identity.json`, exactly as re-homing its subnet is. | ||
| 70 | |||
| 71 | Where the answer is given: `join`. The mode is identity and join is the | ||
| 72 | ceremony that mints identity, so `eitri-agent join --bridge br0` is the | ||
| 73 | operator answering at the moment the host is asked. Serve accepts the same | ||
| 74 | flag as backfill only — consulted when the record is silent, the role | ||
| 75 | `cfg.BridgeCIDR` already plays in `resolveGuestCIDR` — and a restart that | ||
| 76 | passes it lands in one of three rows: | ||
| 77 | |||
| 78 | - record silent → the flag answers and is persisted. | ||
| 79 | - record agrees → no-op; restating the recorded answer is harmless. | ||
| 80 | - record contradicts (`nat` recorded, or a different bridge named) → refuse, | ||
| 81 | loudly. Obeying would re-home every guest as a side effect of a restart | ||
| 82 | flag; ignoring would silently discard an instruction the operator stopped | ||
| 83 | the agent to give. The refusal names the record and the sanctioned path: | ||
| 84 | edit `identity.json` with the agent stopped, or decommission and re-join. | ||
| 85 | |||
| 86 | Two orderings that matter. The resolver runs before anything persists a | ||
| 87 | fabricated CIDR — `newPlatform` persists `lastResortGuestCIDR` today, and a | ||
| 88 | fresh host that reached that code first would classify itself as legacy NAT. | ||
| 89 | And `--bridge` with `--bridge-cidr` is a contradiction in terms — one names a | ||
| 90 | network the operator owns, the other a network eitri would build — and is | ||
| 91 | refused rather than resolved. | ||
| 92 | |||
| 93 | The refusal is the report. The machine that knows which bridge to name is | ||
| 94 | the host itself, so when `--bridge` is missing the agent enumerates before it | ||
| 95 | exits. A host with bridges gets the candidates and the evidence that matters — | ||
| 96 | `br0: up, ports enp4s0` against `docker0: up, no physical port` — and the | ||
| 97 | operator reads their own answer off the list. A host with none gets the name | ||
| 98 | of the interface they are about to enslave: the default-route interface, | ||
| 99 | offered as a suggestion — wrong on an odd topology, and costing nothing | ||
| 100 | there, because a suggestion can be ignored. What the agent never does is | ||
| 101 | choose. A filter that picked the one plausible candidate would ship guests | ||
| 102 | onto the wrong segment silently on exactly the host where it guessed wrong, | ||
| 103 | and the whole design rests on the mode being an answer the operator gave. | ||
| 104 | Suggest loudly, choose never. | ||
| 105 | |||
| 106 | ### 2. Addressing flips from allocate to observe | ||
| 107 | |||
| 108 | There is no reservation to make. The site's DHCP server hands the lease, and the | ||
| 109 | agent's job is to find out what it was. | ||
| 110 | |||
| 111 | `ReserveIP` becomes a no-op returning `""` — `attachNet` passes its result | ||
| 112 | straight into `CreateTap` (`cloudhv.go:317`) and bridged `CreateTap` ignores it. | ||
| 113 | `Address` becomes discovery, and returns `""` until it has an answer. | ||
| 114 | |||
| 115 | Nothing above this needs teaching, because macOS already taught it. | ||
| 116 | `noteAddress` treats an empty answer as "I don't know yet" and refuses to blank | ||
| 117 | a known address (`reconcile.go:563`); the control plane's guard stopped asking | ||
| 118 | about topology; the host reports its own subnet. The consumers that poll for | ||
| 119 | `assigned_ip` — the console column, `eitri-mcp`'s wait, the smoke gate — already | ||
| 120 | wait for a Mac and will wait the same way here. | ||
| 121 | |||
| 122 | **Discovery mechanism.** Two sources, cheapest first: | ||
| 123 | |||
| 124 | - `/proc/net/arp` — free, no exec, scoped by its Device column, and populated | ||
| 125 | for any guest the host has already spoken to. | ||
| 126 | - A passive ARP watcher on the bridge for the cold case, which is the common | ||
| 127 | one: nothing has dialled the guest yet, and dialling is what needs the address. | ||
| 128 | An `AF_PACKET` socket filtered to `ETH_P_ARP` yields sender-MAC → sender-IP, | ||
| 129 | matched against `state.MAC(vmID)`. Every guest broadcasts within seconds of | ||
| 130 | its lease — an RFC 5227 announcement for its own address, and a request for | ||
| 131 | its gateway — so the answer arrives unprompted and updates itself if the | ||
| 132 | site renumbers the guest. The watcher drops unspecified sender addresses: a | ||
| 133 | 5227 *probe* carries sender-IP `0.0.0.0` by definition — junk that | ||
| 134 | `noteAddress` has no reason to refuse and the tunnel would then dial. | ||
| 135 | |||
| 136 | ARP rather than DHCP snooping deliberately: a DHCP reply may be unicast to the | ||
| 137 | guest's MAC, which the bridge will not deliver to the host without putting the | ||
| 138 | interface in promiscuous mode. ARP is broadcast and needs no such thing. | ||
| 139 | |||
| 140 | A discovered address is already durable — `reconcile` persists `rec.IP` — so an | ||
| 141 | agent restart keeps the last known address while the watcher re-warms, and | ||
| 142 | `syncclient`'s tunnel (which refuses an empty address, `client.go:530`) never | ||
| 143 | sees a gap it would not have seen on a Mac. | ||
| 144 | |||
| 145 | ### 3. Most of the host-wide setup stops running | ||
| 146 | |||
| 147 | Under bridged, `newPlatform` (`wire_linux.go:84`) does none of this: | ||
| 148 | |||
| 149 | | Step | Why it goes | | ||
| 150 | |---|---| | ||
| 151 | | `EnsureBridge` | the bridge is the operator's; we validate, never create | | ||
| 152 | | `StartDHCP` | an eitri DHCP server on the LAN hijacks the site's | | ||
| 153 | | `replayReservations` | there are no reservations to replay | | ||
| 154 | | `CheckUplinkCollision` | it refuses precisely the bridged case, by definition | | ||
| 155 | | `lastResortGuestCIDR` | a fabricated subnet is only right where something builds one | | ||
| 156 | |||
| 157 | `CheckUplinkCollision` (`uplink.go:67`) deserves a note rather than a deletion. | ||
| 158 | It fails when the guest subnet contains an address the host is configured with, | ||
| 159 | which is the definition of bridged — but it is the correct check for the shape | ||
| 160 | it was written for, and the failure it prevents (the host's own address becomes | ||
| 161 | local, it stops answering, and the machine best placed to fix it is the one that | ||
| 162 | just lost the route) is real. It stays, scoped to legacy NAT. | ||
| 163 | |||
| 164 | What replaces it is validation of what we were given, at startup, before a | ||
| 165 | guest can fail illegibly later — split on the line `CheckUplinkCollision` | ||
| 166 | already draws: refuse on facts, warn on diagnostics. The facts are that the | ||
| 167 | named link exists, is a bridge (`/sys/class/net/<name>/bridge`), and is up. | ||
| 168 | These are never wrong under any topology; an operator whose bridge fails them | ||
| 169 | typed a name that does not hold. Whether the bridge leads anywhere is not a | ||
| 170 | fact but the operator's intent: the default route may sit on the bridge | ||
| 171 | itself (the canonical shape — the host's address lives on `br0` and the | ||
| 172 | uplink is a port), on another interface entirely with a guests-only bridge on | ||
| 173 | a second NIC, or nowhere by design on an isolated segment. Any rule that | ||
| 174 | consults the routing table refuses one of the legitimate shapes, so the | ||
| 175 | routing table is not consulted. The one inference worth making is a warning: | ||
| 176 | a bridge with no physical port enslaved — the `docker0` signature, nothing | ||
| 177 | under `brif/` but taps and veths — is logged plainly at startup as guests | ||
| 178 | that may have no way off this host. A guest that then never gets an address | ||
| 179 | is a legible symptom whose explanation is already in the log, which is the | ||
| 180 | whole point of checking. | ||
| 181 | |||
| 182 | One more warning belongs beside it: `br_netfilter`. With that module loaded — | ||
| 183 | Docker loads it — frames bridged through the operator's bridge traverse | ||
| 184 | iptables, and a `FORWARD` policy of `DROP` (Docker again) eats them: tap up, | ||
| 185 | ARP seen, no traffic, nothing to look at. The ruleset cannot be proven safe | ||
| 186 | from here, but the module's presence can be named at startup, and the FAQ | ||
| 187 | carries the rest. | ||
| 188 | |||
| 189 | `GuestCIDR` reports the bridge's own IPv4 network, masked to its own prefix | ||
| 190 | length. This is the shape `vfkit.GuestCIDR` already uses (`vfkit/guestnet.go`) | ||
| 191 | and the same rule applies: no answer leaves the fleet's record alone. | ||
| 192 | |||
| 193 | ## macOS is untouched | ||
| 194 | |||
| 195 | Virtualization.framework needs `com.apple.vm.networking` to bridge — a | ||
| 196 | restricted entitlement, granted by Apple on request, and it lives in the code | ||
| 197 | signature of the binary holding it. That binary is vfkit, not ours, and vfkit | ||
| 198 | ships `nat` (`vfkit.go:236`). A Mac keeps vmnet, keeps discovering its address | ||
| 199 | from `/var/db/dhcpd_leases`, and keeps reporting `192.168.64.0/24`. | ||
| 200 | |||
| 201 | So the honest summary of the end state: bridged on Linux, NAT on macOS, and no | ||
| 202 | user-facing toggle anywhere. | ||
| 203 | |||
| 204 | ## The hosts that are on NAT stay on NAT | ||
| 205 | |||
| 206 | Not a grace period and not a deprecation: a supported configuration, chosen for | ||
| 207 | the hosts that hold it. crucible and mewtwo keep the network they have, across | ||
| 208 | this change and across every agent upgrade after it, with no operator action and | ||
| 209 | no re-homing. The agent states which shape it is running at startup, at info, | ||
| 210 | once — a fact about the host, not a warning about it. | ||
| 211 | |||
| 212 | So `netenv`'s NAT half, `internal/agent/dhcp` and `internal/agent/ipalloc` are | ||
| 213 | not on a path to deletion. They are the implementation of one of the two shapes | ||
| 214 | a Linux host can have, and they keep their tests and their maintenance. | ||
| 215 | |||
| 216 | What "default" means, precisely, is that a host with nothing recorded gets | ||
| 217 | bridged and must be told which bridge. It says nothing about a host that has | ||
| 218 | already answered the question. | ||
| 219 | |||
| 220 | The one edge worth knowing: a host that is decommissioned and re-enrolled has no | ||
| 221 | identity, so it is a new host and needs `--bridge`. Moving crucible or mewtwo to | ||
| 222 | bridged is that, or an edit to `identity.json` — deliberately, and for the reason | ||
| 223 | in §1. The edit route completes per guest, not per host: the tap master check | ||
| 224 | in `bridgenet` (step 2) re-wires each tap under the new bridge at the next | ||
| 225 | converge, but a migrated guest keeps its NAT address until DHCP next makes it | ||
| 226 | ask — a renewal the site's server will not honour, or a power-cycle. | ||
| 227 | |||
| 228 | ## Steps | ||
| 229 | |||
| 230 | 1. `state.Identity.GuestNet` and `.Bridge`, `--bridge` on join and serve, and | ||
| 231 | the resolver beside `resolveGuestCIDR` in `internal/agent/run` — including | ||
| 232 | the one-time persist, which is what an existing host's first start under | ||
| 233 | this agent depends on, and the resolver running before anything persists a | ||
| 234 | fabricated CIDR. Table-driven over the identity shapes: the upgrade case | ||
| 235 | (a v0.0.2 identity, untouched), the flag that agrees, the flag that | ||
| 236 | contradicts (refused), and `--bridge` with `--bridge-cidr` (refused), each | ||
| 237 | pinned by its own test. | ||
| 238 | 2. `internal/agent/bridgenet`: the second `cloudhv.Network` implementation. | ||
| 239 | `CreateTap` creates the tap and enslaves it to the named bridge; | ||
| 240 | `DeleteTap` removes it; `ReserveIP` returns `""`. Idempotency verifies the | ||
| 241 | tap's master, not its existence (still rejecting a non-tap squatter, as | ||
| 242 | `netenv` does): a tap left enslaved to `eitri0` by a host migrated off NAT | ||
| 243 | passes an existence check and leaves its guest wired to a dead bridge — | ||
| 244 | right master is done, wrong master is re-enslaved. | ||
| 245 | 3. Bridge validation and the refusal enumeration, with the sysfs root | ||
| 246 | injectable so every mode is testable without a bridge: refuse (missing, | ||
| 247 | not-a-bridge, down), warn (no physical port, `br_netfilter` loaded), | ||
| 248 | enumerate (interfaces in, message out). | ||
| 249 | 4. The ARP watcher, as its own package with the socket injectable — parsing is | ||
| 250 | pure and testable against captured frames, including the dropped | ||
| 251 | `0.0.0.0`-sender probe. | ||
| 252 | 5. `Address`: `/proc/net/arp` first, watcher second. | ||
| 253 | 6. `wire_linux.go` picks the implementation; the NAT-only startup steps move | ||
| 254 | behind the legacy branch. `--bridge` joins `Config`; `proposeGuestCIDR` | ||
| 255 | returns nil under bridged (the fleet has nothing useful to suggest). | ||
| 256 | 7. `GuestCIDR` from the bridge's own address, and the shape said out loud: | ||
| 257 | `GuestNet` and the bridge name in hostinfo, so the console's host page | ||
| 258 | reads "bridged on br0" beside the network it already names. | ||
| 259 | 8. Docs: `faq.md`'s network answer is now false in its first two sentences, | ||
| 260 | and gains the `br_netfilter` entry; `quickstart.md` gains the host-prep | ||
| 261 | step, written in the reversible form (`netplan try` or the distro | ||
| 262 | equivalent — converting the uplink into a bridge over SSH is how you lose | ||
| 263 | the box, and the docs say so); the join card notes the `--bridge` | ||
| 264 | ingredient it cannot pre-fill — the console mints the command before the | ||
| 265 | host exists, and the refusal enumeration is what backfills the name; an | ||
| 266 | `assumptions.md` entry for the ARP-discovery bet (IPv4-only, trusts the | ||
| 267 | LAN) and one for the tenancy consequence below. | ||
| 268 | |||
| 269 | ## Gate | ||
| 270 | |||
| 271 | The boot-gate needs no new leg: `internal/smoke/scenario.go` polls for | ||
| 272 | `assigned_ip` and then SSHes to it through the gate, so a green gate is | ||
| 273 | discovery working, the tap on the right bridge, and the tunnel reaching a guest | ||
| 274 | the site's DHCP addressed. The problem is where it runs. | ||
| 275 | |||
| 276 | The gate fleet is a physical host on the real LAN, whose guests a real DHCP | ||
| 277 | server can address — so this work gates on the ordinary `make deploy` whenever | ||
| 278 | it is picked up. A bridged guest on a nested host can never gate: its "LAN" is | ||
| 279 | its parent's guest subnet, and the DHCP server there is an eitri agent that | ||
| 280 | fail-closes on a MAC it holds no reservation for (`dhcp.go:106`) — the same | ||
| 281 | fact recorded in the costs below. | ||
| 282 | |||
| 283 | ## What this costs, stated plainly | ||
| 284 | |||
| 285 | - **L2 isolation is gone.** Two tenants' guests on one host share a broadcast | ||
| 286 | domain with each other and with everything else on the operator's LAN. This is | ||
| 287 | a self-hosted posture. A fleet that needs tenant isolation wants the bridge on | ||
| 288 | a tagged VLAN sub-interface, which this design permits and does not manage. | ||
| 289 | - **`52:54:00` + 24 bits of sha256 now has to be unique on a real network**, not | ||
| 290 | just within one host's private bridge. Widening it renumbers every existing | ||
| 291 | guest, so it stays as it is — but the blast radius of a collision grew from | ||
| 292 | one host to one site. | ||
| 293 | - **No sticky addresses** unless the site's DHCP has reservations. A rebooted | ||
| 294 | guest gets what it is given. | ||
| 295 | - **Discovery trusts the LAN.** Anything in the broadcast domain can claim a | ||
| 296 | guest's MAC and re-point the recorded address. The SSH host CA makes the | ||
| 297 | hijack fail closed at the crypto layer — misdirection and denial, not | ||
| 298 | compromise — the same shared-broadcast-domain posture as the first bullet. | ||
| 299 | - **Discovery is IPv4-only.** ARP and DHCPv4. An IPv6-only site gets no | ||
| 300 | address column. | ||
| 301 | - **A host that is itself an eitri guest cannot be bridged.** Its LAN is its | ||
| 302 | parent's guest subnet, and the DHCP server there is an eitri agent that | ||
| 303 | answers only reservations it holds — by design, and correctly. Bridged assumes | ||
| 304 | a site DHCP that will address a stranger. Nested hosts stay on NAT, which is | ||
| 305 | the shape they already have and keep. | ||
| 306 | - **mewtwo runs dnsmasq proxy-DHCP and TFTP on `enp4s0`.** Bridged guests join | ||
| 307 | that broadcast domain. They boot UEFI from disk, so PXE should never win the | ||
| 308 | race — but it is now a race that exists, and it is worth watching on the first | ||
| 309 | guest booted there. | ||