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:
+105
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user