From 08a2b636753b3d57387b2b177849dc6df5a6cfaf Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Tue, 16 Jun 2026 12:27:58 +0200 Subject: [PATCH] [client] propagate exit-node deselect to synthesized v6 (::/0) route (#6296) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [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. * [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. * [client] clear orphaned v6 exit selection when v4 pair is toggled Root cause of the leaking ::/0 route, confirmed from client logs: the synthesized "-v6" exit route could stay explicitly selected in the persisted route-selector state while its v4 base was deselected (selected=[...-v6], deselected=[...v4base]). Because the v6 entry then has its own explicit state, effectiveNetID stops mirroring the v4 base, so FilterSelectedExitNodes keeps ::/0 and it is installed on the tunnel even though the user disabled the exit node. This happened because the iOS SDK's deselect only pairs the "-v6" sibling via ExpandV6ExitPairs when the v6 route is present in the current routesMap; a deselect at a moment it wasn't expanded left the v6 selection orphaned. Fix at the selector write path so it is independent of routesMap timing: when a v4 exit NetID is selected or deselected, clear any orphaned explicit state on its "-v6" sibling (clearPairedV6Locked), unless the sibling is part of the same batch (the deliberate ExpandV6ExitPairs case). The v6 then falls back to inheriting the v4 base via effectiveNetID, so a v4 deselect also drops ::/0 and a v4 select brings both back. Adds regression tests: a stale explicit v6 selection is cleared by a later v4 deselect, and an explicit v6 select made in the same batch is preserved. * [ios] compute route connection status in the bridge The iOS bridge exposed a route's Network as a possibly comma-joined string ("0.0.0.0/0, ::/0" for a merged exit node) but no connection status, forcing the UI to infer status by string-matching that joined value against peer routes — which never matched for the merged exit node, leaving it stuck as not-connected. Android already computes status in the core (findBestRoutePeer). Mirror that here: add a Status field to RoutesSelectionInfo and compute it from the connected peers' route tables, matching the route's primary prefix, a merged exit node's extra v6 prefix, or a dynamic route's domain pattern (the key the route manager records). The UI can now read the status directly. * [client] remove exit-node v6 DIAG logging and tidy routeselector Drop the temporary DIAG diagnostics added to trace the leaking ::/0 route (the root cause is fixed and confirmed). Also reorganize routeselector.go so the exit-node helpers (clearPairedV6Locked, isExitNode) sit next to the exit-node code paths and MarshalJSON/UnmarshalJSON are grouped together. * [client] mirror v4 exit selection onto v6 pair at write time The synthesized "-v6" exit route shares its v4 base's NetID plus a "-v6" suffix. Selection state was reconciled at read time via effectiveNetID, a mirror that could only be applied on exit-node code paths, which forced a parallel IsSelectedForExitNode() alongside IsSelected() and a clearPairedV6Locked() orphan cleanup on every toggle. That machinery still missed the case observed in the field: a persisted state with the v4 base deselected but its "-v6" sibling explicitly selected (orphaned). Because effectiveNetID returns the v6 entry itself once it carries explicit state, and clearPairedV6Locked only fires on a live toggle, the loaded orphan survived and the ::/0 route leaked onto the tunnel despite the exit node being disabled, breaking IPv6 (happy eyeballs). Treat the v4/v6 exit pair as a single toggle and keep state consistent at write time instead. RouteSelector.SyncPairedSelection forces the "-v6" entry to match its v4 base unconditionally, resetting any orphaned explicit state. The route manager, which knows the route prefixes, computes the pairs (V6ExitMergeSet) and calls it from updateRouteSelectorFromManagement before selection is read, so both collectExitNodeInfo and FilterSelectedExitNodes see consistent state, including pairs loaded from persisted selector state. This removes effectiveNetID, IsSelectedForExitNode and clearPairedV6Locked; the selector is literal again and no longer needs the "exit-node paths only" caveat. HasUserSelectionForRoute and applyExitNodeFilter use the raw NetID. Adds a selector test for SyncPairedSelection (including the orphaned-v6 case) and a route-manager test reproducing the persisted-orphan scenario from the field log. * [client] add DIAG logging to trace v6 exit-pair mirror The write-time mirror did not eliminate the leak in field testing. Re-add the DIAG diagnostics around the exit-node selection flow to capture a fresh trace: - UpdateRoutes: incoming client networks, selector state before/after the management update, and the networks remaining after FilterSelectedExitNodes. - mirrorV6ExitPairSelections: the NetIDs present in this update and the v6 pairs V6ExitMergeSet derives from them (reveals whether the v4 base and its ::/0 pair are present in the same update so the pair can be matched). - SyncPairedSelection: the base/paired state before and after the sync. - FilterSelectedExitNodes / applyExitNodeFilter: per-route SKIP/KEEP/DROP and the selection lookups behind each decision. - updateExitNodeSelections / logExitNodeUpdate: categorization and deselect set. Temporary; to be removed once the root cause is confirmed. * [client] remove v6 exit-pair mirror DIAG logging Drop the temporary DIAG diagnostics added to trace the v4/v6 exit-pair mirror. The field log confirmed the write-time mirror keeps the pair consistent (the ::/0 route is only ever applied alongside its v4 base and is dropped on deselect), so the diagnostics are no longer needed. --- client/internal/routemanager/manager.go | 21 +++ .../routemanager/manager_v6exit_test.go | 47 +++++ .../internal/routeselector/routeselector.go | 168 +++++++++--------- .../routeselector/routeselector_test.go | 102 +++++++---- client/ios/NetBirdSDK/client.go | 50 ++++++ client/ios/NetBirdSDK/routes.go | 1 + 6 files changed, 273 insertions(+), 116 deletions(-) create mode 100644 client/internal/routemanager/manager_v6exit_test.go diff --git a/client/internal/routemanager/manager.go b/client/internal/routemanager/manager.go index f10a2b5e0..0edf4607f 100644 --- a/client/internal/routemanager/manager.go +++ b/client/internal/routemanager/manager.go @@ -9,6 +9,7 @@ import ( "net/url" "runtime" "slices" + "strings" "sync" "sync/atomic" "time" @@ -700,6 +701,8 @@ func resolveURLsToIPs(urls []string) []net.IP { // updateRouteSelectorFromManagement updates the route selector based on the isSelected status from the management server func (m *DefaultManager) updateRouteSelectorFromManagement(clientRoutes route.HAMap) { + m.mirrorV6ExitPairSelections(clientRoutes) + // An explicit user "deselect all" must not be overridden by management auto-apply. // Auto-applying an exit node here would call SelectRoutes, which clears the // deselect-all flag and re-enables every route the user turned off. @@ -716,6 +719,24 @@ func (m *DefaultManager) updateRouteSelectorFromManagement(clientRoutes route.HA m.logExitNodeUpdate(exitNodeInfo) } +// mirrorV6ExitPairSelections keeps every synthesized "-v6" exit route's selection +// consistent with its v4 base. The v4/v6 exit pair is a single toggle, so the v6 +// entry always follows the base: deselecting the v4 exit node also drops its ::/0 +// pair, and any stale (orphaned) explicit selection on the v6 entry is reset. This +// runs before selection is read so both collectExitNodeInfo and FilterSelectedExitNodes +// see consistent state, including pairs loaded from persisted selector state. +func (m *DefaultManager) mirrorV6ExitPairSelections(clientRoutes route.HAMap) { + routesByNetID := make(map[route.NetID][]*route.Route, len(clientRoutes)) + for haID, routes := range clientRoutes { + routesByNetID[haID.NetID()] = routes + } + + for v6ID := range route.V6ExitMergeSet(routesByNetID) { + baseID := route.NetID(strings.TrimSuffix(string(v6ID), route.V6ExitSuffix)) + m.routeSelector.SyncPairedSelection(baseID, v6ID) + } +} + type exitNodeInfo struct { allIDs []route.NetID selectedByManagement []route.NetID diff --git a/client/internal/routemanager/manager_v6exit_test.go b/client/internal/routemanager/manager_v6exit_test.go new file mode 100644 index 000000000..15ab99cbd --- /dev/null +++ b/client/internal/routemanager/manager_v6exit_test.go @@ -0,0 +1,47 @@ +package routemanager + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/routeselector" + "github.com/netbirdio/netbird/route" +) + +// TestUpdateRouteSelectorFromManagement_MirrorsV6ExitPair reproduces the bug seen +// in netbird-engine.log: persisted selector state has the v4 exit node deselected +// but its synthesized "-v6" pair explicitly selected (orphaned), so the ::/0 route +// leaked onto the tunnel. The management update must mirror the v4 deselect onto the +// v6 pair so FilterSelectedExitNodes drops it. +func TestUpdateRouteSelectorFromManagement_MirrorsV6ExitPair(t *testing.T) { + const ( + v4ID = route.NetID("Exit Node (raspberrypi)") + v6ID = route.NetID("Exit Node (raspberrypi)-v6") + ) + all := []route.NetID{v4ID, v6ID} + + rs := routeselector.NewRouteSelector() + // Orphan the v6 selection: select the pair, then deselect only the v4 base. + require.NoError(t, rs.SelectRoutes([]route.NetID{v4ID, v6ID}, true, all)) + require.NoError(t, rs.DeselectRoutes([]route.NetID{v4ID}, all)) + require.True(t, rs.IsSelected(v6ID), "precondition: orphaned v6 selection survives v4 deselect") + + m := &DefaultManager{routeSelector: rs} + + v4Route := &route.Route{NetID: v4ID, Network: netip.MustParsePrefix("0.0.0.0/0")} + v6Route := &route.Route{NetID: v6ID, Network: netip.MustParsePrefix("::/0")} + clientRoutes := route.HAMap{ + "Exit Node (raspberrypi)|0.0.0.0/0": {v4Route}, + "Exit Node (raspberrypi)-v6|::/0": {v6Route}, + } + + m.updateRouteSelectorFromManagement(clientRoutes) + + assert.False(t, rs.IsSelected(v6ID), "v6 pair must follow the v4 base deselect after the management update") + + filtered := rs.FilterSelectedExitNodes(clientRoutes) + assert.Empty(t, filtered, "deselected v4 exit node must not leak its ::/0 pair onto the tunnel") +} diff --git a/client/internal/routeselector/routeselector.go b/client/internal/routeselector/routeselector.go index b9991cd37..232baf746 100644 --- a/client/internal/routeselector/routeselector.go +++ b/client/internal/routeselector/routeselector.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "slices" - "strings" "sync" "github.com/hashicorp/go-multierror" @@ -132,6 +131,33 @@ func (rs *RouteSelector) IsSelected(routeID route.NetID) bool { return rs.isSelectedLocked(routeID) } +// SyncPairedSelection forces pairedID's explicit selection state to match baseID's, +// so a synthesized "-v6" exit route always follows its v4 base: selecting or +// deselecting the v4 exit node governs the ::/0 pair, and any stale (orphaned) +// explicit state on the v6 entry is reset. The v4/v6 exit pair is treated as a single +// toggle, so the v6 entry carries no independent selection of its own. +func (rs *RouteSelector) SyncPairedSelection(baseID, pairedID route.NetID) { + rs.mu.Lock() + defer rs.mu.Unlock() + + if rs.deselectAll { + return + } + + _, baseSelected := rs.selectedRoutes[baseID] + _, baseDeselected := rs.deselectedRoutes[baseID] + + delete(rs.selectedRoutes, pairedID) + delete(rs.deselectedRoutes, pairedID) + + switch { + case baseSelected: + rs.selectedRoutes[pairedID] = struct{}{} + case baseDeselected: + rs.deselectedRoutes[pairedID] = struct{}{} + } +} + // FilterSelected removes unselected routes from the provided map. func (rs *RouteSelector) FilterSelected(routes route.HAMap) route.HAMap { rs.mu.RLock() @@ -151,14 +177,13 @@ func (rs *RouteSelector) FilterSelected(routes route.HAMap) route.HAMap { } // HasUserSelectionForRoute returns true if the user has explicitly selected or deselected this route. -// Intended for exit-node code paths: a v6 exit-node pair (e.g. "MyExit-v6") with no explicit state of -// its own inherits its v4 base's state, so legacy persisted selections that predate v6 pairing -// transparently apply to the synthesized v6 entry. +// The lookup is literal; v4/v6 exit pairs are kept consistent at write time via SyncPairedSelection, +// so a synthesized "-v6" entry carries the same explicit state as its v4 base. func (rs *RouteSelector) HasUserSelectionForRoute(routeID route.NetID) bool { rs.mu.RLock() defer rs.mu.RUnlock() - return rs.hasUserSelectionForRouteLocked(rs.effectiveNetID(routeID)) + return rs.hasUserSelectionForRouteLocked(routeID) } func (rs *RouteSelector) FilterSelectedExitNodes(routes route.HAMap) route.HAMap { @@ -187,83 +212,6 @@ func (rs *RouteSelector) FilterSelectedExitNodes(routes route.HAMap) route.HAMap return filtered } -// 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 -// would make it inherit unrelated v4 state. Must be called with rs.mu held. -func (rs *RouteSelector) effectiveNetID(id route.NetID) route.NetID { - name := string(id) - if !strings.HasSuffix(name, route.V6ExitSuffix) { - return id - } - if _, ok := rs.selectedRoutes[id]; ok { - return id - } - if _, ok := rs.deselectedRoutes[id]; ok { - return id - } - return route.NetID(strings.TrimSuffix(name, route.V6ExitSuffix)) -} - -func (rs *RouteSelector) isSelectedLocked(routeID route.NetID) bool { - if rs.deselectAll { - return false - } - _, deselected := rs.deselectedRoutes[routeID] - return !deselected -} - -func (rs *RouteSelector) isDeselectedLocked(netID route.NetID) bool { - if rs.deselectAll { - return true - } - _, deselected := rs.deselectedRoutes[netID] - return deselected -} - -func (rs *RouteSelector) hasUserSelectionForRouteLocked(routeID route.NetID) bool { - _, selected := rs.selectedRoutes[routeID] - _, deselected := rs.deselectedRoutes[routeID] - return selected || deselected -} - -func isExitNode(rt []*route.Route) bool { - return len(rt) > 0 && (route.IsV4DefaultRoute(rt[0].Network) || route.IsV6DefaultRoute(rt[0].Network)) -} - -func (rs *RouteSelector) applyExitNodeFilter( - id route.HAUniqueID, - netID route.NetID, - rt []*route.Route, - out route.HAMap, -) { - // 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) - if rs.hasUserSelectionForRouteLocked(effective) { - if rs.isSelectedLocked(effective) { - out[id] = rt - } - return - } - - // no explicit selection for this route: defer to management's SkipAutoApply flag - sel := collectSelected(rt) - if len(sel) > 0 { - out[id] = sel - } -} - -func collectSelected(rt []*route.Route) []*route.Route { - var sel []*route.Route - for _, r := range rt { - if !r.SkipAutoApply { - sel = append(sel, r) - } - } - return sel -} - // MarshalJSON implements the json.Marshaler interface func (rs *RouteSelector) MarshalJSON() ([]byte, error) { rs.mu.RLock() @@ -317,3 +265,59 @@ func (rs *RouteSelector) UnmarshalJSON(data []byte) error { return nil } + +func (rs *RouteSelector) isSelectedLocked(routeID route.NetID) bool { + if rs.deselectAll { + return false + } + _, deselected := rs.deselectedRoutes[routeID] + return !deselected +} + +func (rs *RouteSelector) isDeselectedLocked(netID route.NetID) bool { + if rs.deselectAll { + return true + } + _, deselected := rs.deselectedRoutes[netID] + return deselected +} + +func (rs *RouteSelector) hasUserSelectionForRouteLocked(routeID route.NetID) bool { + _, selected := rs.selectedRoutes[routeID] + _, deselected := rs.deselectedRoutes[routeID] + return selected || deselected +} + +func (rs *RouteSelector) applyExitNodeFilter( + id route.HAUniqueID, + netID route.NetID, + rt []*route.Route, + out route.HAMap, +) { + if rs.hasUserSelectionForRouteLocked(netID) { + if rs.isSelectedLocked(netID) { + out[id] = rt + } + return + } + + // no explicit selection for this route: defer to management's SkipAutoApply flag + sel := collectSelected(rt) + if len(sel) > 0 { + out[id] = sel + } +} + +func isExitNode(rt []*route.Route) bool { + return len(rt) > 0 && (route.IsV4DefaultRoute(rt[0].Network) || route.IsV6DefaultRoute(rt[0].Network)) +} + +func collectSelected(rt []*route.Route) []*route.Route { + var sel []*route.Route + for _, r := range rt { + if !r.SkipAutoApply { + sel = append(sel, r) + } + } + return sel +} diff --git a/client/internal/routeselector/routeselector_test.go b/client/internal/routeselector/routeselector_test.go index 3f0d9f120..c9d6acb4d 100644 --- a/client/internal/routeselector/routeselector_test.go +++ b/client/internal/routeselector/routeselector_test.go @@ -330,39 +330,73 @@ func TestRouteSelector_FilterSelectedExitNodes(t *testing.T) { assert.Len(t, filtered, 0) // No routes should be selected } -// TestRouteSelector_V6ExitPairInherits covers the v4/v6 exit-node pair selection -// mirror. The mirror is scoped to exit-node code paths: HasUserSelectionForRoute -// and FilterSelectedExitNodes resolve a "-v6" entry without explicit state to its -// v4 base, so legacy persisted selections that predate v6 pairing transparently -// apply to the synthesized v6 entry. General lookups (IsSelected, FilterSelected) -// stay literal so unrelated routes named "*-v6" don't inherit unrelated state. -func TestRouteSelector_V6ExitPairInherits(t *testing.T) { +// TestRouteSelector_V6ExitPairSync covers SyncPairedSelection, which keeps a v4 +// exit node and its synthesized "-v6" counterpart consistent. The selector itself +// is literal and never infers a v6 entry's state from its v4 base; callers that know +// the pairing (exit-node code paths) call SyncPairedSelection to force the v6 entry +// to follow the base, treating the pair as a single toggle. +func TestRouteSelector_V6ExitPairSync(t *testing.T) { all := []route.NetID{"exit1", "exit1-v6", "exit2", "exit2-v6", "corp", "corp-v6"} - t.Run("HasUserSelectionForRoute mirrors deselected v4 base", func(t *testing.T) { + t.Run("selector lookups stay literal without sync", func(t *testing.T) { rs := routeselector.NewRouteSelector() require.NoError(t, rs.DeselectRoutes([]route.NetID{"exit1"}, all)) - assert.True(t, rs.HasUserSelectionForRoute("exit1-v6"), "v6 pair sees v4 base's user selection") + // The selector does not pair-resolve: the v6 entry is independent until synced. + assert.False(t, rs.HasUserSelectionForRoute("exit1-v6"), "v6 entry has no state of its own") + assert.True(t, rs.IsSelected("exit1-v6"), "unsynced v6 entry stays selected by default") - // unrelated v6 with no v4 base touched is unaffected - assert.False(t, rs.HasUserSelectionForRoute("exit2-v6")) + // A route literally named "exit1-something" must never pair-resolve either. + assert.False(t, rs.HasUserSelectionForRoute("exit1-something")) }) - t.Run("IsSelected stays literal for non-exit lookups", func(t *testing.T) { - rs := routeselector.NewRouteSelector() - require.NoError(t, rs.DeselectRoutes([]route.NetID{"corp"}, all)) - - // A non-exit route literally named "corp-v6" must not inherit "corp"'s state - // via the mirror; the mirror only applies in exit-node code paths. - assert.False(t, rs.IsSelected("corp")) - assert.True(t, rs.IsSelected("corp-v6"), "non-exit *-v6 routes must not inherit unrelated v4 state") - }) - - t.Run("explicit v6 state overrides v4 base in filter", func(t *testing.T) { + t.Run("sync mirrors deselected v4 base onto v6", func(t *testing.T) { rs := routeselector.NewRouteSelector() require.NoError(t, rs.DeselectRoutes([]route.NetID{"exit1"}, all)) + + rs.SyncPairedSelection("exit1", "exit1-v6") + + assert.False(t, rs.IsSelected("exit1")) + assert.False(t, rs.IsSelected("exit1-v6"), "v6 pair follows v4 base deselect") + assert.True(t, rs.HasUserSelectionForRoute("exit1-v6"), "v6 carries explicit deselect after sync") + }) + + t.Run("sync mirrors selected v4 base onto v6", func(t *testing.T) { + rs := routeselector.NewRouteSelector() + require.NoError(t, rs.SelectRoutes([]route.NetID{"exit1"}, false, all)) + + rs.SyncPairedSelection("exit1", "exit1-v6") + + assert.True(t, rs.IsSelected("exit1")) + assert.True(t, rs.IsSelected("exit1-v6"), "v6 pair follows v4 base select") + }) + + t.Run("sync clears v6 state when base has no explicit selection", func(t *testing.T) { + rs := routeselector.NewRouteSelector() require.NoError(t, rs.SelectRoutes([]route.NetID{"exit1-v6"}, true, all)) + require.True(t, rs.HasUserSelectionForRoute("exit1-v6")) + + rs.SyncPairedSelection("exit1", "exit1-v6") + + assert.False(t, rs.HasUserSelectionForRoute("exit1-v6"), + "v6 explicit state is cleared so it follows management like its base") + }) + + // Regression for the observed bug (see netbird-engine.log): persisted state has + // the v4 base deselected but the v6 sibling explicitly selected (orphaned). The + // sync must reset the orphan so the ::/0 route does not leak onto the tunnel. + t.Run("sync clears orphaned explicit v6 selection on deselected base", func(t *testing.T) { + rs := routeselector.NewRouteSelector() + + // Prior state: both explicitly selected, then only the v4 base deselected, + // leaving the v6 entry as a stale explicit selection. + require.NoError(t, rs.SelectRoutes([]route.NetID{"exit1", "exit1-v6"}, true, all)) + require.NoError(t, rs.DeselectRoutes([]route.NetID{"exit1"}, all)) + require.True(t, rs.IsSelected("exit1-v6"), "precondition: orphaned v6 selection") + + rs.SyncPairedSelection("exit1", "exit1-v6") + + assert.False(t, rs.IsSelected("exit1-v6"), "orphaned v6 selection reset to follow v4 deselect") v4Route := &route.Route{NetID: "exit1", Network: netip.MustParsePrefix("0.0.0.0/0")} v6Route := &route.Route{NetID: "exit1-v6", Network: netip.MustParsePrefix("::/0")} @@ -370,23 +404,14 @@ func TestRouteSelector_V6ExitPairInherits(t *testing.T) { "exit1|0.0.0.0/0": {v4Route}, "exit1-v6|::/0": {v6Route}, } - filtered := rs.FilterSelectedExitNodes(routes) - assert.NotContains(t, filtered, route.HAUniqueID("exit1|0.0.0.0/0")) - assert.Contains(t, filtered, route.HAUniqueID("exit1-v6|::/0"), "explicit v6 select wins over v4 base") + assert.Empty(t, filtered, "deselecting v4 base must drop the v6 pair even if it was explicitly selected before") }) - t.Run("non-v6-suffix routes unaffected", func(t *testing.T) { - rs := routeselector.NewRouteSelector() - require.NoError(t, rs.DeselectRoutes([]route.NetID{"exit1"}, all)) - - // A route literally named "exit1-something" must not pair-resolve. - assert.False(t, rs.HasUserSelectionForRoute("exit1-something")) - }) - - t.Run("filter v6 paired with deselected v4 base", func(t *testing.T) { + t.Run("filter drops synced v6 pair of deselected v4 base", func(t *testing.T) { rs := routeselector.NewRouteSelector() require.NoError(t, rs.DeselectRoutes([]route.NetID{"exit1"}, all)) + rs.SyncPairedSelection("exit1", "exit1-v6") v4Route := &route.Route{NetID: "exit1", Network: netip.MustParsePrefix("0.0.0.0/0")} v6Route := &route.Route{NetID: "exit1-v6", Network: netip.MustParsePrefix("::/0")} @@ -399,6 +424,15 @@ func TestRouteSelector_V6ExitPairInherits(t *testing.T) { assert.Empty(t, filtered, "deselecting v4 base must also drop the v6 pair") }) + t.Run("deselectAll makes sync a no-op", func(t *testing.T) { + rs := routeselector.NewRouteSelector() + rs.DeselectAllRoutes() + + rs.SyncPairedSelection("exit1", "exit1-v6") + + assert.False(t, rs.HasUserSelectionForRoute("exit1-v6"), "sync must not write explicit state under deselectAll") + }) + t.Run("non-exit *-v6 routes pass through FilterSelectedExitNodes", func(t *testing.T) { rs := routeselector.NewRouteSelector() require.NoError(t, rs.DeselectRoutes([]route.NetID{"corp"}, all)) diff --git a/client/ios/NetBirdSDK/client.go b/client/ios/NetBirdSDK/client.go index bafbb0031..bfcef6331 100644 --- a/client/ios/NetBirdSDK/client.go +++ b/client/ios/NetBirdSDK/client.go @@ -54,6 +54,7 @@ type selectRoute struct { Network netip.Prefix Domains domain.List Selected bool + Status string extraNetworks []netip.Prefix } @@ -377,9 +378,57 @@ func (c *Client) GetRoutesSelectionDetails() (*RoutesSelectionDetails, error) { routes := buildSelectRoutes(routesMap, routeSelector.IsSelected, v6ExitMerged) resolvedDomains := c.recorder.GetResolvedDomainsStates() + // Compute each route's connection status in the core (mirroring the Android + // bridge), so the UI doesn't have to infer it by string-matching the joined + // Network value against peer routes. For a merged exit node the status reflects + // whichever of the v4/v6 prefixes is served by a connected peer; for dynamic + // (DNS) routes the peer route key is the domain pattern (see dynamic.Route.String). + connectedRoutes := c.connectedRouteSet() + for _, r := range routes { + r.Status = routeStatus(r, connectedRoutes) + } + return prepareRouteSelectionDetails(routes, resolvedDomains), nil } +// connectedRouteSet returns the set of route keys (as strings) currently served by a +// connected peer, gathered across all connected peers' route tables. The keys match +// what the route manager records: a prefix string for static routes (e.g. "0.0.0.0/0") +// and the domain pattern for dynamic routes (e.g. "*.example.com"). +func (c *Client) connectedRouteSet() map[string]struct{} { + connected := map[string]struct{}{} + for _, p := range c.recorder.GetFullStatus().Peers { + if p.ConnStatus != peer.StatusConnected { + continue + } + for r := range p.GetRoutes() { + connected[r] = struct{}{} + } + } + return connected +} + +// routeStatus reports "Connected" if any of the route's keys is served by a connected +// peer: the primary Network prefix, an extra v6 network of a merged exit node, or the +// domain pattern for a dynamic DNS route. Otherwise "Idle". +func routeStatus(r *selectRoute, connectedRoutes map[string]struct{}) string { + keys := make([]string, 0, 1+len(r.extraNetworks)) + if len(r.Domains) > 0 { + keys = append(keys, r.Domains.SafeString()) + } else { + keys = append(keys, r.Network.String()) + } + for _, extra := range r.extraNetworks { + keys = append(keys, extra.String()) + } + for _, k := range keys { + if _, ok := connectedRoutes[k]; ok { + return peer.StatusConnected.String() + } + } + return peer.StatusIdle.String() +} + func buildSelectRoutes(routesMap map[route.NetID][]*route.Route, isSelected func(route.NetID) bool, v6Merged map[route.NetID]struct{}) []*selectRoute { var routes []*selectRoute for id, rt := range routesMap { @@ -462,6 +511,7 @@ func prepareRouteSelectionDetails(routes []*selectRoute, resolvedDomains map[dom Network: netStr, Domains: &domainDetails, Selected: r.Selected, + Status: r.Status, }) } diff --git a/client/ios/NetBirdSDK/routes.go b/client/ios/NetBirdSDK/routes.go index 025313bfa..56af2a1ad 100644 --- a/client/ios/NetBirdSDK/routes.go +++ b/client/ios/NetBirdSDK/routes.go @@ -20,6 +20,7 @@ type RoutesSelectionInfo struct { Network string Domains *DomainDetails Selected bool + Status string } type DomainCollection interface {