feat(discovery): Windows local-discovery + local route pinning + prompt reconnect

Completes the mDNS local-discovery feature on Windows (was Linux-only since
v2.1.2). Three parts:

1. Windows hosts override (hosts_override_windows.go): %SystemRoot%...\\hosts,
   CRLF-aware read/write, ipconfig /flushdns after each change. The agent runs
   as a SYSTEM service so elevation is a non-issue. hosts_override.go split
   into shared rewrite logic + platform files; the hosts tests now run the real
   Windows write path on CI instead of skipping.

2. Local route pinning (local_route*.go): the hosts override only fixes name
   resolution -- the packet path is the routing table's job. If the WG mesh
   tunnel is up with AllowedIPs covering the LAN (or full-tunnel 0.0.0.0/0) it
   swallows the direct connection. Discovery now pins a /32 host route via the
   owning local interface (route.exe metric 1 on Windows, ip route replace on
   Linux) and drops it on revert. Closes a gap in the shipped Linux path too.

3. Prompt reconnect: apply/revert signals the WS loop so it reconnects
   immediately instead of waiting out the 5s backoff.

Route/hosts code is injectable + unit tested; go test passes natively on
Windows (this machine), and linux/amd64 + windows/arm64 cross-builds are clean.
This commit is contained in:
2026-08-10 17:24:21 -07:00
parent a3eeed8112
commit b2ad8f4844
11 changed files with 525 additions and 49 deletions
+40 -12
View File
@@ -3,7 +3,6 @@ package main
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
@@ -12,14 +11,6 @@ import (
func withTempHostsFile(t *testing.T, initial string) string {
t.Helper()
// applyHostsOverride refuses unconditionally on non-Linux (see
// hosts_override.go) -- these tests exercise the Linux write path
// specifically, so they'd fail for the right reason on the Windows CI
// runner if not skipped. Confirmed the hard way: a real CI run failed
// here after this was missed.
if runtime.GOOS != "linux" {
t.Skip("applyHostsOverride is Linux-only; skipping on " + runtime.GOOS)
}
dir := t.TempDir()
path := filepath.Join(dir, "hosts")
if initial != "" {
@@ -27,9 +18,12 @@ func withTempHostsFile(t *testing.T, initial string) string {
t.Fatalf("seeding temp hosts file: %v", err)
}
}
orig := hostsFilePathLinux
hostsFilePathLinux = path
t.Cleanup(func() { hostsFilePathLinux = orig })
// setTestHostsPath redirects the platform hosts path at this temp file and
// restores it on cleanup. Runs on every OS: Windows hosts tests use the
// real Windows write path (minus the ipconfig flush, which the injected
// path suppresses), so this is where the CRLF/Windows behavior is guarded.
restore := setTestHostsPath(path)
t.Cleanup(restore)
return path
}
@@ -94,6 +88,40 @@ func TestApplyHostsOverride_EmptyEntriesRemovesBlockEntirely(t *testing.T) {
}
}
func TestApplyHostsOverride_CRLFWindowsHostsFile(t *testing.T) {
// Windows hosts files use CRLF. The rewrite must (a) match the block
// markers on a CRLF file, (b) write back with the platform EOL, and (c)
// not double up \r\r\n from the read side.
path := withTempHostsFile(t, "127.0.0.1\tlocalhost\r\n192.168.1.5\tsomeotherhost\r\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{"sso.example.com": "10.0.0.9"}); err != nil {
t.Fatalf("reapply: %v", err)
}
got, _ := os.ReadFile(path)
s := string(got)
if strings.Contains(s, "\r\r\n") {
t.Fatalf("doubled CR detected (CRLF handled wrong): %q", s)
}
if strings.Contains(s, "10.0.0.5") {
t.Errorf("stale override should be replaced on a CRLF file, got: %q", s)
}
if !strings.Contains(s, "10.0.0.9\tsso.example.com") {
t.Errorf("override entry missing on CRLF file, got: %q", s)
}
if strings.Count(s, hostsBlockBegin) != 1 {
t.Errorf("expected exactly one managed block, got: %q", s)
}
for _, want := range []string{"127.0.0.1\tlocalhost", "192.168.1.5\tsomeotherhost"} {
if !strings.Contains(s, want) {
t.Errorf("pre-existing content %q was clobbered, got: %q", want, s)
}
}
}
func TestHostFromURL(t *testing.T) {
cases := map[string]string{
"https://sso.example.com:443/api": "sso.example.com",