package routemanager import ( "fmt" "slices" "github.com/hashicorp/go-multierror" log "github.com/sirupsen/logrus" "golang.org/x/exp/maps" nberrors "github.com/netbirdio/netbird/client/errors" "github.com/netbirdio/netbird/route" ) // SelectRoutes selects the routes with the given network IDs and applies the // new selection. V4/v6 exit-node pairs are expanded automatically. Exit nodes // are mutually exclusive: if the selection activates an exit node, every other // available exit node is deselected so two can't be active at once. With // appendRoute=false the previous selection is replaced instead of extended. func (m *DefaultManager) SelectRoutes(ids []route.NetID, appendRoute bool) error { if err := m.selectRoutes(ids, appendRoute); err != nil { return err } m.TriggerSelection(m.GetClientRoutes()) return nil } // DeselectRoutes removes the routes with the given network IDs from the // selection and applies the change. V4/v6 exit-node pairs are expanded // automatically. func (m *DefaultManager) DeselectRoutes(ids []route.NetID) error { if err := m.deselectRoutes(ids); err != nil { return err } m.TriggerSelection(m.GetClientRoutes()) return nil } func (m *DefaultManager) deselectRoutes(ids []route.NetID) error { routesMap := m.GetClientRoutesWithNetID() routes := route.ExpandV6ExitPairs(slices.Clone(ids), routesMap) log.Debugf("deselecting routes with ids: %v", routes) if err := m.routeSelector.DeselectRoutes(routes, maps.Keys(routesMap)); err != nil { return fmt.Errorf("deselect routes: %w", err) } return nil } // SelectAllRoutes selects every available route and applies the selection. // Exit nodes stay mutually exclusive: at most one remains active. func (m *DefaultManager) SelectAllRoutes() { m.selectAllRoutes() m.TriggerSelection(m.GetClientRoutes()) } func (m *DefaultManager) selectAllRoutes() { m.routeSelector.SelectAllRoutes() // Select-all wipes every explicit selection, so exit nodes fall back to // management's auto-apply flags — which may mark several at once. // Reconcile immediately so at most one exit node stays active instead of // waiting for the next network map to enforce it. m.mux.Lock() defer m.mux.Unlock() m.updateRouteSelectorFromManagement(m.clientRoutes) } // DeselectAllRoutes deselects every route and applies the change. func (m *DefaultManager) DeselectAllRoutes() { m.routeSelector.DeselectAllRoutes() m.TriggerSelection(m.GetClientRoutes()) } func (m *DefaultManager) selectRoutes(ids []route.NetID, appendRoute bool) error { routesMap := m.GetClientRoutesWithNetID() routes := route.ExpandV6ExitPairs(slices.Clone(ids), routesMap) allIDs := maps.Keys(routesMap) log.Debugf("selecting routes with ids: %v", routes) // A partial failure (e.g. an unknown ID in the request) still selects the // valid routes, so exclusivity below must run regardless of the error. var merr *multierror.Error if err := m.routeSelector.SelectRoutes(routes, appendRoute, allIDs); err != nil { merr = multierror.Append(merr, fmt.Errorf("select routes: %w", err)) } // Exit nodes are mutually exclusive: if this selection activates an // exit node, deselect every other available exit node so two can't be // selected at once. Non-exit route selections are left untouched. if requestActivatesExitNode(routes, routesMap) { if others := otherExitNodeIDs(routesMap, routes); len(others) > 0 { if err := m.routeSelector.DeselectRoutes(others, allIDs); err != nil { merr = multierror.Append(merr, fmt.Errorf("deselect sibling exit nodes: %w", err)) } } } return nberrors.FormatErrorOrNil(merr) } func isExitNodeRoutes(routes []*route.Route) bool { return len(routes) > 0 && (route.IsV4DefaultRoute(routes[0].Network) || route.IsV6DefaultRoute(routes[0].Network)) } // requestActivatesExitNode reports whether any requested NetID maps to an exit // node (default route) in the current route table. func requestActivatesExitNode(requested []route.NetID, routesMap map[route.NetID][]*route.Route) bool { for _, id := range requested { if isExitNodeRoutes(routesMap[id]) { return true } } return false } // otherExitNodeIDs returns every available exit-node NetID that is not in the // requested set — the siblings to deselect so a single exit node stays active. func otherExitNodeIDs(routesMap map[route.NetID][]*route.Route, requested []route.NetID) []route.NetID { keep := make(map[route.NetID]struct{}, len(requested)) for _, id := range requested { keep[id] = struct{}{} } var others []route.NetID for id, routes := range routesMap { if !isExitNodeRoutes(routes) { continue } if _, ok := keep[id]; ok { continue } others = append(others, id) } return others }