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:
@@ -0,0 +1,143 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user