Compare commits

...

2 Commits

Author SHA1 Message Date
Zoltán Papp
84867c7e45 [client] add DIAG logging to trace exit-node v6 (::/0) route filtering
Temporary diagnostics to find why a deselected v4 exit node's synthesized
::/0 route still reaches the tunnel. Logs the full install path: incoming
client networks, route-selector state before/after the management-driven
update, what updateExitNodeSelections deselects/selects, and per-route
KEEP/SKIP/DROP decisions in FilterSelectedExitNodes and applyExitNodeFilter.
To be reverted once the real root cause is confirmed from a client log.
2026-05-31 14:25:50 +02:00
Zoltán Papp
c7499cf8fc [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.
2026-05-31 13:48:51 +02:00
3 changed files with 95 additions and 1 deletions

View File

@@ -428,10 +428,16 @@ func (m *DefaultManager) UpdateRoutes(
var merr *multierror.Error
if !m.disableClientRoutes {
log.Debugf("DIAG UpdateRoutes: incoming %d client networks: %v", len(clientRoutes), haKeysList(clientRoutes))
log.Debugf("DIAG UpdateRoutes: selector BEFORE management update: %s", m.routeSelector.MarshalSummary())
// Update route selector based on management server's isSelected status
m.updateRouteSelectorFromManagement(clientRoutes)
log.Debugf("DIAG UpdateRoutes: selector AFTER management update: %s", m.routeSelector.MarshalSummary())
filteredClientRoutes := m.routeSelector.FilterSelectedExitNodes(clientRoutes)
log.Debugf("DIAG UpdateRoutes: AFTER filter, %d networks remain: %v", len(filteredClientRoutes), haKeysList(filteredClientRoutes))
if err := m.updateSystemRoutes(filteredClientRoutes); err != nil {
merr = multierror.Append(merr, fmt.Errorf("update system routes: %w", err))
@@ -745,7 +751,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)
@@ -763,6 +772,8 @@ func (m *DefaultManager) checkManagementSelection(routes []*route.Route, netID r
func (m *DefaultManager) updateExitNodeSelections(info exitNodeInfo) {
routesToDeselect := m.getRoutesToDeselect(info.allIDs)
log.Debugf("DIAG updateExitNodeSelections: allIDs=%v userSelected=%v userDeselected=%v selectedByManagement=%v -> routesToDeselect(no user selection)=%v",
info.allIDs, info.userSelected, info.userDeselected, info.selectedByManagement, routesToDeselect)
m.deselectExitNodes(routesToDeselect)
m.selectExitNodesByManagement(info.selectedByManagement, info.allIDs)
}
@@ -802,4 +813,14 @@ func (m *DefaultManager) selectExitNodesByManagement(selectedByManagement []rout
func (m *DefaultManager) logExitNodeUpdate(info exitNodeInfo) {
log.Debugf("Updated route selector: %d exit nodes available, %d selected by management, %d user-selected, %d user-deselected",
len(info.allIDs), len(info.selectedByManagement), len(info.userSelected), len(info.userDeselected))
log.Debugf("DIAG logExitNodeUpdate: allIDs=%v selectedByManagement=%v userSelected=%v userDeselected=%v",
info.allIDs, info.selectedByManagement, info.userSelected, info.userDeselected)
}
func haKeysList(m route.HAMap) []route.HAUniqueID {
out := make([]route.HAUniqueID, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}

View File

@@ -8,6 +8,7 @@ import (
"sync"
"github.com/hashicorp/go-multierror"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/errors"
"github.com/netbirdio/netbird/route"
@@ -124,6 +125,24 @@ 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))
}
// MarshalSummary returns a short human-readable description of the selector state for diagnostics.
func (rs *RouteSelector) MarshalSummary() string {
rs.mu.RLock()
defer rs.mu.RUnlock()
return fmt.Sprintf("deselectAll=%v selected=%v deselected=%v", rs.deselectAll, keysOf(rs.selectedRoutes), keysOf(rs.deselectedRoutes))
}
// FilterSelected removes unselected routes from the provided map.
func (rs *RouteSelector) FilterSelected(routes route.HAMap) route.HAMap {
rs.mu.RLock()
@@ -161,24 +180,47 @@ func (rs *RouteSelector) FilterSelectedExitNodes(routes route.HAMap) route.HAMap
return route.HAMap{}
}
log.Debugf("DIAG FilterSelectedExitNodes: incoming %d networks, deselected=%v selected=%v deselectAll=%v",
len(routes), keysOf(rs.deselectedRoutes), keysOf(rs.selectedRoutes), rs.deselectAll)
filtered := make(route.HAMap, len(routes))
for id, rt := range routes {
netID := id.NetID()
if rs.isDeselectedLocked(netID) {
log.Debugf("DIAG FilterSelectedExitNodes: SKIP id=%q netID=%q (literally deselected)", id, netID)
continue
}
if !isExitNode(rt) {
log.Debugf("DIAG FilterSelectedExitNodes: KEEP id=%q netID=%q (not an exit node)", id, netID)
filtered[id] = rt
continue
}
log.Debugf("DIAG FilterSelectedExitNodes: EXITNODE id=%q netID=%q -> applyExitNodeFilter", id, netID)
rs.applyExitNodeFilter(id, netID, rt, filtered)
}
log.Debugf("DIAG FilterSelectedExitNodes: result keeps %d networks: %v", len(filtered), haKeysOf(filtered))
return filtered
}
func keysOf(m map[route.NetID]struct{}) []route.NetID {
out := make([]route.NetID, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
func haKeysOf(m route.HAMap) []route.HAUniqueID {
out := make([]route.HAUniqueID, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
// effectiveNetID returns the v4 base for a "-v6" exit pair entry that has no explicit
// state of its own, so selections made on the v4 entry govern the v6 entry automatically.
// Only call this from exit-node-specific code paths: applying it to a non-exit "-v6" route
@@ -232,15 +274,22 @@ func (rs *RouteSelector) applyExitNodeFilter(
// Exit-node path: apply the v4/v6 pair mirror so a deselect on the v4 base also
// drops the synthesized v6 entry that lacks its own explicit state.
effective := rs.effectiveNetID(netID)
log.Debugf("DIAG applyExitNodeFilter: id=%q netID=%q effective=%q hasUserSel=%v isSelected=%v",
id, netID, effective, rs.hasUserSelectionForRouteLocked(effective), rs.isSelectedLocked(effective))
if rs.hasUserSelectionForRouteLocked(effective) {
if rs.isSelectedLocked(effective) {
log.Debugf("DIAG applyExitNodeFilter: KEEP id=%q (effective %q is selected)", id, effective)
out[id] = rt
} else {
log.Debugf("DIAG applyExitNodeFilter: DROP id=%q (effective %q is deselected)", id, effective)
}
return
}
// no explicit selection for this route: defer to management's SkipAutoApply flag
sel := collectSelected(rt)
log.Debugf("DIAG applyExitNodeFilter: no user selection for effective %q; SkipAutoApply filter kept %d/%d routes for id=%q",
effective, len(sel), len(rt), id)
if len(sel) > 0 {
out[id] = sel
}

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