mirror of
https://github.com/netbirdio/netbird.git
synced 2026-06-01 13:39:56 +00:00
Compare commits
5 Commits
peer-acl-m
...
fix/exit-n
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e5130cda7 | ||
|
|
aa164c93cf | ||
|
|
99223a310d | ||
|
|
84867c7e45 | ||
|
|
c7499cf8fc |
@@ -745,7 +745,10 @@ func (m *DefaultManager) isExitNodeRoute(routes []*route.Route) bool {
|
||||
}
|
||||
|
||||
func (m *DefaultManager) categorizeUserSelection(netID route.NetID, info *exitNodeInfo) {
|
||||
if m.routeSelector.IsSelected(netID) {
|
||||
// Use the exit-node-aware check so a synthesized "-v6" route inherits the v4
|
||||
// base's selection: deselecting the v4 exit node must also drop its ::/0 pair,
|
||||
// otherwise the v6 default route leaks into the tunnel despite the deselect.
|
||||
if m.routeSelector.IsSelectedForExitNode(netID) {
|
||||
info.userSelected = append(info.userSelected, netID)
|
||||
} else {
|
||||
info.userDeselected = append(info.userDeselected, netID)
|
||||
|
||||
@@ -55,6 +55,11 @@ func (rs *RouteSelector) SelectRoutes(routes []route.NetID, appendRoute bool, al
|
||||
}
|
||||
delete(rs.deselectedRoutes, route)
|
||||
rs.selectedRoutes[route] = struct{}{}
|
||||
// Keep the v4/v6 exit pair consistent: clear any orphaned explicit state on
|
||||
// the "-v6" sibling so it inherits the v4 base via effectiveNetID. Skip when
|
||||
// the pair is itself part of this batch (callers expand it deliberately when
|
||||
// it should diverge).
|
||||
rs.clearPairedV6Locked(route, routes)
|
||||
}
|
||||
|
||||
rs.deselectAll = false
|
||||
@@ -95,6 +100,10 @@ func (rs *RouteSelector) DeselectRoutes(routes []route.NetID, allRoutes []route.
|
||||
}
|
||||
rs.deselectedRoutes[route] = struct{}{}
|
||||
delete(rs.selectedRoutes, route)
|
||||
// Keep the v4/v6 exit pair consistent: clear any orphaned explicit selection
|
||||
// on the "-v6" sibling so it falls back to inheriting the v4 base's state
|
||||
// (via effectiveNetID) instead of staying stuck as explicitly selected.
|
||||
rs.clearPairedV6Locked(route, routes)
|
||||
}
|
||||
|
||||
return errors.FormatErrorOrNil(err)
|
||||
@@ -124,6 +133,17 @@ func (rs *RouteSelector) IsSelected(routeID route.NetID) bool {
|
||||
return rs.isSelectedLocked(routeID)
|
||||
}
|
||||
|
||||
// IsSelectedForExitNode checks if an exit-node route is selected, mirroring the
|
||||
// v4/v6 pair: a synthesized "-v6" entry with no explicit state of its own inherits
|
||||
// its v4 base's selection, so a deselect on the v4 base also deselects the v6 entry.
|
||||
// Only call this from exit-node code paths (see effectiveNetID).
|
||||
func (rs *RouteSelector) IsSelectedForExitNode(routeID route.NetID) bool {
|
||||
rs.mu.RLock()
|
||||
defer rs.mu.RUnlock()
|
||||
|
||||
return rs.isSelectedLocked(rs.effectiveNetID(routeID))
|
||||
}
|
||||
|
||||
// FilterSelected removes unselected routes from the provided map.
|
||||
func (rs *RouteSelector) FilterSelected(routes route.HAMap) route.HAMap {
|
||||
rs.mu.RLock()
|
||||
@@ -179,83 +199,6 @@ func (rs *RouteSelector) FilterSelectedExitNodes(routes route.HAMap) route.HAMap
|
||||
return filtered
|
||||
}
|
||||
|
||||
// effectiveNetID returns the v4 base for a "-v6" exit pair entry that has no explicit
|
||||
// state of its own, so selections made on the v4 entry govern the v6 entry automatically.
|
||||
// Only call this from exit-node-specific code paths: applying it to a non-exit "-v6" route
|
||||
// would make it inherit unrelated v4 state. Must be called with rs.mu held.
|
||||
func (rs *RouteSelector) effectiveNetID(id route.NetID) route.NetID {
|
||||
name := string(id)
|
||||
if !strings.HasSuffix(name, route.V6ExitSuffix) {
|
||||
return id
|
||||
}
|
||||
if _, ok := rs.selectedRoutes[id]; ok {
|
||||
return id
|
||||
}
|
||||
if _, ok := rs.deselectedRoutes[id]; ok {
|
||||
return id
|
||||
}
|
||||
return route.NetID(strings.TrimSuffix(name, route.V6ExitSuffix))
|
||||
}
|
||||
|
||||
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 isExitNode(rt []*route.Route) bool {
|
||||
return len(rt) > 0 && (route.IsV4DefaultRoute(rt[0].Network) || route.IsV6DefaultRoute(rt[0].Network))
|
||||
}
|
||||
|
||||
func (rs *RouteSelector) applyExitNodeFilter(
|
||||
id route.HAUniqueID,
|
||||
netID route.NetID,
|
||||
rt []*route.Route,
|
||||
out route.HAMap,
|
||||
) {
|
||||
// Exit-node path: apply the v4/v6 pair mirror so a deselect on the v4 base also
|
||||
// drops the synthesized v6 entry that lacks its own explicit state.
|
||||
effective := rs.effectiveNetID(netID)
|
||||
if rs.hasUserSelectionForRouteLocked(effective) {
|
||||
if rs.isSelectedLocked(effective) {
|
||||
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 collectSelected(rt []*route.Route) []*route.Route {
|
||||
var sel []*route.Route
|
||||
for _, r := range rt {
|
||||
if !r.SkipAutoApply {
|
||||
sel = append(sel, r)
|
||||
}
|
||||
}
|
||||
return sel
|
||||
}
|
||||
|
||||
// MarshalJSON implements the json.Marshaler interface
|
||||
func (rs *RouteSelector) MarshalJSON() ([]byte, error) {
|
||||
rs.mu.RLock()
|
||||
@@ -309,3 +252,97 @@ func (rs *RouteSelector) UnmarshalJSON(data []byte) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// effectiveNetID returns the v4 base for a "-v6" exit pair entry that has no explicit
|
||||
// state of its own, so selections made on the v4 entry govern the v6 entry automatically.
|
||||
// Only call this from exit-node-specific code paths: applying it to a non-exit "-v6" route
|
||||
// would make it inherit unrelated v4 state. Must be called with rs.mu held.
|
||||
func (rs *RouteSelector) effectiveNetID(id route.NetID) route.NetID {
|
||||
name := string(id)
|
||||
if !strings.HasSuffix(name, route.V6ExitSuffix) {
|
||||
return id
|
||||
}
|
||||
if _, ok := rs.selectedRoutes[id]; ok {
|
||||
return id
|
||||
}
|
||||
if _, ok := rs.deselectedRoutes[id]; ok {
|
||||
return id
|
||||
}
|
||||
return route.NetID(strings.TrimSuffix(name, route.V6ExitSuffix))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// clearPairedV6Locked removes any explicit selected/deselected state on the "-v6"
|
||||
// sibling of a v4 exit-node NetID, so the synthesized v6 entry resolves through
|
||||
// effectiveNetID to its v4 base. No-op for IDs that already carry the "-v6" suffix,
|
||||
// or when the sibling is itself part of the current batch (the caller is setting it
|
||||
// deliberately, e.g. via ExpandV6ExitPairs). Must be called with rs.mu held.
|
||||
func (rs *RouteSelector) clearPairedV6Locked(id route.NetID, batch []route.NetID) {
|
||||
if strings.HasSuffix(string(id), route.V6ExitSuffix) {
|
||||
return
|
||||
}
|
||||
v6ID := route.NetID(string(id) + route.V6ExitSuffix)
|
||||
if slices.Contains(batch, v6ID) {
|
||||
return
|
||||
}
|
||||
delete(rs.selectedRoutes, v6ID)
|
||||
delete(rs.deselectedRoutes, v6ID)
|
||||
}
|
||||
|
||||
func (rs *RouteSelector) applyExitNodeFilter(
|
||||
id route.HAUniqueID,
|
||||
netID route.NetID,
|
||||
rt []*route.Route,
|
||||
out route.HAMap,
|
||||
) {
|
||||
// Exit-node path: apply the v4/v6 pair mirror so a deselect on the v4 base also
|
||||
// drops the synthesized v6 entry that lacks its own explicit state.
|
||||
effective := rs.effectiveNetID(netID)
|
||||
if rs.hasUserSelectionForRouteLocked(effective) {
|
||||
if rs.isSelectedLocked(effective) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -359,6 +359,30 @@ func TestRouteSelector_V6ExitPairInherits(t *testing.T) {
|
||||
assert.True(t, rs.IsSelected("corp-v6"), "non-exit *-v6 routes must not inherit unrelated v4 state")
|
||||
})
|
||||
|
||||
t.Run("IsSelectedForExitNode mirrors deselected v4 base", func(t *testing.T) {
|
||||
rs := routeselector.NewRouteSelector()
|
||||
require.NoError(t, rs.DeselectRoutes([]route.NetID{"exit1"}, all))
|
||||
|
||||
// Regression: deselecting the v4 exit node must also report the synthesized
|
||||
// "-v6" pair as not selected, otherwise the ::/0 route leaks into the tunnel.
|
||||
assert.False(t, rs.IsSelectedForExitNode("exit1"))
|
||||
assert.False(t, rs.IsSelectedForExitNode("exit1-v6"), "v6 pair inherits v4 base deselect")
|
||||
|
||||
// An exit node with no user selection at all stays selected by default.
|
||||
assert.True(t, rs.IsSelectedForExitNode("exit2"))
|
||||
assert.True(t, rs.IsSelectedForExitNode("exit2-v6"))
|
||||
})
|
||||
|
||||
t.Run("IsSelectedForExitNode respects explicit v6 state", func(t *testing.T) {
|
||||
rs := routeselector.NewRouteSelector()
|
||||
require.NoError(t, rs.DeselectRoutes([]route.NetID{"exit1"}, all))
|
||||
require.NoError(t, rs.SelectRoutes([]route.NetID{"exit1-v6"}, true, all))
|
||||
|
||||
// Explicit selection on the v6 entry overrides the v4 base's deselect.
|
||||
assert.False(t, rs.IsSelectedForExitNode("exit1"))
|
||||
assert.True(t, rs.IsSelectedForExitNode("exit1-v6"), "explicit v6 select wins over v4 base")
|
||||
})
|
||||
|
||||
t.Run("explicit v6 state overrides v4 base in filter", func(t *testing.T) {
|
||||
rs := routeselector.NewRouteSelector()
|
||||
require.NoError(t, rs.DeselectRoutes([]route.NetID{"exit1"}, all))
|
||||
@@ -399,6 +423,45 @@ func TestRouteSelector_V6ExitPairInherits(t *testing.T) {
|
||||
assert.Empty(t, filtered, "deselecting v4 base must also drop the v6 pair")
|
||||
})
|
||||
|
||||
// Regression for the observed bug: a stale explicit selection on the "-v6"
|
||||
// sibling (e.g. persisted from an earlier select where the pair was expanded)
|
||||
// must not survive a later deselect of the v4 base. Without clearing the orphan,
|
||||
// effectiveNetID sees the v6's own explicit "selected" state and the ::/0 route
|
||||
// leaks into the tunnel despite the user disabling the exit node.
|
||||
t.Run("deselect v4 base clears orphaned explicit v6 selection", func(t *testing.T) {
|
||||
rs := routeselector.NewRouteSelector()
|
||||
|
||||
// Prior state: both v4 and v6 explicitly selected (pair was expanded once).
|
||||
require.NoError(t, rs.SelectRoutes([]route.NetID{"exit1", "exit1-v6"}, true, all))
|
||||
require.True(t, rs.IsSelected("exit1-v6"))
|
||||
|
||||
// User later deselects only the v4 base (v6 not expanded into this batch).
|
||||
require.NoError(t, rs.DeselectRoutes([]route.NetID{"exit1"}, all))
|
||||
|
||||
// The orphaned explicit v6 selection must be gone, so the v6 inherits the
|
||||
// v4 deselect via effectiveNetID instead of staying selected.
|
||||
assert.False(t, rs.IsSelectedForExitNode("exit1-v6"), "v6 must inherit v4 deselect after orphan cleared")
|
||||
|
||||
v4Route := &route.Route{NetID: "exit1", Network: netip.MustParsePrefix("0.0.0.0/0")}
|
||||
v6Route := &route.Route{NetID: "exit1-v6", Network: netip.MustParsePrefix("::/0")}
|
||||
routes := route.HAMap{
|
||||
"exit1|0.0.0.0/0": {v4Route},
|
||||
"exit1-v6|::/0": {v6Route},
|
||||
}
|
||||
filtered := rs.FilterSelectedExitNodes(routes)
|
||||
assert.Empty(t, filtered, "deselecting v4 base must drop the v6 pair even if it was explicitly selected before")
|
||||
})
|
||||
|
||||
// The inverse: an explicit v6 selection made in the SAME batch as the v4 (the
|
||||
// deliberate ExpandV6ExitPairs case) must be preserved, not wiped by the pair sync.
|
||||
t.Run("explicit v6 select in same batch is preserved", func(t *testing.T) {
|
||||
rs := routeselector.NewRouteSelector()
|
||||
require.NoError(t, rs.SelectRoutes([]route.NetID{"exit1", "exit1-v6"}, true, all))
|
||||
|
||||
assert.True(t, rs.IsSelectedForExitNode("exit1"))
|
||||
assert.True(t, rs.IsSelectedForExitNode("exit1-v6"), "v6 selected in the same batch must survive")
|
||||
})
|
||||
|
||||
t.Run("non-exit *-v6 routes pass through FilterSelectedExitNodes", func(t *testing.T) {
|
||||
rs := routeselector.NewRouteSelector()
|
||||
require.NoError(t, rs.DeselectRoutes([]route.NetID{"corp"}, all))
|
||||
|
||||
@@ -54,6 +54,7 @@ type selectRoute struct {
|
||||
Network netip.Prefix
|
||||
Domains domain.List
|
||||
Selected bool
|
||||
Status string
|
||||
extraNetworks []netip.Prefix
|
||||
}
|
||||
|
||||
@@ -377,9 +378,57 @@ func (c *Client) GetRoutesSelectionDetails() (*RoutesSelectionDetails, error) {
|
||||
routes := buildSelectRoutes(routesMap, routeSelector.IsSelected, v6ExitMerged)
|
||||
resolvedDomains := c.recorder.GetResolvedDomainsStates()
|
||||
|
||||
// Compute each route's connection status in the core (mirroring the Android
|
||||
// bridge), so the UI doesn't have to infer it by string-matching the joined
|
||||
// Network value against peer routes. For a merged exit node the status reflects
|
||||
// whichever of the v4/v6 prefixes is served by a connected peer; for dynamic
|
||||
// (DNS) routes the peer route key is the domain pattern (see dynamic.Route.String).
|
||||
connectedRoutes := c.connectedRouteSet()
|
||||
for _, r := range routes {
|
||||
r.Status = routeStatus(r, connectedRoutes)
|
||||
}
|
||||
|
||||
return prepareRouteSelectionDetails(routes, resolvedDomains), nil
|
||||
}
|
||||
|
||||
// connectedRouteSet returns the set of route keys (as strings) currently served by a
|
||||
// connected peer, gathered across all connected peers' route tables. The keys match
|
||||
// what the route manager records: a prefix string for static routes (e.g. "0.0.0.0/0")
|
||||
// and the domain pattern for dynamic routes (e.g. "*.example.com").
|
||||
func (c *Client) connectedRouteSet() map[string]struct{} {
|
||||
connected := map[string]struct{}{}
|
||||
for _, p := range c.recorder.GetFullStatus().Peers {
|
||||
if p.ConnStatus != peer.StatusConnected {
|
||||
continue
|
||||
}
|
||||
for r := range p.GetRoutes() {
|
||||
connected[r] = struct{}{}
|
||||
}
|
||||
}
|
||||
return connected
|
||||
}
|
||||
|
||||
// routeStatus reports "Connected" if any of the route's keys is served by a connected
|
||||
// peer: the primary Network prefix, an extra v6 network of a merged exit node, or the
|
||||
// domain pattern for a dynamic DNS route. Otherwise "Idle".
|
||||
func routeStatus(r *selectRoute, connectedRoutes map[string]struct{}) string {
|
||||
keys := make([]string, 0, 1+len(r.extraNetworks))
|
||||
if len(r.Domains) > 0 {
|
||||
keys = append(keys, r.Domains.SafeString())
|
||||
} else {
|
||||
keys = append(keys, r.Network.String())
|
||||
}
|
||||
for _, extra := range r.extraNetworks {
|
||||
keys = append(keys, extra.String())
|
||||
}
|
||||
for _, k := range keys {
|
||||
if _, ok := connectedRoutes[k]; ok {
|
||||
return peer.StatusConnected.String()
|
||||
}
|
||||
}
|
||||
return peer.StatusIdle.String()
|
||||
}
|
||||
|
||||
func buildSelectRoutes(routesMap map[route.NetID][]*route.Route, isSelected func(route.NetID) bool, v6Merged map[route.NetID]struct{}) []*selectRoute {
|
||||
var routes []*selectRoute
|
||||
for id, rt := range routesMap {
|
||||
@@ -462,6 +511,7 @@ func prepareRouteSelectionDetails(routes []*selectRoute, resolvedDomains map[dom
|
||||
Network: netStr,
|
||||
Domains: &domainDetails,
|
||||
Selected: r.Selected,
|
||||
Status: r.Status,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ type RoutesSelectionInfo struct {
|
||||
Network string
|
||||
Domains *DomainDetails
|
||||
Selected bool
|
||||
Status string
|
||||
}
|
||||
|
||||
type DomainCollection interface {
|
||||
|
||||
Reference in New Issue
Block a user