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,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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user