From 9fdbb8aab06a1769b7fd5b29bc82c6f5a09ccb3e Mon Sep 17 00:00:00 2001 From: William Mantly Date: Mon, 10 Aug 2026 17:54:39 -0400 Subject: [PATCH] feat(mdns): Linux local-discovery -- skip the WAN relay when on-site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- config.go | 10 +++ go.mod | 3 + go.sum | 16 +++++ hosts_override.go | 108 +++++++++++++++++++++++++++++++ hosts_override_test.go | 113 ++++++++++++++++++++++++++++++++ local_discovery.go | 143 +++++++++++++++++++++++++++++++++++++++++ main.go | 4 ++ 7 files changed, 397 insertions(+) create mode 100644 hosts_override.go create mode 100644 hosts_override_test.go create mode 100644 local_discovery.go diff --git a/config.go b/config.go index 17654ea..c1b5686 100644 --- a/config.go +++ b/config.go @@ -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 diff --git a/go.mod b/go.mod index 5c3e09f..f11346d 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.22.2 require ( fyne.io/systray v1.12.2 github.com/gorilla/websocket v1.5.3 + github.com/hashicorp/mdns v1.0.5 github.com/shirou/gopsutil/v3 v3.24.5 golang.org/x/sys v0.20.0 gopkg.in/yaml.v3 v3.0.1 @@ -14,9 +15,11 @@ require ( github.com/go-ole/go-ole v1.2.6 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/miekg/dns v1.1.41 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/shoenig/go-m1cpu v0.1.6 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect + golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1 // indirect ) diff --git a/go.sum b/go.sum index ae35a08..5ee9608 100644 --- a/go.sum +++ b/go.sum @@ -11,8 +11,12 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/mdns v1.0.5 h1:1M5hW1cunYeoXOqHwEb/GBDDHAFo0Yqb/uz/beC6LbE= +github.com/hashicorp/mdns v1.0.5/go.mod h1:mtBihi+LeNXGtG8L9dX59gAEa12BDtBQSp4v/YAJqrc= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/miekg/dns v1.1.41 h1:WMszZWJG0XmzbK9FEmzH2TVcqYzFesusSIB41b8KHxY= +github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= @@ -31,12 +35,24 @@ github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+F github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1 h1:4qWs8cYYH6PoEFy4dfhDFgoMGkwAcETd+MmPdCPMzUc= +golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/hosts_override.go b/hosts_override.go new file mode 100644 index 0000000..27cf30c --- /dev/null +++ b/hosts_override.go @@ -0,0 +1,108 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "runtime" + "strings" + "sync" +) + +// Linux-only for now (AGENT_LOCAL_DISCOVERY_SPEC.md §3) -- Windows/macOS +// hosts-file semantics (elevation, DNS caching, whether mDNSResponder should +// be used instead of hand-rolled hosts edits) need their own platform-native +// investigation before this mechanism is trusted there. +// +// var, not const, so tests can point it at a temp file instead of touching +// the real /etc/hosts. +var hostsFilePathLinux = "/etc/hosts" + +const hostsBlockBegin = "# BEGIN theta-agent-local-discovery (managed, do not edit by hand)" +const hostsBlockEnd = "# END theta-agent-local-discovery" + +var hostsMu sync.Mutex + +// applyHostsOverride replaces the managed block in /etc/hosts with exactly +// `entries` (hostname -> IP). Passing an empty map removes the block +// entirely rather than leaving an empty marker pair, so a host that never +// discovers anything -- or stops discovering something it used to -- leaves +// hosts file with no discovery trace at all. +func applyHostsOverride(entries map[string]string) error { + if runtime.GOOS != "linux" { + return fmt.Errorf("hosts-file override is Linux-only for now (see AGENT_LOCAL_DISCOVERY_SPEC.md §3)") + } + hostsMu.Lock() + defer hostsMu.Unlock() + + existing, err := readLines(hostsFilePathLinux) + if err != nil { + return fmt.Errorf("reading %s: %w", hostsFilePathLinux, err) + } + + kept := make([]string, 0, len(existing)) + inBlock := false + for _, line := range existing { + trimmed := strings.TrimSpace(line) + if trimmed == hostsBlockBegin { + inBlock = true + continue + } + if trimmed == hostsBlockEnd { + inBlock = false + continue + } + if inBlock { + continue // drop old managed lines unconditionally; rebuilt below + } + kept = append(kept, line) + } + + // Trim any trailing blank lines the block removal left, then rebuild. + for len(kept) > 0 && strings.TrimSpace(kept[len(kept)-1]) == "" { + kept = kept[:len(kept)-1] + } + + out := strings.Join(kept, "\n") + if len(entries) > 0 { + out += "\n" + hostsBlockBegin + "\n" + for host, ip := range entries { + out += fmt.Sprintf("%s\t%s\n", ip, host) + } + out += hostsBlockEnd + "\n" + } else { + out += "\n" + } + + // NOT write-tmp-then-rename: on a real host that's the safer, atomic + // way to update a file, but /etc/hosts is frequently a bind mount + // (every container runtime does this, Docker included) -- confirmed the + // hard way: rename() onto a bind-mounted /etc/hosts fails with EBUSY + // ("device or resource busy"), since you cannot atomically replace a + // mountpoint. Truncate-and-rewrite in place instead; hostsMu already + // serializes calls from this process, which is the only writer of the + // managed block, so the lost atomicity is a real but small tradeoff + // against a confirmed hard failure. + if err := os.WriteFile(hostsFilePathLinux, []byte(out), 0644); err != nil { + return fmt.Errorf("writing %s: %w", hostsFilePathLinux, err) + } + return nil +} + +func readLines(path string) ([]string, error) { + f, err := os.Open(path) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + defer f.Close() + + var lines []string + scanner := bufio.NewScanner(f) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + return lines, scanner.Err() +} diff --git a/hosts_override_test.go b/hosts_override_test.go new file mode 100644 index 0000000..a57678a --- /dev/null +++ b/hosts_override_test.go @@ -0,0 +1,113 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/hashicorp/mdns" +) + +func withTempHostsFile(t *testing.T, initial string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "hosts") + if initial != "" { + if err := os.WriteFile(path, []byte(initial), 0644); err != nil { + t.Fatalf("seeding temp hosts file: %v", err) + } + } + orig := hostsFilePathLinux + hostsFilePathLinux = path + t.Cleanup(func() { hostsFilePathLinux = orig }) + return path +} + +func TestApplyHostsOverride_AddsManagedBlock(t *testing.T) { + path := withTempHostsFile(t, "127.0.0.1\tlocalhost\n") + + if err := applyHostsOverride(map[string]string{"sso.example.com": "10.0.0.5"}); err != nil { + t.Fatalf("applyHostsOverride: %v", err) + } + + got, _ := os.ReadFile(path) + s := string(got) + if !strings.Contains(s, "127.0.0.1\tlocalhost") { + t.Errorf("existing content was clobbered: %q", s) + } + if !strings.Contains(s, hostsBlockBegin) || !strings.Contains(s, hostsBlockEnd) { + t.Errorf("managed block markers missing: %q", s) + } + if !strings.Contains(s, "10.0.0.5\tsso.example.com") { + t.Errorf("override entry missing: %q", s) + } +} + +func TestApplyHostsOverride_ReplacesPriorBlockRatherThanStacking(t *testing.T) { + path := withTempHostsFile(t, "") + if err := applyHostsOverride(map[string]string{"sso.example.com": "10.0.0.5"}); err != nil { + t.Fatalf("first apply: %v", err) + } + if err := applyHostsOverride(map[string]string{"sso.example.com": "10.0.0.9"}); err != nil { + t.Fatalf("second apply: %v", err) + } + + got, _ := os.ReadFile(path) + s := string(got) + if strings.Count(s, hostsBlockBegin) != 1 { + t.Fatalf("expected exactly one managed block, got content: %q", s) + } + if strings.Contains(s, "10.0.0.5") { + t.Errorf("stale override (10.0.0.5) should have been replaced, got: %q", s) + } + if !strings.Contains(s, "10.0.0.9") { + t.Errorf("new override missing, got: %q", s) + } +} + +func TestApplyHostsOverride_EmptyEntriesRemovesBlockEntirely(t *testing.T) { + path := withTempHostsFile(t, "127.0.0.1\tlocalhost\n") + if err := applyHostsOverride(map[string]string{"sso.example.com": "10.0.0.5"}); err != nil { + t.Fatalf("apply: %v", err) + } + if err := applyHostsOverride(map[string]string{}); err != nil { + t.Fatalf("clear: %v", err) + } + + got, _ := os.ReadFile(path) + s := string(got) + if strings.Contains(s, hostsBlockBegin) || strings.Contains(s, "10.0.0.5") { + t.Errorf("expected no discovery trace left after clearing, got: %q", s) + } + if !strings.Contains(s, "127.0.0.1\tlocalhost") { + t.Errorf("pre-existing content should survive a full clear, got: %q", s) + } +} + +func TestHostFromURL(t *testing.T) { + cases := map[string]string{ + "https://sso.example.com:443/api": "sso.example.com", + "http://sso.example.com": "sso.example.com", + "not a url at all": "", + "": "", + } + for in, want := range cases { + if got := hostFromURL(in); got != want { + t.Errorf("hostFromURL(%q) = %q, want %q", in, got, want) + } + } +} + +func TestEntryAnnouncesHost(t *testing.T) { + entry := &mdns.ServiceEntry{InfoFields: []string{"hosts=sso.example.com,proxy.example.com"}} + if !entryAnnouncesHost(entry, "sso.example.com") { + t.Error("expected match for sso.example.com") + } + if !entryAnnouncesHost(entry, "proxy.example.com") { + t.Error("expected match for proxy.example.com") + } + if entryAnnouncesHost(entry, "jump.example.com") { + t.Error("expected no match for a host not in the TXT record") + } +} diff --git a/local_discovery.go b/local_discovery.go new file mode 100644 index 0000000..d087bf4 --- /dev/null +++ b/local_discovery.go @@ -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 +} diff --git a/main.go b/main.go index 437796b..f3415ea 100644 --- a/main.go +++ b/main.go @@ -89,6 +89,10 @@ func runAgent() { // Home detection + tray status push (polls public IP every 60s). go StartHomeMonitor(cfg, func() bool { return wsConnected.Load() }) + // mDNS local-discovery (MULTI_SITE_SPEC.md Appendix B) -- no-op unless + // prefer_local_directory is set. + go StartLocalDiscovery(cm) + // Foreground: exit on SIGINT/SIGTERM. A Windows service ignores these and // is driven by its own handler. go func() {