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) PublicIPDetect *bool `yaml:"public_ip_detect"` // false disables external lookups (air-gap)
AutoVPN bool `yaml:"auto_vpn"` // auto-connect WireGuard when away AutoVPN bool `yaml:"auto_vpn"` // auto-connect WireGuard when away
WireGuard WireGuardConfig `yaml:"wireguard"` 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 // DetectPublicIP reports whether the agent may perform external public-IP
+3
View File
@@ -5,6 +5,7 @@ go 1.22.2
require ( require (
fyne.io/systray v1.12.2 fyne.io/systray v1.12.2
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/hashicorp/mdns v1.0.5
github.com/shirou/gopsutil/v3 v3.24.5 github.com/shirou/gopsutil/v3 v3.24.5
golang.org/x/sys v0.20.0 golang.org/x/sys v0.20.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
@@ -14,9 +15,11 @@ require (
github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-ole/go-ole v1.2.6 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // 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/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect github.com/tklauser/numcpus v0.6.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1 // indirect
) )
+16
View File
@@ -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/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 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 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 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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= 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/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 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= 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-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-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.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.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 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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= 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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+108
View File
@@ -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()
}
+113
View File
@@ -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")
}
}
+143
View File
@@ -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
}
+4
View File
@@ -89,6 +89,10 @@ func runAgent() {
// Home detection + tray status push (polls public IP every 60s). // Home detection + tray status push (polls public IP every 60s).
go StartHomeMonitor(cfg, func() bool { return wsConnected.Load() }) 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 // Foreground: exit on SIGINT/SIGTERM. A Windows service ignores these and
// is driven by its own handler. // is driven by its own handler.
go func() { go func() {