diff --git a/client/internal/routemanager/manager.go b/client/internal/routemanager/manager.go index 839ec14c0..a9a1f5eed 100644 --- a/client/internal/routemanager/manager.go +++ b/client/internal/routemanager/manager.go @@ -745,7 +745,10 @@ func (m *DefaultManager) isExitNodeRoute(routes []*route.Route) bool { } func (m *DefaultManager) categorizeUserSelection(netID route.NetID, info *exitNodeInfo) { - if m.routeSelector.IsSelected(netID) { + // Use the exit-node-aware check so a synthesized "-v6" route inherits the v4 + // base's selection: deselecting the v4 exit node must also drop its ::/0 pair, + // otherwise the v6 default route leaks into the tunnel despite the deselect. + if m.routeSelector.IsSelectedForExitNode(netID) { info.userSelected = append(info.userSelected, netID) } else { info.userDeselected = append(info.userDeselected, netID) diff --git a/client/internal/routeselector/routeselector.go b/client/internal/routeselector/routeselector.go index 2ddc24bf2..00c467650 100644 --- a/client/internal/routeselector/routeselector.go +++ b/client/internal/routeselector/routeselector.go @@ -124,6 +124,17 @@ func (rs *RouteSelector) IsSelected(routeID route.NetID) bool { return rs.isSelectedLocked(routeID) } +// IsSelectedForExitNode checks if an exit-node route is selected, mirroring the +// v4/v6 pair: a synthesized "-v6" entry with no explicit state of its own inherits +// its v4 base's selection, so a deselect on the v4 base also deselects the v6 entry. +// Only call this from exit-node code paths (see effectiveNetID). +func (rs *RouteSelector) IsSelectedForExitNode(routeID route.NetID) bool { + rs.mu.RLock() + defer rs.mu.RUnlock() + + return rs.isSelectedLocked(rs.effectiveNetID(routeID)) +} + // FilterSelected removes unselected routes from the provided map. func (rs *RouteSelector) FilterSelected(routes route.HAMap) route.HAMap { rs.mu.RLock() diff --git a/client/internal/routeselector/routeselector_test.go b/client/internal/routeselector/routeselector_test.go index 3f0d9f120..396a6f81e 100644 --- a/client/internal/routeselector/routeselector_test.go +++ b/client/internal/routeselector/routeselector_test.go @@ -359,6 +359,30 @@ func TestRouteSelector_V6ExitPairInherits(t *testing.T) { assert.True(t, rs.IsSelected("corp-v6"), "non-exit *-v6 routes must not inherit unrelated v4 state") }) + t.Run("IsSelectedForExitNode mirrors deselected v4 base", func(t *testing.T) { + rs := routeselector.NewRouteSelector() + require.NoError(t, rs.DeselectRoutes([]route.NetID{"exit1"}, all)) + + // Regression: deselecting the v4 exit node must also report the synthesized + // "-v6" pair as not selected, otherwise the ::/0 route leaks into the tunnel. + assert.False(t, rs.IsSelectedForExitNode("exit1")) + assert.False(t, rs.IsSelectedForExitNode("exit1-v6"), "v6 pair inherits v4 base deselect") + + // An exit node with no user selection at all stays selected by default. + assert.True(t, rs.IsSelectedForExitNode("exit2")) + assert.True(t, rs.IsSelectedForExitNode("exit2-v6")) + }) + + t.Run("IsSelectedForExitNode respects explicit v6 state", func(t *testing.T) { + rs := routeselector.NewRouteSelector() + require.NoError(t, rs.DeselectRoutes([]route.NetID{"exit1"}, all)) + require.NoError(t, rs.SelectRoutes([]route.NetID{"exit1-v6"}, true, all)) + + // Explicit selection on the v6 entry overrides the v4 base's deselect. + assert.False(t, rs.IsSelectedForExitNode("exit1")) + assert.True(t, rs.IsSelectedForExitNode("exit1-v6"), "explicit v6 select wins over v4 base") + }) + t.Run("explicit v6 state overrides v4 base in filter", func(t *testing.T) { rs := routeselector.NewRouteSelector() require.NoError(t, rs.DeselectRoutes([]route.NetID{"exit1"}, all))