a73x

docs/superpowers/plans/2026-08-05-bridged-guest-networking.md

Ref:   Size: 17.5 KiB   History

# Bridged by default: a guest is a machine on the network

**Status:** parked — demand-driven. Service exposure covers reaching a guest's
services; what this plan alone provides is LAN citizenship — broadcast-domain
discovery (mDNS/SSDP) and operator-owned addressing. It is revived by the
first workload that needs those. A UDP want alone is not that workload:
`protocol: udp` on exposures is the smaller answer.
**Branch:** `v0.0.3`
**Ethos:** the guest owns the guest, eitri owns the fleet — and the network
belongs to whoever runs it.

## The decision

A Linux host puts its guests on the operator's network. It does not build a
network of its own, does not hand out addresses, and does not masquerade. It is
told which bridge to attach taps to, and it refuses to start without one.

This is a default, not a mode: there is no `--guest-net` flag and nothing in the
console flips it. A Linux host that has a bridge is bridged; a Linux host that
does not is a host that will not serve.

Two things it is not. It is not fleet-wide — macOS cannot bridge, so the NAT
shape survives on Macs whatever we do here (below). And it is not a licence to
rebuild the operator's uplink: eitri never enslaves an interface it did not
create. The bridge is the operator's, built by whatever manages that host's
network, and the agent's only claim on it is a tap.

## What is there now

Every guest sits behind NAT on a host-private bridge. `netenv.Net` builds
`eitri0`, takes `.1`, enables forwarding, installs one `nft` masquerade rule
(`netenv.go:172`), and runs eitri's own DHCP responder with a per-VM
reservation keyed on a deterministic MAC (`netenv.go:240`, `state.go:94`).

The seam this all hangs off is already the right one. `cloudhv` does not import
`netenv`: it declares a five-method consumer-owned `Network` interface
(`cloudhv.go:46`) — `ReserveIP`, `Address`, `CreateTap`, `DeleteTap`,
`TapName` — and `netenv` happens to satisfy it. The VMM sees one line of the
whole arrangement, `--net tap=eit-xxxxxxxx,mac=52:54:00:…`
(`cloudhv.go:151`), and that line is identical under either regime.

So bridged is a second implementation of an interface that already exists.
`reconcile`, `buildArgs`, the pumps and the pidfiles do not move.

## The three things that actually change

### 1. The mode is derived from the identity, never set

`state.Identity` (`state.go:43`) gains two fields — `GuestNet string` (`nat` or
`bridged`) and `Bridge string` — and the answer comes from the record, with no
flag to disagree with:

- `GuestNet` set → that, and for `bridged`, on the bridge named in `Bridge`.
- unset, `BridgeCIDR != ""` → a host enrolled by an older agent. NAT, recorded
  as `GuestNet: "nat"` on this first start and read from the record thereafter.
- unset, nothing else → a new host. `--bridge` is required; refuse to start.

The recording matters more than it looks. Deriving "this host is on NAT" from a
missing field works exactly once — the next change to what absence means would
silently re-home hosts that were never asked. Writing the answer down the first
time a host resolves it makes an existing host's NAT a decision the fleet holds,
not an inference it re-makes every start. It is the same one-time persist
`persistGuestCIDR` already does for the subnet (`guestcidr.go:51`).

This is the precedence `resolveGuestCIDR` already enforces for the subnet
(`guestcidr.go:33`), and for the reason stated there: a flag that overrides a
persisted identity moves the network under live guests and every one of them
loses its address at the next restart. Changing a host's bridge is an edit to
`identity.json`, exactly as re-homing its subnet is.

Where the answer is given: `join`. The mode is identity and join is the
ceremony that mints identity, so `eitri-agent join --bridge br0` is the
operator answering at the moment the host is asked. Serve accepts the same
flag as backfill only — consulted when the record is silent, the role
`cfg.BridgeCIDR` already plays in `resolveGuestCIDR` — and a restart that
passes it lands in one of three rows:

- record silent → the flag answers and is persisted.
- record agrees → no-op; restating the recorded answer is harmless.
- record contradicts (`nat` recorded, or a different bridge named) → refuse,
  loudly. Obeying would re-home every guest as a side effect of a restart
  flag; ignoring would silently discard an instruction the operator stopped
  the agent to give. The refusal names the record and the sanctioned path:
  edit `identity.json` with the agent stopped, or decommission and re-join.

