Files
theta-agent/local_discovery.go
wmantly 9fdbb8aab0 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).
2026-08-10 17:54:39 -04:00

144 lines
5.1 KiB
Go

package main
import (
"log"
"net/url"
"strings"
"time"
"github.com/hashicorp/mdns"
)
// mDNS local-discovery (AGENT_LOCAL_DISCOVERY_SPEC.md): when a
// theta-gateway/theta-proxy on the local network segment announces itself
// as fronting this agent's own server hostname, skip the relay/WAN path and
// talk to it directly. Opt-in via Config.PreferLocalDirectory.
//
// HARD RULE (non-negotiable): this changes WHERE we connect (DNS
// resolution via /etc/hosts), never WHETHER we trust what answers. Nothing
// here touches TLS/certificate validation -- the agent's normal TLS client
// code path is completely untouched, so a spoofed rogue mDNS announcement
// just produces a TLS handshake failure against the real hostname's cert,
// not a silent MITM. Do not "fix" a discovery-related connection failure by
// loosening cert checks; that would defeat the entire point of this rule.
const mdnsServiceName = "_theta-suite._tcp"
const mdnsPollInterval = 30 * time.Second
const mdnsLookupTimeout = 3 * time.Second
// StartLocalDiscovery runs until the process exits. No-op (logs once, then
// returns) if the feature isn't enabled or the target host can't be
// determined -- callers just `go StartLocalDiscovery(cm)` unconditionally.
func StartLocalDiscovery(cm *ConfigManager) {
cfg := cm.Get()
if !cfg.PreferLocalDirectory {
return
}
targetHost := hostFromURL(cfg.ServerURL)
if targetHost == "" {
log.Printf("[local-discovery] could not parse a hostname out of server_url %q -- disabled", cfg.ServerURL)
return
}
log.Printf("[local-discovery] enabled, watching for a local announcement fronting %s", targetHost)
currentlyOverridden := false
for {
ip := findLocalAnnouncement(targetHost)
switch {
case ip != "" && !currentlyOverridden:
if err := applyHostsOverride(map[string]string{targetHost: ip}); err != nil {
log.Printf("[local-discovery] found %s locally at %s but failed to apply hosts override: %v", targetHost, ip, err)
} else {
log.Printf("[local-discovery] %s announced locally at %s -- routing directly, skipping the relay/WAN path", targetHost, ip)
currentlyOverridden = true
}
case ip == "" && currentlyOverridden:
if err := applyHostsOverride(map[string]string{}); err != nil {
log.Printf("[local-discovery] lost local announcement for %s but failed to clear hosts override: %v", targetHost, err)
} else {
log.Printf("[local-discovery] %s no longer announced locally -- reverting to normal resolution", targetHost)
currentlyOverridden = false
}
}
time.Sleep(mdnsPollInterval)
}
}
func hostFromURL(raw string) string {
u, err := url.Parse(raw)
if err != nil || u.Hostname() == "" {
return ""
}
return u.Hostname()
}
// findLocalAnnouncement browses for _theta-suite._tcp on the local segment
// and returns the announcing host's IP if its TXT "hosts" field lists
// targetHost, or "" if nothing matching is currently visible. mDNS is
// inherently link-local (multicast doesn't cross routers/VLANs), so "found
// vs not found" naturally tracks "on this LAN vs not" with no separate
// network-detection logic needed.
func findLocalAnnouncement(targetHost string) string {
entriesCh := make(chan *mdns.ServiceEntry, 8)
done := make(chan struct{})
var found string
go func() {
for entry := range entriesCh {
if entryAnnouncesHost(entry, targetHost) && found == "" {
if entry.AddrV4 != nil {
found = entry.AddrV4.String()
} else if entry.AddrV6 != nil {
found = entry.AddrV6.String()
}
}
}
close(done)
}()
// NOT mdns.Lookup() -- its DefaultParams() requests both IPv4 and IPv6,
// and the underlying client sends the v4 query, THEN the v6 query, and
// returns whatever error the v6 send produced -- aborting the entire
// Query() synchronously if IPv6 isn't available, even though the v4
// query it already sent may have already gotten (or will get) a valid
// response. Confirmed with a packet capture: the v4 query and its
// response both went out/came back fine, but Query() still returned
// "network is unreachable" (from the v6 send) before the response-
// listening loop ever started, so the entry was silently discarded.
// IPv6 multicast isn't guaranteed present on every host this runs on
// (many servers/containers are v4-only) -- disable it explicitly rather
// than depend on IPv6 being configured for IPv4 discovery to work at all.
params := mdns.DefaultParams(mdnsServiceName)
params.Entries = entriesCh
params.Timeout = mdnsLookupTimeout
params.DisableIPv6 = true
err := mdns.Query(params)
close(entriesCh)
<-done
if err != nil {
// Transient lookup errors (e.g. no multicast-capable interface at
// the moment) are expected on some networks -- treat as "not found
// right now", not a fatal condition.
return ""
}
return found
}
func entryAnnouncesHost(entry *mdns.ServiceEntry, targetHost string) bool {
for _, field := range entry.InfoFields {
// TXT format: "hosts=sso.example.com,proxy.example.com"
if !strings.HasPrefix(field, "hosts=") {
continue
}
hosts := strings.Split(strings.TrimPrefix(field, "hosts="), ",")
for _, h := range hosts {
if strings.TrimSpace(h) == targetHost {
return true
}
}
}
return false
}