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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user