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
+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)
}
}