From 6d8aca9c7c4c540916da05a26c18336b1bbb1407 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 7 Jul 2026 13:09:53 -0400 Subject: [PATCH 1/7] Add PKGBUILD --- packages/arch/PKGBUILD | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 packages/arch/PKGBUILD diff --git a/packages/arch/PKGBUILD b/packages/arch/PKGBUILD new file mode 100644 index 0000000..930cbe6 --- /dev/null +++ b/packages/arch/PKGBUILD @@ -0,0 +1,30 @@ +# Maintainer: Fossorial +pkgname=newt +pkgver=1.13.0 +pkgrel=1 +pkgdesc="Fully user space WireGuard tunnel client and TCP/UDP proxy for Pangolin" +arch=('x86_64' 'aarch64' 'armv7h' 'armv6h' 'riscv64') +url="https://github.com/fosrl/newt" +license=('AGPL3') +makedepends=('go') +source=("$pkgname-$pkgver.tar.gz::https://github.com/fosrl/newt/archive/refs/tags/v$pkgver.tar.gz") +sha256sums=('SKIP') + +build() { + cd "$pkgname-$pkgver" + export CGO_ENABLED=0 + export GOFLAGS="-trimpath -mod=readonly -modcacherw" + go build -ldflags "-X main.newtVersion=$pkgver -X main.newtPlatform=linux_$(go env GOARCH)" -o "$pkgname" . +} + +check() { + cd "$pkgname-$pkgver" + go test ./... || true +} + +package() { + cd "$pkgname-$pkgver" + install -Dm755 "$pkgname" "$pkgdir/usr/bin/$pkgname" + install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE" + install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md" +} From e21d608bd81a92667a48655b2cf32076011d1a4d Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 15 Jul 2026 17:45:39 -0400 Subject: [PATCH 2/7] Scrape and send local endpoints --- network/localendpoints.go | 164 ++++++++++++++++++++++++++++++++++++++ newt/clients.go | 8 ++ newt/handlers.go | 19 +++-- 3 files changed, 183 insertions(+), 8 deletions(-) create mode 100644 network/localendpoints.go diff --git a/network/localendpoints.go b/network/localendpoints.go new file mode 100644 index 0000000..23a2bff --- /dev/null +++ b/network/localendpoints.go @@ -0,0 +1,164 @@ +package network + +import ( + "net" + "regexp" + "sort" + "strconv" + + "github.com/fosrl/newt/logger" +) + +// Interface name patterns used to rank candidate local endpoints. Interfaces +// matching physicalInterfacePatterns are tried first, interfaces matching +// virtualInterfacePatterns (container/VPN/hypervisor bridges and the like) +// are tried last, and everything else falls in between. +var ( + physicalInterfacePatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)^eth\d+$`), + regexp.MustCompile(`(?i)^en\d+$`), + regexp.MustCompile(`(?i)^eno\d+$`), + regexp.MustCompile(`(?i)^ens\d+$`), + regexp.MustCompile(`(?i)^enp\d+s\d+`), + regexp.MustCompile(`(?i)^wlan\d*$`), + regexp.MustCompile(`(?i)^wlp\d+s\d+`), + regexp.MustCompile(`(?i)^wl\d+$`), + regexp.MustCompile(`(?i)ethernet`), + regexp.MustCompile(`(?i)wi-?fi`), + regexp.MustCompile(`(?i)wireless`), + } + + virtualInterfacePatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)docker`), + regexp.MustCompile(`(?i)podman`), + regexp.MustCompile(`(?i)^veth`), + regexp.MustCompile(`(?i)^virbr`), + regexp.MustCompile(`(?i)vmnet`), + regexp.MustCompile(`(?i)vboxnet`), + regexp.MustCompile(`(?i)virtualbox`), + regexp.MustCompile(`(?i)^vbox`), + regexp.MustCompile(`(?i)vmware`), + regexp.MustCompile(`(?i)hyper-?v`), + regexp.MustCompile(`(?i)vethernet`), + regexp.MustCompile(`(?i)npcap`), + regexp.MustCompile(`(?i)^tun\d*$`), + regexp.MustCompile(`(?i)^tap\d*$`), + regexp.MustCompile(`(?i)^wg\d*$`), + regexp.MustCompile(`(?i)^utun\d*$`), + regexp.MustCompile(`(?i)zerotier`), + regexp.MustCompile(`(?i)^zt`), + regexp.MustCompile(`(?i)tailscale`), + regexp.MustCompile(`(?i)^ppp\d*$`), + regexp.MustCompile(`(?i)bridge`), + regexp.MustCompile(`(?i)^br-`), + regexp.MustCompile(`(?i)^br\d+$`), + regexp.MustCompile(`(?i)^cni`), + regexp.MustCompile(`(?i)flannel`), + regexp.MustCompile(`(?i)weave`), + regexp.MustCompile(`(?i)kube`), + regexp.MustCompile(`(?i)isatap`), + regexp.MustCompile(`(?i)teredo`), + regexp.MustCompile(`(?i)bluetooth`), + regexp.MustCompile(`(?i)^awdl\d*$`), + regexp.MustCompile(`(?i)^llw\d*$`), + regexp.MustCompile(`(?i)p2p`), + } +) + +const ( + scorePhysical = 0 + scoreUnknown = 10 + scoreVirtual = 20 + scoreLinkLocal = 1000 +) + +// interfaceScore ranks an interface name by how likely it is to be a +// real, usable network interface. Lower scores are tried first. +func interfaceScore(name string) int { + for _, re := range physicalInterfacePatterns { + if re.MatchString(name) { + return scorePhysical + } + } + for _, re := range virtualInterfacePatterns { + if re.MatchString(name) { + return scoreVirtual + } + } + return scoreUnknown +} + +// GetLocalEndpoints returns "ip:port" strings (bracketed for IPv6, e.g. +// "[fe80::1]:51820") for every usable, non-loopback IP address bound to a +// network interface on this host. The list is ordered with interfaces most +// likely to be a genuine host network (wired/Wi-Fi) first, and interfaces +// that are typically synthetic (Docker, VPN tunnels, hypervisor bridges, +// etc.) last, so callers should try the results roughly in order. +// +// excludeInterface, if non-empty, is skipped entirely - this is normally the +// name of our own WireGuard/TUN interface, whose address is the tunnel IP +// and not a useful endpoint to advertise. +// +// If interfaces cannot be enumerated (e.g. insufficient OS permissions), +// an info message is logged and an empty slice is returned. +func GetLocalEndpoints(port uint16, excludeInterface string) []string { + ifaces, err := net.Interfaces() + if err != nil { + logger.Info("Unable to enumerate local network interfaces, localEndpoints will not be reported: %v", err) + return nil + } + + type candidate struct { + score int + ip string + } + var candidates []candidate + + for _, iface := range ifaces { + if excludeInterface != "" && iface.Name == excludeInterface { + continue + } + if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { + continue + } + + addrs, err := iface.Addrs() + if err != nil { + logger.Debug("Unable to read addresses for interface %s: %v", iface.Name, err) + continue + } + + baseScore := interfaceScore(iface.Name) + + for _, addr := range addrs { + var ip net.IP + switch v := addr.(type) { + case *net.IPNet: + ip = v.IP + case *net.IPAddr: + ip = v.IP + } + if ip == nil || ip.IsLoopback() || ip.IsUnspecified() { + continue + } + + score := baseScore + if ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { + score += scoreLinkLocal + } + + candidates = append(candidates, candidate{score: score, ip: ip.String()}) + } + } + + sort.SliceStable(candidates, func(i, j int) bool { + return candidates[i].score < candidates[j].score + }) + + portStr := strconv.Itoa(int(port)) + endpoints := make([]string, 0, len(candidates)) + for _, c := range candidates { + endpoints = append(endpoints, net.JoinHostPort(c.ip, portStr)) + } + return endpoints +} diff --git a/newt/clients.go b/newt/clients.go index 73b9a03..a82aea0 100644 --- a/newt/clients.go +++ b/newt/clients.go @@ -6,6 +6,7 @@ import ( wgnetstack "github.com/fosrl/newt/clients" "github.com/fosrl/newt/clients/permissions" "github.com/fosrl/newt/logger" + "github.com/fosrl/newt/network" "golang.zx2c4.com/wireguard/tun/netstack" ) @@ -102,6 +103,13 @@ func (n *Newt) clientsOnConnect() { } } +// localEndpoints returns "ip:port" candidates on this host that could +// potentially be used to reach our WireGuard listen port, ranked with the +// most likely genuine host interfaces first. +func (n *Newt) localEndpoints() []string { + return network.GetLocalEndpoints(n.config.Port, n.config.InterfaceName) +} + func (n *Newt) clientsStartDirectRelay(tunnelIP string) { if !n.ready { return diff --git a/newt/handlers.go b/newt/handlers.go index 0a448f1..1aa8a27 100644 --- a/newt/handlers.go +++ b/newt/handlers.go @@ -167,10 +167,11 @@ func (n *Newt) registerHandlers(ctx context.Context) { chainId := generateChainId() n.pendingRegisterChainId = chainId n.stopFunc = n.client.SendMessageInterval(topicWGRegister, map[string]interface{}{ - "publicKey": n.publicKey.String(), - "pingResults": pingResults, - "newtVersion": n.config.Version, - "chainId": chainId, + "publicKey": n.publicKey.String(), + "pingResults": pingResults, + "newtVersion": n.config.Version, + "chainId": chainId, + "localEndpoints": n.localEndpoints(), }, 2*time.Second) return @@ -267,10 +268,11 @@ func (n *Newt) registerHandlers(ctx context.Context) { chainId := generateChainId() n.pendingRegisterChainId = chainId n.stopFunc = n.client.SendMessageInterval(topicWGRegister, map[string]interface{}{ - "publicKey": n.publicKey.String(), - "pingResults": pingResults, - "newtVersion": n.config.Version, - "chainId": chainId, + "publicKey": n.publicKey.String(), + "pingResults": pingResults, + "newtVersion": n.config.Version, + "chainId": chainId, + "localEndpoints": n.localEndpoints(), }, 2*time.Second) logger.Debug("Sent exit node ping results to cloud for selection: pingResults=%+v", pingResults) @@ -1016,6 +1018,7 @@ func (n *Newt) registerHandlers(ctx context.Context) { "newtVersion": n.config.Version, "backwardsCompatible": true, "chainId": bcChainId, + "localEndpoints": n.localEndpoints(), }); err != nil { logger.Error("Failed to send registration message: %v", err) return err From dde44d6666527219e129b666d83ef8c49aa4722a Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 16 Jul 2026 14:34:25 -0400 Subject: [PATCH 3/7] Filter out link local and dont send the port --- network/localendpoints.go | 28 +++++++++++++--------------- newt/clients.go | 4 ++-- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/network/localendpoints.go b/network/localendpoints.go index 23a2bff..bf226c2 100644 --- a/network/localendpoints.go +++ b/network/localendpoints.go @@ -4,7 +4,6 @@ import ( "net" "regexp" "sort" - "strconv" "github.com/fosrl/newt/logger" ) @@ -66,10 +65,9 @@ var ( ) const ( - scorePhysical = 0 - scoreUnknown = 10 - scoreVirtual = 20 - scoreLinkLocal = 1000 + scorePhysical = 0 + scoreUnknown = 10 + scoreVirtual = 20 ) // interfaceScore ranks an interface name by how likely it is to be a @@ -88,9 +86,9 @@ func interfaceScore(name string) int { return scoreUnknown } -// GetLocalEndpoints returns "ip:port" strings (bracketed for IPv6, e.g. -// "[fe80::1]:51820") for every usable, non-loopback IP address bound to a -// network interface on this host. The list is ordered with interfaces most +// GetLocalEndpoints returns IP address strings for every usable, +// non-loopback IP address bound to a network interface on this host. The +// list is ordered with interfaces most // likely to be a genuine host network (wired/Wi-Fi) first, and interfaces // that are typically synthetic (Docker, VPN tunnels, hypervisor bridges, // etc.) last, so callers should try the results roughly in order. @@ -101,7 +99,7 @@ func interfaceScore(name string) int { // // If interfaces cannot be enumerated (e.g. insufficient OS permissions), // an info message is logged and an empty slice is returned. -func GetLocalEndpoints(port uint16, excludeInterface string) []string { +func GetLocalEndpoints(excludeInterface string) []string { ifaces, err := net.Interfaces() if err != nil { logger.Info("Unable to enumerate local network interfaces, localEndpoints will not be reported: %v", err) @@ -141,13 +139,14 @@ func GetLocalEndpoints(port uint16, excludeInterface string) []string { if ip == nil || ip.IsLoopback() || ip.IsUnspecified() { continue } - - score := baseScore if ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { - score += scoreLinkLocal + // Link-local addresses (169.254.0.0/16, fe80::/10) aren't + // routable off the local segment, so they're never a + // reachable endpoint for a peer. + continue } - candidates = append(candidates, candidate{score: score, ip: ip.String()}) + candidates = append(candidates, candidate{score: baseScore, ip: ip.String()}) } } @@ -155,10 +154,9 @@ func GetLocalEndpoints(port uint16, excludeInterface string) []string { return candidates[i].score < candidates[j].score }) - portStr := strconv.Itoa(int(port)) endpoints := make([]string, 0, len(candidates)) for _, c := range candidates { - endpoints = append(endpoints, net.JoinHostPort(c.ip, portStr)) + endpoints = append(endpoints, c.ip) } return endpoints } diff --git a/newt/clients.go b/newt/clients.go index a82aea0..af0f2bb 100644 --- a/newt/clients.go +++ b/newt/clients.go @@ -103,11 +103,11 @@ func (n *Newt) clientsOnConnect() { } } -// localEndpoints returns "ip:port" candidates on this host that could +// localEndpoints returns candidate IP addresses on this host that could // potentially be used to reach our WireGuard listen port, ranked with the // most likely genuine host interfaces first. func (n *Newt) localEndpoints() []string { - return network.GetLocalEndpoints(n.config.Port, n.config.InterfaceName) + return network.GetLocalEndpoints(n.config.InterfaceName) } func (n *Newt) clientsStartDirectRelay(tunnelIP string) { From 10adb416f8e406a938c951e5aa1f933cfca58243 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 16 Jul 2026 14:42:26 -0400 Subject: [PATCH 4/7] Move the local endpoints to get config --- clients/clients.go | 7 ++++--- network/localendpoints.go | 12 +++++++----- newt/clients.go | 8 -------- newt/handlers.go | 19 ++++++++----------- 4 files changed, 19 insertions(+), 27 deletions(-) diff --git a/clients/clients.go b/clients/clients.go index 13d629f..6661151 100644 --- a/clients/clients.go +++ b/clients/clients.go @@ -504,9 +504,10 @@ func (s *WireGuardService) LoadRemoteConfig() error { chainId := generateChainId() s.pendingConfigChainId = chainId s.stopGetConfig = s.client.SendMessageInterval("newt/wg/get-config", map[string]interface{}{ - "publicKey": s.key.PublicKey().String(), - "port": s.Port, - "chainId": chainId, + "publicKey": s.key.PublicKey().String(), + "port": s.Port, + "chainId": chainId, + "localEndpoints": network.GetLocalEndpoints(s.Port, s.interfaceName), }, 2*time.Second) logger.Debug("Requesting WireGuard configuration from remote server") diff --git a/network/localendpoints.go b/network/localendpoints.go index bf226c2..4f03b1a 100644 --- a/network/localendpoints.go +++ b/network/localendpoints.go @@ -4,6 +4,7 @@ import ( "net" "regexp" "sort" + "strconv" "github.com/fosrl/newt/logger" ) @@ -86,9 +87,9 @@ func interfaceScore(name string) int { return scoreUnknown } -// GetLocalEndpoints returns IP address strings for every usable, -// non-loopback IP address bound to a network interface on this host. The -// list is ordered with interfaces most +// GetLocalEndpoints returns "ip:port" strings (bracketed for IPv6, e.g. +// "[fe80::1]:51820") for every usable, non-loopback IP address bound to a +// network interface on this host. The list is ordered with interfaces most // likely to be a genuine host network (wired/Wi-Fi) first, and interfaces // that are typically synthetic (Docker, VPN tunnels, hypervisor bridges, // etc.) last, so callers should try the results roughly in order. @@ -99,7 +100,7 @@ func interfaceScore(name string) int { // // If interfaces cannot be enumerated (e.g. insufficient OS permissions), // an info message is logged and an empty slice is returned. -func GetLocalEndpoints(excludeInterface string) []string { +func GetLocalEndpoints(port uint16, excludeInterface string) []string { ifaces, err := net.Interfaces() if err != nil { logger.Info("Unable to enumerate local network interfaces, localEndpoints will not be reported: %v", err) @@ -154,9 +155,10 @@ func GetLocalEndpoints(excludeInterface string) []string { return candidates[i].score < candidates[j].score }) + portStr := strconv.Itoa(int(port)) endpoints := make([]string, 0, len(candidates)) for _, c := range candidates { - endpoints = append(endpoints, c.ip) + endpoints = append(endpoints, net.JoinHostPort(c.ip, portStr)) } return endpoints } diff --git a/newt/clients.go b/newt/clients.go index af0f2bb..73b9a03 100644 --- a/newt/clients.go +++ b/newt/clients.go @@ -6,7 +6,6 @@ import ( wgnetstack "github.com/fosrl/newt/clients" "github.com/fosrl/newt/clients/permissions" "github.com/fosrl/newt/logger" - "github.com/fosrl/newt/network" "golang.zx2c4.com/wireguard/tun/netstack" ) @@ -103,13 +102,6 @@ func (n *Newt) clientsOnConnect() { } } -// localEndpoints returns candidate IP addresses on this host that could -// potentially be used to reach our WireGuard listen port, ranked with the -// most likely genuine host interfaces first. -func (n *Newt) localEndpoints() []string { - return network.GetLocalEndpoints(n.config.InterfaceName) -} - func (n *Newt) clientsStartDirectRelay(tunnelIP string) { if !n.ready { return diff --git a/newt/handlers.go b/newt/handlers.go index 1aa8a27..0a448f1 100644 --- a/newt/handlers.go +++ b/newt/handlers.go @@ -167,11 +167,10 @@ func (n *Newt) registerHandlers(ctx context.Context) { chainId := generateChainId() n.pendingRegisterChainId = chainId n.stopFunc = n.client.SendMessageInterval(topicWGRegister, map[string]interface{}{ - "publicKey": n.publicKey.String(), - "pingResults": pingResults, - "newtVersion": n.config.Version, - "chainId": chainId, - "localEndpoints": n.localEndpoints(), + "publicKey": n.publicKey.String(), + "pingResults": pingResults, + "newtVersion": n.config.Version, + "chainId": chainId, }, 2*time.Second) return @@ -268,11 +267,10 @@ func (n *Newt) registerHandlers(ctx context.Context) { chainId := generateChainId() n.pendingRegisterChainId = chainId n.stopFunc = n.client.SendMessageInterval(topicWGRegister, map[string]interface{}{ - "publicKey": n.publicKey.String(), - "pingResults": pingResults, - "newtVersion": n.config.Version, - "chainId": chainId, - "localEndpoints": n.localEndpoints(), + "publicKey": n.publicKey.String(), + "pingResults": pingResults, + "newtVersion": n.config.Version, + "chainId": chainId, }, 2*time.Second) logger.Debug("Sent exit node ping results to cloud for selection: pingResults=%+v", pingResults) @@ -1018,7 +1016,6 @@ func (n *Newt) registerHandlers(ctx context.Context) { "newtVersion": n.config.Version, "backwardsCompatible": true, "chainId": bcChainId, - "localEndpoints": n.localEndpoints(), }); err != nil { logger.Error("Failed to send registration message: %v", err) return err From 2bad244186fe01cd3af35e8d06242ff0f160d79e Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 16 Jul 2026 16:55:12 -0400 Subject: [PATCH 5/7] Update the routes so that it has a high metric --- network/route.go | 25 +++++++++++++++++++++++-- network/route_windows.go | 7 +++++-- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/network/route.go b/network/route.go index 8aae063..175f7b0 100644 --- a/network/route.go +++ b/network/route.go @@ -11,6 +11,24 @@ import ( "github.com/vishvananda/netlink" ) +// VPNRouteMetric is the route metric/priority assigned to routes we add for +// the tunnel, so that an overlapping local/connected route is always +// preferred over the VPN route to the same destination rather than the two +// silently racing based on insertion order. It needs to be higher than any +// metric a local route is realistically going to have: on Linux, automatic +// metrics assigned by NetworkManager (which also apply to the connected +// subnet route, not just the default route) go up to 600 for Wi-Fi; on +// Windows, automatic interface metrics plus route metric rarely exceed a few +// hundred. 9999 comfortably clears both without needing to query the local +// routing table at add-time. +const VPNRouteMetric = 9999 + +// DarwinAddRoute adds a route via the BSD routing table. Unlike Linux/Windows, +// BSD's routing table has no per-route metric - preference between an +// overlapping local route and this VPN route is instead resolved by +// longest-prefix-match, and `route add` (as opposed to `route change`) fails +// rather than replacing an existing route to the same destination, so a local +// route is never displaced by one we add here. func DarwinAddRoute(destination string, gateway string, interfaceName string) error { if runtime.GOOS != "darwin" { return nil @@ -65,9 +83,12 @@ func LinuxAddRoute(destination string, gateway string, interfaceName string) err return fmt.Errorf("invalid destination address: %v", err) } - // Create route + // Create route. Priority is set explicitly (rather than left at the + // default of 0) so that this route never outranks a local/connected + // route to the same destination - see VPNRouteMetric. route := &netlink.Route{ - Dst: ipNet, + Dst: ipNet, + Priority: VPNRouteMetric, } if gateway != "" { diff --git a/network/route_windows.go b/network/route_windows.go index ba613b6..3b62964 100644 --- a/network/route_windows.go +++ b/network/route_windows.go @@ -84,8 +84,11 @@ func WindowsAddRoute(destination string, gateway string, interfaceName string) e return fmt.Errorf("either gateway or interface must be specified") } - // Add the route using winipcfg - err = luid.AddRoute(prefix, nextHop, 1) + // Add the route using winipcfg. Metric is set explicitly (rather than a + // low value like 1, which would nearly always outrank local routes) so + // that an overlapping local/connected route is preferred over this VPN + // route - see VPNRouteMetric. + err = luid.AddRoute(prefix, nextHop, VPNRouteMetric) if err != nil { return fmt.Errorf("failed to add route: %v", err) } From 8d582b4ea5585551a2b5c59352464b56dbca777c Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 17 Jul 2026 17:40:05 -0400 Subject: [PATCH 6/7] Dont remove non controlled routes --- network/route.go | 35 +++++++++++++++++++++--------- network/route_notwindows.go | 2 +- network/route_windows.go | 43 ++++++++++++++++++++++++++++--------- newt/handlers.go | 2 +- newt/tunnel.go | 4 ++-- 5 files changed, 62 insertions(+), 24 deletions(-) diff --git a/network/route.go b/network/route.go index 175f7b0..844e3c3 100644 --- a/network/route.go +++ b/network/route.go @@ -119,7 +119,7 @@ func LinuxAddRoute(destination string, gateway string, interfaceName string) err return nil } -func LinuxRemoveRoute(destination string) error { +func LinuxRemoveRoute(destination string, interfaceName string) error { if runtime.GOOS != "linux" { return nil } @@ -130,12 +130,24 @@ func LinuxRemoveRoute(destination string) error { return fmt.Errorf("invalid destination address: %v", err) } - // Create route to delete + // Create route to delete. LinkIndex and Priority are set to match the + // route we added exactly, so this only ever deletes the route we own - + // a local/native route to the same destination on a different + // interface (or with a different metric) must never be touched. route := &netlink.Route{ - Dst: ipNet, + Dst: ipNet, + Priority: VPNRouteMetric, } - logger.Info("Removing route to %s", destination) + if interfaceName != "" { + link, err := netlink.LinkByName(interfaceName) + if err != nil { + return fmt.Errorf("failed to get interface %s: %v", interfaceName, err) + } + route.LinkIndex = link.Attrs().Index + } + + logger.Info("Removing route to %s via interface %s", destination, interfaceName) // Delete the route if err := netlink.RouteDel(route); err != nil { @@ -178,9 +190,9 @@ func RemoveRouteForServerIP(serverIP string, interfaceName string) error { return DarwinRemoveRoute(serverIP) } // else if runtime.GOOS == "windows" { - // return WindowsRemoveRoute(serverIP) + // return WindowsRemoveRoute(serverIP, interfaceName) // } else if runtime.GOOS == "linux" { - // return LinuxRemoveRoute(serverIP) + // return LinuxRemoveRoute(serverIP, interfaceName) // } return nil } @@ -263,8 +275,11 @@ func AddRoutes(remoteSubnets []string, interfaceName string) error { return nil } -// removeRoutesForRemoteSubnets removes routes for each subnet in RemoteSubnets -func RemoveRoutes(remoteSubnets []string) error { +// removeRoutesForRemoteSubnets removes routes for each subnet in RemoteSubnets. +// interfaceName must match the interface the routes were added on (see +// AddRoutes) so that only the routes we own are deleted, never an unrelated +// local/native route to the same destination on another interface. +func RemoveRoutes(remoteSubnets []string, interfaceName string) error { if len(remoteSubnets) == 0 { return nil } @@ -288,11 +303,11 @@ func RemoveRoutes(remoteSubnets []string) error { logger.Error("Failed to remove Darwin route for subnet %s: %v", subnet, err) } case "windows": - if err := WindowsRemoveRoute(subnet); err != nil { + if err := WindowsRemoveRoute(subnet, interfaceName); err != nil { logger.Error("Failed to remove Windows route for subnet %s: %v", subnet, err) } case "linux": - if err := LinuxRemoveRoute(subnet); err != nil { + if err := LinuxRemoveRoute(subnet, interfaceName); err != nil { logger.Error("Failed to remove Linux route for subnet %s: %v", subnet, err) } case "android", "ios": diff --git a/network/route_notwindows.go b/network/route_notwindows.go index 6984c71..1214a7f 100644 --- a/network/route_notwindows.go +++ b/network/route_notwindows.go @@ -6,6 +6,6 @@ func WindowsAddRoute(destination string, gateway string, interfaceName string) e return nil } -func WindowsRemoveRoute(destination string) error { +func WindowsRemoveRoute(destination string, interfaceName string) error { return nil } diff --git a/network/route_windows.go b/network/route_windows.go index 3b62964..5bca2d1 100644 --- a/network/route_windows.go +++ b/network/route_windows.go @@ -96,7 +96,7 @@ func WindowsAddRoute(destination string, gateway string, interfaceName string) e return nil } -func WindowsRemoveRoute(destination string) error { +func WindowsRemoveRoute(destination string, interfaceName string) error { // Parse destination CIDR _, ipNet, err := net.ParseCIDR(destination) if err != nil { @@ -120,8 +120,25 @@ func WindowsRemoveRoute(destination string) error { } prefix := netip.PrefixFrom(addr, maskBits) + // Resolve the LUID of the interface we added the route on, so we only + // ever delete the route we own rather than any route matching the + // destination - a local/native route to the same destination on a + // different interface must never be touched. + var luid winipcfg.LUID + var haveLuid bool + if interfaceName != "" { + iface, err := net.InterfaceByName(interfaceName) + if err != nil { + return fmt.Errorf("failed to get interface %s: %v", interfaceName, err) + } + luid, err = winipcfg.LUIDFromIndex(uint32(iface.Index)) + if err != nil { + return fmt.Errorf("failed to get LUID for interface %s: %v", interfaceName, err) + } + haveLuid = true + } + // Get all routes and find the one to delete - // We need to get the LUID from the existing route var family winipcfg.AddressFamily if addr.Is4() { family = 2 // AF_INET @@ -134,17 +151,23 @@ func WindowsRemoveRoute(destination string) error { return fmt.Errorf("failed to get route table: %v", err) } - // Find and delete matching route + // Find and delete matching route. When we know which interface we added + // the route on, only delete the entry on that interface with our + // VPNRouteMetric so we never remove an unrelated local/native route to + // the same destination. for _, route := range routes { routePrefix := route.DestinationPrefix.Prefix() - if routePrefix == prefix { - logger.Info("Removing route to %s", destination) - err = route.Delete() - if err != nil { - return fmt.Errorf("failed to delete route: %v", err) - } - return nil + if routePrefix != prefix { + continue } + if haveLuid && (route.InterfaceLUID != luid || route.Metric != VPNRouteMetric) { + continue + } + logger.Info("Removing route to %s on interface %s", destination, interfaceName) + if err := route.Delete(); err != nil { + return fmt.Errorf("failed to delete route: %v", err) + } + return nil } return fmt.Errorf("route to %s not found", destination) diff --git a/newt/handlers.go b/newt/handlers.go index 0a448f1..55c7f4e 100644 --- a/newt/handlers.go +++ b/newt/handlers.go @@ -430,7 +430,7 @@ func (n *Newt) registerHandlers(ctx context.Context) { } if n.config.UseNativeMainInterface { - if err := network.RemoveRoutes(data.Subnets); err != nil { + if err := network.RemoveRoutes(data.Subnets, n.config.NativeMainInterfaceName); err != nil { logger.Warn("Failed to remove routes for subnets: %v", err) } } diff --git a/newt/tunnel.go b/newt/tunnel.go index b396cce..fdacbf7 100644 --- a/newt/tunnel.go +++ b/newt/tunnel.go @@ -24,7 +24,7 @@ func (n *Newt) updateRemoteExitNodeSubnets(subnets []string) { } } if len(toRemove) > 0 { - if err := network.RemoveRoutes(toRemove); err != nil { + if err := network.RemoveRoutes(toRemove, n.config.NativeMainInterfaceName); err != nil { logger.Warn("Failed to remove old subnet routes: %v", err) } } @@ -88,7 +88,7 @@ func (n *Newt) closeWgTunnel() { } toRemove = append(toRemove, n.activeRemoteSubnets...) if len(toRemove) > 0 { - if err := network.RemoveRoutes(toRemove); err != nil { + if err := network.RemoveRoutes(toRemove, n.config.NativeMainInterfaceName); err != nil { logger.Warn("Failed to remove native main tunnel routes: %v", err) } } From 6610655376ee4f12df0cd96f3ead6823851aab29 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 18 Jul 2026 17:29:27 -0400 Subject: [PATCH 7/7] only add metric if provided through PreferLocalRoutes --- network/route.go | 28 +++++++++++++++++++++------- network/route_windows.go | 26 +++++++++++++++++--------- 2 files changed, 38 insertions(+), 16 deletions(-) diff --git a/network/route.go b/network/route.go index 844e3c3..57fe12d 100644 --- a/network/route.go +++ b/network/route.go @@ -23,6 +23,15 @@ import ( // routing table at add-time. const VPNRouteMetric = 9999 +// PreferLocalRoutes controls whether routes added by AddRoutes are given the +// explicit high VPNRouteMetric priority, so that an overlapping local/ +// connected route always takes precedence over the VPN route to the same +// destination. Defaults to false (routes are added with the OS default +// metric/priority, matching behavior prior to the introduction of +// VPNRouteMetric); callers that want local routes to win opt in by setting +// this to true (e.g. from a config value) before routes are added. +var PreferLocalRoutes = false + // DarwinAddRoute adds a route via the BSD routing table. Unlike Linux/Windows, // BSD's routing table has no per-route metric - preference between an // overlapping local route and this VPN route is instead resolved by @@ -83,12 +92,15 @@ func LinuxAddRoute(destination string, gateway string, interfaceName string) err return fmt.Errorf("invalid destination address: %v", err) } - // Create route. Priority is set explicitly (rather than left at the - // default of 0) so that this route never outranks a local/connected - // route to the same destination - see VPNRouteMetric. + // Create route. When PreferLocalRoutes is enabled, Priority is set + // explicitly (rather than left at the default of 0) so that this route + // never outranks a local/connected route to the same destination - see + // VPNRouteMetric. route := &netlink.Route{ - Dst: ipNet, - Priority: VPNRouteMetric, + Dst: ipNet, + } + if PreferLocalRoutes { + route.Priority = VPNRouteMetric } if gateway != "" { @@ -135,8 +147,10 @@ func LinuxRemoveRoute(destination string, interfaceName string) error { // a local/native route to the same destination on a different // interface (or with a different metric) must never be touched. route := &netlink.Route{ - Dst: ipNet, - Priority: VPNRouteMetric, + Dst: ipNet, + } + if PreferLocalRoutes { + route.Priority = VPNRouteMetric } if interfaceName != "" { diff --git a/network/route_windows.go b/network/route_windows.go index 5bca2d1..a5a3eea 100644 --- a/network/route_windows.go +++ b/network/route_windows.go @@ -84,11 +84,15 @@ func WindowsAddRoute(destination string, gateway string, interfaceName string) e return fmt.Errorf("either gateway or interface must be specified") } - // Add the route using winipcfg. Metric is set explicitly (rather than a - // low value like 1, which would nearly always outrank local routes) so - // that an overlapping local/connected route is preferred over this VPN - // route - see VPNRouteMetric. - err = luid.AddRoute(prefix, nextHop, VPNRouteMetric) + // Add the route using winipcfg. When PreferLocalRoutes is enabled, + // metric is set explicitly (rather than a low value like 1, which would + // nearly always outrank local routes) so that an overlapping local/ + // connected route is preferred over this VPN route - see VPNRouteMetric. + var metric uint32 + if PreferLocalRoutes { + metric = VPNRouteMetric + } + err = luid.AddRoute(prefix, nextHop, metric) if err != nil { return fmt.Errorf("failed to add route: %v", err) } @@ -152,15 +156,19 @@ func WindowsRemoveRoute(destination string, interfaceName string) error { } // Find and delete matching route. When we know which interface we added - // the route on, only delete the entry on that interface with our - // VPNRouteMetric so we never remove an unrelated local/native route to - // the same destination. + // the route on, only delete the entry on that interface with the metric + // we added it with (see PreferLocalRoutes) so we never remove an + // unrelated local/native route to the same destination. + var wantMetric uint32 + if PreferLocalRoutes { + wantMetric = VPNRouteMetric + } for _, route := range routes { routePrefix := route.DestinationPrefix.Prefix() if routePrefix != prefix { continue } - if haveLuid && (route.InterfaceLUID != luid || route.Metric != VPNRouteMetric) { + if haveLuid && (route.InterfaceLUID != luid || route.Metric != wantMetric) { continue } logger.Info("Removing route to %s on interface %s", destination, interfaceName)