b2ad8f4844
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.
106 lines
3.4 KiB
Go
106 lines
3.4 KiB
Go
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)
|
|
}
|
|
}
|