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
+10
View File
@@ -5,6 +5,16 @@ All notable changes to the `theta-agent` daemon will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- **Windows mDNS local-discovery** (`hosts_override_windows.go`) — completes the Linux mechanism from v2.1.2 on Windows. The hosts override now runs on Windows: `%SystemRoot%\System32\drivers\etc\hosts` (reachable because the agent runs as a SYSTEM service), CRLF-aware read/write, and `ipconfig /flushdns` after every change so the override takes effect promptly despite the Windows DNS Client cache. Verified by the Windows CI leg, which now runs the real Windows hosts path against a temp file instead of skipping.
- **Local route pinning** (`local_route.go`, `local_route_windows.go`, `local_route_unix.go`) — the hosts override only fixes *name resolution*; the packet path is decided by the routing table. If the agent's WireGuard mesh tunnel is up with `AllowedIPs` covering the LAN subnet (or a full-tunnel `0.0.0.0/0`), the tunnel route would swallow the direct connection to the discovered LAN IP. Discovery now also pins a `/32` host route for the discovered IP via the owning local interface (`route.exe add ... metric 1` on Windows, `ip route replace` on Linux) and drops it again on revert. This closes a real gap in the shipped Linux path too.
- **Prompt reconnect on discovery change** — an apply/revert now signals the WebSocket loop, which reconnects immediately (skipping its 5s backoff) so the new resolution/routing is picked up right away.
### Changed
- `hosts_override.go` split into shared rewrite logic plus platform files; `hosts_override_test.go` no longer skips on non-Linux and covers the CRLF/Windows write path.
## [v2.1.3] - 2026-08-10 ## [v2.1.3] - 2026-08-10
### Fixed ### Fixed
+41 -36
View File
@@ -4,46 +4,57 @@ import (
"bufio" "bufio"
"fmt" "fmt"
"os" "os"
"runtime"
"strings" "strings"
"sync" "sync"
) )
// Linux-only for now (AGENT_LOCAL_DISCOVERY_SPEC.md §3) -- Windows/macOS // Local-discovery hosts override (AGENT_LOCAL_DISCOVERY_SPEC.md):
// hosts-file semantics (elevation, DNS caching, whether mDNSResponder should // applyHostsOverride replaces the managed block in the platform hosts file
// be used instead of hand-rolled hosts edits) need their own platform-native // with exactly `entries` (hostname -> IP). Passing an empty map removes the
// investigation before this mechanism is trusted there. // block entirely rather than leaving an empty marker pair, so a host that
// never discovers anything -- or stops discovering something it used to --
// leaves the hosts file with no discovery trace at all.
// //
// var, not const, so tests can point it at a temp file instead of touching // Platform specifics live in hosts_override_windows.go / hosts_override_unix.go:
// the real /etc/hosts. // the file path, line-ending convention, and any DNS-cache flush needed for a
var hostsFilePathLinux = "/etc/hosts" // hosts edit to take effect promptly (ipconfig /flushdns on Windows).
//
// NOT write-tmp-then-rename: on a real host that's the safer, atomic way to
// update a file, but the hosts file is frequently a bind mount (every
// container runtime does this, Docker included) -- confirmed the hard way on
// Linux: 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. On Windows the same in-place write preserves the file's ACLs,
// which a rename onto the system hosts file would not.
const hostsBlockBegin = "# BEGIN theta-agent-local-discovery (managed, do not edit by hand)" const hostsBlockBegin = "# BEGIN theta-agent-local-discovery (managed, do not edit by hand)"
const hostsBlockEnd = "# END theta-agent-local-discovery" const hostsBlockEnd = "# END theta-agent-local-discovery"
var hostsMu sync.Mutex var hostsMu sync.Mutex
// applyHostsOverride replaces the managed block in /etc/hosts with exactly // applyHostsOverride replaces the managed block in the platform hosts file.
// `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 { 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() hostsMu.Lock()
defer hostsMu.Unlock() defer hostsMu.Unlock()
existing, err := readLines(hostsFilePathLinux) path := hostsFilePath()
eol := hostsEOL()
existing, err := readLines(path)
if err != nil { if err != nil {
return fmt.Errorf("reading %s: %w", hostsFilePathLinux, err) return fmt.Errorf("reading %s: %w", path, err)
} }
kept := make([]string, 0, len(existing)) kept := make([]string, 0, len(existing))
inBlock := false inBlock := false
for _, line := range existing { for _, line := range existing {
trimmed := strings.TrimSpace(line) // Normalize CRLF away so marker comparison is platform-agnostic and
// a CRLF file written back out with hostsEOL() doesn't double up \r.
normalized := strings.TrimSuffix(line, "\r")
trimmed := strings.TrimSpace(normalized)
if trimmed == hostsBlockBegin { if trimmed == hostsBlockBegin {
inBlock = true inBlock = true
continue continue
@@ -55,7 +66,7 @@ func applyHostsOverride(entries map[string]string) error {
if inBlock { if inBlock {
continue // drop old managed lines unconditionally; rebuilt below continue // drop old managed lines unconditionally; rebuilt below
} }
kept = append(kept, line) kept = append(kept, normalized)
} }
// Trim any trailing blank lines the block removal left, then rebuild. // Trim any trailing blank lines the block removal left, then rebuild.
@@ -63,29 +74,23 @@ func applyHostsOverride(entries map[string]string) error {
kept = kept[:len(kept)-1] kept = kept[:len(kept)-1]
} }
out := strings.Join(kept, "\n") var out strings.Builder
out.WriteString(strings.Join(kept, eol))
if len(entries) > 0 { if len(entries) > 0 {
out += "\n" + hostsBlockBegin + "\n" out.WriteString(eol + hostsBlockBegin + eol)
for host, ip := range entries { for host, ip := range entries {
out += fmt.Sprintf("%s\t%s\n", ip, host) out.WriteString(fmt.Sprintf("%s\t%s%s", ip, host, eol))
} }
out += hostsBlockEnd + "\n" out.WriteString(hostsBlockEnd + eol)
} else { } else {
out += "\n" out.WriteString(eol)
} }
// NOT write-tmp-then-rename: on a real host that's the safer, atomic if err := os.WriteFile(path, []byte(out.String()), 0644); err != nil {
// way to update a file, but /etc/hosts is frequently a bind mount return fmt.Errorf("writing %s: %w", path, err)
// (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)
} }
flushDNSOnHostsChange()
return nil return nil
} }
+40 -12
View File
@@ -3,7 +3,6 @@ package main
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
"testing" "testing"
@@ -12,14 +11,6 @@ import (
func withTempHostsFile(t *testing.T, initial string) string { func withTempHostsFile(t *testing.T, initial string) string {
t.Helper() 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() dir := t.TempDir()
path := filepath.Join(dir, "hosts") path := filepath.Join(dir, "hosts")
if initial != "" { if initial != "" {
@@ -27,9 +18,12 @@ func withTempHostsFile(t *testing.T, initial string) string {
t.Fatalf("seeding temp hosts file: %v", err) t.Fatalf("seeding temp hosts file: %v", err)
} }
} }
orig := hostsFilePathLinux // setTestHostsPath redirects the platform hosts path at this temp file and
hostsFilePathLinux = path // restores it on cleanup. Runs on every OS: Windows hosts tests use the
t.Cleanup(func() { hostsFilePathLinux = orig }) // 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 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) { func TestHostFromURL(t *testing.T) {
cases := map[string]string{ cases := map[string]string{
"https://sso.example.com:443/api": "sso.example.com", "https://sso.example.com:443/api": "sso.example.com",
+28
View File
@@ -0,0 +1,28 @@
//go:build !windows
package main
// Unix hosts override (Linux today; macOS slots in here later with its own
// dscacheutil -flushcache flush -- see AGENT_LOCAL_DISCOVERY_SPEC.md).
// hostsFilePathUnix is a var, not a const, so tests can point it at a temp
// file instead of touching the real /etc/hosts.
var hostsFilePathUnix = "/etc/hosts"
func hostsFilePath() string { return hostsFilePathUnix }
func hostsEOL() string { return "\n" }
// flushDNSOnHostsChange is a no-op on Linux: resolvers read /etc/hosts per
// lookup, and nscd/systemd-resolved -- where present -- pick up hosts edits
// without an explicit flush. (macOS will need dscacheutil -flushcache here.)
func flushDNSOnHostsChange() {}
// setTestHostsPath points hostsFilePath() at a temp file for tests and
// returns a restore func. Exists in both platform files so the shared test
// code can compile everywhere.
func setTestHostsPath(path string) (restore func()) {
prev := hostsFilePathUnix
hostsFilePathUnix = path
return func() { hostsFilePathUnix = prev }
}
+63
View File
@@ -0,0 +1,63 @@
//go:build windows
package main
import (
"log"
"os"
"strings"
)
// Windows hosts override (AGENT_LOCAL_DISCOVERY_SPEC.md):
// - The hosts file lives at %SystemRoot%\System32\drivers\etc\hosts. The
// theta-agent runs as a SYSTEM service (DESIGN-WINDOWS.md), so elevation
// is not a blocker here -- SYSTEM can write it directly.
// - Windows caches DNS in the DNS Client service. An edit to the hosts file
// does not immediately change resolution until the cache is flushed, so
// every successful change runs `ipconfig /flushdns`.
// - Windows hosts files conventionally use CRLF line endings; the shared
// rewrite normalizes on read and writes back with hostsEOL().
// hostsFilePathWindows, when set (tests only), redirects hostsFilePath() at a
// temp file so unit tests never touch the real system hosts file.
var hostsFilePathWindows string
// systemHostsPath resolves the real system hosts file.
func systemHostsPath() string {
root := os.Getenv("SystemRoot")
if root == "" {
root = `C:\Windows`
}
return root + `\System32\drivers\etc\hosts`
}
func hostsFilePath() string {
if hostsFilePathWindows != "" {
return hostsFilePathWindows
}
return systemHostsPath()
}
func hostsEOL() string { return "\r\n" }
// flushDNSOnHostsChange invalidates the Windows DNS cache after a hosts edit.
// No-op when a test redirected the path to a temp file -- a temp file has no
// cached entries and running ipconfig here would just slow the tests down.
func flushDNSOnHostsChange() {
if hostsFilePathWindows != "" {
return
}
out, err := (&SystemExecutor{}).Execute("ipconfig", "/flushdns")
if err != nil {
log.Printf("[local-discovery] ipconfig /flushdns failed (hosts override may not take effect immediately): %v: %s", err, strings.TrimSpace(string(out)))
}
}
// setTestHostsPath points hostsFilePath() at a temp file for tests and
// returns a restore func. Exists in both platform files so the shared test
// code can compile everywhere.
func setTestHostsPath(path string) (restore func()) {
prev := hostsFilePathWindows
hostsFilePathWindows = path
return func() { hostsFilePathWindows = prev }
}
+28
View File
@@ -42,6 +42,7 @@ func StartLocalDiscovery(cm *ConfigManager) {
log.Printf("[local-discovery] enabled, watching for a local announcement fronting %s", targetHost) log.Printf("[local-discovery] enabled, watching for a local announcement fronting %s", targetHost)
currentlyOverridden := false currentlyOverridden := false
lastIP := ""
for { for {
ip := findLocalAnnouncement(targetHost) ip := findLocalAnnouncement(targetHost)
@@ -50,21 +51,48 @@ func StartLocalDiscovery(cm *ConfigManager) {
if err := applyHostsOverride(map[string]string{targetHost: ip}); err != nil { 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) log.Printf("[local-discovery] found %s locally at %s but failed to apply hosts override: %v", targetHost, ip, err)
} else { } else {
// Pin the packet path too: the hosts override only fixes name
// resolution, the route table decides where the packets go.
// If the WireGuard mesh tunnel is up with AllowedIPs covering
// this LAN subnet, it would swallow the direct connection.
if err := applyLocalRoute(ip); err != nil {
log.Printf("[local-discovery] found %s locally at %s but failed to pin a direct host route (a WireGuard tunnel may override it): %v", targetHost, ip, err)
}
log.Printf("[local-discovery] %s announced locally at %s -- routing directly, skipping the relay/WAN path", targetHost, ip) log.Printf("[local-discovery] %s announced locally at %s -- routing directly, skipping the relay/WAN path", targetHost, ip)
lastIP = ip
currentlyOverridden = true currentlyOverridden = true
notifyDiscoveryChange()
} }
case ip == "" && currentlyOverridden: case ip == "" && currentlyOverridden:
if err := applyHostsOverride(map[string]string{}); err != nil { 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) log.Printf("[local-discovery] lost local announcement for %s but failed to clear hosts override: %v", targetHost, err)
} else { } else {
if lastIP != "" {
removeLocalRoute(lastIP)
}
log.Printf("[local-discovery] %s no longer announced locally -- reverting to normal resolution", targetHost) log.Printf("[local-discovery] %s no longer announced locally -- reverting to normal resolution", targetHost)
currentlyOverridden = false currentlyOverridden = false
lastIP = ""
notifyDiscoveryChange()
} }
} }
time.Sleep(mdnsPollInterval) time.Sleep(mdnsPollInterval)
} }
} }
// discoveryChangedCh is signaled (non-blocking) whenever a local-discovery
// apply/revert changes name resolution or routing, so the WebSocket loop can
// reconnect promptly and pick up the new path instead of waiting out its
// reconnect backoff.
var discoveryChangedCh = make(chan struct{}, 1)
func notifyDiscoveryChange() {
select {
case discoveryChangedCh <- struct{}{}:
default:
}
}
func hostFromURL(raw string) string { func hostFromURL(raw string) string {
u, err := url.Parse(raw) u, err := url.Parse(raw)
if err != nil || u.Hostname() == "" { if err != nil || u.Hostname() == "" {
+105
View File
@@ -0,0 +1,105 @@
package main
import (
"fmt"
"log"
"net"
)
// Local-route pinning for local-discovery (AGENT_LOCAL_DISCOVERY_SPEC.md).
//
// The hosts override redirects NAME resolution of the server hostname to the
// discovered LAN IP, but the packet path is decided by the routing table, not
// by DNS. If the agent's WireGuard mesh tunnel is up with AllowedIPs covering
// the LAN subnet (or a full-tunnel 0.0.0.0/0), that tunnel route will swallow
// the direct connection to the discovered IP -- the discovery optimization
// silently stops working, and worse, the LAN IP may not even be reachable
// through the tunnel. So when an override is applied, also pin a /32 host
// route for the discovered IP on the owning local interface (it is on-link by
// definition -- mDNS never crosses routers), with priority over the tunnel's
// routes; and drop that route again when the override is reverted.
//
// HARD RULE unchanged: this only changes where packets go. Nothing here
// touches TLS/certificate validation; a spoofed announcement still produces a
// TLS handshake failure against the real hostname's cert, not a silent MITM.
// routeExec is injectable so tests can assert on the commands instead of
// mutating the real routing table.
var routeExec = func(name string, args ...string) ([]byte, error) {
return (&SystemExecutor{}).Execute(name, args...)
}
// localIface is a minimal view of a local network interface for route
// pinning: its index, name, and the subnets configured on it.
type localIface struct {
index int
name string
nets []*net.IPNet
}
// localInterfaces lists up, non-loopback interfaces and their subnets.
// Injectable so tests can fake the machine's network layout.
var localInterfaces = func() ([]localIface, error) {
ifaces, err := net.Interfaces()
if err != nil {
return nil, err
}
out := make([]localIface, 0, len(ifaces))
for _, iface := range ifaces {
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
continue
}
li := localIface{index: iface.Index, name: iface.Name}
for _, a := range addrs {
if ipn, ok := a.(*net.IPNet); ok {
li.nets = append(li.nets, ipn)
}
}
out = append(out, li)
}
return out, nil
}
// interfaceForIP returns the local interface whose subnet contains ip, which
// is the one the discovered (on-link) IP must route through.
func interfaceForIP(ip string) (index int, name string, ok bool) {
target := net.ParseIP(ip)
if target == nil {
return 0, "", false
}
ifaces, err := localInterfaces()
if err != nil {
return 0, "", false
}
for _, iface := range ifaces {
for _, n := range iface.nets {
if n.Contains(target) {
return iface.index, iface.name, true
}
}
}
return 0, "", false
}
// applyLocalRoute pins the discovered IP on the owning local interface so the
// packet path stays direct even with the WireGuard tunnel up.
func applyLocalRoute(ip string) error {
index, name, ok := interfaceForIP(ip)
if !ok {
return fmt.Errorf("no local interface contains %s (cannot pin a direct route)", ip)
}
return addHostRoute(ip, index, name)
}
// removeLocalRoute drops the host route added by applyLocalRoute. Best-effort
// by design: a leftover /32 is harmless and a failed delete should not fail
// the discovery revert itself.
func removeLocalRoute(ip string) {
if err := delHostRoute(ip); err != nil {
log.Printf("[local-discovery] failed to remove host route for %s: %v", ip, err)
}
}
+118
View File
@@ -0,0 +1,118 @@
package main
import (
"errors"
"net"
"strings"
"testing"
)
var (
errAlreadyExists = errors.New("already exists")
errRouteOp = errors.New("route op failed")
)
func withFakeInterfaces(nets []*net.IPNet) func() {
orig := localInterfaces
localInterfaces = func() ([]localIface, error) {
return []localIface{{index: 7, name: "fake0", nets: nets}}, nil
}
return func() { localInterfaces = orig }
}
func TestInterfaceForIP_FindsOwningInterface(t *testing.T) {
restore := withFakeInterfaces([]*net.IPNet{ipNet("192.168.1.0/24")})
defer restore()
idx, name, ok := interfaceForIP("192.168.1.50")
if !ok || idx != 7 || name != "fake0" {
t.Fatalf("interfaceForIP(192.168.1.50) = (%d, %q, %v), want (7, fake0, true)", idx, name, ok)
}
}
func TestInterfaceForIP_NotOnLocalSegment(t *testing.T) {
restore := withFakeInterfaces([]*net.IPNet{ipNet("192.168.1.0/24")})
defer restore()
if _, _, ok := interfaceForIP("10.99.99.99"); ok {
t.Fatal("interfaceForIP should not claim a non-local IP")
}
}
func TestInterfaceForIP_InvalidIP(t *testing.T) {
restore := withFakeInterfaces([]*net.IPNet{ipNet("192.168.1.0/24")})
defer restore()
if _, _, ok := interfaceForIP("not-an-ip"); ok {
t.Fatal("interfaceForIP should reject garbage input")
}
}
func TestInterfaceForIP_TakesFirstMatchAcrossInterfaces(t *testing.T) {
orig := localInterfaces
defer func() { localInterfaces = orig }()
localInterfaces = func() ([]localIface, error) {
return []localIface{
{index: 1, name: "eth0", nets: []*net.IPNet{ipNet("10.0.0.0/24")}},
{index: 2, name: "wlan0", nets: []*net.IPNet{ipNet("192.168.50.0/24")}},
}, nil
}
idx, name, ok := interfaceForIP("192.168.50.9")
if !ok || idx != 2 || name != "wlan0" {
t.Fatalf("expected wlan0 (idx 2) to own 192.168.50.9, got (%d, %q, %v)", idx, name, ok)
}
}
func TestAddHostRoute_IgnoresAlreadyExists(t *testing.T) {
orig := routeExec
defer func() { routeExec = orig }()
routeExec = func(name string, args ...string) ([]byte, error) {
return []byte("The object already exists."), errAlreadyExists
}
if err := addHostRoute("192.168.1.50", 7, "fake0"); err != nil {
t.Fatalf("addHostRoute should treat an already-present route as success, got %v", err)
}
}
func TestAddHostRoute_ReturnsOtherErrors(t *testing.T) {
orig := routeExec
defer func() { routeExec = orig }()
routeExec = func(name string, args ...string) ([]byte, error) {
return []byte("The parameter is incorrect."), errRouteOp
}
if err := addHostRoute("192.168.1.50", 7, "fake0"); err == nil {
t.Fatal("addHostRoute should surface non-already-exists errors")
}
}
func TestDelHostRoute_IgnoresMissingRoute(t *testing.T) {
orig := routeExec
defer func() { routeExec = orig }()
routeExec = func(name string, args ...string) ([]byte, error) {
return []byte("route not found"), errRouteOp
}
if err := delHostRoute("192.168.1.50"); err != nil {
t.Fatalf("delHostRoute should treat a missing route as success, got %v", err)
}
}
func TestApplyLocalRoute_FailsWhenNoOwningInterface(t *testing.T) {
restore := withFakeInterfaces([]*net.IPNet{ipNet("192.168.1.0/24")})
defer restore()
if err := applyLocalRoute("172.16.0.9"); err == nil || !strings.Contains(err.Error(), "no local interface") {
t.Fatalf("applyLocalRoute should fail with a clear error for a non-local IP, got %v", err)
}
}
func ipNet(cidr string) *net.IPNet {
_, n, err := net.ParseCIDR(cidr)
if err != nil {
panic(err)
}
return n
}
+36
View File
@@ -0,0 +1,36 @@
//go:build !windows
package main
import (
"fmt"
"strings"
)
// Unix host-route pinning via `ip route`. The discovered IP is on-link, so a
// /32 route straight out the owning interface is enough; `ip route replace`
// is idempotent (re-adds instead of failing when the route is already there).
// The /32 wins by longest-prefix-match over any broader tunnel route, even a
// full-tunnel 0.0.0.0/0 -- no metric games needed on Linux.
func addHostRoute(ip string, _ int, ifaceName string) error {
out, err := routeExec("ip", "route", "replace", ip+"/32", "dev", ifaceName)
if err != nil {
return fmt.Errorf("ip route replace %s via %s: %v: %s", ip, ifaceName, err, strings.TrimSpace(string(out)))
}
return nil
}
func delHostRoute(ip string) error {
out, err := routeExec("ip", "route", "del", ip+"/32")
if err != nil {
// "No such process" / RTNETLINK errors mean the route isn't there;
// nothing to drop, not an error.
lower := strings.ToLower(string(out))
if strings.Contains(lower, "no such process") || strings.Contains(lower, "cannot find") {
return nil
}
return fmt.Errorf("ip route del %s: %v: %s", ip, err, strings.TrimSpace(string(out)))
}
return nil
}
+47
View File
@@ -0,0 +1,47 @@
//go:build windows
package main
import (
"fmt"
"strconv"
"strings"
)
// Windows host-route pinning: add a /32 route for the discovered IP via the
// owning interface with metric 1. WireGuard's tunnel service adds routes for
// its AllowedIPs with a low metric; a /32 host route at metric 1 wins for the
// exact discovered IP, keeping the discovery path direct even with the tunnel
// up. `route.exe` is used rather than the wireguard.exe client because the
// tunnel is owned by a service we shouldn't rip down just to adjust one route.
func addHostRoute(ip string, ifaceIndex int, _ string) error {
out, err := routeExec("route.exe",
"add", ip,
"mask", "255.255.255.255",
"0.0.0.0", // on-link gateway; the interface index pins the interface
"metric", "1",
"IF", strconv.Itoa(ifaceIndex),
)
if err != nil {
// Already present (previous apply never reverted, or route.exe
// re-add) is the expected steady-state case -- treat as success.
if strings.Contains(strings.ToLower(string(out)), "already exists") {
return nil
}
return fmt.Errorf("route add %s: %v: %s", ip, err, strings.TrimSpace(string(out)))
}
return nil
}
func delHostRoute(ip string) error {
out, err := routeExec("route.exe", "delete", ip, "mask", "255.255.255.255")
if err != nil {
lower := strings.ToLower(string(out))
if strings.Contains(lower, "route not found") || strings.Contains(lower, "cannot find") {
return nil // nothing to drop; not an error
}
return fmt.Errorf("route delete %s: %v: %s", ip, err, strings.TrimSpace(string(out)))
}
return nil
}
+9 -1
View File
@@ -241,7 +241,15 @@ func connectWebSocket(cm *ConfigManager, exec Executor) {
} }
log.Println("WebSocket disconnected. Reconnecting in 5 seconds...") log.Println("WebSocket disconnected. Reconnecting in 5 seconds...")
time.Sleep(5 * time.Second) // A local-discovery apply/revert (hosts override + route change) wants
// the new resolution path picked up right away rather than after the
// full backoff. discoveryChangedCh is drained here only; a change
// while still connected takes effect on the next natural reconnect.
select {
case <-discoveryChangedCh:
log.Println("Local-discovery routing changed; reconnecting immediately.")
case <-time.After(5 * time.Second):
}
} }
} }