From b53312939fe65c0d61da0bbb603b25c3e120715c Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 24 Sep 2026 11:18:53 -0400 Subject: [PATCH] Exclude the holepunch endpoints as well --- olm/connect.go | 6 ++++ olm/exitnode.go | 12 +++++++ olm/gateway.go | 24 ++++++++++++++ olm/olm.go | 71 ++++++++++++++++++++++++++++++++++----- peers/manager.go | 86 ++++++++++++++++++++++++++++++++++++++++-------- 5 files changed, 178 insertions(+), 21 deletions(-) diff --git a/olm/connect.go b/olm/connect.go index fda77ce..aa70b35 100644 --- a/olm/connect.go +++ b/olm/connect.go @@ -251,6 +251,12 @@ func (o *Olm) handleConnect(msg websocket.WSMessage) { o.peerManager.Start() + // OnTokenUpdate (see olm.go) typically fires before this peer manager + // existed - it runs during the initial token/auth fetch, well before this + // "olm/wg/connect" message - so push in whatever hole-punch bypass + // endpoints it already recorded now that there's somewhere to put them. + o.flushPendingHolepunchBypassEndpoints() + if err := o.dnsProxy.Start(); err != nil { // start DNS proxy first so there is no downtime logger.Error("Failed to start DNS proxy: %v", err) } diff --git a/olm/exitnode.go b/olm/exitnode.go index 26ae59c..65d2d54 100644 --- a/olm/exitnode.go +++ b/olm/exitnode.go @@ -210,7 +210,14 @@ persistent_keepalive_interval=%d`, util.FixKey(cfg.PublicKey), allowedIP, resolv if pm := o.getPeerManager(); pm != nil { pm.SetExitNode(strings.Split(cfg.ServerIP, "/")[0], strings.Split(cfg.TunnelIP, "/")[0]) + // Distinct from the hole-punch exit nodes registered in olm.go's + // OnTokenUpdate handler: this is the exit node actually connected as a + // WireGuard peer above. Its own traffic must stay off the gateway + // route the same way a site peer's endpoint does, or it would loop + // through the tunnel it's part of maintaining. + pm.AddGatewayBypassEndpoint(resolvedEndpoint) } + o.exitNodeResolvedEndpoint = resolvedEndpoint logger.Info("Connected to exit node at %s", resolvedEndpoint) return nil @@ -232,9 +239,14 @@ func (o *Olm) removeExitNodePeerLocked() error { } cfg := o.exitNode o.exitNode = nil + resolvedEndpoint := o.exitNodeResolvedEndpoint + o.exitNodeResolvedEndpoint = "" if pm := o.getPeerManager(); pm != nil { pm.ClearExitNode() + if resolvedEndpoint != "" { + pm.RemoveGatewayBypassEndpoint(resolvedEndpoint) + } } if o.dnsProxy != nil { diff --git a/olm/gateway.go b/olm/gateway.go index fa5b33c..3c89ced 100644 --- a/olm/gateway.go +++ b/olm/gateway.go @@ -81,6 +81,30 @@ func (o *Olm) applyPendingGatewayConfig(requestedSiteIds []int) { } } +// flushPendingHolepunchBypassEndpoints re-registers every currently-known +// hole-punch bypass endpoint (see the OnTokenUpdate handler in olm.go) with +// the peer manager. OnTokenUpdate typically fires before the peer manager +// exists - it runs during the initial token/auth fetch in +// websocket.Client.establishConnection, well before the server's +// "olm/wg/connect" message creates the peer manager here in handleConnect - +// so anything recorded into o.hpBypassEndpoints while pm was nil needs to be +// pushed in once it becomes available. AddGatewayBypassEndpoint is +// idempotent, so calling it again for an endpoint OnTokenUpdate already +// managed to register directly (e.g. a later token refresh, once the peer +// manager already existed) is harmless. +func (o *Olm) flushPendingHolepunchBypassEndpoints() { + pm := o.getPeerManager() + if pm == nil { + return + } + + o.hpBypassMu.Lock() + defer o.hpBypassMu.Unlock() + for hostport := range o.hpBypassEndpoints { + pm.AddGatewayBypassEndpoint(hostport) + } +} + // extractControlEndpointHost returns the bare host (no scheme/port) of the // Pangolin server olm is registered against, for gateway bypass-route // purposes. Falls back to the raw endpoint string on parse failure - diff --git a/olm/olm.go b/olm/olm.go index 6e90222..ab9b376 100644 --- a/olm/olm.go +++ b/olm/olm.go @@ -12,6 +12,7 @@ import ( "net/netip" "os" "os/exec" + "strconv" "sync" "time" @@ -64,6 +65,20 @@ type Olm struct { // secondary address on the same interface/WireGuard device as the site peers. exitNode *ExitNodeConfig exitNodeMu sync.Mutex + // exitNodeResolvedEndpoint is the exit node's WireGuard endpoint (already + // DNS-resolved to "ip:port") as of the last successful connectExitNode + // call, kept alongside exitNode purely so removeExitNodePeerLocked can + // unregister the exact same gateway bypass-route target it registered - + // see connectExitNode's AddGatewayBypassEndpoint call. Guarded by exitNodeMu. + exitNodeResolvedEndpoint string + + // hpBypassEndpoints tracks the "host:relayPort" endpoints currently + // registered as gateway bypass targets for hole-punch exit nodes (STUN-like + // probing, distinct from a connected exit node's own WireGuard peer above) - + // diffed against each OnTokenUpdate so stale entries are unregistered and + // new ones protected while gateway mode is active. + hpBypassEndpoints map[string]bool + hpBypassMu sync.Mutex // primaryTunnelIP is the site tunnel's own address (wgData.TunnelIP), set once // per connect in handleConnect. It's the interface's first/primary address - @@ -222,13 +237,14 @@ func Init(ctx context.Context, config OlmConfig) (*Olm, error) { apiServer.SetAgent(config.Agent) newOlm := &Olm{ - logFile: logFile, - olmCtx: ctx, - apiServer: apiServer, - olmConfig: config, - stopPeerSends: make(map[string]func()), - stopPeerInits: make(map[string]func()), - jitPendingSites: make(map[int]string), + logFile: logFile, + olmCtx: ctx, + apiServer: apiServer, + olmConfig: config, + stopPeerSends: make(map[string]func()), + stopPeerInits: make(map[string]func()), + jitPendingSites: make(map[int]string), + hpBypassEndpoints: make(map[string]bool), } newOlm.registerAPICallbacks() @@ -746,6 +762,36 @@ func (o *Olm) StartTunnel(config TunnelConfig) { logger.Debug("Updated hole punch exit nodes: %v", hpExitNodes) + // pm can be nil here: this callback runs from establishConnection, as + // part of the initial token/auth fetch, which happens well before the + // server's "olm/wg/connect" message creates the peer manager in + // handleConnect - so on a fresh connect there usually isn't one yet. + // o.hpBypassEndpoints is still updated unconditionally so it reflects + // the current set regardless; flushPendingHolepunchBypassEndpoints + // (called from handleConnect once the peer manager exists) pushes + // whatever was recorded here into it. + pm := o.getPeerManager() + newBypassEndpoints := make(map[string]bool, len(hpExitNodes)) + for _, node := range hpExitNodes { + newBypassEndpoints[net.JoinHostPort(node.Endpoint, strconv.Itoa(int(node.RelayPort)))] = true + } + + o.hpBypassMu.Lock() + if pm != nil { + for hostport := range newBypassEndpoints { + if !o.hpBypassEndpoints[hostport] { + pm.AddGatewayBypassEndpoint(hostport) + } + } + for hostport := range o.hpBypassEndpoints { + if !newBypassEndpoints[hostport] { + pm.RemoveGatewayBypassEndpoint(hostport) + } + } + } + o.hpBypassEndpoints = newBypassEndpoints + o.hpBypassMu.Unlock() + // Start hole punching using the manager logger.Info("Starting hole punch for %d exit nodes", len(exitNodes)) if err := o.holePunchManager.StartMultipleExitNodes(hpExitNodes); err != nil { @@ -878,11 +924,20 @@ func (o *Olm) Close() { // The WireGuard device and TUN interface are being torn down below, which takes // the exit node peer and its secondary address with them - just clear the - // in-memory record so a stale config isn't reused on the next connect. + // in-memory record so a stale config isn't reused on the next connect. The + // peer manager (and its gateway bypass-route state, including anything + // registered for this exit node or for hole-punch nodes below) was already + // torn down above, so these resets are purely to avoid stale diffing state + // carrying into the next connect, not for route cleanup. o.exitNodeMu.Lock() o.exitNode = nil + o.exitNodeResolvedEndpoint = "" o.exitNodeMu.Unlock() + o.hpBypassMu.Lock() + o.hpBypassEndpoints = make(map[string]bool) + o.hpBypassMu.Unlock() + if o.uapiListener != nil { _ = o.uapiListener.Close() o.uapiListener = nil diff --git a/peers/manager.go b/peers/manager.go index c64dceb..6e45f5d 100644 --- a/peers/manager.go +++ b/peers/manager.go @@ -81,6 +81,17 @@ type PeerManager struct { gatewaySiteIds map[int]bool gatewayExcludedIPs map[string]int gatewayControlIP string + + // gatewayExtraEndpoints tracks "host:port" (or already-resolved "ip:port") + // endpoints registered by callers outside the normal site-peer lifecycle - + // currently hole-punch exit node probing endpoints and a connected exit + // node's own WireGuard endpoint (see olm's OnTokenUpdate handler and + // connectExitNode) - that must stay off the gateway route the same way a + // site peer's own endpoint does, so hole punching / the exit node + // connection still originates from the local network rather than looping + // through the tunnel. Business intent, independent of gatewayActive - see + // AddGatewayBypassEndpoint/RemoveGatewayBypassEndpoint. + gatewayExtraEndpoints map[string]bool } // gatewayCIDR is the WireGuard AllowedIPs claim key for "this site is the @@ -130,19 +141,20 @@ 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, - lastOwnerChange: make(map[string]time.Time), - gatewaySiteIds: make(map[int]bool), - gatewayExcludedIPs: make(map[string]int), + 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, + 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 @@ -302,6 +314,12 @@ func (pm *PeerManager) activateGatewayLocked(controlEndpointHost string) error { } } + for hostport := range pm.gatewayExtraEndpoints { + if ip, ok := pm.resolveEndpointIPLocked(hostport); ok { + pm.excludeEndpointLocked(ip) + } + } + if err := network.AddGatewayDefaultRoute(pm.interfaceName, pm.localIP); err != nil { return fmt.Errorf("failed to install gateway route: %v", err) } @@ -454,6 +472,48 @@ func (pm *PeerManager) ClearGateway() error { return nil } +// AddGatewayBypassEndpoint registers hostport (a "host:port" string, or an +// already-resolved "ip:port") as needing protection from the gateway +// default-route-equivalent, for endpoints outside the normal site-peer +// lifecycle - hole-punch exit node probing endpoints and a connected exit +// node's own WireGuard endpoint. If gateway mode is currently active, the +// bypass route is installed immediately; otherwise this only records intent, +// applied the next time gateway activates. Safe to call repeatedly with the +// same hostport (idempotent). +func (pm *PeerManager) AddGatewayBypassEndpoint(hostport string) { + pm.mu.Lock() + defer pm.mu.Unlock() + + if pm.gatewayExtraEndpoints[hostport] { + return + } + pm.gatewayExtraEndpoints[hostport] = true + + if pm.gatewayActive { + if ip, ok := pm.resolveEndpointIPLocked(hostport); ok { + pm.excludeEndpointLocked(ip) + } + } +} + +// RemoveGatewayBypassEndpoint reverses AddGatewayBypassEndpoint. Safe to call +// on a hostport that was never registered (no-op). +func (pm *PeerManager) RemoveGatewayBypassEndpoint(hostport string) { + pm.mu.Lock() + defer pm.mu.Unlock() + + if !pm.gatewayExtraEndpoints[hostport] { + return + } + delete(pm.gatewayExtraEndpoints, hostport) + + if pm.gatewayActive { + if ip, ok := pm.resolveEndpointIPLocked(hostport); ok { + pm.unexcludeEndpointLocked(ip) + } + } +} + func (pm *PeerManager) GetAllPeers() []SiteConfig { pm.mu.RLock() defer pm.mu.RUnlock()