* [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.
324 lines
8.1 KiB
Go
324 lines
8.1 KiB
Go
package routeselector
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"slices"
|
|
"sync"
|
|
|
|
"github.com/hashicorp/go-multierror"
|
|
|
|
"github.com/netbirdio/netbird/client/errors"
|
|
"github.com/netbirdio/netbird/route"
|
|
)
|
|
|
|
type RouteSelector struct {
|
|
mu sync.RWMutex
|
|
deselectedRoutes map[route.NetID]struct{}
|
|
selectedRoutes map[route.NetID]struct{}
|
|
deselectAll bool
|
|
}
|
|
|
|
func NewRouteSelector() *RouteSelector {
|
|
return &RouteSelector{
|
|
deselectedRoutes: map[route.NetID]struct{}{},
|
|
selectedRoutes: map[route.NetID]struct{}{},
|
|
deselectAll: false,
|
|
}
|
|
}
|
|
|
|
// SelectRoutes updates the selected routes based on the provided route IDs.
|
|
func (rs *RouteSelector) SelectRoutes(routes []route.NetID, appendRoute bool, allRoutes []route.NetID) error {
|
|
rs.mu.Lock()
|
|
defer rs.mu.Unlock()
|
|
|
|
if !appendRoute || rs.deselectAll {
|
|
if rs.deselectedRoutes == nil {
|
|
rs.deselectedRoutes = map[route.NetID]struct{}{}
|
|
}
|
|
if rs.selectedRoutes == nil {
|
|
rs.selectedRoutes = map[route.NetID]struct{}{}
|
|
}
|
|
clear(rs.deselectedRoutes)
|
|
clear(rs.selectedRoutes)
|
|
for _, r := range allRoutes {
|
|
rs.deselectedRoutes[r] = struct{}{}
|
|
}
|
|
}
|
|
|
|
var err *multierror.Error
|
|
for _, route := range routes {
|
|
if !slices.Contains(allRoutes, route) {
|
|
err = multierror.Append(err, fmt.Errorf("route '%s' is not available", route))
|
|
continue
|
|
}
|
|
delete(rs.deselectedRoutes, route)
|
|
rs.selectedRoutes[route] = struct{}{}
|
|
}
|
|
|
|
rs.deselectAll = false
|
|
|
|
return errors.FormatErrorOrNil(err)
|
|
}
|
|
|
|
// SelectAllRoutes sets the selector to select all routes.
|
|
func (rs *RouteSelector) SelectAllRoutes() {
|
|
rs.mu.Lock()
|
|
defer rs.mu.Unlock()
|
|
|
|
rs.deselectAll = false
|
|
if rs.deselectedRoutes == nil {
|
|
rs.deselectedRoutes = map[route.NetID]struct{}{}
|
|
}
|
|
if rs.selectedRoutes == nil {
|
|
rs.selectedRoutes = map[route.NetID]struct{}{}
|
|
}
|
|
clear(rs.deselectedRoutes)
|
|
clear(rs.selectedRoutes)
|
|
}
|
|
|
|
// DeselectRoutes removes specific routes from the selection.
|
|
func (rs *RouteSelector) DeselectRoutes(routes []route.NetID, allRoutes []route.NetID) error {
|
|
rs.mu.Lock()
|
|
defer rs.mu.Unlock()
|
|
|
|
if rs.deselectAll {
|
|
return nil
|
|
}
|
|
|
|
var err *multierror.Error
|
|
for _, route := range routes {
|
|
if !slices.Contains(allRoutes, route) {
|
|
err = multierror.Append(err, fmt.Errorf("route '%s' is not available", route))
|
|
continue
|
|
}
|
|
rs.deselectedRoutes[route] = struct{}{}
|
|
delete(rs.selectedRoutes, route)
|
|
}
|
|
|
|
return errors.FormatErrorOrNil(err)
|
|
}
|
|
|
|
// DeselectAllRoutes deselects all routes, effectively disabling route selection.
|
|
func (rs *RouteSelector) DeselectAllRoutes() {
|
|
rs.mu.Lock()
|
|
defer rs.mu.Unlock()
|
|
|
|
rs.deselectAll = true
|
|
if rs.deselectedRoutes == nil {
|
|
rs.deselectedRoutes = map[route.NetID]struct{}{}
|
|
}
|
|
if rs.selectedRoutes == nil {
|
|
rs.selectedRoutes = map[route.NetID]struct{}{}
|
|
}
|
|
clear(rs.deselectedRoutes)
|
|
clear(rs.selectedRoutes)
|
|
}
|
|
|
|
// IsDeselectAll reports whether the user has explicitly deselected all routes.
|
|
func (rs *RouteSelector) IsDeselectAll() bool {
|
|
rs.mu.RLock()
|
|
defer rs.mu.RUnlock()
|
|
|
|
return rs.deselectAll
|
|
}
|
|
|
|
// IsSelected checks if a specific route is selected.
|
|
func (rs *RouteSelector) IsSelected(routeID route.NetID) bool {
|
|
rs.mu.RLock()
|
|
defer rs.mu.RUnlock()
|
|
|
|
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()
|
|
defer rs.mu.RUnlock()
|
|
|
|
if rs.deselectAll {
|
|
return route.HAMap{}
|
|
}
|
|
|
|
filtered := route.HAMap{}
|
|
for id, rt := range routes {
|
|
if !rs.isDeselectedLocked(id.NetID()) {
|
|
filtered[id] = rt
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
// HasUserSelectionForRoute returns true if the user has explicitly selected or deselected this route.
|
|
// 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(routeID)
|
|
}
|
|
|
|
func (rs *RouteSelector) FilterSelectedExitNodes(routes route.HAMap) route.HAMap {
|
|
rs.mu.RLock()
|
|
defer rs.mu.RUnlock()
|
|
|
|
if rs.deselectAll {
|
|
return route.HAMap{}
|
|
}
|
|
|
|
filtered := make(route.HAMap, len(routes))
|
|
for id, rt := range routes {
|
|
netID := id.NetID()
|
|
if rs.isDeselectedLocked(netID) {
|
|
continue
|
|
}
|
|
|
|
if !isExitNode(rt) {
|
|
filtered[id] = rt
|
|
continue
|
|
}
|
|
|
|
rs.applyExitNodeFilter(id, netID, rt, filtered)
|
|
}
|
|
|
|
return filtered
|
|
}
|
|
|
|
// MarshalJSON implements the json.Marshaler interface
|
|
func (rs *RouteSelector) MarshalJSON() ([]byte, error) {
|
|
rs.mu.RLock()
|
|
defer rs.mu.RUnlock()
|
|
|
|
return json.Marshal(struct {
|
|
SelectedRoutes map[route.NetID]struct{} `json:"selected_routes"`
|
|
DeselectedRoutes map[route.NetID]struct{} `json:"deselected_routes"`
|
|
DeselectAll bool `json:"deselect_all"`
|
|
}{
|
|
SelectedRoutes: rs.selectedRoutes,
|
|
DeselectedRoutes: rs.deselectedRoutes,
|
|
DeselectAll: rs.deselectAll,
|
|
})
|
|
}
|
|
|
|
// UnmarshalJSON implements the json.Unmarshaler interface
|
|
// If the JSON is empty or null, it will initialize like a NewRouteSelector.
|
|
func (rs *RouteSelector) UnmarshalJSON(data []byte) error {
|
|
rs.mu.Lock()
|
|
defer rs.mu.Unlock()
|
|
|
|
// Check for null or empty JSON
|
|
if len(data) == 0 || string(data) == "null" {
|
|
rs.deselectedRoutes = map[route.NetID]struct{}{}
|
|
rs.selectedRoutes = map[route.NetID]struct{}{}
|
|
rs.deselectAll = false
|
|
return nil
|
|
}
|
|
|
|
var temp struct {
|
|
SelectedRoutes map[route.NetID]struct{} `json:"selected_routes"`
|
|
DeselectedRoutes map[route.NetID]struct{} `json:"deselected_routes"`
|
|
DeselectAll bool `json:"deselect_all"`
|
|
}
|
|
|
|
if err := json.Unmarshal(data, &temp); err != nil {
|
|
return err
|
|
}
|
|
|
|
rs.selectedRoutes = temp.SelectedRoutes
|
|
rs.deselectedRoutes = temp.DeselectedRoutes
|
|
rs.deselectAll = temp.DeselectAll
|
|
|
|
if rs.deselectedRoutes == nil {
|
|
rs.deselectedRoutes = map[route.NetID]struct{}{}
|
|
}
|
|
if rs.selectedRoutes == nil {
|
|
rs.selectedRoutes = map[route.NetID]struct{}{}
|
|
}
|
|
|
|
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
|
|
}
|