mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-17 12:09:58 +00:00
[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:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user