diff --git a/olm/gateway.go b/olm/gateway.go index 02661db..5d096b4 100644 --- a/olm/gateway.go +++ b/olm/gateway.go @@ -313,6 +313,13 @@ func (o *Olm) updateControlBypassEndpoints(ips []string) { } } o.controlBypassEndpoints = newBypassEndpoints + + // A (re)connect is a good moment to check the bypass routes: the + // previous connection may have dropped because the network changed and + // took them with it. + if pm != nil { + pm.ReconcileGatewayBypassRoutes() + } } // flushPendingControlBypassEndpoints re-registers every currently-known diff --git a/olm/olm.go b/olm/olm.go index ef1215f..2c1cf00 100644 --- a/olm/olm.go +++ b/olm/olm.go @@ -1406,6 +1406,12 @@ func (o *Olm) RebindSocket() error { logger.Info("Successfully rebound UDP socket on port %d", newPort) + // A rebind is requested on network changes; the bypass routes may need + // to follow the new physical path too. + if pm := o.getPeerManager(); pm != nil { + pm.ReconcileGatewayBypassRoutes() + } + // Check if we're in low power mode before triggering hole punch o.powerModeMu.Lock() isLowPower := o.currentPowerMode == "low" diff --git a/peers/manager.go b/peers/manager.go index 19d2f69..dfec96b 100644 --- a/peers/manager.go +++ b/peers/manager.go @@ -1,6 +1,8 @@ package peers import ( + "context" + "errors" "fmt" "net" "sort" @@ -127,8 +129,17 @@ type PeerManager struct { // through the tunnel. Business intent, independent of gatewayActive - see // AddGatewayBypassEndpoint/RemoveGatewayBypassEndpoint. gatewayExtraEndpoints map[string]bool + + // gatewayRouteWatchCancel stops the goroutines started by + // startBypassRouteWatcherLocked; nil while gateway mode is inactive. + gatewayRouteWatchCancel context.CancelFunc } +// bypassRouteReconcileInterval is how often bypass routes are re-checked +// while gateway mode is active, as a fallback for route changes the OS +// watcher (network.WatchRouteChanges) misses. +const bypassRouteReconcileInterval = 30 * time.Second + // gatewayCIDR is the WireGuard AllowedIPs claim key for "this site is the // gateway (full-tunnel/default-route)". It is never added to // SiteConfig.RemoteSubnets/AllowedIps and never sent over the wire - it only @@ -346,11 +357,11 @@ func (pm *PeerManager) unexcludeEndpointLocked(ip string) { // activateGatewayLocked performs the one-time setup for gateway mode: // resolving and excluding every currently-tracked peer's active endpoint and -// every extra bypass endpoint (including the control plane - see -// gatewayExtraEndpoints) (so none of them can be captured -// by the default-route-equivalent installed at the end), then installing -// that route. Must be called with pm.mu held, and only once (guarded by -// pm.gatewayActive in the caller). +// every extra bypass endpoint (see gatewayExtraEndpoints), so none of them +// can be captured by the default-route-equivalent installed at the end, then +// installing that route and starting to watch for network changes that need +// the bypass routes reconciled. Must be called with pm.mu held, and only once +// (guarded by pm.gatewayActive in the caller). func (pm *PeerManager) activateGatewayLocked() error { for _, peer := range pm.peers { if ip, ok := pm.resolveActiveEndpointIPLocked(peer); ok { @@ -368,13 +379,89 @@ func (pm *PeerManager) activateGatewayLocked() error { return fmt.Errorf("failed to install gateway route: %v", err) } + pm.startBypassRouteWatcherLocked() + return nil } -// deactivateGatewayLocked reverses activateGatewayLocked: removes the -// default-route-equivalent, then every remaining bypass route, and resets gateway exclusion state. Must be called with +// startBypassRouteWatcherLocked keeps the bypass routes in step with the +// physical network for as long as gateway mode is active: the OS removes +// them along with the interface or address they were on when the network +// changes (e.g. switching Wi-Fi networks, Wi-Fi to Ethernet), and without +// them the tunnel's own traffic would be captured by the gateway route. +// Reconciles on every OS route change notification, and periodically as a +// fallback. A no-op where bypasses are applied by the host app from +// NetworkSettings (mobile, macOS NetworkExtension), which tracks the physical +// network itself. Must be called with pm.mu held. +func (pm *PeerManager) startBypassRouteWatcherLocked() { + if !network.ManagesHostRoutes() || pm.gatewayRouteWatchCancel != nil { + return + } + ctx, cancel := context.WithCancel(context.Background()) + pm.gatewayRouteWatchCancel = cancel + + if err := network.WatchRouteChanges(ctx, pm.ReconcileGatewayBypassRoutes); err != nil { + logger.Warn("Gateway: failed to watch for route changes, bypass routes will only be re-checked every %v: %v", bypassRouteReconcileInterval, err) + } + + go func() { + ticker := time.NewTicker(bypassRouteReconcileInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + pm.ReconcileGatewayBypassRoutes() + } + } + }() +} + +// stopBypassRouteWatcherLocked reverses startBypassRouteWatcherLocked. A +// reconcile already waiting on pm.mu may still run once afterwards, which is +// harmless: it is a no-op once gateway mode is inactive. Must be called with // pm.mu held. +func (pm *PeerManager) stopBypassRouteWatcherLocked() { + if pm.gatewayRouteWatchCancel != nil { + pm.gatewayRouteWatchCancel() + pm.gatewayRouteWatchCancel = nil + } +} + +// ReconcileGatewayBypassRoutes re-checks every bypass route while gateway +// mode is active, re-adding any the OS has dropped and moving any that no +// longer follow the current physical path (see network.ReconcileBypassRoute). +// Also repairs routes that failed to install in the first place (e.g. added +// while offline). Idempotent and cheap when nothing has changed; safe to call +// at any time. +func (pm *PeerManager) ReconcileGatewayBypassRoutes() { + pm.mu.Lock() + defer pm.mu.Unlock() + + if !pm.gatewayActive { + return + } + for ip := range pm.gatewayExcludedIPs { + changed, err := network.ReconcileBypassRoute(ip, pm.interfaceName) + switch { + case errors.Is(err, network.ErrNoPhysicalRoute): + logger.Debug("Gateway: no physical route for bypass %s yet: %v", ip, err) + case err != nil: + logger.Warn("Gateway: failed to reconcile bypass route for %s: %v", ip, err) + case changed: + logger.Info("Gateway: restored bypass route for %s after a network change", ip) + } + } +} + +// deactivateGatewayLocked reverses activateGatewayLocked: stops watching +// for network changes, removes the default-route-equivalent, then every +// remaining bypass route, and resets gateway exclusion state. Must be called +// with pm.mu held. func (pm *PeerManager) deactivateGatewayLocked() { + pm.stopBypassRouteWatcherLocked() + if err := network.RemoveGatewayDefaultRoute(pm.interfaceName); err != nil { logger.Error("Gateway: failed to remove gateway route: %v", err) }