[client] propagate exit-node deselect to synthesized v6 (::/0) route

When a client deselects an IPv4 exit node, the auto-generated IPv6 default
route (::/0) was still selected and pushed onto the tunnel interface, even
though the user disabled the exit node. On an exit node without a real IPv6
egress this blackholes IPv6 traffic, and because clients prefer IPv6 (happy
eyeballs) it can break general connectivity.

Root cause: the synthesized v6 route gets a different NetID than its v4 base
(base + "-v6"). The route selector keys deselects by NetID and defaults
unknown NetIDs to selected, so the "-v6" entry was never matched by the v4
deselect. The effectiveNetID() mirror that solves exactly this is used by
HasUserSelectionForRoute and FilterSelectedExitNodes, but categorizeUserSelection
called the raw IsSelected(), bypassing it and mis-categorizing the v6 pair as
user-selected.

Add RouteSelector.IsSelectedForExitNode(), which applies effectiveNetID before
the selection check, and use it in categorizeUserSelection. IsSelected() is left
untouched so non-exit code paths don't make unrelated "*-v6" routes inherit v4
state. Adds regression tests for the v4/v6 deselect mirror and explicit-v6
override.
This commit is contained in:
Zoltán Papp
2026-05-31 13:47:10 +02:00
parent 9189625487
commit c7499cf8fc
3 changed files with 39 additions and 1 deletions

View File

@@ -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)

View File

@@ -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()

View File

@@ -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))