Tie DisableRoutesAndAliases to exit nodes being in or not

This commit is contained in:
Owen
2026-09-28 15:08:23 -04:00
parent 8d58df58e6
commit 29d1d91ebb
8 changed files with 448 additions and 170 deletions
+19 -19
View File
@@ -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
+41 -41
View File
@@ -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"))
+20 -20
View File
@@ -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 {
+34 -27
View File
@@ -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 {
+16 -16
View File
@@ -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
+14 -6
View File
@@ -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
+106
View File
@@ -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")
}
}
+198 -41
View File
@@ -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 {