Two orderings that matter. The resolver runs before anything persists a
fabricated CIDR — `newPlatform` persists `lastResortGuestCIDR` today, and a
fresh host that reached that code first would classify itself as legacy NAT.
And `--bridge` with `--bridge-cidr` is a contradiction in terms — one names a
network the operator owns, the other a network eitri would build — and is
refused rather than resolved.

The refusal is the report. The machine that knows which bridge to name is
the host itself, so when `--bridge` is missing the agent enumerates before it
exits. A host with bridges gets the candidates and the evidence that matters —
`br0: up, ports enp4s0` against `docker0: up, no physical port` — and the
operator reads their own answer off the list. A host with none gets the name
of the interface they are about to enslave: the default-route interface,
offered as a suggestion — wrong on an odd topology, and costing nothing
there, because a suggestion can be ignored. What the agent never does is
choose. A filter that picked the one plausible candidate would ship guests
onto the wrong segment silently on exactly the host where it guessed wrong,
and the whole design rests on the mode being an answer the operator gave.
Suggest loudly, choose never.

### 2. Addressing flips from allocate to observe

There is no reservation to make. The site's DHCP server hands the lease, and the
agent's job is to find out what it was.

`ReserveIP` becomes a no-op returning `""` — `attachNet` passes its result
straight into `CreateTap` (`cloudhv.go:317`) and bridged `CreateTap` ignores it.
`Address` becomes discovery, and returns `""` until it has an answer.

Nothing above this needs teaching, because macOS already taught it.
`noteAddress` treats an empty answer as "I don't know yet" and refuses to blank
a known address (`reconcile.go:563`); the control plane's guard stopped asking
about topology; the host reports its own subnet. The consumers that poll for
`assigned_ip` — the console column, `eitri-mcp`'s wait, the smoke gate — already
wait for a Mac and will wait the same way here.

**Discovery mechanism.** Two sources, cheapest first:

- `/proc/net/arp` — free, no exec, scoped by its Device column, and populated
  for any guest the host has already spoken to.
- A passive ARP watcher on the bridge for the cold case, which is the common
  one: nothing has dialled the guest yet, and dialling is what needs the address.
  An `AF_PACKET` socket filtered to `ETH_P_ARP` yields sender-MAC → sender-IP,
  matched against `state.MAC(vmID)`. Every guest broadcasts within seconds of
  its lease — an RFC 5227 announcement for its own address, and a request for
  its gateway — so the answer arrives unprompted and updates itself if the
  site renumbers the guest. The watcher drops unspecified sender addresses: a
  5227 *probe* carries sender-IP `0.0.0.0` by definition — junk that
  `noteAddress` has no reason to refuse and the tunnel would then dial.

ARP rather than DHCP snooping deliberately: a DHCP reply may be unicast to the
guest's MAC, which the bridge will not deliver to the host without putting the
interface in promiscuous mode. ARP is broadcast and needs no such thing.

A discovered address is already durable — `reconcile` persists `rec.IP` — so an
agent restart keeps the last known address while the watcher re-warms, and
`syncclient`'s tunnel (which refuses an empty address, `client.go:530`) never
sees a gap it would not have seen on a Mac.

### 3. Most of the host-wide setup stops running

Under bridged, `newPlatform` (`wire_linux.go:84`) does none of this:

| Step | Why it goes |
|---|---|
| `EnsureBridge` | the bridge is the operator's; we validate, never create |
| `StartDHCP` | an eitri DHCP server on the LAN hijacks the site's |
| `replayReservations` | there are no reservations to replay |
| `CheckUplinkCollision` | it refuses precisely the bridged case, by definition |
| `lastResortGuestCIDR` | a fabricated subnet is only right where something builds one |

`CheckUplinkCollision` (`uplink.go:67`) deserves a note rather than a deletion.
It fails when the guest subnet contains an address the host is configured with,
which is the definition of bridged — but it is the correct check for the shape
it was written for, and the failure it prevents (the host's own address becomes
local, it stops answering, and the machine best placed to fix it is the one that
just lost the route) is real. It stays, scoped to legacy NAT.

