diff --git a/network/route.go b/network/route.go index 7081bdf..2fda70f 100644 --- a/network/route.go +++ b/network/route.go @@ -209,6 +209,193 @@ func LinuxRemoveRoute(destination string, interfaceName string) error { return nil } +// LinuxAddBypassRoute adds an explicit /32 host route for destIP via +// whatever gateway/interface the kernel currently uses to reach it, so a +// broader route added afterward (e.g. a gateway/full-tunnel default route) +// can never capture this destination - see AddBypassRouteForDestination. +func LinuxAddBypassRoute(destIP string) error { + if runtime.GOOS != "linux" { + return nil + } + + ip := net.ParseIP(destIP) + if ip == nil { + return fmt.Errorf("invalid destination address: %s", destIP) + } + + routes, err := netlink.RouteGet(ip) + if err != nil { + return fmt.Errorf("failed to look up current route to %s: %v", destIP, err) + } + if len(routes) == 0 { + return fmt.Errorf("no route found to %s", destIP) + } + current := routes[0] + + link, err := netlink.LinkByIndex(current.LinkIndex) + if err != nil { + return fmt.Errorf("failed to resolve interface for route to %s: %v", destIP, err) + } + + route := &netlink.Route{ + Dst: &net.IPNet{IP: ip, Mask: net.CIDRMask(32, 32)}, + Gw: current.Gw, + LinkIndex: link.Attrs().Index, + } + + logger.Info("Adding bypass route to %s via %s (interface %s)", destIP, current.Gw, link.Attrs().Name) + + if err := netlink.RouteAdd(route); err != nil { + return fmt.Errorf("failed to add bypass route to %s: %v", destIP, err) + } + + return nil +} + +// LinuxRemoveBypassRoute removes a route previously added by +// LinuxAddBypassRoute. It deliberately does not re-derive the route via +// RouteGet - by the time this runs, our own /32 bypass route is the most +// specific match for destIP and RouteGet would just find itself - so it +// instead deletes by destination alone. +func LinuxRemoveBypassRoute(destIP string) error { + if runtime.GOOS != "linux" { + return nil + } + + ip := net.ParseIP(destIP) + if ip == nil { + return fmt.Errorf("invalid destination address: %s", destIP) + } + + route := &netlink.Route{ + Dst: &net.IPNet{IP: ip, Mask: net.CIDRMask(32, 32)}, + } + + if err := netlink.RouteDel(route); err != nil { + return fmt.Errorf("failed to remove bypass route to %s: %v", destIP, err) + } + + return nil +} + +// DarwinAddBypassRoute adds an explicit /32 host route for destIP via +// whatever gateway/interface the kernel currently uses to reach it (parsed +// from `route -n get`), so a broader route added afterward can never capture +// this destination - see AddBypassRouteForDestination. +func DarwinAddBypassRoute(destIP string) error { + if runtime.GOOS != "darwin" { + return nil + } + if NativeConfigDisabled { + return nil + } + + cmd := exec.Command("route", "-n", "get", destIP) + logger.Info("Running command: %v", cmd) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("route get command failed: %v, output: %s", err, out) + } + + var gateway, iface string + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + switch { + case strings.HasPrefix(line, "gateway:"): + gateway = strings.TrimSpace(strings.TrimPrefix(line, "gateway:")) + case strings.HasPrefix(line, "interface:"): + iface = strings.TrimSpace(strings.TrimPrefix(line, "interface:")) + } + } + if gateway == "" && iface == "" { + return fmt.Errorf("could not determine current route to %s from `route get` output: %s", destIP, out) + } + + return DarwinAddRouteWithSource(destIP+"/32", gateway, iface, "") +} + +// DarwinRemoveBypassRoute removes a route previously added by +// DarwinAddBypassRoute. +func DarwinRemoveBypassRoute(destIP string) error { + if runtime.GOOS != "darwin" { + return nil + } + return DarwinRemoveRoute(destIP + "/32") +} + +// AddGatewayDefaultRoute installs the OS-level "route everything" equivalent +// for a full-tunnel/gateway peer. NetworkSettings is always populated first +// (regardless of GOOS - mobile packet-tunnel providers read it independent +// of platform, see AddRouteForServerIPWithSource), via an IsDefault included +// route. On desktop platforms, where olm manages the OS routing table +// directly, this then also installs the standard wg-quick split-default-route +// technique (0.0.0.0/1 + 128.0.0.0/1) instead of a literal 0.0.0.0/0, so the +// host's real default route is never replaced or raced with - it is only +// outranked by two strictly more-specific halves. PreferLocalRoutes (if set) +// still applies to these routes exactly as it does to any other tunnel +// route, so an overlapping local/LAN route continues to win even in gateway +// mode. +func AddGatewayDefaultRoute(interfaceName, sourceIP string) error { + AddIPv4IncludedRoute(IPv4Route{DestinationAddress: "0.0.0.0", SubnetMask: "0.0.0.0", IsDefault: true}) + + if runtime.GOOS == "android" || runtime.GOOS == "ios" { + return nil + } + return AddRoutesWithSource([]string{"0.0.0.0/1", "128.0.0.0/1"}, interfaceName, sourceIP) +} + +// RemoveGatewayDefaultRoute reverses AddGatewayDefaultRoute. +func RemoveGatewayDefaultRoute(interfaceName string) error { + RemoveIPv4IncludedRoute(IPv4Route{DestinationAddress: "0.0.0.0", SubnetMask: "0.0.0.0", IsDefault: true}) + + if runtime.GOOS == "android" || runtime.GOOS == "ios" { + return nil + } + return RemoveRoutes([]string{"0.0.0.0/1", "128.0.0.0/1"}, interfaceName) +} + +// AddBypassRouteForDestination installs an explicit /32 host route for destIP +// using whatever gateway/interface the OS routing table currently uses to +// reach it - i.e. the physical/original path, not the tunnel. It must be +// called BEFORE AddGatewayDefaultRoute so the destination's own path is +// pinned down first and can never be captured by the more general gateway +// route. This is the same technique wg-quick uses (set_endpoint_direct_route) +// to keep a WireGuard peer's own UDP traffic from being captured by the +// gateway route it is itself responsible for installing. +// +// NetworkSettings is always populated (an excluded route, for mobile +// packet-tunnel providers), regardless of GOOS; the OS routing table is only +// touched on desktop platforms, which have no equivalent of +// NEIPv4Settings.excludedRoutes/VpnService.Builder.excludeRoute. +func AddBypassRouteForDestination(destIP string) error { + AddIPv4ExcludedRoute(IPv4Route{DestinationAddress: destIP, SubnetMask: "255.255.255.255"}) + + switch runtime.GOOS { + case "linux": + return LinuxAddBypassRoute(destIP) + case "darwin": + return DarwinAddBypassRoute(destIP) + case "windows": + return WindowsAddBypassRoute(destIP) + } + return nil +} + +// RemoveBypassRouteForDestination reverses AddBypassRouteForDestination. +func RemoveBypassRouteForDestination(destIP string) error { + RemoveIPv4ExcludedRoute(IPv4Route{DestinationAddress: destIP, SubnetMask: "255.255.255.255"}) + + switch runtime.GOOS { + case "linux": + return LinuxRemoveBypassRoute(destIP) + case "darwin": + return DarwinRemoveBypassRoute(destIP) + case "windows": + return WindowsRemoveBypassRoute(destIP) + } + return nil +} + // addRouteForServerIP adds an OS-specific route for the server IP func AddRouteForServerIP(serverIP, interfaceName string) error { return AddRouteForServerIPWithSource(serverIP, interfaceName, "") diff --git a/network/route_notwindows.go b/network/route_notwindows.go index 1214a7f..0fb1d25 100644 --- a/network/route_notwindows.go +++ b/network/route_notwindows.go @@ -9,3 +9,11 @@ func WindowsAddRoute(destination string, gateway string, interfaceName string) e func WindowsRemoveRoute(destination string, interfaceName string) error { return nil } + +func WindowsAddBypassRoute(destIP string) error { + return nil +} + +func WindowsRemoveBypassRoute(destIP string) error { + return nil +} diff --git a/network/route_windows.go b/network/route_windows.go index a5a3eea..2f1e564 100644 --- a/network/route_windows.go +++ b/network/route_windows.go @@ -100,6 +100,94 @@ func WindowsAddRoute(destination string, gateway string, interfaceName string) e return nil } +// WindowsAddBypassRoute adds an explicit /32 host route for destIP via +// whatever gateway/interface the OS routing table currently uses to reach +// it, so a broader route added afterward (e.g. a gateway/full-tunnel default +// route) can never capture this destination - see +// network.AddBypassRouteForDestination. +func WindowsAddBypassRoute(destIP string) error { + addr, err := netip.ParseAddr(destIP) + if err != nil { + return fmt.Errorf("invalid destination address: %v", err) + } + + var family winipcfg.AddressFamily + if addr.Is4() { + family = 2 // AF_INET + } else { + family = 23 // AF_INET6 + } + + routes, err := winipcfg.GetIPForwardTable2(family) + if err != nil { + return fmt.Errorf("failed to get route table: %v", err) + } + + var best *winipcfg.MibIPforwardRow2 + bestBits := -1 + for i := range routes { + route := &routes[i] + prefix := route.DestinationPrefix.Prefix() + if !prefix.Contains(addr) { + continue + } + if prefix.Bits() > bestBits || (prefix.Bits() == bestBits && best != nil && route.Metric < best.Metric) { + bestBits = prefix.Bits() + best = route + } + } + if best == nil { + return fmt.Errorf("no route found to %s", destIP) + } + + prefix := netip.PrefixFrom(addr, addr.BitLen()) + logger.Info("Adding bypass route to %s via interface LUID %v", destIP, best.InterfaceLUID) + + if err := best.InterfaceLUID.AddRoute(prefix, best.NextHop.Addr(), 0); err != nil { + return fmt.Errorf("failed to add bypass route: %v", err) + } + + return nil +} + +// WindowsRemoveBypassRoute removes a route previously added by +// WindowsAddBypassRoute. It deliberately does not re-derive the route via a +// longest-prefix-match lookup - by the time this runs, our own /32 bypass +// route is the most specific match for destIP and the lookup would just find +// itself - so it instead deletes by exact destination prefix alone. +func WindowsRemoveBypassRoute(destIP string) error { + addr, err := netip.ParseAddr(destIP) + if err != nil { + return fmt.Errorf("invalid destination address: %v", err) + } + prefix := netip.PrefixFrom(addr, addr.BitLen()) + + var family winipcfg.AddressFamily + if addr.Is4() { + family = 2 + } else { + family = 23 + } + + routes, err := winipcfg.GetIPForwardTable2(family) + if err != nil { + return fmt.Errorf("failed to get route table: %v", err) + } + + for _, route := range routes { + if route.DestinationPrefix.Prefix() != prefix { + continue + } + logger.Info("Removing bypass route to %s on interface LUID %v", destIP, route.InterfaceLUID) + if err := route.Delete(); err != nil { + return fmt.Errorf("failed to delete bypass route: %v", err) + } + return nil + } + + return fmt.Errorf("bypass route to %s not found", destIP) +} + func WindowsRemoveRoute(destination string, interfaceName string) error { // Parse destination CIDR _, ipNet, err := net.ParseCIDR(destination) diff --git a/network/settings.go b/network/settings.go index f0520ed..9509f41 100644 --- a/network/settings.go +++ b/network/settings.go @@ -181,6 +181,43 @@ func SetIPv4ExcludedRoutes(routes []IPv4Route) { logger.Info("Set IPv4 excluded routes: %d routes", len(routes)) } +// AddIPv4ExcludedRoute adds a single excluded route, e.g. so mobile +// (iOS/Android) packet-tunnel providers keep a specific destination (a site's +// live endpoint, the control-plane server) out of an otherwise-broad included +// route such as a full-tunnel/gateway default route. Mirrors +// AddIPv4IncludedRoute's dedup-by-equality behavior. +func AddIPv4ExcludedRoute(route IPv4Route) { + networkSettingsMutex.Lock() + defer networkSettingsMutex.Unlock() + + for _, r := range networkSettings.IPv4ExcludedRoutes { + if r == route { + logger.Info("IPv4 excluded route already exists: %+v", route) + return + } + } + + networkSettings.IPv4ExcludedRoutes = append(networkSettings.IPv4ExcludedRoutes, route) + incrementor++ + logger.Info("Added IPv4 excluded route: %+v", route) +} + +// RemoveIPv4ExcludedRoute reverses AddIPv4ExcludedRoute. +func RemoveIPv4ExcludedRoute(route IPv4Route) { + networkSettingsMutex.Lock() + defer networkSettingsMutex.Unlock() + routes := networkSettings.IPv4ExcludedRoutes + for i, r := range routes { + if r == route { + networkSettings.IPv4ExcludedRoutes = append(routes[:i], routes[i+1:]...) + incrementor++ + logger.Info("Removed IPv4 excluded route: %+v", route) + return + } + } + logger.Info("IPv4 excluded route not found for removal: %+v", route) +} + // SetIPv6Settings sets IPv6 addresses and network prefixes func SetIPv6Settings(addresses []string, networkPrefixes []string) { networkSettingsMutex.Lock()