mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-19 04:59:56 +00:00
* [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.
834 lines
25 KiB
Go
834 lines
25 KiB
Go
package routemanager
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/netip"
|
|
"net/url"
|
|
"runtime"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/hashicorp/go-multierror"
|
|
log "github.com/sirupsen/logrus"
|
|
"golang.org/x/exp/maps"
|
|
|
|
nberrors "github.com/netbirdio/netbird/client/errors"
|
|
firewall "github.com/netbirdio/netbird/client/firewall/manager"
|
|
"github.com/netbirdio/netbird/client/iface/configurer"
|
|
"github.com/netbirdio/netbird/client/iface/netstack"
|
|
"github.com/netbirdio/netbird/client/internal/dns"
|
|
"github.com/netbirdio/netbird/client/internal/listener"
|
|
"github.com/netbirdio/netbird/client/internal/peer"
|
|
"github.com/netbirdio/netbird/client/internal/peerstore"
|
|
"github.com/netbirdio/netbird/client/internal/routemanager/client"
|
|
"github.com/netbirdio/netbird/client/internal/routemanager/common"
|
|
"github.com/netbirdio/netbird/client/internal/routemanager/fakeip"
|
|
"github.com/netbirdio/netbird/client/internal/routemanager/iface"
|
|
"github.com/netbirdio/netbird/client/internal/routemanager/notifier"
|
|
"github.com/netbirdio/netbird/client/internal/routemanager/refcounter"
|
|
"github.com/netbirdio/netbird/client/internal/routemanager/server"
|
|
"github.com/netbirdio/netbird/client/internal/routemanager/systemops"
|
|
"github.com/netbirdio/netbird/client/internal/routemanager/vars"
|
|
"github.com/netbirdio/netbird/client/internal/routeselector"
|
|
"github.com/netbirdio/netbird/client/internal/statemanager"
|
|
nbnet "github.com/netbirdio/netbird/client/net"
|
|
nbdns "github.com/netbirdio/netbird/dns"
|
|
"github.com/netbirdio/netbird/route"
|
|
relayClient "github.com/netbirdio/netbird/shared/relay/client"
|
|
"github.com/netbirdio/netbird/version"
|
|
)
|
|
|
|
// Manager is a route manager interface
|
|
type Manager interface {
|
|
Init() error
|
|
UpdateRoutes(updateSerial uint64, serverRoutes map[route.ID]*route.Route, clientRoutes route.HAMap, useNewDNSRoute bool) error
|
|
ClassifyRoutes(newRoutes []*route.Route) (map[route.ID]*route.Route, route.HAMap)
|
|
TriggerSelection(route.HAMap)
|
|
GetRouteSelector() *routeselector.RouteSelector
|
|
GetClientRoutes() route.HAMap
|
|
GetSelectedClientRoutes() route.HAMap
|
|
GetActiveClientRoutes() route.HAMap
|
|
GetClientRoutesWithNetID() map[route.NetID][]*route.Route
|
|
SetRouteChangeListener(listener listener.NetworkChangeListener)
|
|
InitialRouteRange() []string
|
|
SetFirewall(firewall.Manager) error
|
|
SetDNSForwarderPort(port uint16)
|
|
Stop(stateManager *statemanager.Manager)
|
|
}
|
|
|
|
type ManagerConfig struct {
|
|
Context context.Context
|
|
PublicKey string
|
|
DNSRouteInterval time.Duration
|
|
WGInterface iface.WGIface
|
|
StatusRecorder *peer.Status
|
|
RelayManager *relayClient.Manager
|
|
InitialRoutes []*route.Route
|
|
StateManager *statemanager.Manager
|
|
DNSServer dns.Server
|
|
DNSFeatureFlag bool
|
|
PeerStore *peerstore.Store
|
|
DisableClientRoutes bool
|
|
DisableServerRoutes bool
|
|
}
|
|
|
|
// DefaultManager is the default instance of a route manager
|
|
type DefaultManager struct {
|
|
ctx context.Context
|
|
stop context.CancelFunc
|
|
mux sync.Mutex
|
|
shutdownWg sync.WaitGroup
|
|
clientNetworks map[route.HAUniqueID]*client.Watcher
|
|
routeSelector *routeselector.RouteSelector
|
|
serverRouter *server.Router
|
|
sysOps *systemops.SysOps
|
|
statusRecorder *peer.Status
|
|
relayMgr *relayClient.Manager
|
|
wgInterface iface.WGIface
|
|
pubKey string
|
|
notifier *notifier.Notifier
|
|
routeRefCounter *refcounter.RouteRefCounter
|
|
allowedIPsRefCounter *refcounter.AllowedIPsRefCounter
|
|
dnsRouteInterval time.Duration
|
|
stateManager *statemanager.Manager
|
|
// clientRoutes is the most recent list of clientRoutes received from the Management Service
|
|
clientRoutes route.HAMap
|
|
dnsServer dns.Server
|
|
firewall firewall.Manager
|
|
peerStore *peerstore.Store
|
|
useNewDNSRoute bool
|
|
disableClientRoutes bool
|
|
disableServerRoutes bool
|
|
activeRoutes map[route.HAUniqueID]client.RouteHandler
|
|
fakeIPManager *fakeip.Manager
|
|
dnsForwarderPort atomic.Uint32
|
|
}
|
|
|
|
func NewManager(config ManagerConfig) *DefaultManager {
|
|
mCTX, cancel := context.WithCancel(config.Context)
|
|
notifier := notifier.NewNotifier()
|
|
sysOps := systemops.New(config.WGInterface, notifier)
|
|
|
|
if runtime.GOOS == "windows" && config.WGInterface != nil {
|
|
nbnet.SetVPNInterfaceName(config.WGInterface.Name())
|
|
}
|
|
|
|
dm := &DefaultManager{
|
|
ctx: mCTX,
|
|
stop: cancel,
|
|
dnsRouteInterval: config.DNSRouteInterval,
|
|
clientNetworks: make(map[route.HAUniqueID]*client.Watcher),
|
|
relayMgr: config.RelayManager,
|
|
sysOps: sysOps,
|
|
statusRecorder: config.StatusRecorder,
|
|
wgInterface: config.WGInterface,
|
|
pubKey: config.PublicKey,
|
|
notifier: notifier,
|
|
stateManager: config.StateManager,
|
|
dnsServer: config.DNSServer,
|
|
peerStore: config.PeerStore,
|
|
disableClientRoutes: config.DisableClientRoutes,
|
|
disableServerRoutes: config.DisableServerRoutes,
|
|
activeRoutes: make(map[route.HAUniqueID]client.RouteHandler),
|
|
}
|
|
dm.dnsForwarderPort.Store(uint32(nbdns.ForwarderClientPort))
|
|
|
|
useNoop := netstack.IsEnabled() || config.DisableClientRoutes
|
|
dm.setupRefCounters(useNoop)
|
|
|
|
// don't proceed with client routes if it is disabled
|
|
if config.DisableClientRoutes {
|
|
return dm
|
|
}
|
|
|
|
if runtime.GOOS == "android" {
|
|
dm.setupAndroidRoutes(config)
|
|
}
|
|
return dm
|
|
}
|
|
func (m *DefaultManager) setupAndroidRoutes(config ManagerConfig) {
|
|
cr := m.initialClientRoutes(config.InitialRoutes)
|
|
|
|
routesForComparison := slices.Clone(cr)
|
|
|
|
if config.DNSFeatureFlag {
|
|
m.fakeIPManager = fakeip.NewManager()
|
|
|
|
v4ID := uuid.NewString()
|
|
fakeIPRoute := &route.Route{
|
|
ID: route.ID(v4ID),
|
|
Network: m.fakeIPManager.GetFakeIPBlock(),
|
|
NetID: route.NetID(v4ID),
|
|
Peer: m.pubKey,
|
|
NetworkType: route.IPv4Network,
|
|
}
|
|
v6ID := uuid.NewString()
|
|
fakeIPv6Route := &route.Route{
|
|
ID: route.ID(v6ID),
|
|
Network: m.fakeIPManager.GetFakeIPv6Block(),
|
|
NetID: route.NetID(v6ID),
|
|
Peer: m.pubKey,
|
|
NetworkType: route.IPv6Network,
|
|
}
|
|
cr = append(cr, fakeIPRoute, fakeIPv6Route)
|
|
m.notifier.SetFakeIPRoutes([]*route.Route{fakeIPRoute, fakeIPv6Route})
|
|
}
|
|
|
|
m.notifier.SetInitialClientRoutes(cr, routesForComparison)
|
|
}
|
|
|
|
func (m *DefaultManager) setupRefCounters(useNoop bool) {
|
|
var once sync.Once
|
|
var wgIface *net.Interface
|
|
toInterface := func() *net.Interface {
|
|
once.Do(func() {
|
|
wgIface = m.wgInterface.ToInterface()
|
|
})
|
|
return wgIface
|
|
}
|
|
|
|
m.routeRefCounter = refcounter.New(
|
|
func(prefix netip.Prefix, _ struct{}) (struct{}, error) {
|
|
return struct{}{}, m.sysOps.AddVPNRoute(prefix, toInterface())
|
|
},
|
|
func(prefix netip.Prefix, _ struct{}) error {
|
|
return m.sysOps.RemoveVPNRoute(prefix, toInterface())
|
|
},
|
|
)
|
|
|
|
if useNoop {
|
|
m.routeRefCounter = refcounter.New(
|
|
func(netip.Prefix, struct{}) (struct{}, error) {
|
|
return struct{}{}, refcounter.ErrIgnore
|
|
},
|
|
func(netip.Prefix, struct{}) error {
|
|
return nil
|
|
},
|
|
)
|
|
}
|
|
|
|
m.allowedIPsRefCounter = refcounter.New(
|
|
func(prefix netip.Prefix, peerKey string) (string, error) {
|
|
// save peerKey to use it in the remove function
|
|
return peerKey, m.wgInterface.AddAllowedIP(peerKey, prefix)
|
|
},
|
|
func(prefix netip.Prefix, peerKey string) error {
|
|
if err := m.wgInterface.RemoveAllowedIP(peerKey, prefix); err != nil {
|
|
if !errors.Is(err, configurer.ErrPeerNotFound) && !errors.Is(err, configurer.ErrAllowedIPNotFound) {
|
|
return err
|
|
}
|
|
log.Tracef("Remove allowed IPs %s for %s: %v", prefix, peerKey, err)
|
|
}
|
|
return nil
|
|
},
|
|
)
|
|
}
|
|
|
|
// Init sets up the routing
|
|
func (m *DefaultManager) Init() error {
|
|
m.routeSelector = m.initSelector()
|
|
|
|
if nbnet.CustomRoutingDisabled() || m.disableClientRoutes {
|
|
return nil
|
|
}
|
|
|
|
if err := m.sysOps.CleanupRouting(nil, nbnet.AdvancedRouting()); err != nil {
|
|
log.Warnf("Failed cleaning up routing: %v", err)
|
|
}
|
|
|
|
initialAddresses := []string{m.statusRecorder.GetManagementState().URL, m.statusRecorder.GetSignalState().URL}
|
|
if m.relayMgr != nil {
|
|
initialAddresses = append(initialAddresses, m.relayMgr.ServerURLs()...)
|
|
}
|
|
|
|
ips := resolveURLsToIPs(initialAddresses)
|
|
|
|
if err := m.sysOps.SetupRouting(ips, m.stateManager, nbnet.AdvancedRouting()); err != nil {
|
|
return fmt.Errorf("setup routing: %w", err)
|
|
}
|
|
|
|
log.Info("Routing setup complete")
|
|
return nil
|
|
}
|
|
|
|
func (m *DefaultManager) initSelector() *routeselector.RouteSelector {
|
|
var state *SelectorState
|
|
m.stateManager.RegisterState(state)
|
|
|
|
// restore selector state if it exists
|
|
if err := m.stateManager.LoadState(state); err != nil {
|
|
log.Warnf("failed to load state: %v", err)
|
|
return routeselector.NewRouteSelector()
|
|
}
|
|
|
|
if state := m.stateManager.GetState(state); state != nil {
|
|
if selector, ok := state.(*SelectorState); ok {
|
|
return (*routeselector.RouteSelector)(selector)
|
|
}
|
|
|
|
log.Warnf("failed to convert state with type %T to SelectorState", state)
|
|
}
|
|
|
|
return routeselector.NewRouteSelector()
|
|
}
|
|
|
|
// SetFirewall sets the firewall manager for the DefaultManager
|
|
// Not thread-safe, should be called before starting the manager
|
|
func (m *DefaultManager) SetFirewall(firewall firewall.Manager) error {
|
|
m.firewall = firewall
|
|
|
|
if m.disableServerRoutes || firewall == nil {
|
|
log.Info("server routes are disabled")
|
|
return nil
|
|
}
|
|
|
|
var err error
|
|
m.serverRouter, err = server.NewRouter(m.ctx, m.wgInterface, firewall, m.statusRecorder)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SetDNSForwarderPort sets the DNS forwarder port for route handlers
|
|
func (m *DefaultManager) SetDNSForwarderPort(port uint16) {
|
|
m.dnsForwarderPort.Store(uint32(port))
|
|
}
|
|
|
|
// Stop stops the manager watchers and clean firewall rules
|
|
func (m *DefaultManager) Stop(stateManager *statemanager.Manager) {
|
|
m.stop()
|
|
m.shutdownWg.Wait()
|
|
if m.serverRouter != nil {
|
|
m.serverRouter.CleanUp()
|
|
}
|
|
|
|
if m.routeRefCounter != nil {
|
|
if err := m.routeRefCounter.Flush(); err != nil {
|
|
log.Errorf("Error flushing route ref counter: %v", err)
|
|
}
|
|
}
|
|
if m.allowedIPsRefCounter != nil {
|
|
if err := m.allowedIPsRefCounter.Flush(); err != nil {
|
|
log.Errorf("Error flushing allowed IPs ref counter: %v", err)
|
|
}
|
|
}
|
|
|
|
if !nbnet.CustomRoutingDisabled() && !m.disableClientRoutes {
|
|
if err := m.sysOps.CleanupRouting(stateManager, nbnet.AdvancedRouting()); err != nil {
|
|
log.Errorf("Error cleaning up routing: %v", err)
|
|
} else {
|
|
log.Info("Routing cleanup complete")
|
|
}
|
|
|
|
if runtime.GOOS == "windows" {
|
|
nbnet.SetVPNInterfaceName("")
|
|
}
|
|
}
|
|
|
|
m.mux.Lock()
|
|
defer m.mux.Unlock()
|
|
m.clientRoutes = nil
|
|
}
|
|
|
|
// UpdateRoutes compares received routes with existing routes and removes, updates or adds them to the client and server maps
|
|
func (m *DefaultManager) updateSystemRoutes(newRoutes route.HAMap) error {
|
|
toAdd := make(map[route.HAUniqueID]*route.Route)
|
|
toRemove := make(map[route.HAUniqueID]client.RouteHandler)
|
|
|
|
for id, routes := range newRoutes {
|
|
if len(routes) > 0 {
|
|
toAdd[id] = routes[0]
|
|
}
|
|
}
|
|
|
|
for id, activeHandler := range m.activeRoutes {
|
|
if _, exists := toAdd[id]; exists {
|
|
delete(toAdd, id)
|
|
} else {
|
|
toRemove[id] = activeHandler
|
|
}
|
|
}
|
|
|
|
var merr *multierror.Error
|
|
|
|
// Begin batch mode to avoid calling applyHostConfig() after each DNS handler operation
|
|
batchStarted := false
|
|
if m.dnsServer != nil {
|
|
m.dnsServer.BeginBatch()
|
|
batchStarted = true
|
|
defer func() {
|
|
if merr != nil {
|
|
// On error, cancel batch to discard partial DNS state
|
|
m.dnsServer.CancelBatch()
|
|
} else {
|
|
// On success, apply accumulated DNS changes
|
|
m.dnsServer.EndBatch()
|
|
}
|
|
}()
|
|
}
|
|
|
|
for id, handler := range toRemove {
|
|
if err := handler.RemoveRoute(); err != nil {
|
|
merr = multierror.Append(merr, fmt.Errorf("remove route %s: %w", handler.String(), err))
|
|
}
|
|
delete(m.activeRoutes, id)
|
|
}
|
|
|
|
for id, route := range toAdd {
|
|
params := common.HandlerParams{
|
|
Route: route,
|
|
RouteRefCounter: m.routeRefCounter,
|
|
AllowedIPsRefCounter: m.allowedIPsRefCounter,
|
|
DnsRouterInterval: m.dnsRouteInterval,
|
|
StatusRecorder: m.statusRecorder,
|
|
WgInterface: m.wgInterface,
|
|
DnsServer: m.dnsServer,
|
|
PeerStore: m.peerStore,
|
|
UseNewDNSRoute: m.useNewDNSRoute,
|
|
Firewall: m.firewall,
|
|
FakeIPManager: m.fakeIPManager,
|
|
ForwarderPort: &m.dnsForwarderPort,
|
|
}
|
|
handler := client.HandlerFromRoute(params)
|
|
if err := handler.AddRoute(m.ctx); err != nil {
|
|
merr = multierror.Append(merr, fmt.Errorf("add route %s: %w", handler.String(), err))
|
|
continue
|
|
}
|
|
m.activeRoutes[id] = handler
|
|
}
|
|
|
|
_ = batchStarted // Mark as used
|
|
return nberrors.FormatErrorOrNil(merr)
|
|
}
|
|
|
|
func (m *DefaultManager) UpdateRoutes(
|
|
updateSerial uint64,
|
|
serverRoutes map[route.ID]*route.Route,
|
|
clientRoutes route.HAMap,
|
|
useNewDNSRoute bool,
|
|
) error {
|
|
select {
|
|
case <-m.ctx.Done():
|
|
log.Infof("not updating routes as context is closed")
|
|
return nil
|
|
default:
|
|
}
|
|
|
|
m.mux.Lock()
|
|
defer m.mux.Unlock()
|
|
m.useNewDNSRoute = useNewDNSRoute
|
|
|
|
var merr *multierror.Error
|
|
if !m.disableClientRoutes {
|
|
|
|
// Update route selector based on management server's isSelected status
|
|
m.updateRouteSelectorFromManagement(clientRoutes)
|
|
|
|
filteredClientRoutes := m.routeSelector.FilterSelectedExitNodes(clientRoutes)
|
|
|
|
if err := m.updateSystemRoutes(filteredClientRoutes); err != nil {
|
|
merr = multierror.Append(merr, fmt.Errorf("update system routes: %w", err))
|
|
}
|
|
|
|
m.updateClientNetworks(updateSerial, filteredClientRoutes)
|
|
m.notifier.OnNewRoutes(filteredClientRoutes)
|
|
}
|
|
m.clientRoutes = clientRoutes
|
|
|
|
if m.serverRouter == nil {
|
|
return nberrors.FormatErrorOrNil(merr)
|
|
}
|
|
|
|
if err := m.serverRouter.UpdateRoutes(serverRoutes, useNewDNSRoute); err != nil {
|
|
merr = multierror.Append(merr, fmt.Errorf("update server routes: %w", err))
|
|
}
|
|
|
|
return nberrors.FormatErrorOrNil(merr)
|
|
}
|
|
|
|
// SetRouteChangeListener set RouteListener for route change Notifier
|
|
func (m *DefaultManager) SetRouteChangeListener(listener listener.NetworkChangeListener) {
|
|
m.notifier.SetListener(listener)
|
|
}
|
|
|
|
// InitialRouteRange return the list of initial routes. It used by mobile systems
|
|
func (m *DefaultManager) InitialRouteRange() []string {
|
|
return m.notifier.GetInitialRouteRanges()
|
|
}
|
|
|
|
// GetRouteSelector returns the route selector
|
|
func (m *DefaultManager) GetRouteSelector() *routeselector.RouteSelector {
|
|
return m.routeSelector
|
|
}
|
|
|
|
// GetClientRoutes returns most recent list of clientRoutes received from the Management Service
|
|
func (m *DefaultManager) GetClientRoutes() route.HAMap {
|
|
m.mux.Lock()
|
|
defer m.mux.Unlock()
|
|
|
|
return maps.Clone(m.clientRoutes)
|
|
}
|
|
|
|
// GetSelectedClientRoutes returns only the currently selected/active client routes,
|
|
// filtering out deselected exit nodes. Use this instead of GetClientRoutes when checking
|
|
// if traffic should be routed through the tunnel.
|
|
func (m *DefaultManager) GetSelectedClientRoutes() route.HAMap {
|
|
m.mux.Lock()
|
|
defer m.mux.Unlock()
|
|
|
|
return m.routeSelector.FilterSelectedExitNodes(maps.Clone(m.clientRoutes))
|
|
}
|
|
|
|
// GetActiveClientRoutes returns the subset of selected client routes
|
|
// that are currently reachable: the route's peer is Connected and is
|
|
// the one actively carrying the route (not just an HA sibling).
|
|
func (m *DefaultManager) GetActiveClientRoutes() route.HAMap {
|
|
m.mux.Lock()
|
|
selected := m.routeSelector.FilterSelectedExitNodes(maps.Clone(m.clientRoutes))
|
|
recorder := m.statusRecorder
|
|
m.mux.Unlock()
|
|
|
|
if recorder == nil {
|
|
return selected
|
|
}
|
|
|
|
out := make(route.HAMap, len(selected))
|
|
for id, routes := range selected {
|
|
for _, r := range routes {
|
|
st, err := recorder.GetPeer(r.Peer)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if st.ConnStatus != peer.StatusConnected {
|
|
continue
|
|
}
|
|
if _, hasRoute := st.GetRoutes()[r.Network.String()]; !hasRoute {
|
|
continue
|
|
}
|
|
out[id] = routes
|
|
break
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// GetClientRoutesWithNetID returns the current routes from the route map, but the keys consist of the network ID only
|
|
func (m *DefaultManager) GetClientRoutesWithNetID() map[route.NetID][]*route.Route {
|
|
m.mux.Lock()
|
|
defer m.mux.Unlock()
|
|
|
|
routes := make(map[route.NetID][]*route.Route, len(m.clientRoutes))
|
|
for id, v := range m.clientRoutes {
|
|
routes[id.NetID()] = v
|
|
}
|
|
return routes
|
|
}
|
|
|
|
// TriggerSelection triggers the selection of routes, stopping deselected watchers and starting newly selected ones
|
|
func (m *DefaultManager) TriggerSelection(networks route.HAMap) {
|
|
m.mux.Lock()
|
|
defer m.mux.Unlock()
|
|
|
|
networks = m.routeSelector.FilterSelectedExitNodes(networks)
|
|
|
|
m.notifier.OnNewRoutes(networks)
|
|
|
|
if err := m.updateSystemRoutes(networks); err != nil {
|
|
log.Errorf("failed to update system routes during selection: %v", err)
|
|
}
|
|
|
|
m.stopObsoleteClients(networks)
|
|
|
|
for id, routes := range networks {
|
|
if _, found := m.clientNetworks[id]; found {
|
|
// don't touch existing client network watchers
|
|
continue
|
|
}
|
|
|
|
handler := m.activeRoutes[id]
|
|
if handler == nil {
|
|
log.Warnf("no active handler found for route %s", id)
|
|
continue
|
|
}
|
|
|
|
config := client.WatcherConfig{
|
|
Context: m.ctx,
|
|
DNSRouteInterval: m.dnsRouteInterval,
|
|
WGInterface: m.wgInterface,
|
|
StatusRecorder: m.statusRecorder,
|
|
Route: routes[0],
|
|
Handler: handler,
|
|
}
|
|
clientNetworkWatcher := client.NewWatcher(config)
|
|
m.clientNetworks[id] = clientNetworkWatcher
|
|
m.shutdownWg.Add(1)
|
|
go func() {
|
|
defer m.shutdownWg.Done()
|
|
clientNetworkWatcher.Start()
|
|
}()
|
|
clientNetworkWatcher.SendUpdate(client.RoutesUpdate{Routes: routes})
|
|
}
|
|
|
|
if err := m.stateManager.UpdateState((*SelectorState)(m.routeSelector)); err != nil {
|
|
log.Errorf("failed to update state: %v", err)
|
|
}
|
|
}
|
|
|
|
// stopObsoleteClients stops the client network watcher for the networks that are not in the new list
|
|
func (m *DefaultManager) stopObsoleteClients(networks route.HAMap) {
|
|
for id, client := range m.clientNetworks {
|
|
if _, ok := networks[id]; !ok {
|
|
client.Stop()
|
|
delete(m.clientNetworks, id)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *DefaultManager) updateClientNetworks(updateSerial uint64, networks route.HAMap) {
|
|
// removing routes that do not exist as per the update from the Management service.
|
|
m.stopObsoleteClients(networks)
|
|
|
|
for id, routes := range networks {
|
|
clientNetworkWatcher, found := m.clientNetworks[id]
|
|
if !found {
|
|
handler := m.activeRoutes[id]
|
|
if handler == nil {
|
|
log.Errorf("No active handler found for route %s", id)
|
|
continue
|
|
}
|
|
|
|
config := client.WatcherConfig{
|
|
Context: m.ctx,
|
|
DNSRouteInterval: m.dnsRouteInterval,
|
|
WGInterface: m.wgInterface,
|
|
StatusRecorder: m.statusRecorder,
|
|
Route: routes[0],
|
|
Handler: handler,
|
|
}
|
|
clientNetworkWatcher = client.NewWatcher(config)
|
|
m.clientNetworks[id] = clientNetworkWatcher
|
|
m.shutdownWg.Add(1)
|
|
go func() {
|
|
defer m.shutdownWg.Done()
|
|
clientNetworkWatcher.Start()
|
|
}()
|
|
}
|
|
update := client.RoutesUpdate{
|
|
UpdateSerial: updateSerial,
|
|
Routes: routes,
|
|
}
|
|
clientNetworkWatcher.SendUpdate(update)
|
|
}
|
|
}
|
|
|
|
func (m *DefaultManager) ClassifyRoutes(newRoutes []*route.Route) (map[route.ID]*route.Route, route.HAMap) {
|
|
newClientRoutesIDMap := make(route.HAMap)
|
|
newServerRoutesMap := make(map[route.ID]*route.Route)
|
|
ownNetworkIDs := make(map[route.HAUniqueID]bool)
|
|
|
|
for _, newRoute := range newRoutes {
|
|
haID := newRoute.GetHAUniqueID()
|
|
if newRoute.Peer == m.pubKey {
|
|
ownNetworkIDs[haID] = true
|
|
newServerRoutesMap[newRoute.ID] = newRoute
|
|
}
|
|
}
|
|
|
|
for _, newRoute := range newRoutes {
|
|
haID := newRoute.GetHAUniqueID()
|
|
if !ownNetworkIDs[haID] {
|
|
if !isRouteSupported(newRoute) {
|
|
continue
|
|
}
|
|
newClientRoutesIDMap[haID] = append(newClientRoutesIDMap[haID], newRoute)
|
|
}
|
|
}
|
|
|
|
return newServerRoutesMap, newClientRoutesIDMap
|
|
}
|
|
|
|
func (m *DefaultManager) initialClientRoutes(initialRoutes []*route.Route) []*route.Route {
|
|
_, crMap := m.ClassifyRoutes(initialRoutes)
|
|
rs := make([]*route.Route, 0, len(crMap))
|
|
for _, routes := range crMap {
|
|
rs = append(rs, routes...)
|
|
}
|
|
|
|
return rs
|
|
}
|
|
|
|
func isRouteSupported(route *route.Route) bool {
|
|
if netstack.IsEnabled() || !nbnet.CustomRoutingDisabled() || route.IsDynamic() {
|
|
return true
|
|
}
|
|
|
|
// If prefix is too small, lets assume it is a possible default prefix which is not yet supported
|
|
// we skip this prefix management
|
|
if route.Network.Bits() <= vars.MinRangeBits {
|
|
log.Warnf("This agent version: %s, doesn't support default routes, received %s, skipping this prefix",
|
|
version.NetbirdVersion(), route.Network)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// resolveURLsToIPs takes a slice of URLs, resolves them to IP addresses and returns a slice of IPs.
|
|
func resolveURLsToIPs(urls []string) []net.IP {
|
|
var ips []net.IP
|
|
for _, rawurl := range urls {
|
|
u, err := url.Parse(rawurl)
|
|
if err != nil {
|
|
log.Errorf("Failed to parse url %s: %v", rawurl, err)
|
|
continue
|
|
}
|
|
ipAddrs, err := net.LookupIP(u.Hostname())
|
|
if err != nil {
|
|
log.Errorf("Failed to resolve host %s: %v", u.Hostname(), err)
|
|
continue
|
|
}
|
|
ips = append(ips, ipAddrs...)
|
|
}
|
|
return ips
|
|
}
|
|
|
|
// 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.
|
|
if m.routeSelector.IsDeselectAll() {
|
|
return
|
|
}
|
|
|
|
exitNodeInfo := m.collectExitNodeInfo(clientRoutes)
|
|
if len(exitNodeInfo.allIDs) == 0 {
|
|
return
|
|
}
|
|
|
|
m.updateExitNodeSelections(exitNodeInfo)
|
|
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
|
|
userSelected []route.NetID
|
|
userDeselected []route.NetID
|
|
}
|
|
|
|
func (m *DefaultManager) collectExitNodeInfo(clientRoutes route.HAMap) exitNodeInfo {
|
|
var info exitNodeInfo
|
|
|
|
for haID, routes := range clientRoutes {
|
|
if !m.isExitNodeRoute(routes) {
|
|
continue
|
|
}
|
|
|
|
netID := haID.NetID()
|
|
info.allIDs = append(info.allIDs, netID)
|
|
|
|
if m.routeSelector.HasUserSelectionForRoute(netID) {
|
|
m.categorizeUserSelection(netID, &info)
|
|
} else {
|
|
m.checkManagementSelection(routes, netID, &info)
|
|
}
|
|
}
|
|
|
|
return info
|
|
}
|
|
|
|
func (m *DefaultManager) isExitNodeRoute(routes []*route.Route) bool {
|
|
if len(routes) == 0 {
|
|
return false
|
|
}
|
|
return route.IsV4DefaultRoute(routes[0].Network) || route.IsV6DefaultRoute(routes[0].Network)
|
|
}
|
|
|
|
func (m *DefaultManager) categorizeUserSelection(netID route.NetID, info *exitNodeInfo) {
|
|
if m.routeSelector.IsSelected(netID) {
|
|
info.userSelected = append(info.userSelected, netID)
|
|
} else {
|
|
info.userDeselected = append(info.userDeselected, netID)
|
|
}
|
|
}
|
|
|
|
func (m *DefaultManager) checkManagementSelection(routes []*route.Route, netID route.NetID, info *exitNodeInfo) {
|
|
for _, route := range routes {
|
|
if !route.SkipAutoApply {
|
|
info.selectedByManagement = append(info.selectedByManagement, netID)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *DefaultManager) updateExitNodeSelections(info exitNodeInfo) {
|
|
routesToDeselect := m.getRoutesToDeselect(info.allIDs)
|
|
m.deselectExitNodes(routesToDeselect)
|
|
m.selectExitNodesByManagement(info.selectedByManagement, info.allIDs)
|
|
}
|
|
|
|
func (m *DefaultManager) getRoutesToDeselect(allIDs []route.NetID) []route.NetID {
|
|
var routesToDeselect []route.NetID
|
|
for _, netID := range allIDs {
|
|
if !m.routeSelector.HasUserSelectionForRoute(netID) {
|
|
routesToDeselect = append(routesToDeselect, netID)
|
|
}
|
|
}
|
|
return routesToDeselect
|
|
}
|
|
|
|
func (m *DefaultManager) deselectExitNodes(routesToDeselect []route.NetID) {
|
|
if len(routesToDeselect) == 0 {
|
|
return
|
|
}
|
|
|
|
err := m.routeSelector.DeselectRoutes(routesToDeselect, routesToDeselect)
|
|
if err != nil {
|
|
log.Warnf("Failed to deselect exit nodes: %v", err)
|
|
}
|
|
}
|
|
|
|
func (m *DefaultManager) selectExitNodesByManagement(selectedByManagement []route.NetID, allIDs []route.NetID) {
|
|
if len(selectedByManagement) == 0 {
|
|
return
|
|
}
|
|
|
|
err := m.routeSelector.SelectRoutes(selectedByManagement, true, allIDs)
|
|
if err != nil {
|
|
log.Warnf("Failed to select exit nodes: %v", err)
|
|
}
|
|
}
|
|
|
|
func (m *DefaultManager) logExitNodeUpdate(info exitNodeInfo) {
|
|
log.Debugf("Updated route selector: %d exit nodes available, %d selected by management, %d user-selected, %d user-deselected",
|
|
len(info.allIDs), len(info.selectedByManagement), len(info.userSelected), len(info.userDeselected))
|
|
}
|