Exclude the holepunch endpoints as well

This commit is contained in:
Owen
2026-09-24 11:56:42 -04:00
parent 8b02cf2f32
commit b53312939f
5 changed files with 178 additions and 21 deletions
+6
View File
@@ -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)
}
+12
View File
@@ -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 {
+24
View File
@@ -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 -
+63 -8
View File
@@ -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
+73 -13
View File
@@ -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()