mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-16 19:59:07 +02:00
* routemanager: enforce a single selected exit node
Backport of the exit-node exclusivity reconcile from the 0.75.0 line
(upstream commit 966fbec11) onto v0.74.0. Exit nodes are mutually
exclusive, but the RouteSelector stores routes with default-on semantics,
so every available exit node reported as selected at once.
Reconcile exit-node selection on each network map: keep at most one
selected -- the user's persisted pick, else whatever management marks for
auto-apply (SkipAutoApply=false), else none. Never auto-activate an exit
node the map does not request.
Carries over only the manager/routeselector logic and its test; the
desktop-only client/server changes and the BumpNetworksRevision UI-push
feature from the original commit are intentionally excluded.
* routeselector: make exit-node reconciliation atomic
enforceSingleExitNode took the RouteSelector lock three separate times
(IsDeselectAll, then DeselectRoutes, then SelectRoutes), so a concurrent
DeselectAllRoutes could interleave and be silently undone: SelectRoutes on
its deselectAll branch clears the flag and re-selects the preferred exit
node, overriding the user's "all off".
Move the whole reconciliation into a single locked RouteSelector method
(SetExclusiveExitNode) that checks deselectAll inside the critical section,
so a deselect-all either fully precedes the reconcile (left untouched) or
fully follows it (honoured). No interleaving is possible.
355 lines
9.2 KiB
Go
355 lines
9.2 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)
|
|
}
|
|
|
|
// SetExclusiveExitNode atomically makes preferred the only selected exit node
|
|
// among exitIDs: every other ID in exitIDs is deselected and preferred (when
|
|
// non-empty) is selected, all under a single lock. Holding the lock across the
|
|
// whole reconciliation prevents a concurrent DeselectAllRoutes from interleaving
|
|
// between the deselect and select steps and being silently undone. A global
|
|
// deselect-all is left untouched so the user's "all off" stays in effect;
|
|
// non-exit routes are never referenced, so their selection is preserved.
|
|
func (rs *RouteSelector) SetExclusiveExitNode(preferred route.NetID, exitIDs []route.NetID) {
|
|
rs.mu.Lock()
|
|
defer rs.mu.Unlock()
|
|
|
|
if rs.deselectAll {
|
|
return
|
|
}
|
|
|
|
for _, id := range exitIDs {
|
|
if id == preferred {
|
|
continue
|
|
}
|
|
rs.deselectedRoutes[id] = struct{}{}
|
|
delete(rs.selectedRoutes, id)
|
|
}
|
|
|
|
if preferred != "" {
|
|
delete(rs.deselectedRoutes, preferred)
|
|
rs.selectedRoutes[preferred] = struct{}{}
|
|
}
|
|
}
|
|
|
|
// IsDeselectAll reports whether the global "deselect all" flag is set, i.e. the
|
|
// user explicitly disabled every route. Callers enforcing per-route invariants
|
|
// (e.g. single exit node) should leave the selection untouched when it is.
|
|
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
|
|
}
|