a73x

508d3d97

docs: bridged guest networking

a73x   2026-08-06 09:12

Commit message
docs: bridged guest networking

A Linux host can put its guests on the operator's own network: it is told
which bridge to attach taps to, the site's DHCP addresses the guests, and
the agent discovers the result instead of allocating it. Validation of the
bridge it was handed replaces the bridge it used to build.

Hosts already on NAT keep it, recorded in their identity rather than
re-inferred each start. A host that is itself an eitri guest stays there:
its network's DHCP is an eitri agent, which answers only reservations it
holds.

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.