What replaces it is validation of what we were given, at startup, before a
guest can fail illegibly later — split on the line `CheckUplinkCollision`
already draws: refuse on facts, warn on diagnostics. The facts are that the
named link exists, is a bridge (`/sys/class/net/<name>/bridge`), and is up.
These are never wrong under any topology; an operator whose bridge fails them
typed a name that does not hold. Whether the bridge leads anywhere is not a
fact but the operator's intent: the default route may sit on the bridge
itself (the canonical shape — the host's address lives on `br0` and the
uplink is a port), on another interface entirely with a guests-only bridge on
a second NIC, or nowhere by design on an isolated segment. Any rule that
consults the routing table refuses one of the legitimate shapes, so the
routing table is not consulted. The one inference worth making is a warning:
a bridge with no physical port enslaved — the `docker0` signature, nothing
under `brif/` but taps and veths — is logged plainly at startup as guests
that may have no way off this host. A guest that then never gets an address
is a legible symptom whose explanation is already in the log, which is the
whole point of checking.

One more warning belongs beside it: `br_netfilter`. With that module loaded —
Docker loads it — frames bridged through the operator's bridge traverse
iptables, and a `FORWARD` policy of `DROP` (Docker again) eats them: tap up,
ARP seen, no traffic, nothing to look at. The ruleset cannot be proven safe
from here, but the module's presence can be named at startup, and the FAQ
carries the rest.

`GuestCIDR` reports the bridge's own IPv4 network, masked to its own prefix
length. This is the shape `vfkit.GuestCIDR` already uses (`vfkit/guestnet.go`)
and the same rule applies: no answer leaves the fleet's record alone.

## macOS is untouched

Virtualization.framework needs `com.apple.vm.networking` to bridge — a
restricted entitlement, granted by Apple on request, and it lives in the code
signature of the binary holding it. That binary is vfkit, not ours, and vfkit
ships `nat` (`vfkit.go:236`). A Mac keeps vmnet, keeps discovering its address
from `/var/db/dhcpd_leases`, and keeps reporting `192.168.64.0/24`.

So the honest summary of the end state: bridged on Linux, NAT on macOS, and no
user-facing toggle anywhere.

## The hosts that are on NAT stay on NAT

Not a grace period and not a deprecation: a supported configuration, chosen for
the hosts that hold it. crucible and mewtwo keep the network they have, across
this change and across every agent upgrade after it, with no operator action and
no re-homing. The agent states which shape it is running at startup, at info,
once — a fact about the host, not a warning about it.

So `netenv`'s NAT half, `internal/agent/dhcp` and `internal/agent/ipalloc` are
not on a path to deletion. They are the implementation of one of the two shapes
a Linux host can have, and they keep their tests and their maintenance.

What "default" means, precisely, is that a host with nothing recorded gets
bridged and must be told which bridge. It says nothing about a host that has
already answered the question.

The one edge worth knowing: a host that is decommissioned and re-enrolled has no
identity, so it is a new host and needs `--bridge`. Moving crucible or mewtwo to
bridged is that, or an edit to `identity.json` — deliberately, and for the reason
in §1. The edit route completes per guest, not per host: the tap master check
in `bridgenet` (step 2) re-wires each tap under the new bridge at the next
converge, but a migrated guest keeps its NAT address until DHCP next makes it
ask — a renewal the site's server will not honour, or a power-cycle.

## Steps

1. `state.Identity.GuestNet` and `.Bridge`, `--bridge` on join and serve, and
   the resolver beside `resolveGuestCIDR` in `internal/agent/run` — including
   the one-time persist, which is what an existing host's first start under
   this agent depends on, and the resolver running before anything persists a
   fabricated CIDR. Table-driven over the identity shapes: the upgrade case
   (a v0.0.2 identity, untouched), the flag that agrees, the flag that
   contradicts (refused), and `--bridge` with `--bridge-cidr` (refused), each
   pinned by its own test.
