From fadce960fbc95ea5f1dda08ece2a3534fac6efb2 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 4 Sep 2026 12:51:01 -0400 Subject: [PATCH] Basic subnet router and dns server config added --- api/api.go | 1 + config.go | 16 +++ dns/dns_proxy.go | 23 +++ go.mod | 3 + go.sum | 6 + main.go | 1 + olm/connect.go | 38 +++-- olm/dns_config.go | 121 ++++++++++++++++ olm/olm.go | 13 ++ olm/types.go | 23 +++ subnetrouter/subnetrouter_linux.go | 222 +++++++++++++++++++++++++++++ subnetrouter/subnetrouter_other.go | 21 +++ 12 files changed, 468 insertions(+), 20 deletions(-) create mode 100644 olm/dns_config.go create mode 100644 subnetrouter/subnetrouter_linux.go create mode 100644 subnetrouter/subnetrouter_other.go diff --git a/api/api.go b/api/api.go index b2811fd..deb67d2 100644 --- a/api/api.go +++ b/api/api.go @@ -30,6 +30,7 @@ type ConnectionRequest struct { PingTimeout string `json:"pingTimeout,omitempty"` OrgID string `json:"orgId,omitempty"` MatchDomains []string `json:"matchDomains,omitempty"` + SubnetRouter bool `json:"subnetRouter,omitempty"` } // SwitchOrgRequest defines the structure for switching organizations diff --git a/config.go b/config.go index 44b030c..2440bbb 100644 --- a/config.go +++ b/config.go @@ -53,6 +53,7 @@ type OlmConfig struct { TunnelDNS bool `json:"tunnelDNS"` DisableRelay bool `json:"disableRelay"` PreferLocalRoutes bool `json:"preferLocalRoutes"` + SubnetRouter bool `json:"subnetRouter"` // DoNotCreateNewClient bool `json:"doNotCreateNewClient"` // Parsed values (not in JSON) @@ -120,6 +121,7 @@ func DefaultConfig() *OlmConfig { config.sources["tunnelDNS"] = string(SourceDefault) config.sources["disableRelay"] = string(SourceDefault) config.sources["preferLocalRoutes"] = string(SourceDefault) + config.sources["subnetRouter"] = string(SourceDefault) // config.sources["doNotCreateNewClient"] = string(SourceDefault) return config @@ -291,6 +293,10 @@ func loadConfigFromEnv(config *OlmConfig) { config.TunnelDNS = true config.sources["tunnelDNS"] = string(SourceEnv) } + if val := os.Getenv("SUBNET_ROUTER"); val == "true" { + config.SubnetRouter = true + config.sources["subnetRouter"] = string(SourceEnv) + } // if val := os.Getenv("DO_NOT_CREATE_NEW_CLIENT"); val == "true" { // config.DoNotCreateNewClient = true // config.sources["doNotCreateNewClient"] = string(SourceEnv) @@ -324,6 +330,7 @@ func loadConfigFromCLI(config *OlmConfig, args []string) (bool, bool, error) { "disableRelay": config.DisableRelay, "preferLocalRoutes": config.PreferLocalRoutes, "tunnelDNS": config.TunnelDNS, + "subnetRouter": config.SubnetRouter, // "doNotCreateNewClient": config.DoNotCreateNewClient, } @@ -351,6 +358,7 @@ func loadConfigFromCLI(config *OlmConfig, args []string) (bool, bool, error) { serviceFlags.BoolVar(&config.DisableRelay, "disable-relay", config.DisableRelay, "Disable relay connections") serviceFlags.BoolVar(&config.PreferLocalRoutes, "prefer-local-routes", config.PreferLocalRoutes, "Add tunnel routes with a high metric so overlapping local/connected routes take precedence (default false)") serviceFlags.BoolVar(&config.TunnelDNS, "tunnel-dns", config.TunnelDNS, "When enabled, DNS queries are routed through the tunnel for remote resolution. To ensure queries are tunneled correctly, you must define the DNS server as a Pangolin resource and enter its address as an Upstream DNS Server. (default false)") + serviceFlags.BoolVar(&config.SubnetRouter, "subnet-router", config.SubnetRouter, "Enable this client to act as a subnet router: traffic forwarded from the local network is NATed to this client's own tunnel IP before going out over the tunnel. Linux only, requires CAP_NET_ADMIN. (default false)") // serviceFlags.BoolVar(&config.DoNotCreateNewClient, "do-not-create-new-client", config.DoNotCreateNewClient, "Do not create new client") version := serviceFlags.Bool("version", false, "Print the version") @@ -440,6 +448,9 @@ func loadConfigFromCLI(config *OlmConfig, args []string) (bool, bool, error) { if config.TunnelDNS != origValues["tunnelDNS"].(bool) { config.sources["tunnelDNS"] = string(SourceCLI) } + if config.SubnetRouter != origValues["subnetRouter"].(bool) { + config.sources["subnetRouter"] = string(SourceCLI) + } // if config.DoNotCreateNewClient != origValues["doNotCreateNewClient"].(bool) { // config.sources["doNotCreateNewClient"] = string(SourceCLI) // } @@ -572,6 +583,10 @@ func mergeConfigs(dest, src *OlmConfig) { dest.PreferLocalRoutes = src.PreferLocalRoutes dest.sources["preferLocalRoutes"] = string(SourceFile) } + if src.SubnetRouter { + dest.SubnetRouter = src.SubnetRouter + dest.sources["subnetRouter"] = string(SourceFile) + } // if src.DoNotCreateNewClient { // dest.DoNotCreateNewClient = src.DoNotCreateNewClient // dest.sources["doNotCreateNewClient"] = string(SourceFile) @@ -665,6 +680,7 @@ func (c *OlmConfig) ShowConfig() { fmt.Printf(" tunnel-dns = %v [%s]\n", c.TunnelDNS, getSource("tunnelDNS")) fmt.Printf(" disable-relay = %v [%s]\n", c.DisableRelay, getSource("disableRelay")) fmt.Printf(" prefer-local-routes = %v [%s]\n", c.PreferLocalRoutes, getSource("preferLocalRoutes")) + fmt.Printf(" subnet-router = %v [%s]\n", c.SubnetRouter, getSource("subnetRouter")) // fmt.Printf(" do-not-create-new-client = %v [%s]\n", c.DoNotCreateNewClient, getSource("doNotCreateNewClient")) if c.TlsClientCert != "" { fmt.Printf(" tls-cert = %s [%s]\n", c.TlsClientCert, getSource("tlsClientCert")) diff --git a/dns/dns_proxy.go b/dns/dns_proxy.go index 979f42e..3c8ee9a 100644 --- a/dns/dns_proxy.go +++ b/dns/dns_proxy.go @@ -866,6 +866,29 @@ func (p *DNSProxy) SetUpstreamDNS(servers []string) { p.upstreamDNS = servers } +// SetTunnelDNS changes whether upstream DNS queries are sent over the +// WireGuard tunnel (true) or directly via host networking (false). Only +// takes effect for queries issued after the call; in-flight queries keep +// using whichever path they already started on. Switching to true after the +// proxy has already started lazily brings up the tunnel netstack and its +// packet-forwarding goroutine if they weren't already running - NewDNSProxy +// only does that eagerly when tunnelDns is true from the start. +func (p *DNSProxy) SetTunnelDNS(tunnelDNS bool) { + if tunnelDNS && p.tunnelStack == nil { + if !p.tunnelIP.IsValid() { + logger.Warn("Cannot enable tunnel DNS: tunnel IP not set") + return + } + if err := p.initTunnelNetstack(); err != nil { + logger.Error("Failed to initialize tunnel netstack for tunnel DNS: %v", err) + return + } + p.wg.Add(1) + go p.runTunnelPacketSender() + } + p.tunnelDNS = tunnelDNS +} + // AddDNSRecord adds a DNS record to the local store // domain should be a domain name (e.g., "example.com" or "example.com.") // ip should be a valid IPv4 or IPv6 address diff --git a/go.mod b/go.mod index fefd89a..f0fa360 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/Microsoft/go-winio v0.6.2 github.com/fosrl/newt v1.16.0 github.com/godbus/dbus/v5 v5.2.2 + github.com/google/nftables v0.3.0 github.com/gorilla/websocket v1.5.3 github.com/miekg/dns v1.1.70 golang.org/x/net v0.56.0 @@ -19,6 +20,8 @@ require ( require ( github.com/google/btree v1.1.3 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect + github.com/mdlayher/socket v0.5.1 // indirect github.com/vishvananda/netlink v1.3.1 // indirect github.com/vishvananda/netns v0.0.5 // indirect golang.org/x/crypto v0.53.0 // indirect diff --git a/go.sum b/go.sum index 091951b..b41a6e2 100644 --- a/go.sum +++ b/go.sum @@ -8,8 +8,14 @@ github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/nftables v0.3.0 h1:bkyZ0cbpVeMHXOrtlFc8ISmfVqq5gPJukoYieyVmITg= +github.com/google/nftables v0.3.0/go.mod h1:BCp9FsrbF1Fn/Yu6CLUc9GGZFw/+hsxfluNXXmxBfRM= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg= +github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o= +github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos= +github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= github.com/miekg/dns v1.1.70 h1:DZ4u2AV35VJxdD9Fo9fIWm119BsQL5cZU1cQ9s0LkqA= github.com/miekg/dns v1.1.70/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0= diff --git a/main.go b/main.go index 060dc79..5cd6bf4 100644 --- a/main.go +++ b/main.go @@ -273,6 +273,7 @@ func runOlmMainWithArgs(ctx context.Context, cancel context.CancelFunc, signalCt OverrideDNS: config.OverrideDNS, DisableRelay: config.DisableRelay, PreferLocalRoutes: config.PreferLocalRoutes, + SubnetRouter: config.SubnetRouter, EnableUAPI: true, } go olm.StartTunnel(tunnelConfig) diff --git a/olm/connect.go b/olm/connect.go index 4a5d0dd..1fb2aba 100644 --- a/olm/connect.go +++ b/olm/connect.go @@ -15,8 +15,8 @@ import ( "github.com/fosrl/newt/util" olmDevice "github.com/fosrl/olm/device" "github.com/fosrl/olm/dns" - dnsOverride "github.com/fosrl/olm/dns/override" "github.com/fosrl/olm/peers" + "github.com/fosrl/olm/subnetrouter" "github.com/fosrl/olm/websocket" "golang.zx2c4.com/wireguard/device" "golang.zx2c4.com/wireguard/tun" @@ -75,6 +75,12 @@ func (o *Olm) handleConnect(msg websocket.WSMessage) { return } + // A server-provided DNS config always overrides the client's own local + // config (from CLI flags / API connect request) - see applyDNSConfigUpdate. + if wgData.DNSConfig != nil { + o.applyDNSConfigUpdate(*wgData.DNSConfig) + } + // When handed an already-open FD (mobile/NetworkExtension platforms), the // TUN device's addresses and routes are owned and reconciled by the host // platform from NetworkSettings (e.g. Apple's NEPacketTunnelProvider via @@ -193,6 +199,14 @@ func (o *Olm) handleConnect(msg websocket.WSMessage) { logger.Error("Failed to o.tunnelConfigure interface: %v", err) } + if o.tunnelConfig.SubnetRouter { + if err := subnetrouter.Enable(o.tunnelConfig.InterfaceName, o.primaryTunnelIP); err != nil { + logger.Error("Failed to enable subnet router: %v", err) + } else { + logger.Info("Subnet router enabled on %s (SNAT to %s)", o.tunnelConfig.InterfaceName, o.primaryTunnelIP) + } + } + if err := network.AddRoutesWithSource([]string{wgData.UtilitySubnet}, o.tunnelConfig.InterfaceName, interfaceIP); err != nil { // also route the utility subnet logger.Error("Failed to add route for utility subnet: %v", err) } @@ -273,26 +287,10 @@ func (o *Olm) handleConnect(msg websocket.WSMessage) { }) if o.tunnelConfig.OverrideDNS { - // When the host platform already applies DNS natively (NEDNSSettings on - // macOS/iOS, scoped to the tunnel session and auto-cleaned by the OS no - // matter how the session ends), skip olm's own raw scutil-based override - - // there is nothing for it to add and, unlike NEDNSSettings, it has no way - // to guarantee cleanup if this process dies uncleanly. See NativeDNSManaged. - if !o.tunnelConfig.NativeDNSManaged { - // Set up DNS override to use our DNS proxy - if err := dnsOverride.SetupDNSOverride(o.tunnelConfig.InterfaceName, o.dnsProxy.GetProxyIP()); err != nil { - logger.Error("Failed to setup DNS override: %v", err) - return - } - - // Start the external watchdog (if configured). The watchdog will - // reset DNS if this process dies before it can call - // RestoreDNSOverride. This is a no-op when no watchdog - // subcommand has been configured on the OlmConfig. - o.startDNSWatchdog(o.tunnelConfig.InterfaceName) + if err := o.applyDNSOverride(true); err != nil { + logger.Error("%v", err) + return } - - network.SetDNSServers([]string{o.dnsProxy.GetProxyIP().String()}) } if wgData.ExitNode != nil && wgData.ExitNode.Connect { diff --git a/olm/dns_config.go b/olm/dns_config.go new file mode 100644 index 0000000..de29246 --- /dev/null +++ b/olm/dns_config.go @@ -0,0 +1,121 @@ +package olm + +import ( + "encoding/json" + "fmt" + + "github.com/fosrl/newt/logger" + "github.com/fosrl/newt/network" + dnsOverride "github.com/fosrl/olm/dns/override" + "github.com/fosrl/olm/websocket" +) + +// applyDNSConfigUpdate merges a server-provided DNS config override into the +// running tunnel config. Every set field always overrides the client's own +// local config (from CLI flags / API connect request, or a previous update); +// an unset field leaves the current value alone. Called both from the +// initial "olm/wg/connect" message, before the DNS proxy exists yet (the +// updated tunnelConfig feeds into its construction in handleConnect), and +// from a later live "olm/wg/dns/update" push, where the running proxy is +// updated directly. +func (o *Olm) applyDNSConfigUpdate(cfg DNSConfigUpdate) { + logger.Info("Applying DNS config from server: %+v", cfg) + + if len(cfg.UpstreamDNS) > 0 { + o.tunnelConfig.UpstreamDNS = cfg.UpstreamDNS + if o.dnsProxy != nil { + o.dnsProxy.SetUpstreamDNS(cfg.UpstreamDNS) + } + } + + if len(cfg.MatchDomains) > 0 { + o.tunnelConfig.MatchDomains = cfg.MatchDomains + if o.dnsProxy != nil { + o.dnsProxy.SetMatchDomains(cfg.MatchDomains) + } + } + + if cfg.TunnelDNS != nil { + o.tunnelConfig.TunnelDNS = *cfg.TunnelDNS + if o.dnsProxy != nil { + o.dnsProxy.SetTunnelDNS(*cfg.TunnelDNS) + } + } + + if cfg.OverrideDNS != nil && *cfg.OverrideDNS != o.tunnelConfig.OverrideDNS { + if o.dnsProxy != nil { + if err := o.applyDNSOverride(*cfg.OverrideDNS); err != nil { + logger.Error("Failed to apply DNS override update: %v", err) + return + } + } + o.tunnelConfig.OverrideDNS = *cfg.OverrideDNS + } +} + +// applyDNSOverride installs or removes olm's own system DNS override +// (pointing the host resolver at the DNS proxy) and requires the DNS proxy +// to already be running. When the host platform already manages DNS +// natively (see NativeDNSManaged), this only updates the OS resolver list - +// there's no raw override to add or remove. Used both at initial connect +// (see handleConnect) and for a live "olm/wg/dns/update" toggle of +// OverrideDNS via applyDNSConfigUpdate. +func (o *Olm) applyDNSOverride(enable bool) error { + if o.dnsProxy == nil { + return fmt.Errorf("cannot toggle DNS override: DNS proxy is not running") + } + + if enable { + if !o.tunnelConfig.NativeDNSManaged { + if err := dnsOverride.SetupDNSOverride(o.tunnelConfig.InterfaceName, o.dnsProxy.GetProxyIP()); err != nil { + return fmt.Errorf("failed to setup DNS override: %w", err) + } + + // Start the external watchdog (if configured). The watchdog will + // reset DNS if this process dies before it can call + // RestoreDNSOverride. This is a no-op when no watchdog + // subcommand has been configured on the OlmConfig. + o.startDNSWatchdog(o.tunnelConfig.InterfaceName) + } + + network.SetDNSServers([]string{o.dnsProxy.GetProxyIP().String()}) + } else { + if !o.tunnelConfig.NativeDNSManaged { + if err := dnsOverride.RestoreDNSOverride(); err != nil { + return fmt.Errorf("failed to restore DNS: %w", err) + } + + o.stopDNSWatchdog() + } + } + + return nil +} + +// handleDNSConfigUpdate handles a server-initiated request to change the +// client's DNS configuration (upstream DNS, tunnel DNS, override DNS, match +// domains) after it is already connected, without requiring a full +// reconnect. Mirrors the DNSConfig field sent on the initial +// "olm/wg/connect" message - see DNSConfigUpdate. +func (o *Olm) handleDNSConfigUpdate(msg websocket.WSMessage) { + logger.Debug("Received DNS config update message: %v", msg.Data) + + if !o.tunnelRunning { + logger.Debug("Tunnel stopped, ignoring DNS config update message") + return + } + + jsonData, err := json.Marshal(msg.Data) + if err != nil { + logger.Error("Error marshaling DNS config update data: %v", err) + return + } + + var update DNSConfigUpdate + if err := json.Unmarshal(jsonData, &update); err != nil { + logger.Error("Error unmarshaling DNS config update data: %v", err) + return + } + + o.applyDNSConfigUpdate(update) +} diff --git a/olm/olm.go b/olm/olm.go index 8391076..04a4f18 100644 --- a/olm/olm.go +++ b/olm/olm.go @@ -27,6 +27,7 @@ import ( "github.com/fosrl/olm/dns" dnsOverride "github.com/fosrl/olm/dns/override" "github.com/fosrl/olm/peers" + "github.com/fosrl/olm/subnetrouter" "github.com/fosrl/olm/websocket" "golang.zx2c4.com/wireguard/device" "golang.zx2c4.com/wireguard/tun" @@ -254,6 +255,7 @@ func (o *Olm) registerAPICallbacks() { TlsClientCert: req.TlsClientCert, OrgID: req.OrgID, MatchDomains: req.MatchDomains, + SubnetRouter: req.SubnetRouter, } var err error @@ -577,6 +579,11 @@ func (o *Olm) StartTunnel(config TunnelConfig) { o.websocket.RegisterHandler("olm/wg/exitnode/disconnect", o.handleExitNodeDisconnect) o.websocket.RegisterHandler("olm/wg/exitnode/data/update", o.handleExitNodeUpdateData) + // Handler for the server to push a live DNS config override (upstream DNS, + // tunnel DNS, override DNS, match domains) after registration, mirroring the + // DNSConfig field sent on the initial "olm/wg/connect" message. + o.websocket.RegisterHandler("olm/wg/dns/update", o.handleDNSConfigUpdate) + o.websocket.RegisterHandler("olm/ping/exitNodes", func(msg websocket.WSMessage) { logger.Debug("Received exit node ping request") @@ -831,6 +838,12 @@ func (o *Olm) Close() { o.stopDNSWatchdog() } + if o.tunnelConfig.SubnetRouter { + if err := subnetrouter.Disable(o.tunnelConfig.InterfaceName); err != nil { + logger.Error("Failed to disable subnet router: %v", err) + } + } + if o.holePunchManager != nil { o.holePunchManager.Stop() o.holePunchManager = nil diff --git a/olm/types.go b/olm/types.go index afde6cb..5d4d39c 100644 --- a/olm/types.go +++ b/olm/types.go @@ -11,6 +11,22 @@ type WgData struct { TunnelIP string `json:"tunnelIP"` UtilitySubnet string `json:"utilitySubnet"` // this is for things like the DNS server, and alias addresses ExitNode *ExitNodeConfig `json:"exitNode,omitempty"` + DNSConfig *DNSConfigUpdate `json:"dnsConfig,omitempty"` +} + +// DNSConfigUpdate describes a server-driven override of the olm client's DNS +// configuration - the same settings that can otherwise only be set locally +// (see TunnelConfig's UpstreamDNS/OverrideDNS/TunnelDNS/MatchDomains). It +// arrives on the initial "olm/wg/connect" message and can also be sent later +// via "olm/wg/dns/update" to change the running config without reconnecting. +// Every field is optional/nil-able: an omitted field leaves the client's +// current value (local config, or whatever a previous update set) unchanged, +// while a present field always overrides it. +type DNSConfigUpdate struct { + UpstreamDNS []string `json:"upstreamDns,omitempty"` + OverrideDNS *bool `json:"overrideDns,omitempty"` + TunnelDNS *bool `json:"tunnelDns,omitempty"` + MatchDomains []string `json:"matchDomains,omitempty"` } // ExitNodeConfig describes an exit node the olm client can connect to for @@ -158,4 +174,11 @@ type TunnelConfig struct { // false, preserving the routing behavior from before this option was // introduced. PreferLocalRoutes bool + + // SubnetRouter, when enabled, lets this client forward traffic from its + // local network out over the tunnel: forwarded packets are NATed to the + // client's own tunnel IP before being encrypted, since the server side + // authorizes traffic by the client's tunnel identity, not by whatever + // LAN address it originally arrived with. Linux only. Defaults to false. + SubnetRouter bool } diff --git a/subnetrouter/subnetrouter_linux.go b/subnetrouter/subnetrouter_linux.go new file mode 100644 index 0000000..917dde8 --- /dev/null +++ b/subnetrouter/subnetrouter_linux.go @@ -0,0 +1,222 @@ +//go:build linux + +// Package subnetrouter lets this client forward LAN traffic out over its own +// WireGuard tunnel, source-NAT'd to the tunnel's own IP. Pangolin's +// server-side routing/ACLs are keyed on the client's tunnel IP as its +// identity, so traffic merely forwarded from the LAN (which arrives with the +// LAN device's own source address) would not be recognized - it must be +// rewritten to look like it came from this client before it goes out over +// the tunnel, the same way a NAT router masquerades LAN traffic behind its +// WAN IP. +package subnetrouter + +import ( + "fmt" + "net/netip" + "os" + "strings" + "sync" + + "github.com/fosrl/newt/logger" + "github.com/google/nftables" + "github.com/google/nftables/expr" + "golang.org/x/sys/unix" +) + +const ( + tableName = "olm_subnet_router" + ipForwardSys = "/proc/sys/net/ipv4/ip_forward" +) + +// ipForwardMu guards the two package-level fields below, which record +// whether Enable had to flip ip_forward on itself, so Disable only ever +// restores a value it actually changed - mirroring dns/override's +// instance-free save/restore convention. +var ( + ipForwardMu sync.Mutex + weEnabledForward bool +) + +// Enable turns this host into a subnet router: it enables IPv4 forwarding +// (if not already on) and installs an nftables table that SNATs anything +// leaving interfaceName whose source isn't already tunnelIP, and accepts +// forwarding to/from interfaceName so a default-deny FORWARD policy +// elsewhere on the host doesn't drop it. +// +// It is idempotent: any table left behind by a previous run (e.g. after a +// crash) is torn down first, so repeated Enable/Disable cycles across +// reconnects never conflict with stale state. +func Enable(interfaceName string, tunnelIP netip.Addr) error { + if !tunnelIP.Is4() { + return fmt.Errorf("subnet router requires an IPv4 tunnel address, got %v", tunnelIP) + } + + // Best-effort cleanup of anything left over from a previous run. + if err := Disable(interfaceName); err != nil { + logger.Debug("subnetrouter: pre-enable cleanup: %v", err) + } + + if err := enableIPForward(); err != nil { + return fmt.Errorf("failed to enable IPv4 forwarding: %w", err) + } + + conn := &nftables.Conn{} + + table := conn.AddTable(&nftables.Table{ + Family: nftables.TableFamilyIPv4, + Name: tableName, + }) + + postrouting := conn.AddChain(&nftables.Chain{ + Name: "postrouting", + Table: table, + Type: nftables.ChainTypeNAT, + Hooknum: nftables.ChainHookPostrouting, + Priority: nftables.ChainPriorityNATSource, + }) + + addr := tunnelIP.As4() + conn.AddRule(&nftables.Rule{ + Table: table, + Chain: postrouting, + Exprs: []expr.Any{ + // oifname == interfaceName + &expr.Meta{Key: expr.MetaKeyOIFNAME, Register: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: ifname(interfaceName)}, + // ip saddr != tunnelIP + &expr.Payload{ + DestRegister: 1, + Base: expr.PayloadBaseNetworkHeader, + Offset: 12, // IPv4 source address offset + Len: 4, + }, + &expr.Cmp{Op: expr.CmpOpNeq, Register: 1, Data: addr[:]}, + // snat to tunnelIP + &expr.Immediate{Register: 1, Data: addr[:]}, + &expr.NAT{ + Type: expr.NATTypeSourceNAT, + Family: unix.NFPROTO_IPV4, + RegAddrMin: 1, + RegAddrMax: 1, + }, + }, + }) + + forward := conn.AddChain(&nftables.Chain{ + Name: "forward", + Table: table, + Type: nftables.ChainTypeFilter, + Hooknum: nftables.ChainHookForward, + Priority: nftables.ChainPriorityFilter, + }) + + for _, key := range []expr.MetaKey{expr.MetaKeyIIFNAME, expr.MetaKeyOIFNAME} { + conn.AddRule(&nftables.Rule{ + Table: table, + Chain: forward, + Exprs: []expr.Any{ + &expr.Meta{Key: key, Register: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: ifname(interfaceName)}, + &expr.Verdict{Kind: expr.VerdictAccept}, + }, + }) + } + + if err := conn.Flush(); err != nil { + // Roll back the forwarding sysctl change too, so a failed Enable + // doesn't leave the host with forwarding on and no NAT rules. + _ = disableIPForwardIfWeEnabledIt() + return fmt.Errorf("failed to apply nftables rules: %w", err) + } + + logger.Debug("subnetrouter: enabled on %s (snat to %s)", interfaceName, tunnelIP) + return nil +} + +// Disable removes the nftables table added by Enable (a no-op if it doesn't +// exist) and restores ip_forward to whatever it was before Enable, but only +// if Enable is what changed it. +func Disable(interfaceName string) error { + conn := &nftables.Conn{} + + tables, err := conn.ListTables() + if err != nil { + return fmt.Errorf("failed to list nftables tables: %w", err) + } + + var found bool + for _, t := range tables { + if t.Name == tableName && t.Family == nftables.TableFamilyIPv4 { + conn.DelTable(t) + found = true + break + } + } + + var flushErr error + if found { + flushErr = conn.Flush() + } + + forwardErr := disableIPForwardIfWeEnabledIt() + + if flushErr != nil { + return fmt.Errorf("failed to remove nftables table: %w", flushErr) + } + return forwardErr +} + +// enableIPForward turns on IPv4 forwarding if it isn't already on, recording +// whether this call is the one that changed it. +func enableIPForward() error { + ipForwardMu.Lock() + defer ipForwardMu.Unlock() + + current, err := readIPForward() + if err != nil { + return err + } + if current { + weEnabledForward = false + return nil + } + + if err := os.WriteFile(ipForwardSys, []byte("1\n"), 0644); err != nil { + return err + } + weEnabledForward = true + return nil +} + +// disableIPForwardIfWeEnabledIt restores ip_forward to 0, but only if a +// prior enableIPForward call is what turned it on. +func disableIPForwardIfWeEnabledIt() error { + ipForwardMu.Lock() + defer ipForwardMu.Unlock() + + if !weEnabledForward { + return nil + } + + if err := os.WriteFile(ipForwardSys, []byte("0\n"), 0644); err != nil { + return err + } + weEnabledForward = false + return nil +} + +func readIPForward() (bool, error) { + data, err := os.ReadFile(ipForwardSys) + if err != nil { + return false, err + } + return strings.TrimSpace(string(data)) == "1", nil +} + +// ifname encodes an interface name the way nftables expects it: NUL-padded +// to IFNAMSIZ (16) bytes. +func ifname(name string) []byte { + b := make([]byte, 16) + copy(b, name) + return b +} diff --git a/subnetrouter/subnetrouter_other.go b/subnetrouter/subnetrouter_other.go new file mode 100644 index 0000000..eafa7c2 --- /dev/null +++ b/subnetrouter/subnetrouter_other.go @@ -0,0 +1,21 @@ +//go:build !linux + +package subnetrouter + +import ( + "fmt" + "net/netip" +) + +// Enable always fails on non-Linux platforms: there is no nftables/netfilter +// to install SNAT rules into. Callers should log this as a warning, not +// treat it as fatal. +func Enable(interfaceName string, tunnelIP netip.Addr) error { + return fmt.Errorf("subnet router is only supported on Linux") +} + +// Disable is a no-op on non-Linux platforms, since Enable never succeeds +// there and so never leaves anything to clean up. +func Disable(interfaceName string) error { + return nil +}