- Status: done
- Date: 2026-06-11
- Specs touched:
BOOT.md# What downstream services do (stale static-file mechanism + wrong restart-durability claim corrected),DISCOVERY.md# What we publish (host-record attribution corrected); everything else realized, not changed.
Closes issue #129. Picks up the "local-IP detection" known gap from avahi-dbus-publisher.md (frozen — its gap list stands as written). App .local records could announce a Docker bridge address instead of the LAN address (verified live: avahi-resolve memos.local → 172.25.0.1, a br-* bridge, while the LAN IP was 192.168.1.126), making apps unreachable from every other device — iOS resolved the name fine but got an unroutable address. Root cause: detectLocalIPv4() returned the first non-loopback, non-link-local IPv4 from net.InterfaceAddrs(), and on any box running Docker (every malmo box) a bridge can win by enumeration order. Not dev-only: cmd/host-agent-real uses the same DBusPublisher, so the bug was latent in production.
probeLANIPv4()— connect a UDP socket to192.0.2.1:9(TEST-NET-1; UDP connect performs only the kernel route lookup, no packet is sent) and read the chosen source address fromLocalAddr. The route lookup structurally excludes Docker bridges and other virtual interfaces — they are never the route toward a non-local address — with no RFC1918 blacklist (172.16/12 is a valid home-LAN block, so blacklisting would be wrong).enumerateLocalIPv4()— the old enumeration heuristic, kept verbatim as the fallback when the probe errors (a box with no default route — e.g. static IP without a gateway — must still publish so app installs don't fail), with aslog.Warnthat the announced address may be wrong.detectIPv4(probe, enumerate)— the composition, split out so the fallback branches are unit-testable without touching the routing table.- The file carries no build tag (pure
netcode) so the tests run on every platform, same asslug.go.
- Test override split from the cache: the
localIPfield doubled as both; it's replaced by adetectIP func() (string, error)field tests can stub. Production leaves it nil and gets the probe. - Process-lifetime cache dropped: the IP is re-detected on every
Publish, so apps installed (or stopped/started — start re-publishes via the lifecycle start path) after a DHCP change announce the current address. Honest limit: entry groups committed before the change keep their old literal IP until the brain replays them at restart (mixed-announcement window); live IP-change replay is slice 2 (#130 carries the network-state surface). - Package comment documents the probe caveat: a full-tunnel VPN on a dev machine routes the probe through the tunnel and announces the VPN address. Malmo installs don't run client VPNs.
BOOT.md~L95 still described the dead/etc/avahi/services/static-file mechanism (the 0012 false start) and claimed restart durability came "for free" from Avahi watching the directory. Replaced with the real mechanism: DBus entry groups, process-local, lost on host-agent restart, replayed bylifecycle.Reconcileat brain startup (DISCOVERY.md# Restart durability).DISCOVERY.md# What we publish said all three record categories are "driven by the brain via host-agent" — the host record (malmo.local) is avahi-daemon's native per-interface host record driven by the system hostname, not published by our code.
- Unit tests (
localip_test.go, no build tag): probe-wins / falls-back / both-fail composition with stubs;probeLANIPv4against the real routing table (skips if no default route). - Integration tests (
dbus_linux_test.go,avahitesttag, run locally against the real avahi-daemon):TestDBusPublisher_RedetectsIPPerPublish(stub counts two detections for two publishes — no cache);TestDBusPublisher_AnnouncesRouteProbedAddress— the #129 regression check: publish, thenavahi-resolve -4 -n, asserting the resolved address equals the route-probed one. - Real-system done-when, on a box with six Docker bridges up (
docker0+ fivebr-*, LAN oneno1192.168.2.160): builtcmd/host-agent-real, publishedmalmotest129throughPOST /v1/discovery/publishover the UNIX socket, andavahi-resolve -4 -n malmotest129.localreturned192.168.2.160— the LAN address, not172.x; unpublish withdrew the name (resolve times out after). Same steps asdev/test-avahi-publisher.sh(the script itself couldn't run unmodified because.dev/on this machine is root-owned from a past QEMU run —make host-agent-realcan't write the binary there; built to/tmpinstead, identical flow). make checkgreen.
- Mixed-announcement window after an IP change: already-committed entry groups keep the literal IP they were committed with; only a brain restart (reconcile replay) or per-app republish refreshes them. Full IP-change replay is the network-state slice (#130).
- The probe follows the default route, so a full-tunnel VPN on a dev machine announces the tunnel address (documented in the package comment). Not a production concern.
- Fallback enumeration is still order-dependent — by design, it only runs when the probe fails (no default route), and it warns.
- The
avahitest-tagged integration tests still don't run in CI (no nspawn lane yet — carried from avahi-dbus-publisher.md); they were run by hand on this box.
- Nothing new from this slice. The network-state slice (#130: per-LAN-interface announcement, Avahi interface allowlist, IP-change replay) is the follow-up that subsumes the mixed-announcement window.