diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f9a42e..79c85c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/), 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 ### Fixed diff --git a/hosts_override.go b/hosts_override.go index 27cf30c..da5cdbf 100644 --- a/hosts_override.go +++ b/hosts_override.go @@ -4,46 +4,57 @@ 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. +// Local-discovery hosts override (AGENT_LOCAL_DISCOVERY_SPEC.md): +// applyHostsOverride replaces the managed block in the platform hosts file +// 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 the hosts file with no discovery trace at all. // -// var, not const, so tests can point it at a temp file instead of touching -// the real /etc/hosts. -var hostsFilePathLinux = "/etc/hosts" +// Platform specifics live in hosts_override_windows.go / hosts_override_unix.go: +// the file path, line-ending convention, and any DNS-cache flush needed for a +// 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 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. +// applyHostsOverride replaces the managed block in the platform hosts file. 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) + path := hostsFilePath() + eol := hostsEOL() + + existing, err := readLines(path) 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)) inBlock := false 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 { inBlock = true continue @@ -55,7 +66,7 @@ func applyHostsOverride(entries map[string]string) error { if inBlock { 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. @@ -63,29 +74,23 @@ func applyHostsOverride(entries map[string]string) error { kept = kept[:len(kept)-1] } - out := strings.Join(kept, "\n") + var out strings.Builder + out.WriteString(strings.Join(kept, eol)) if len(entries) > 0 { - out += "\n" + hostsBlockBegin + "\n" + out.WriteString(eol + hostsBlockBegin + eol) 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 { - out += "\n" + out.WriteString(eol) } - // 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) + if err := os.WriteFile(path, []byte(out.String()), 0644); err != nil { + return fmt.Errorf("writing %s: %w", path, err) } + + flushDNSOnHostsChange() return nil } diff --git a/hosts_override_test.go b/hosts_override_test.go index e7941e6..57df4ad 100644 --- a/hosts_override_test.go +++ b/hosts_override_test.go @@ -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", diff --git a/hosts_override_unix.go b/hosts_override_unix.go new file mode 100644 index 0000000..4ae4a7d --- /dev/null +++ b/hosts_override_unix.go @@ -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 } +} diff --git a/hosts_override_windows.go b/hosts_override_windows.go new file mode 100644 index 0000000..a675d8b --- /dev/null +++ b/hosts_override_windows.go @@ -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 } +} diff --git a/local_discovery.go b/local_discovery.go index d087bf4..5dcacfd 100644 --- a/local_discovery.go +++ b/local_discovery.go @@ -42,6 +42,7 @@ func StartLocalDiscovery(cm *ConfigManager) { log.Printf("[local-discovery] enabled, watching for a local announcement fronting %s", targetHost) currentlyOverridden := false + lastIP := "" for { ip := findLocalAnnouncement(targetHost) @@ -50,21 +51,48 @@ func StartLocalDiscovery(cm *ConfigManager) { 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 { + // 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) + lastIP = ip currentlyOverridden = true + notifyDiscoveryChange() } 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 { + if lastIP != "" { + removeLocalRoute(lastIP) + } log.Printf("[local-discovery] %s no longer announced locally -- reverting to normal resolution", targetHost) currentlyOverridden = false + lastIP = "" + notifyDiscoveryChange() } } 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 { u, err := url.Parse(raw) if err != nil || u.Hostname() == "" { diff --git a/local_route.go b/local_route.go new file mode 100644 index 0000000..1a8f37b --- /dev/null +++ b/local_route.go @@ -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) + } +} diff --git a/local_route_test.go b/local_route_test.go new file mode 100644 index 0000000..1bba387 --- /dev/null +++ b/local_route_test.go @@ -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 +} diff --git a/local_route_unix.go b/local_route_unix.go new file mode 100644 index 0000000..30c391d --- /dev/null +++ b/local_route_unix.go @@ -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 +} diff --git a/local_route_windows.go b/local_route_windows.go new file mode 100644 index 0000000..4b52109 --- /dev/null +++ b/local_route_windows.go @@ -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 +} diff --git a/websocket.go b/websocket.go index 1f1045f..8ec0eee 100644 --- a/websocket.go +++ b/websocket.go @@ -241,7 +241,15 @@ func connectWebSocket(cm *ConfigManager, exec Executor) { } 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): + } } }