2. `internal/agent/bridgenet`: the second `cloudhv.Network` implementation.
   `CreateTap` creates the tap and enslaves it to the named bridge;
   `DeleteTap` removes it; `ReserveIP` returns `""`. Idempotency verifies the
   tap's master, not its existence (still rejecting a non-tap squatter, as
   `netenv` does): a tap left enslaved to `eitri0` by a host migrated off NAT
   passes an existence check and leaves its guest wired to a dead bridge —
   right master is done, wrong master is re-enslaved.
3. Bridge validation and the refusal enumeration, with the sysfs root
   injectable so every mode is testable without a bridge: refuse (missing,
   not-a-bridge, down), warn (no physical port, `br_netfilter` loaded),
   enumerate (interfaces in, message out).
4. The ARP watcher, as its own package with the socket injectable — parsing is
   pure and testable against captured frames, including the dropped
   `0.0.0.0`-sender probe.
5. `Address`: `/proc/net/arp` first, watcher second.
6. `wire_linux.go` picks the implementation; the NAT-only startup steps move
   behind the legacy branch. `--bridge` joins `Config`; `proposeGuestCIDR`
   returns nil under bridged (the fleet has nothing useful to suggest).
7. `GuestCIDR` from the bridge's own address, and the shape said out loud:
   `GuestNet` and the bridge name in hostinfo, so the console's host page
   reads "bridged on br0" beside the network it already names.
8. Docs: `faq.md`'s network answer is now false in its first two sentences,
   and gains the `br_netfilter` entry; `quickstart.md` gains the host-prep
   step, written in the reversible form (`netplan try` or the distro
   equivalent — converting the uplink into a bridge over SSH is how you lose
   the box, and the docs say so); the join card notes the `--bridge`
   ingredient it cannot pre-fill — the console mints the command before the
   host exists, and the refusal enumeration is what backfills the name; an
   `assumptions.md` entry for the ARP-discovery bet (IPv4-only, trusts the
   LAN) and one for the tenancy consequence below.

## Gate

The boot-gate needs no new leg: `internal/smoke/scenario.go` polls for
`assigned_ip` and then SSHes to it through the gate, so a green gate is
discovery working, the tap on the right bridge, and the tunnel reaching a guest
the site's DHCP addressed. The problem is where it runs.

The gate fleet is a physical host on the real LAN, whose guests a real DHCP
server can address — so this work gates on the ordinary `make deploy` whenever
it is picked up. A bridged guest on a nested host can never gate: its "LAN" is
its parent's guest subnet, and the DHCP server there is an eitri agent that
fail-closes on a MAC it holds no reservation for (`dhcp.go:106`) — the same
fact recorded in the costs below.

## What this costs, stated plainly

- **L2 isolation is gone.** Two tenants' guests on one host share a broadcast
  domain with each other and with everything else on the operator's LAN. This is
  a self-hosted posture. A fleet that needs tenant isolation wants the bridge on
  a tagged VLAN sub-interface, which this design permits and does not manage.
- **`52:54:00` + 24 bits of sha256 now has to be unique on a real network**, not
  just within one host's private bridge. Widening it renumbers every existing
  guest, so it stays as it is — but the blast radius of a collision grew from
  one host to one site.
- **No sticky addresses** unless the site's DHCP has reservations. A rebooted
  guest gets what it is given.
- **Discovery trusts the LAN.** Anything in the broadcast domain can claim a
  guest's MAC and re-point the recorded address. The SSH host CA makes the
  hijack fail closed at the crypto layer — misdirection and denial, not
  compromise — the same shared-broadcast-domain posture as the first bullet.
- **Discovery is IPv4-only.** ARP and DHCPv4. An IPv6-only site gets no
  address column.
- **A host that is itself an eitri guest cannot be bridged.** Its LAN is its
  parent's guest subnet, and the DHCP server there is an eitri agent that
  answers only reservations it holds — by design, and correctly. Bridged assumes
  a site DHCP that will address a stranger. Nested hosts stay on NAT, which is
  the shape they already have and keep.
- **mewtwo runs dnsmasq proxy-DHCP and TFTP on `enp4s0`.** Bridged guests join
  that broadcast domain. They boot UEFI from disk, so PXE should never win the
  race — but it is now a race that exists, and it is worth watching on the first
  guest booted there.