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 new file mode 100644 index 0000000..4f03b1a --- /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 +) + +// 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 + } + if ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { + // 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: baseScore, 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/network/route.go b/network/route.go index 8aae063..57fe12d 100644 --- a/network/route.go +++ b/network/route.go @@ -11,6 +11,33 @@ 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 + +// 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 +// 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,10 +92,16 @@ func LinuxAddRoute(destination string, gateway string, interfaceName string) err return fmt.Errorf("invalid destination address: %v", err) } - // Create route + // 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, } + if PreferLocalRoutes { + route.Priority = VPNRouteMetric + } if gateway != "" { // Route with specific gateway @@ -98,7 +131,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 } @@ -109,12 +142,26 @@ 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, } + if PreferLocalRoutes { + route.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 { @@ -157,9 +204,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 } @@ -242,8 +289,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 } @@ -267,11 +317,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 ba613b6..a5a3eea 100644 --- a/network/route_windows.go +++ b/network/route_windows.go @@ -84,8 +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 - err = luid.AddRoute(prefix, nextHop, 1) + // 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) } @@ -93,7 +100,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 { @@ -117,8 +124,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 @@ -131,17 +155,27 @@ 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 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 { - 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 != wantMetric) { + 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) } } 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" +}