mirror of
https://github.com/fosrl/newt.git
synced 2026-08-31 03:01:28 +02:00
413 lines
14 KiB
Go
413 lines
14 KiB
Go
package network
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"os/exec"
|
|
"runtime"
|
|
"strings"
|
|
|
|
"github.com/fosrl/newt/logger"
|
|
"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 {
|
|
return DarwinAddRouteWithSource(destination, gateway, interfaceName, "")
|
|
}
|
|
|
|
// DarwinAddRouteWithSource is DarwinAddRoute with an explicit source address
|
|
// (route(8) `-ifa`). This is required when the interface carries more than
|
|
// one address (e.g. an exit node's secondary tunnel address alongside the
|
|
// site tunnel's primary address): without `-ifa`, BSD picks a source address
|
|
// for the route on its own - typically the interface's primary address - and
|
|
// WireGuard's own reverse-path filtering on the remote end will silently drop
|
|
// packets whose source doesn't match the peer's configured AllowedIPs, even
|
|
// though the tunnel/handshake itself stays up.
|
|
func DarwinAddRouteWithSource(destination string, gateway string, interfaceName string, sourceIP string) error {
|
|
if runtime.GOOS != "darwin" {
|
|
return nil
|
|
}
|
|
|
|
var args []string
|
|
|
|
if gateway != "" {
|
|
// Route with specific gateway
|
|
args = []string{"-q", "-n", "add", "-inet", destination, "-gateway", gateway}
|
|
} else if interfaceName != "" {
|
|
// Route via interface
|
|
args = []string{"-q", "-n", "add", "-inet", destination, "-interface", interfaceName}
|
|
} else {
|
|
return fmt.Errorf("either gateway or interface must be specified")
|
|
}
|
|
|
|
if sourceIP != "" {
|
|
args = append(args, "-ifa", sourceIP)
|
|
}
|
|
|
|
cmd := exec.Command("route", args...)
|
|
|
|
logger.Info("Running command: %v", cmd)
|
|
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("route command failed: %v, output: %s", err, out)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func DarwinRemoveRoute(destination string) error {
|
|
if runtime.GOOS != "darwin" {
|
|
return nil
|
|
}
|
|
|
|
cmd := exec.Command("route", "-q", "-n", "delete", "-inet", destination)
|
|
logger.Info("Running command: %v", cmd)
|
|
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("route delete command failed: %v, output: %s", err, out)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func LinuxAddRoute(destination string, gateway string, interfaceName string) error {
|
|
if runtime.GOOS != "linux" {
|
|
return nil
|
|
}
|
|
|
|
// Parse destination CIDR
|
|
_, ipNet, err := net.ParseCIDR(destination)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid destination address: %v", err)
|
|
}
|
|
|
|
// 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
|
|
gw := net.ParseIP(gateway)
|
|
if gw == nil {
|
|
return fmt.Errorf("invalid gateway address: %s", gateway)
|
|
}
|
|
route.Gw = gw
|
|
logger.Info("Adding route to %s via gateway %s", destination, gateway)
|
|
} else if interfaceName != "" {
|
|
// Route via interface
|
|
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("Adding route to %s via interface %s", destination, interfaceName)
|
|
} else {
|
|
return fmt.Errorf("either gateway or interface must be specified")
|
|
}
|
|
|
|
// Add the route
|
|
if err := netlink.RouteAdd(route); err != nil {
|
|
return fmt.Errorf("failed to add route: %v", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func LinuxRemoveRoute(destination string, interfaceName string) error {
|
|
if runtime.GOOS != "linux" {
|
|
return nil
|
|
}
|
|
|
|
// Parse destination CIDR
|
|
_, ipNet, err := net.ParseCIDR(destination)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid destination address: %v", err)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
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 {
|
|
return fmt.Errorf("failed to delete route: %v", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// addRouteForServerIP adds an OS-specific route for the server IP
|
|
func AddRouteForServerIP(serverIP, interfaceName string) error {
|
|
return AddRouteForServerIPWithSource(serverIP, interfaceName, "")
|
|
}
|
|
|
|
// AddRouteForServerIPWithSource is AddRouteForServerIP with an explicit source
|
|
// address for the darwin route (see DarwinAddRouteWithSource) - needed when
|
|
// the interface carries more than one address, e.g. an exit node connection
|
|
// where the interface's primary address belongs to the site tunnel rather
|
|
// than the exit node.
|
|
func AddRouteForServerIPWithSource(serverIP, interfaceName string, sourceIP string) error {
|
|
if interfaceName == "" {
|
|
return nil
|
|
}
|
|
|
|
// Populate the NetworkSettings entry (and its gatewayAddress, for the
|
|
// NetworkExtension source-pinning trick above) unconditionally, same as
|
|
// AddRoutesWithSource does for remote subnets - mobile packet-tunnel
|
|
// providers rely on this regardless of GOOS.
|
|
if err := AddRouteForNetworkConfigWithGateway(serverIP, sourceIP); err != nil {
|
|
return err
|
|
}
|
|
|
|
// TODO: does this also need to be ios?
|
|
if runtime.GOOS == "darwin" { // macos requires routes for each peer to be added but this messes with other platforms
|
|
return DarwinAddRouteWithSource(serverIP, "", interfaceName, sourceIP)
|
|
}
|
|
// else if runtime.GOOS == "windows" {
|
|
// return WindowsAddRoute(serverIP, "", interfaceName)
|
|
// } else if runtime.GOOS == "linux" {
|
|
// return LinuxAddRoute(serverIP, "", interfaceName)
|
|
// }
|
|
return nil
|
|
}
|
|
|
|
// removeRouteForServerIP removes an OS-specific route for the server IP
|
|
func RemoveRouteForServerIP(serverIP string, interfaceName string) error {
|
|
return RemoveRouteForServerIPWithSource(serverIP, interfaceName, "")
|
|
}
|
|
|
|
// RemoveRouteForServerIPWithSource is RemoveRouteForServerIP with an explicit
|
|
// source/gateway address - must match whatever was passed to
|
|
// AddRouteForServerIPWithSource when the route was added (see
|
|
// RemoveRouteForNetworkConfigWithGateway).
|
|
func RemoveRouteForServerIPWithSource(serverIP string, interfaceName string, sourceIP string) error {
|
|
if interfaceName == "" {
|
|
return nil
|
|
}
|
|
|
|
if err := RemoveRouteForNetworkConfigWithGateway(serverIP, sourceIP); err != nil {
|
|
return err
|
|
}
|
|
|
|
// TODO: does this also need to be ios?
|
|
if runtime.GOOS == "darwin" { // macos requires routes for each peer to be added but this messes with other platforms
|
|
return DarwinRemoveRoute(serverIP)
|
|
}
|
|
// else if runtime.GOOS == "windows" {
|
|
// return WindowsRemoveRoute(serverIP, interfaceName)
|
|
// } else if runtime.GOOS == "linux" {
|
|
// return LinuxRemoveRoute(serverIP, interfaceName)
|
|
// }
|
|
return nil
|
|
}
|
|
|
|
func AddRouteForNetworkConfig(destination string) error {
|
|
return AddRouteForNetworkConfigWithGateway(destination, "")
|
|
}
|
|
|
|
// AddRouteForNetworkConfigWithGateway is AddRouteForNetworkConfig with an
|
|
// explicit gateway address for the route entry surfaced via NetworkSettings.
|
|
// This is consumed by mobile (iOS/macOS NetworkExtension) packet-tunnel
|
|
// providers as NEIPv4Route.gatewayAddress. NetworkExtension gives us no
|
|
// direct way to pin a route's source address (no equivalent of BSD's `route
|
|
// -ifa`) - but setting gatewayAddress to one of the tunnel interface's own
|
|
// addresses makes the OS resolve "how do I reach this gateway" recursively
|
|
// to that address/interface pairing, which is what determines the source
|
|
// address used for packets matching the route. This is the same underlying
|
|
// mechanism as `route add -gateway` (see DarwinAddRoute's gateway branch).
|
|
func AddRouteForNetworkConfigWithGateway(destination string, gateway string) error {
|
|
// Parse the subnet to extract IP and mask
|
|
_, ipNet, err := net.ParseCIDR(destination)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse subnet %s: %v", destination, err)
|
|
}
|
|
|
|
// Convert CIDR mask to dotted decimal format (e.g., 255.255.255.0)
|
|
mask := net.IP(ipNet.Mask).String()
|
|
destinationAddress := ipNet.IP.String()
|
|
|
|
AddIPv4IncludedRoute(IPv4Route{DestinationAddress: destinationAddress, SubnetMask: mask, GatewayAddress: gateway})
|
|
|
|
return nil
|
|
}
|
|
|
|
func RemoveRouteForNetworkConfig(destination string) error {
|
|
return RemoveRouteForNetworkConfigWithGateway(destination, "")
|
|
}
|
|
|
|
// RemoveRouteForNetworkConfigWithGateway is RemoveRouteForNetworkConfig with
|
|
// an explicit gateway address. This must match whatever gateway the route was
|
|
// added with (see AddRouteForNetworkConfigWithGateway) - RemoveIPv4IncludedRoute
|
|
// matches by full struct equality, so a mismatched gateway means the entry is
|
|
// silently never found/removed.
|
|
func RemoveRouteForNetworkConfigWithGateway(destination string, gateway string) error {
|
|
// Parse the subnet to extract IP and mask
|
|
_, ipNet, err := net.ParseCIDR(destination)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse subnet %s: %v", destination, err)
|
|
}
|
|
|
|
// Convert CIDR mask to dotted decimal format (e.g., 255.255.255.0)
|
|
mask := net.IP(ipNet.Mask).String()
|
|
destinationAddress := ipNet.IP.String()
|
|
|
|
RemoveIPv4IncludedRoute(IPv4Route{DestinationAddress: destinationAddress, SubnetMask: mask, GatewayAddress: gateway})
|
|
|
|
return nil
|
|
}
|
|
|
|
// addRoutes adds routes for each subnet in RemoteSubnets
|
|
func AddRoutes(remoteSubnets []string, interfaceName string) error {
|
|
return AddRoutesWithSource(remoteSubnets, interfaceName, "")
|
|
}
|
|
|
|
// AddRoutesWithSource is AddRoutes with an explicit source address for the
|
|
// darwin routes (see DarwinAddRouteWithSource) - needed when the interface
|
|
// carries more than one address (e.g. a site tunnel address alongside an
|
|
// exit node's secondary address), so the routes for these subnets are pinned
|
|
// to the address they actually belong to rather than whichever address
|
|
// darwin would otherwise default to.
|
|
func AddRoutesWithSource(remoteSubnets []string, interfaceName string, sourceIP string) error {
|
|
if len(remoteSubnets) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Add routes for each subnet
|
|
for _, subnet := range remoteSubnets {
|
|
subnet = strings.TrimSpace(subnet)
|
|
if subnet == "" {
|
|
continue
|
|
}
|
|
|
|
if err := AddRouteForNetworkConfig(subnet); err != nil {
|
|
logger.Error("Failed to add network config for subnet %s: %v", subnet, err)
|
|
continue
|
|
}
|
|
|
|
// Add route based on operating system
|
|
if interfaceName == "" {
|
|
continue
|
|
}
|
|
|
|
switch runtime.GOOS {
|
|
case "darwin":
|
|
if err := DarwinAddRouteWithSource(subnet, "", interfaceName, sourceIP); err != nil {
|
|
logger.Error("Failed to add Darwin route for subnet %s: %v", subnet, err)
|
|
}
|
|
case "windows":
|
|
if err := WindowsAddRoute(subnet, "", interfaceName); err != nil {
|
|
logger.Error("Failed to add Windows route for subnet %s: %v", subnet, err)
|
|
}
|
|
case "linux":
|
|
if err := LinuxAddRoute(subnet, "", interfaceName); err != nil {
|
|
logger.Error("Failed to add Linux route for subnet %s: %v", subnet, err)
|
|
}
|
|
case "android", "ios":
|
|
// Routes handled by the OS/VPN service
|
|
continue
|
|
}
|
|
|
|
logger.Info("Added route for remote subnet: %s", subnet)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Remove routes for each subnet
|
|
for _, subnet := range remoteSubnets {
|
|
subnet = strings.TrimSpace(subnet)
|
|
if subnet == "" {
|
|
continue
|
|
}
|
|
|
|
if err := RemoveRouteForNetworkConfig(subnet); err != nil {
|
|
logger.Error("Failed to remove network config for subnet %s: %v", subnet, err)
|
|
continue
|
|
}
|
|
|
|
// Remove route based on operating system
|
|
switch runtime.GOOS {
|
|
case "darwin":
|
|
if err := DarwinRemoveRoute(subnet); err != nil {
|
|
logger.Error("Failed to remove Darwin route for subnet %s: %v", subnet, err)
|
|
}
|
|
case "windows":
|
|
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, interfaceName); err != nil {
|
|
logger.Error("Failed to remove Linux route for subnet %s: %v", subnet, err)
|
|
}
|
|
case "android", "ios":
|
|
// Routes handled by the OS/VPN service
|
|
continue
|
|
}
|
|
|
|
logger.Info("Removed route for remote subnet: %s", subnet)
|
|
}
|
|
|
|
return nil
|
|
}
|