Files
netbird/client/server/network.go
Zoltan Papp 966fbec119 routemanager: enforce a single selected exit node
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 (and on runtime selection):
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 doesn't request; it stays off until the user picks it.

The server deselects sibling exit nodes when the user activates one (leaving
non-exit routes untouched), and the tray/React exit-node toggle now appends so
activating an exit node no longer wipes network-route selections.
2026-05-27 20:48:16 +02:00

298 lines
8.0 KiB
Go

package server
import (
"context"
"fmt"
"net/netip"
"slices"
"sort"
"strings"
"golang.org/x/exp/maps"
"google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/route"
"github.com/netbirdio/netbird/shared/management/domain"
)
type selectRoute struct {
NetID route.NetID
Network netip.Prefix
Domains domain.List
Selected bool
extraNetworks []netip.Prefix
}
// ListNetworks returns a list of all available networks.
func (s *Server) ListNetworks(context.Context, *proto.ListNetworksRequest) (*proto.ListNetworksResponse, error) {
s.mutex.Lock()
defer s.mutex.Unlock()
if s.networksDisabled {
return nil, gstatus.Errorf(codes.Unavailable, errNetworksDisabled)
}
if s.connectClient == nil {
return nil, fmt.Errorf("not connected")
}
engine := s.connectClient.Engine()
if engine == nil {
return nil, fmt.Errorf("not connected")
}
routeMgr := engine.GetRouteManager()
if routeMgr == nil {
return nil, fmt.Errorf("no route manager")
}
routesMap := routeMgr.GetClientRoutesWithNetID()
routeSelector := routeMgr.GetRouteSelector()
v6ExitMerged := route.V6ExitMergeSet(routesMap)
var routes []*selectRoute
for id, rt := range routesMap {
if len(rt) == 0 {
continue
}
// Skip v6 exit nodes that are merged into their v4 counterpart.
if _, ok := v6ExitMerged[id]; ok {
continue
}
r := &selectRoute{
NetID: id,
Network: rt[0].Network,
Domains: rt[0].Domains,
Selected: routeSelector.IsSelected(id),
}
// Merge paired v6 exit node prefix into this entry.
v6ID := route.NetID(string(id) + route.V6ExitSuffix)
if _, ok := v6ExitMerged[v6ID]; ok && len(routesMap[v6ID]) > 0 {
r.extraNetworks = []netip.Prefix{routesMap[v6ID][0].Network}
}
routes = append(routes, r)
}
sort.Slice(routes, func(i, j int) bool {
iPrefix := routes[i].Network.Bits()
jPrefix := routes[j].Network.Bits()
if iPrefix == jPrefix {
iAddr := routes[i].Network.Addr()
jAddr := routes[j].Network.Addr()
if iAddr == jAddr {
return routes[i].NetID < routes[j].NetID
}
return iAddr.String() < jAddr.String()
}
return iPrefix < jPrefix
})
resolvedDomains := s.statusRecorder.GetResolvedDomainsStates()
var pbRoutes []*proto.Network
for _, route := range routes {
rangeStr := route.Network.String()
for _, extra := range route.extraNetworks {
rangeStr += ", " + extra.String()
}
pbRoute := &proto.Network{
ID: string(route.NetID),
Range: rangeStr,
Domains: route.Domains.ToSafeStringList(),
ResolvedIPs: map[string]*proto.IPList{},
Selected: route.Selected,
}
// Group resolved IPs by their parent domain
domainMap := map[domain.Domain][]string{}
for resolvedDomain, info := range resolvedDomains {
// Check if this resolved domain's parent is in our route's domains
if slices.Contains(route.Domains, info.ParentDomain) {
ips := make([]string, 0, len(info.Prefixes))
for _, prefix := range info.Prefixes {
ips = append(ips, prefix.Addr().String())
}
domainMap[resolvedDomain] = ips
}
}
// Convert to proto format
for domain, ips := range domainMap {
pbRoute.ResolvedIPs[domain.SafeString()] = &proto.IPList{
Ips: ips,
}
}
pbRoutes = append(pbRoutes, pbRoute)
}
return &proto.ListNetworksResponse{
Routes: pbRoutes,
}, nil
}
// SelectNetworks selects specific networks based on the client request.
func (s *Server) SelectNetworks(_ context.Context, req *proto.SelectNetworksRequest) (*proto.SelectNetworksResponse, error) {
s.mutex.Lock()
defer s.mutex.Unlock()
if s.networksDisabled {
return nil, gstatus.Errorf(codes.Unavailable, errNetworksDisabled)
}
if s.connectClient == nil {
return nil, fmt.Errorf("not connected")
}
engine := s.connectClient.Engine()
if engine == nil {
return nil, fmt.Errorf("not connected")
}
routeManager := engine.GetRouteManager()
if routeManager == nil {
return nil, fmt.Errorf("no route manager")
}
routeSelector := routeManager.GetRouteSelector()
if req.GetAll() {
routeSelector.SelectAllRoutes()
} else {
routes := toNetIDs(req.GetNetworkIDs())
routesMap := routeManager.GetClientRoutesWithNetID()
routes = route.ExpandV6ExitPairs(routes, routesMap)
netIdRoutes := maps.Keys(routesMap)
if err := routeSelector.SelectRoutes(routes, req.GetAppend(), netIdRoutes); err != nil {
return nil, 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 := routeSelector.DeselectRoutes(others, netIdRoutes); err != nil {
return nil, fmt.Errorf("deselect sibling exit nodes: %w", err)
}
}
}
}
routeManager.TriggerSelection(routeManager.GetClientRoutes())
s.statusRecorder.PublishEvent(
proto.SystemEvent_INFO,
proto.SystemEvent_SYSTEM,
"Network selection changed",
"",
map[string]string{
"networks": strings.Join(req.GetNetworkIDs(), ", "),
"append": fmt.Sprint(req.GetAppend()),
"all": fmt.Sprint(req.GetAll()),
},
)
return &proto.SelectNetworksResponse{}, nil
}
// DeselectNetworks deselects specific networks based on the client request.
func (s *Server) DeselectNetworks(_ context.Context, req *proto.SelectNetworksRequest) (*proto.SelectNetworksResponse, error) {
s.mutex.Lock()
defer s.mutex.Unlock()
if s.networksDisabled {
return nil, gstatus.Errorf(codes.Unavailable, errNetworksDisabled)
}
if s.connectClient == nil {
return nil, fmt.Errorf("not connected")
}
engine := s.connectClient.Engine()
if engine == nil {
return nil, fmt.Errorf("not connected")
}
routeManager := engine.GetRouteManager()
if routeManager == nil {
return nil, fmt.Errorf("no route manager")
}
routeSelector := routeManager.GetRouteSelector()
if req.GetAll() {
routeSelector.DeselectAllRoutes()
} else {
routes := toNetIDs(req.GetNetworkIDs())
routesMap := routeManager.GetClientRoutesWithNetID()
routes = route.ExpandV6ExitPairs(routes, routesMap)
netIdRoutes := maps.Keys(routesMap)
if err := routeSelector.DeselectRoutes(routes, netIdRoutes); err != nil {
return nil, fmt.Errorf("deselect routes: %w", err)
}
}
routeManager.TriggerSelection(routeManager.GetClientRoutes())
s.statusRecorder.PublishEvent(
proto.SystemEvent_INFO,
proto.SystemEvent_SYSTEM,
"Network deselection changed",
"",
map[string]string{
"networks": strings.Join(req.GetNetworkIDs(), ", "),
"append": fmt.Sprint(req.GetAppend()),
"all": fmt.Sprint(req.GetAll()),
},
)
return &proto.SelectNetworksResponse{}, nil
}
func toNetIDs(routes []string) []route.NetID {
var netIDs []route.NetID
for _, rt := range routes {
netIDs = append(netIDs, route.NetID(rt))
}
return netIDs
}
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
}