feat(mdns): Linux local-discovery -- skip the WAN relay when on-site

Implements the Linux half of AGENT_LOCAL_DISCOVERY_SPEC.md: when a local
theta-gateway/theta-proxy announces itself via mDNS as fronting this
agent's ServerURL host, skip the relay/WAN path and talk to it directly.
Off by default (config.PreferLocalDirectory / prefer_local_directory)
since it changes host name resolution.

- local_discovery.go: polls for _theta-suite._tcp every 30s via
  hashicorp/mdns, matches the TXT "hosts" field against the agent's own
  target host, applies/clears a hosts-file override on change. Presence/
  absence of the mDNS announcement IS the "on this LAN or not" signal --
  no separate network detection needed, since multicast doesn't cross
  routers/VLANs.
- hosts_override.go: writes a single marked, idempotent block into
  /etc/hosts (never touches anything else in the file); clearing removes
  the block entirely rather than leaving empty markers.
- HARD RULE preserved: this only ever changes DNS resolution, never TLS
  trust -- nothing here touches certificate validation, so a spoofed
  rogue mDNS announcement produces a TLS failure against the real
  hostname's cert, not a silent MITM.

Verified end-to-end with real containers (Node mDNS announcer + this
actual Go binary, not mocked), which caught two real bugs neither showed
up in code review:

1. mdns.Lookup()'s DefaultParams() requests both IPv4 and IPv6. The
   underlying client sends the v4 query (which got a real, valid
   response per a packet capture), then sends the v6 query, and if THAT
   send fails (no IPv6 route -- common on plain v4 hosts/containers) the
   whole Query() returns that error synchronously, before ever entering
   the response-listening loop. The v4 response was silently discarded.
   Fixed by building QueryParam manually with DisableIPv6: true instead
   of using the Lookup() convenience wrapper.
2. The original hosts-file writer used write-tmp-then-rename for
   atomicity. /etc/hosts is frequently a bind mount (every container
   runtime does this) -- rename() onto a bind-mounted file fails with
   EBUSY, since you can't atomically replace a mountpoint. Switched to
   truncate-and-rewrite in place; the process-local mutex already
   serializes writers, so the lost atomicity is a small, acceptable
   tradeoff against a confirmed hard failure.

Full cycle verified: announcer starts -> agent discovers it -> hosts
override applied -> announcer stops -> override cleanly reverts, no
stale entry, no discovery trace left.

Windows/macOS remain unbuilt -- need platform-native testing this
environment can't do (see AGENT_LOCAL_DISCOVERY_SPEC.md §3's open
question: hosts-file edits vs. a local stub resolver, per-OS elevation
and DNS-cache behavior).
This commit is contained in:
2026-08-10 17:54:39 -04:00
parent d937f8dcba
commit 9fdbb8aab0
7 changed files with 397 additions and 0 deletions
+10
View File
@@ -61,6 +61,16 @@ type Config struct {
PublicIPDetect *bool `yaml:"public_ip_detect"` // false disables external lookups (air-gap)
AutoVPN bool `yaml:"auto_vpn"` // auto-connect WireGuard when away
WireGuard WireGuardConfig `yaml:"wireguard"`
// PreferLocalDirectory opts into mDNS local-discovery (MULTI_SITE_SPEC.md
// Appendix B): when a theta-gateway/theta-proxy on the local network
// segment announces itself as fronting this agent's ServerURL host, skip
// the WAN/relay path and talk to it directly. Off by default -- it
// changes name resolution behavior on the host, so it's opt-in, not
// automatic. Linux only for now (see local_discovery.go); Windows/macOS
// need their own platform-native investigation before this flag does
// anything there.
PreferLocalDirectory bool `yaml:"prefer_local_directory"`
}
// DetectPublicIP reports whether the agent may perform external public-IP