diff --git a/api/api.go b/api/api.go index 8de6729..ca701bf 100644 --- a/api/api.go +++ b/api/api.go @@ -19,25 +19,25 @@ import ( // non-empty, and is how olm later matches server-pushed gateway updates to the // resource the user actually connected through. type ConnectionRequest struct { - ID string `json:"id"` - Secret string `json:"secret"` - Endpoint string `json:"endpoint"` - UserToken string `json:"userToken,omitempty"` - MTU int `json:"mtu,omitempty"` - DNS string `json:"dns,omitempty"` - DNSProxyIP string `json:"dnsProxyIP,omitempty"` - UpstreamDNS []string `json:"upstreamDNS,omitempty"` - InterfaceName string `json:"interfaceName,omitempty"` - Holepunch bool `json:"holepunch,omitempty"` - TlsClientCert string `json:"tlsClientCert,omitempty"` - PingInterval string `json:"pingInterval,omitempty"` - PingTimeout string `json:"pingTimeout,omitempty"` - OrgID string `json:"orgId,omitempty"` - MatchDomains []string `json:"matchDomains,omitempty"` - SubnetRouter bool `json:"subnetRouter,omitempty"` - DisableRoutesAndAliases bool `json:"disableRoutesAndAliases,omitempty"` - GatewaySiteResourceId int `json:"gatewaySiteResourceId,omitempty"` - GatewaySiteIds []int `json:"gatewaySiteIds,omitempty"` + ID string `json:"id"` + Secret string `json:"secret"` + Endpoint string `json:"endpoint"` + UserToken string `json:"userToken,omitempty"` + MTU int `json:"mtu,omitempty"` + DNS string `json:"dns,omitempty"` + DNSProxyIP string `json:"dnsProxyIP,omitempty"` + UpstreamDNS []string `json:"upstreamDNS,omitempty"` + InterfaceName string `json:"interfaceName,omitempty"` + Holepunch bool `json:"holepunch,omitempty"` + TlsClientCert string `json:"tlsClientCert,omitempty"` + PingInterval string `json:"pingInterval,omitempty"` + PingTimeout string `json:"pingTimeout,omitempty"` + OrgID string `json:"orgId,omitempty"` + MatchDomains []string `json:"matchDomains,omitempty"` + SubnetRouter bool `json:"subnetRouter,omitempty"` + DisableRoutesAndAliasesOnExitNode bool `json:"disableRoutesAndAliasesOnExitNode,omitempty"` + GatewaySiteResourceId int `json:"gatewaySiteResourceId,omitempty"` + GatewaySiteIds []int `json:"gatewaySiteIds,omitempty"` } // SwitchOrgRequest defines the structure for switching organizations diff --git a/config.go b/config.go index b07419f..d7ab1eb 100644 --- a/config.go +++ b/config.go @@ -47,14 +47,14 @@ type OlmConfig struct { PingTimeout string `json:"pingTimeout"` // Advanced - DisableHolepunch bool `json:"disableHolepunch"` - TlsClientCert string `json:"tlsClientCert"` - OverrideDNS bool `json:"overrideDNS"` - TunnelDNS bool `json:"tunnelDNS"` - DisableRelay bool `json:"disableRelay"` - PreferLocalRoutes bool `json:"preferLocalRoutes"` - SubnetRouter bool `json:"subnetRouter"` - DisableRoutesAndAliases bool `json:"disableRoutesAndAliases"` + DisableHolepunch bool `json:"disableHolepunch"` + TlsClientCert string `json:"tlsClientCert"` + OverrideDNS bool `json:"overrideDNS"` + TunnelDNS bool `json:"tunnelDNS"` + DisableRelay bool `json:"disableRelay"` + PreferLocalRoutes bool `json:"preferLocalRoutes"` + SubnetRouter bool `json:"subnetRouter"` + DisableRoutesAndAliasesOnExitNode bool `json:"disableRoutesAndAliasesOnExitNode"` // DoNotCreateNewClient bool `json:"doNotCreateNewClient"` // Parsed values (not in JSON) @@ -123,7 +123,7 @@ func DefaultConfig() *OlmConfig { config.sources["disableRelay"] = string(SourceDefault) config.sources["preferLocalRoutes"] = string(SourceDefault) config.sources["subnetRouter"] = string(SourceDefault) - config.sources["disableRoutesAndAliases"] = string(SourceDefault) + config.sources["disableRoutesAndAliasesOnExitNode"] = string(SourceDefault) // config.sources["doNotCreateNewClient"] = string(SourceDefault) return config @@ -300,8 +300,8 @@ func loadConfigFromEnv(config *OlmConfig) { config.sources["subnetRouter"] = string(SourceEnv) } if val := os.Getenv("DISABLE_ROUTES_AND_ALIASES"); val == "true" { - config.DisableRoutesAndAliases = true - config.sources["disableRoutesAndAliases"] = string(SourceEnv) + config.DisableRoutesAndAliasesOnExitNode = true + config.sources["disableRoutesAndAliasesOnExitNode"] = string(SourceEnv) } // if val := os.Getenv("DO_NOT_CREATE_NEW_CLIENT"); val == "true" { // config.DoNotCreateNewClient = true @@ -315,29 +315,29 @@ func loadConfigFromCLI(config *OlmConfig, args []string) (bool, bool, error) { // Store original values to detect changes origValues := map[string]interface{}{ - "endpoint": config.Endpoint, - "id": config.ID, - "secret": config.Secret, - "org": config.OrgID, - "userToken": config.UserToken, - "mtu": config.MTU, - "dns": config.DNS, - "upstreamDNS": fmt.Sprintf("%v", config.UpstreamDNS), - "matchDomains": fmt.Sprintf("%v", config.MatchDomains), - "logLevel": config.LogLevel, - "interface": config.InterfaceName, - "httpAddr": config.HTTPAddr, - "socketPath": config.SocketPath, - "pingInterval": config.PingInterval, - "pingTimeout": config.PingTimeout, - "enableApi": config.EnableAPI, - "disableHolepunch": config.DisableHolepunch, - "overrideDNS": config.OverrideDNS, - "disableRelay": config.DisableRelay, - "preferLocalRoutes": config.PreferLocalRoutes, - "tunnelDNS": config.TunnelDNS, - "subnetRouter": config.SubnetRouter, - "disableRoutesAndAliases": config.DisableRoutesAndAliases, + "endpoint": config.Endpoint, + "id": config.ID, + "secret": config.Secret, + "org": config.OrgID, + "userToken": config.UserToken, + "mtu": config.MTU, + "dns": config.DNS, + "upstreamDNS": fmt.Sprintf("%v", config.UpstreamDNS), + "matchDomains": fmt.Sprintf("%v", config.MatchDomains), + "logLevel": config.LogLevel, + "interface": config.InterfaceName, + "httpAddr": config.HTTPAddr, + "socketPath": config.SocketPath, + "pingInterval": config.PingInterval, + "pingTimeout": config.PingTimeout, + "enableApi": config.EnableAPI, + "disableHolepunch": config.DisableHolepunch, + "overrideDNS": config.OverrideDNS, + "disableRelay": config.DisableRelay, + "preferLocalRoutes": config.PreferLocalRoutes, + "tunnelDNS": config.TunnelDNS, + "subnetRouter": config.SubnetRouter, + "disableRoutesAndAliasesOnExitNode": config.DisableRoutesAndAliasesOnExitNode, // "doNotCreateNewClient": config.DoNotCreateNewClient, } @@ -366,7 +366,7 @@ func loadConfigFromCLI(config *OlmConfig, args []string) (bool, bool, error) { 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.DisableRoutesAndAliases, "disable-routes-and-aliases", config.DisableRoutesAndAliases, "Do not add routes to the system routing table and do not run the DNS proxy (aliases are not resolved). Gateway routes are still added. (default false)") + serviceFlags.BoolVar(&config.DisableRoutesAndAliasesOnExitNode, "disable-routes-and-aliases", config.DisableRoutesAndAliasesOnExitNode, "Make the exit node take precedence over individual resources: while an exit node is connected, remove routes/aliases for site resources, restoring them once it disconnects. Gateway routes are still added. (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") @@ -459,8 +459,8 @@ func loadConfigFromCLI(config *OlmConfig, args []string) (bool, bool, error) { if config.SubnetRouter != origValues["subnetRouter"].(bool) { config.sources["subnetRouter"] = string(SourceCLI) } - if config.DisableRoutesAndAliases != origValues["disableRoutesAndAliases"].(bool) { - config.sources["disableRoutesAndAliases"] = string(SourceCLI) + if config.DisableRoutesAndAliasesOnExitNode != origValues["disableRoutesAndAliasesOnExitNode"].(bool) { + config.sources["disableRoutesAndAliasesOnExitNode"] = string(SourceCLI) } // if config.DoNotCreateNewClient != origValues["doNotCreateNewClient"].(bool) { // config.sources["doNotCreateNewClient"] = string(SourceCLI) @@ -598,9 +598,9 @@ func mergeConfigs(dest, src *OlmConfig) { dest.SubnetRouter = src.SubnetRouter dest.sources["subnetRouter"] = string(SourceFile) } - if src.DisableRoutesAndAliases { - dest.DisableRoutesAndAliases = src.DisableRoutesAndAliases - dest.sources["disableRoutesAndAliases"] = string(SourceFile) + if src.DisableRoutesAndAliasesOnExitNode { + dest.DisableRoutesAndAliasesOnExitNode = src.DisableRoutesAndAliasesOnExitNode + dest.sources["disableRoutesAndAliasesOnExitNode"] = string(SourceFile) } // if src.DoNotCreateNewClient { // dest.DoNotCreateNewClient = src.DoNotCreateNewClient @@ -696,7 +696,7 @@ func (c *OlmConfig) ShowConfig() { 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(" disable-routes-and-aliases = %v [%s]\n", c.DisableRoutesAndAliases, getSource("disableRoutesAndAliases")) + fmt.Printf(" disable-routes-and-aliases = %v [%s]\n", c.DisableRoutesAndAliasesOnExitNode, getSource("disableRoutesAndAliasesOnExitNode")) // 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/main.go b/main.go index 04f147e..9bb9e44 100644 --- a/main.go +++ b/main.go @@ -256,26 +256,26 @@ func runOlmMainWithArgs(ctx context.Context, cancel context.CancelFunc, signalCt if config.ID != "" && config.Secret != "" && config.Endpoint != "" { tunnelConfig := olmpkg.TunnelConfig{ - Endpoint: config.Endpoint, - ID: config.ID, - Secret: config.Secret, - UserToken: config.UserToken, - MTU: config.MTU, - DNS: config.DNS, - UpstreamDNS: config.UpstreamDNS, - MatchDomains: config.MatchDomains, - InterfaceName: config.InterfaceName, - Holepunch: !config.DisableHolepunch, - TlsClientCert: config.TlsClientCert, - PingIntervalDuration: config.PingIntervalDuration, - PingTimeoutDuration: config.PingTimeoutDuration, - OrgID: config.OrgID, - OverrideDNS: config.OverrideDNS, - DisableRelay: config.DisableRelay, - PreferLocalRoutes: config.PreferLocalRoutes, - SubnetRouter: config.SubnetRouter, - DisableRoutesAndAliases: config.DisableRoutesAndAliases, - EnableUAPI: true, + Endpoint: config.Endpoint, + ID: config.ID, + Secret: config.Secret, + UserToken: config.UserToken, + MTU: config.MTU, + DNS: config.DNS, + UpstreamDNS: config.UpstreamDNS, + MatchDomains: config.MatchDomains, + InterfaceName: config.InterfaceName, + Holepunch: !config.DisableHolepunch, + TlsClientCert: config.TlsClientCert, + PingIntervalDuration: config.PingIntervalDuration, + PingTimeoutDuration: config.PingTimeoutDuration, + OrgID: config.OrgID, + OverrideDNS: config.OverrideDNS, + DisableRelay: config.DisableRelay, + PreferLocalRoutes: config.PreferLocalRoutes, + SubnetRouter: config.SubnetRouter, + DisableRoutesAndAliasesOnExitNode: config.DisableRoutesAndAliasesOnExitNode, + EnableUAPI: true, } go olm.StartTunnel(tunnelConfig) } else { diff --git a/olm/connect.go b/olm/connect.go index 879d79e..6e4c3f4 100644 --- a/olm/connect.go +++ b/olm/connect.go @@ -181,17 +181,15 @@ func (o *Olm) handleConnect(msg websocket.WSMessage) { logger.Warn("Failed to parse tunnel IP %q: %v", interfaceIP, err) } - // Create the DNS proxy, unless routes/aliases are disabled - the proxy is - // what resolves aliases, and the utility subnet it lives on is only - // reachable through a system route we wouldn't add. o.dnsProxy stays nil - // in that case; everything that uses it is nil-checked. - if o.tunnelConfig.DisableRoutesAndAliases { - logger.Info("Routes and aliases disabled: not adding system routes and not starting the DNS proxy (gateway routes are unaffected)") - } else { - o.dnsProxy, err = dns.NewDNSProxy(o.middleDev, o.tunnelConfig.MTU, wgData.UtilitySubnet, o.tunnelConfig.UpstreamDNS, o.tunnelConfig.TunnelDNS, interfaceIP, o.tunnelConfig.MatchDomains, o.tunnelConfig.PublicDNS) - if err != nil { - logger.Error("Failed to create DNS proxy: %v", err) - } + // The DNS proxy is always created - it resolves both exit node aliases + // (unaffected by DisableRoutesAndAliasesOnExitNode - see connectExitNode) + // and site resource aliases, which the peer manager created below adds + // and removes dynamically as an exit node connects/disconnects (see + // PeerManager.SetExitNode/ClearExitNode). o.dnsProxy is still nil-checked + // everywhere it's used, in case creation itself fails. + o.dnsProxy, err = dns.NewDNSProxy(o.middleDev, o.tunnelConfig.MTU, wgData.UtilitySubnet, o.tunnelConfig.UpstreamDNS, o.tunnelConfig.TunnelDNS, interfaceIP, o.tunnelConfig.MatchDomains, o.tunnelConfig.PublicDNS) + if err != nil { + logger.Error("Failed to create DNS proxy: %v", err) } // Tell the system DNS monitor to exclude the proxy IP so that subsequent @@ -214,25 +212,34 @@ func (o *Olm) handleConnect(msg websocket.WSMessage) { } } - if !o.tunnelConfig.DisableRoutesAndAliases { - 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) - } + // The utility subnet route is what makes the DNS proxy (always created + // above) reachable at all, so it's always added too, independent of + // DisableRoutesAndAliasesOnExitNode - which only gates routes/aliases for + // individual site resources, applied dynamically below as the peer + // manager is told about the exit node's connection state. + 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) } - // Create peer manager with integrated peer monitoring + // Create peer manager with integrated peer monitoring. If + // DisableRoutesAndAliasesOnExitNode is enabled, resource routes/aliases + // are suppressed reactively once an exit node (ExitNodeConfig peer or + // gateway mode) actually activates below/later - see + // PeerManager.SetExitNode/SetGateway - rather than being pre-seeded here, + // so a failed initial exit-node/gateway setup can never leave routes + // stuck suppressed with nothing active to justify it. o.peerManager = peers.NewPeerManager(peers.PeerManagerConfig{ - Device: o.dev, - DNSProxy: o.dnsProxy, - InterfaceName: o.tunnelConfig.InterfaceName, - PrivateKey: o.privateKey, - MiddleDev: o.middleDev, - LocalIP: interfaceIP, - SharedBind: o.sharedBind, - WSClient: o.websocket, - APIServer: o.apiServer, - PublicDNS: o.tunnelConfig.PublicDNS, - DisableRoutes: o.tunnelConfig.DisableRoutesAndAliases, + Device: o.dev, + DNSProxy: o.dnsProxy, + InterfaceName: o.tunnelConfig.InterfaceName, + PrivateKey: o.privateKey, + MiddleDev: o.middleDev, + LocalIP: interfaceIP, + SharedBind: o.sharedBind, + WSClient: o.websocket, + APIServer: o.apiServer, + PublicDNS: o.tunnelConfig.PublicDNS, + DisableRoutesAndAliasesOnExitNode: o.tunnelConfig.DisableRoutesAndAliasesOnExitNode, }) for i := range wgData.Sites { diff --git a/olm/olm.go b/olm/olm.go index 78f69de..7e7be9a 100644 --- a/olm/olm.go +++ b/olm/olm.go @@ -301,22 +301,22 @@ func (o *Olm) registerAPICallbacks() { logger.Info("Received connection request via HTTP: id=%s, endpoint=%s", req.ID, req.Endpoint) tunnelConfig := TunnelConfig{ - Endpoint: req.Endpoint, - ID: req.ID, - Secret: req.Secret, - UserToken: req.UserToken, - MTU: req.MTU, - DNS: req.DNS, - UpstreamDNS: req.UpstreamDNS, - InterfaceName: req.InterfaceName, - Holepunch: req.Holepunch, - TlsClientCert: req.TlsClientCert, - OrgID: req.OrgID, - MatchDomains: req.MatchDomains, - SubnetRouter: req.SubnetRouter, - DisableRoutesAndAliases: req.DisableRoutesAndAliases, - GatewaySiteIds: req.GatewaySiteIds, - GatewaySiteResourceId: req.GatewaySiteResourceId, + Endpoint: req.Endpoint, + ID: req.ID, + Secret: req.Secret, + UserToken: req.UserToken, + MTU: req.MTU, + DNS: req.DNS, + UpstreamDNS: req.UpstreamDNS, + InterfaceName: req.InterfaceName, + Holepunch: req.Holepunch, + TlsClientCert: req.TlsClientCert, + OrgID: req.OrgID, + MatchDomains: req.MatchDomains, + SubnetRouter: req.SubnetRouter, + DisableRoutesAndAliasesOnExitNode: req.DisableRoutesAndAliasesOnExitNode, + GatewaySiteIds: req.GatewaySiteIds, + GatewaySiteResourceId: req.GatewaySiteResourceId, } var err error diff --git a/olm/types.go b/olm/types.go index de36b45..5e304b0 100644 --- a/olm/types.go +++ b/olm/types.go @@ -182,15 +182,23 @@ type TunnelConfig struct { // LAN address it originally arrived with. Linux only. Defaults to false. SubnetRouter bool - // DisableRoutesAndAliases, when enabled, stops olm from adding routes to - // the host's routing table (server IPs, remote subnets, the utility - // subnet) and from launching the DNS proxy, so aliases are not resolved - // either. WireGuard AllowedIPs are still configured, so the tunnel can + // DisableRoutesAndAliasesOnExitNode, when enabled, makes the exit node + // take precedence over individual resources: for as long as an exit node + // is connected, olm removes routes to the host's routing table for site + // resources (server IPs, remote subnets) and their alias DNS records, and + // restores them the moment the exit node disconnects. If the tunnel + // starts with an exit node already selected, these routes/aliases are + // never added in the first place. The exit node's own routes and aliases + // are unaffected, and it can connect/disconnect at any time - via the + // initial connect, a server push (olm/wg/exitnode/connect|disconnect), or + // the local API - all of which converge through the same connect/ + // disconnect path (see PeerManager.SetExitNode/ClearExitNode). + // WireGuard AllowedIPs are still configured throughout, so the tunnel can // still be used by anything that reaches it without the OS routing table // (e.g. a file descriptor/netstack consumer). Gateway routes (the // default-route-equivalent and its endpoint bypass routes) are unaffected - // and are still installed. Defaults to false. - DisableRoutesAndAliases bool + // and are always installed. Defaults to false. + DisableRoutesAndAliasesOnExitNode bool // GatewaySiteIds, when non-empty, designates these site IDs as gateway // (full-tunnel/default-route) candidates from the moment the tunnel diff --git a/peers/exitnode_routes_test.go b/peers/exitnode_routes_test.go new file mode 100644 index 0000000..75c174f --- /dev/null +++ b/peers/exitnode_routes_test.go @@ -0,0 +1,106 @@ +package peers + +import "testing" + +// newExitNodeTestManager returns a PeerManager for exercising the +// DisableRoutesAndAliasesOnExitNode state machine (resourceRoutesSuppressed, +// exitNodeActive, gatewayActive) without touching the OS routing table, a +// WireGuard device, or a DNS proxy - safe as long as pm.peers stays empty, +// same constraint as newGatewayTestManager in gateway_test.go. Unlike +// NewPeerManager, disableRoutesAndAliasesOnExitNode is the only state seeded +// here; exitNodeActive/gatewayActive/resourceRoutesSuppressed always start +// false, matching NewPeerManager's real (purely reactive, no pre-seeding) +// behavior. +func newExitNodeTestManager(disableRoutesAndAliasesOnExitNode bool) *PeerManager { + return &PeerManager{ + peers: make(map[int]SiteConfig), + allowedIPOwners: make(map[string]int), + allowedIPClaims: make(map[string]map[int]bool), + disableRoutesAndAliasesOnExitNode: disableRoutesAndAliasesOnExitNode, + } +} + +func TestSetClearExitNodeSuppressesResourceRoutesWhenEnabled(t *testing.T) { + pm := newExitNodeTestManager(true) + + if pm.resourceRoutesSuppressed { + t.Fatalf("must start unsuppressed") + } + + pm.SetExitNode("100.64.0.1", "100.64.0.2") + if !pm.exitNodeActive { + t.Fatalf("SetExitNode must mark the exit node active") + } + if !pm.resourceRoutesSuppressed { + t.Fatalf("SetExitNode must suppress resource routes when the feature is enabled") + } + + pm.ClearExitNode() + if pm.exitNodeActive { + t.Fatalf("ClearExitNode must mark the exit node inactive") + } + if pm.resourceRoutesSuppressed { + t.Fatalf("ClearExitNode must restore resource routes") + } +} + +func TestSetClearExitNodeNoopWhenDisabled(t *testing.T) { + pm := newExitNodeTestManager(false) + + pm.SetExitNode("100.64.0.1", "100.64.0.2") + if pm.resourceRoutesSuppressed { + t.Fatalf("resource routes must never be suppressed when the feature is disabled") + } + + pm.ClearExitNode() + if pm.resourceRoutesSuppressed { + t.Fatalf("resource routes must stay unsuppressed when the feature is disabled") + } +} + +// TestClearExitNodeKeepsSuppressedWhileGatewayActive covers the two +// independent "exit node" signals (see exitNodeOrGatewayActiveLocked): +// disconnecting the ExitNodeConfig WireGuard peer must not restore resource +// routes if gateway/full-tunnel mode - what client apps and the CLI actually +// call "select exit node" - is still active. gatewayActive is set directly +// here (bypassing SetGateway, which touches the OS routing table) purely to +// exercise ClearExitNode's OR-check. +func TestClearExitNodeKeepsSuppressedWhileGatewayActive(t *testing.T) { + pm := newExitNodeTestManager(true) + + pm.SetExitNode("100.64.0.1", "100.64.0.2") + if !pm.resourceRoutesSuppressed { + t.Fatalf("SetExitNode must suppress resource routes") + } + + pm.mu.Lock() + pm.gatewayActive = true + pm.mu.Unlock() + + pm.ClearExitNode() + if pm.exitNodeActive { + t.Fatalf("ClearExitNode must mark the exit node inactive regardless of gateway state") + } + if !pm.resourceRoutesSuppressed { + t.Fatalf("resource routes must stay suppressed while gateway mode is still active") + } +} + +func TestExitNodeOrGatewayActiveLocked(t *testing.T) { + pm := newExitNodeTestManager(true) + + if pm.exitNodeOrGatewayActiveLocked() { + t.Fatalf("neither signal is active yet") + } + + pm.exitNodeActive = true + if !pm.exitNodeOrGatewayActiveLocked() { + t.Fatalf("exit node signal alone must count as active") + } + pm.exitNodeActive = false + + pm.gatewayActive = true + if !pm.exitNodeOrGatewayActiveLocked() { + t.Fatalf("gateway signal alone must count as active") + } +} diff --git a/peers/manager.go b/peers/manager.go index a8d0271..c31dac3 100644 --- a/peers/manager.go +++ b/peers/manager.go @@ -36,10 +36,19 @@ type PeerManagerConfig struct { WSClient *websocket.Client APIServer *api.API PublicDNS []string - // DisableRoutes stops the manager from adding/removing routes in the - // system routing table for server IPs and remote subnets. Gateway routes - // are unaffected. - DisableRoutes bool + // DisableRoutesAndAliasesOnExitNode, when true, suppresses routes and + // alias DNS records for individual resources (server IPs, remote + // subnets, alias records) for as long as an "exit node" is active, and + // restores them as soon as it's no longer active. Two distinct + // mechanisms both count as "exit node" here and are ORed together (see + // exitNodeOrGatewayActiveLocked): the ExitNodeConfig WireGuard peer used + // for resources hosted directly on an exit node server (SetExitNode/ + // ClearExitNode), and gateway/full-tunnel mode (SetGateway/ + // clearGatewayLocked) - which is what client apps and the CLI actually + // mean by "select exit node" (a gateway-mode site resource). The + // gateway's own default-route-equivalent and bypass routes are + // unaffected either way. + DisableRoutesAndAliasesOnExitNode bool } type PeerManager struct { @@ -62,7 +71,24 @@ type PeerManager struct { allowedIPClaims map[string]map[int]bool APIServer *api.API publicDNS []string - disableRoutes bool + // disableRoutesAndAliasesOnExitNode is static config (see + // PeerManagerConfig) for whether resource routes/aliases should ever be + // suppressed while an "exit node" (see the doc comment on + // PeerManagerConfig.DisableRoutesAndAliasesOnExitNode) is active. + disableRoutesAndAliasesOnExitNode bool + // exitNodeActive tracks whether the ExitNodeConfig WireGuard peer is + // currently connected - see SetExitNode/ClearExitNode. gatewayActive + // (below, pre-existing) is the other "exit node" signal. + exitNodeActive bool + // resourceRoutesSuppressed is the live, computed state: true exactly + // when disableRoutesAndAliasesOnExitNode && (exitNodeActive || + // gatewayActive) - see exitNodeOrGatewayActiveLocked. It gates + // addRoutes/removeRoutes/addServerRoute/removeServerRoute/addDNSRecord/ + // removeDNSRecord/removeDNSRecordForSite below. Kept as its own field + // (rather than recomputed each time) so suppressResourceRoutesLocked/ + // restoreResourceRoutesLocked can tell whether a transition actually + // occurred. + resourceRoutesSuppressed bool PersistentKeepalive int @@ -153,21 +179,21 @@ func normalizeServerRouteDestination(serverIP string) string { // NewPeerManager creates a new PeerManager with an internal PeerMonitor func NewPeerManager(config PeerManagerConfig) *PeerManager { pm := &PeerManager{ - device: config.Device, - peers: make(map[int]SiteConfig), - dnsProxy: config.DNSProxy, - interfaceName: config.InterfaceName, - localIP: config.LocalIP, - privateKey: config.PrivateKey, - allowedIPOwners: make(map[string]int), - allowedIPClaims: make(map[string]map[int]bool), - APIServer: config.APIServer, - publicDNS: config.PublicDNS, - disableRoutes: config.DisableRoutes, - lastOwnerChange: make(map[string]time.Time), - gatewaySiteIds: make(map[int]bool), - gatewayExcludedIPs: make(map[string]int), - gatewayExtraEndpoints: make(map[string]bool), + device: config.Device, + peers: make(map[int]SiteConfig), + dnsProxy: config.DNSProxy, + interfaceName: config.InterfaceName, + localIP: config.LocalIP, + privateKey: config.PrivateKey, + allowedIPOwners: make(map[string]int), + allowedIPClaims: make(map[string]map[int]bool), + APIServer: config.APIServer, + publicDNS: config.PublicDNS, + disableRoutesAndAliasesOnExitNode: config.DisableRoutesAndAliasesOnExitNode, + lastOwnerChange: make(map[string]time.Time), + gatewaySiteIds: make(map[int]bool), + gatewayExcludedIPs: make(map[string]int), + gatewayExtraEndpoints: make(map[string]bool), } // Create the peer monitor @@ -204,22 +230,36 @@ func (pm *PeerManager) GetPeerMonitor() *monitor.PeerMonitor { // SetExitNode starts (or updates) ICMP connectivity monitoring of the given exit node. // tunnelIP is the secondary address assigned to us for this exit node, which the ping // probe must be sourced from since the exit node's WireGuard peer entry only accepts -// traffic from that address. +// traffic from that address. If DisableRoutesAndAliasesOnExitNode was enabled (see +// PeerManagerConfig), this also suppresses resource routes/aliases for every tracked +// site peer - see suppressResourceRoutesLocked. func (pm *PeerManager) SetExitNode(serverIP, tunnelIP string) { - pm.mu.RLock() - defer pm.mu.RUnlock() + pm.mu.Lock() + defer pm.mu.Unlock() if pm.peerMonitor != nil { pm.peerMonitor.SetExitNode(serverIP, tunnelIP) } + pm.exitNodeActive = true + if pm.disableRoutesAndAliasesOnExitNode { + pm.suppressResourceRoutesLocked() + } } -// ClearExitNode stops ICMP connectivity monitoring of the exit node +// ClearExitNode stops ICMP connectivity monitoring of the exit node. If +// DisableRoutesAndAliasesOnExitNode was enabled (see PeerManagerConfig), this +// also restores resource routes/aliases for every tracked site peer - unless +// gateway mode is still active, since that's the other "exit node" signal +// (see exitNodeOrGatewayActiveLocked) - see restoreResourceRoutesLocked. func (pm *PeerManager) ClearExitNode() { - pm.mu.RLock() - defer pm.mu.RUnlock() + pm.mu.Lock() + defer pm.mu.Unlock() if pm.peerMonitor != nil { pm.peerMonitor.ClearExitNode() } + pm.exitNodeActive = false + if pm.disableRoutesAndAliasesOnExitNode && !pm.exitNodeOrGatewayActiveLocked() { + pm.restoreResourceRoutesLocked() + } } // SetPublicDNS replaces the DNS servers used to resolve WireGuard peer @@ -442,6 +482,12 @@ func (pm *PeerManager) SetGateway(siteResourceId int, siteIds []int, controlEndp return err } pm.gatewayActive = true + // Gateway/full-tunnel mode is what client apps and the CLI call + // "exit node" - see the doc comment on + // PeerManagerConfig.DisableRoutesAndAliasesOnExitNode. + if pm.disableRoutesAndAliasesOnExitNode { + pm.suppressResourceRoutesLocked() + } } newSet := make(map[int]bool, len(siteIds)) @@ -581,6 +627,12 @@ func (pm *PeerManager) clearGatewayLocked() { pm.gatewaySiteResourceId = 0 pm.deactivateGatewayLocked() pm.gatewayActive = false + // Mirror SetGateway's activation hook, restoring resource routes/aliases + // unless the ExitNodeConfig WireGuard peer is still active (the other + // "exit node" signal). + if pm.disableRoutesAndAliasesOnExitNode && !pm.exitNodeOrGatewayActiveLocked() { + pm.restoreResourceRoutesLocked() + } logger.Info("Gateway cleared") } @@ -648,55 +700,160 @@ func (pm *PeerManager) GetAllPeers() []SiteConfig { // addRoutes/removeRoutes/addServerRoute/removeServerRoute wrap the system // route helpers for site traffic (server IPs and remote subnets) and are a -// no-op when route management is disabled. They deliberately do NOT cover the -// gateway default-route-equivalent or its bypass routes, which are always -// installed regardless (see activateGatewayLocked). +// no-op while resource routes are suppressed (see resourceRoutesSuppressed). +// They deliberately do NOT cover the gateway default-route-equivalent or its +// bypass routes, which are always installed regardless (see +// activateGatewayLocked). func (pm *PeerManager) addRoutes(subnets []string) error { - if pm.disableRoutes { + if pm.resourceRoutesSuppressed { return nil } return network.AddRoutesWithSource(subnets, pm.interfaceName, pm.localIP) } func (pm *PeerManager) removeRoutes(subnets []string) error { - if pm.disableRoutes { + if pm.resourceRoutesSuppressed { return nil } return network.RemoveRoutes(subnets, pm.interfaceName) } func (pm *PeerManager) addServerRoute(serverIP string) error { - if pm.disableRoutes { + if pm.resourceRoutesSuppressed { return nil } return network.AddRouteForServerIPWithSource(normalizeServerRouteDestination(serverIP), pm.interfaceName, pm.localIP) } func (pm *PeerManager) removeServerRoute(serverIP string) error { - if pm.disableRoutes { + if pm.resourceRoutesSuppressed { return nil } return network.RemoveRouteForServerIPWithSource(normalizeServerRouteDestination(serverIP), pm.interfaceName, pm.localIP) } -// The DNS proxy is not created when aliases are disabled, so every alias -// record operation must tolerate a nil proxy. +// addDNSRecord/removeDNSRecord/removeDNSRecordForSite tolerate a nil proxy +// (defensively - the proxy is now always created, see olm's handleConnect) +// and are a no-op while resource routes/aliases are suppressed, same as the +// route helpers above. func (pm *PeerManager) addDNSRecord(alias string, address net.IP, siteId int) { - if pm.dnsProxy != nil { - pm.dnsProxy.AddDNSRecord(alias, address, siteId) + if pm.dnsProxy == nil || pm.resourceRoutesSuppressed { + return } + pm.dnsProxy.AddDNSRecord(alias, address, siteId) } func (pm *PeerManager) removeDNSRecord(alias string, address net.IP) { - if pm.dnsProxy != nil { - pm.dnsProxy.RemoveDNSRecord(alias, address) + if pm.dnsProxy == nil || pm.resourceRoutesSuppressed { + return } + pm.dnsProxy.RemoveDNSRecord(alias, address) } func (pm *PeerManager) removeDNSRecordForSite(alias string, address net.IP, siteId int) { - if pm.dnsProxy != nil { - pm.dnsProxy.RemoveDNSRecordForSite(alias, address, siteId) + if pm.dnsProxy == nil || pm.resourceRoutesSuppressed { + return } + pm.dnsProxy.RemoveDNSRecordForSite(alias, address, siteId) +} + +// exitNodeOrGatewayActiveLocked reports whether either "exit node" signal is +// currently active - the ExitNodeConfig WireGuard peer (SetExitNode) or +// gateway/full-tunnel mode (SetGateway), which is what client apps and the +// CLI actually mean by "select exit node" - see the doc comment on +// PeerManagerConfig.DisableRoutesAndAliasesOnExitNode. Must be called with +// pm.mu held. +func (pm *PeerManager) exitNodeOrGatewayActiveLocked() bool { + return pm.exitNodeActive || pm.gatewayActive +} + +// suppressResourceRoutesLocked removes routes and alias DNS records for every +// tracked site peer's resources (server IP, remote subnets, aliases) so that +// only the exit node's own routes remain in effect. WireGuard peer +// configuration (AllowedIps) is left untouched - the tunnel remains usable by +// anything that reaches it without relying on the OS routing table. Called +// when either "exit node" signal becomes active while +// DisableRoutesAndAliasesOnExitNode is enabled (see SetExitNode/SetGateway). +// No-op if already suppressed. Must be called with pm.mu held. +// +// Deliberately calls network.* and pm.dnsProxy directly rather than through +// the addRoutes/removeRoutes/addServerRoute/removeServerRoute/addDNSRecord/ +// removeDNSRecord wrappers above: those are gated on resourceRoutesSuppressed, +// which this function is itself in the middle of flipping - going through +// them here would silently no-op the very removal this function exists to do. +func (pm *PeerManager) suppressResourceRoutesLocked() { + if pm.resourceRoutesSuppressed { + return + } + pm.resourceRoutesSuppressed = true + + removedSubnets := make(map[string]bool, len(pm.peers)) + for _, peer := range pm.peers { + if err := network.RemoveRouteForServerIPWithSource(normalizeServerRouteDestination(peer.ServerIP), pm.interfaceName, pm.localIP); err != nil { + logger.Warn("Exit node active: failed to remove route for server IP %s: %v", peer.ServerIP, err) + } + for _, subnet := range peer.RemoteSubnets { + if removedSubnets[subnet] { + continue + } + removedSubnets[subnet] = true + if err := network.RemoveRoutes([]string{subnet}, pm.interfaceName); err != nil { + logger.Warn("Exit node active: failed to remove route for remote subnet %s: %v", subnet, err) + } + } + if pm.dnsProxy != nil { + for _, alias := range peer.Aliases { + address := net.ParseIP(alias.AliasAddress) + if address == nil { + continue + } + pm.dnsProxy.RemoveDNSRecordForSite(alias.Alias, address, peer.SiteId) + } + } + } + + logger.Info("Exit node active: removed resource routes/aliases for %d site(s)", len(pm.peers)) +} + +// restoreResourceRoutesLocked is suppressResourceRoutesLocked's inverse, +// re-adding routes and alias DNS records for every tracked site peer's +// resources. Called when neither "exit node" signal remains active (see +// ClearExitNode/clearGatewayLocked). No-op if not currently suppressed. Must +// be called with pm.mu held. +func (pm *PeerManager) restoreResourceRoutesLocked() { + if !pm.resourceRoutesSuppressed { + return + } + pm.resourceRoutesSuppressed = false + + addedSubnets := make(map[string]bool, len(pm.peers)) + for _, peer := range pm.peers { + if err := network.AddRouteForServerIPWithSource(normalizeServerRouteDestination(peer.ServerIP), pm.interfaceName, pm.localIP); err != nil { + logger.Warn("Exit node inactive: failed to add route for server IP %s: %v", peer.ServerIP, err) + } + for _, subnet := range peer.RemoteSubnets { + if addedSubnets[subnet] { + continue + } + addedSubnets[subnet] = true + if err := network.AddRoutesWithSource([]string{subnet}, pm.interfaceName, pm.localIP); err != nil { + logger.Warn("Exit node inactive: failed to add route for remote subnet %s: %v", subnet, err) + } + } + if pm.dnsProxy != nil { + for _, alias := range peer.Aliases { + address := net.ParseIP(alias.AliasAddress) + if address == nil { + continue + } + if err := pm.dnsProxy.AddDNSRecord(alias.Alias, address, peer.SiteId); err != nil { + logger.Warn("Exit node inactive: failed to add DNS record for alias %s: %v", alias.Alias, err) + } + } + } + } + + logger.Info("Exit node inactive: restored resource routes/aliases for %d site(s)", len(pm.peers)) } func (pm *PeerManager) AddPeer(siteConfig SiteConfig) error {