diff --git a/client/android/client.go b/client/android/client.go index a8adc68be..632742b5c 100644 --- a/client/android/client.go +++ b/client/android/client.go @@ -7,7 +7,6 @@ import ( "fmt" "os" "slices" - "strings" "sync" "golang.org/x/exp/maps" @@ -239,74 +238,60 @@ func (c *Client) Networks() *NetworkArray { return nil } + routesMap := routeManager.GetClientRoutesWithNetID() + v6Merged := route.V6ExitMergeSet(routesMap) + resolvedDomains := c.recorder.GetResolvedDomainsStates() + networkArray := &NetworkArray{ items: make([]Network, 0), } - resolvedDomains := c.recorder.GetResolvedDomainsStates() - routesMap := routeManager.GetClientRoutesWithNetID() - - // Map v6 exit node IDs (-v6 with ::/0) to their v4 base name. - // Also build a set of v6 IDs to skip during the main loop. - v6ExitByBase := make(map[route.NetID]route.NetID) - v6Merged := make(map[route.NetID]struct{}) for id, routes := range routesMap { if len(routes) == 0 { continue } - name := string(id) - if route.IsV6DefaultRoute(routes[0].Network) && strings.HasSuffix(name, "-v6") { - baseName := route.NetID(strings.TrimSuffix(name, "-v6")) - if baseRt, ok := routesMap[baseName]; ok && len(baseRt) > 0 && route.IsV4DefaultRoute(baseRt[0].Network) { - v6ExitByBase[baseName] = id - v6Merged[id] = struct{}{} - } - } - } - - for id, routes := range routesMap { - if len(routes) == 0 { + if _, skip := v6Merged[id]; skip { continue } - if _, ok := v6Merged[id]; ok { + network := c.buildNetwork(id, routes, routeSelector.IsSelected(id), resolvedDomains, v6Merged) + if network == nil { continue } - - r := routes[0] - domains := c.getNetworkDomainsFromRoute(r, resolvedDomains) - netStr := r.Network.String() - - if r.IsDynamic() { - netStr = r.Domains.SafeString() - } - - routePeer, err := c.findBestRoutePeer(routes) - if err != nil { - log.Errorf("could not get peer info for route %s: %v", id, err) - continue - } - - network := Network{ - Name: string(id), - Network: netStr, - Peer: routePeer.FQDN, - Status: routePeer.ConnStatus.String(), - IsSelected: routeSelector.IsSelected(id), - Domains: domains, - } - - if route.IsV4DefaultRoute(r.Network) { - if _, ok := v6ExitByBase[id]; ok { - network.Network = "0.0.0.0/0, ::/0" - } - } - - networkArray.Add(network) + networkArray.Add(*network) } return networkArray } +func (c *Client) buildNetwork(id route.NetID, routes []*route.Route, selected bool, resolvedDomains map[domain.Domain]peer.ResolvedDomainInfo, v6Merged map[route.NetID]struct{}) *Network { + r := routes[0] + netStr := r.Network.String() + if r.IsDynamic() { + netStr = r.Domains.SafeString() + } + + routePeer, err := c.findBestRoutePeer(routes) + if err != nil { + log.Errorf("could not get peer info for route %s: %v", id, err) + return nil + } + + network := &Network{ + Name: string(id), + Network: netStr, + Peer: routePeer.FQDN, + Status: routePeer.ConnStatus.String(), + IsSelected: selected, + Domains: c.getNetworkDomainsFromRoute(r, resolvedDomains), + } + + if route.IsV4DefaultRoute(r.Network) && route.HasV6ExitPair(id, v6Merged) { + network.Network = "0.0.0.0/0, ::/0" + } + + return network +} + // findBestRoutePeer returns the peer actively routing traffic for the given // HA route group. Falls back to the first connected peer, then the first peer. func (c *Client) findBestRoutePeer(routes []*route.Route) (peer.State, error) { diff --git a/client/ios/NetBirdSDK/client.go b/client/ios/NetBirdSDK/client.go index ddf094168..c73a0dcd1 100644 --- a/client/ios/NetBirdSDK/client.go +++ b/client/ios/NetBirdSDK/client.go @@ -373,27 +373,20 @@ func (c *Client) GetRoutesSelectionDetails() (*RoutesSelectionDetails, error) { return nil, fmt.Errorf("could not get route selector") } - // Identify v6 exit nodes paired with a v4 counterpart. - v6ExitMerged := make(map[route.NetID]struct{}) - for id, rt := range routesMap { - if len(rt) == 0 { - continue - } - name := string(id) - if route.IsV6DefaultRoute(rt[0].Network) && strings.HasSuffix(name, "-v6") { - baseName := route.NetID(strings.TrimSuffix(name, "-v6")) - if baseRt, ok := routesMap[baseName]; ok && len(baseRt) > 0 && route.IsV4DefaultRoute(baseRt[0].Network) { - v6ExitMerged[id] = struct{}{} - } - } - } + v6ExitMerged := route.V6ExitMergeSet(routesMap) + routes := buildSelectRoutes(routesMap, routeSelector.IsSelected, v6ExitMerged) + resolvedDomains := c.recorder.GetResolvedDomainsStates() + return prepareRouteSelectionDetails(routes, resolvedDomains), nil +} + +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 { if len(rt) == 0 { continue } - if _, ok := v6ExitMerged[id]; ok { + if _, ok := v6Merged[id]; ok { continue } @@ -401,38 +394,30 @@ func (c *Client) GetRoutesSelectionDetails() (*RoutesSelectionDetails, error) { NetID: string(id), Network: rt[0].Network, Domains: rt[0].Domains, - Selected: routeSelector.IsSelected(id), + Selected: isSelected(id), } - // Merge paired v6 exit node prefix into this entry. - v6ID := route.NetID(string(id) + "-v6") - if _, ok := v6ExitMerged[v6ID]; ok { - v6Prefix := routesMap[v6ID][0].Network - r.extraNetworks = []netip.Prefix{v6Prefix} + v6ID := route.NetID(string(id) + route.V6ExitSuffix) + if _, ok := v6Merged[v6ID]; ok { + 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() + iBits, jBits := routes[i].Network.Bits(), routes[j].Network.Bits() + if iBits != jBits { + return iBits < jBits } - return iPrefix < jPrefix + iAddr, jAddr := routes[i].Network.Addr(), routes[j].Network.Addr() + if iAddr != jAddr { + return iAddr.Less(jAddr) + } + return routes[i].NetID < routes[j].NetID }) - resolvedDomains := c.recorder.GetResolvedDomainsStates() - - return prepareRouteSelectionDetails(routes, resolvedDomains), nil - + return routes } func prepareRouteSelectionDetails(routes []*selectRoute, resolvedDomains map[domain.Domain]peer.ResolvedDomainInfo) *RoutesSelectionDetails { diff --git a/client/server/network.go b/client/server/network.go index de7f0d3f5..4a439d8cf 100644 --- a/client/server/network.go +++ b/client/server/network.go @@ -45,19 +45,7 @@ func (s *Server) ListNetworks(context.Context, *proto.ListNetworksRequest) (*pro routesMap := routeMgr.GetClientRoutesWithNetID() routeSelector := routeMgr.GetRouteSelector() - v6ExitMerged := make(map[route.NetID]struct{}) - for id, rt := range routesMap { - if len(rt) == 0 { - continue - } - name := string(id) - if route.IsV6DefaultRoute(rt[0].Network) && strings.HasSuffix(name, "-v6") { - baseName := route.NetID(strings.TrimSuffix(name, "-v6")) - if baseRt, ok := routesMap[baseName]; ok && len(baseRt) > 0 && route.IsV4DefaultRoute(baseRt[0].Network) { - v6ExitMerged[id] = struct{}{} - } - } - } + v6ExitMerged := route.V6ExitMergeSet(routesMap) var routes []*selectRoute for id, rt := range routesMap { @@ -77,7 +65,7 @@ func (s *Server) ListNetworks(context.Context, *proto.ListNetworksRequest) (*pro } // Merge paired v6 exit node prefix into this entry. - v6ID := route.NetID(string(id) + "-v6") + v6ID := route.NetID(string(id) + route.V6ExitSuffix) if _, ok := v6ExitMerged[v6ID]; ok { v6Prefix := routesMap[v6ID][0].Network r.extraNetworks = []netip.Prefix{v6Prefix} diff --git a/route/route.go b/route/route.go index 25a63dfbb..97b9721f6 100644 --- a/route/route.go +++ b/route/route.go @@ -20,6 +20,9 @@ const ( MaxMetric = 9999 // MaxNetIDChar Max Network Identifier MaxNetIDChar = 40 + + // V6ExitSuffix is appended to a v4 exit node NetID to form its v6 counterpart. + V6ExitSuffix = "-v6" ) const ( @@ -236,7 +239,7 @@ func ExpandV6ExitPairs(ids []NetID, routesMap map[NetID][]*Route) []NetID { if !ok || len(rt) == 0 || !IsV4DefaultRoute(rt[0].Network) { continue } - v6ID := NetID(string(id) + "-v6") + v6ID := NetID(string(id) + V6ExitSuffix) if v6Rt, ok := routesMap[v6ID]; ok && len(v6Rt) > 0 && IsV6DefaultRoute(v6Rt[0].Network) { if !slices.Contains(ids, v6ID) { ids = append(ids, v6ID) @@ -245,3 +248,31 @@ func ExpandV6ExitPairs(ids []NetID, routesMap map[NetID][]*Route) []NetID { } return ids } + +// V6ExitMergeSet scans routesMap and returns the set of v6 exit node NetIDs +// that should be hidden from the UI because they are paired with a v4 exit node. +// A v6 ID is paired when it has suffix "-v6", its route is ::/0, and the base +// name (without "-v6") exists with route 0.0.0.0/0. +func V6ExitMergeSet(routesMap map[NetID][]*Route) map[NetID]struct{} { + merged := make(map[NetID]struct{}) + for id, rt := range routesMap { + if len(rt) == 0 { + continue + } + name := string(id) + if !IsV6DefaultRoute(rt[0].Network) || !strings.HasSuffix(name, V6ExitSuffix) { + continue + } + baseName := NetID(strings.TrimSuffix(name, V6ExitSuffix)) + if baseRt, ok := routesMap[baseName]; ok && len(baseRt) > 0 && IsV4DefaultRoute(baseRt[0].Network) { + merged[id] = struct{}{} + } + } + return merged +} + +// HasV6ExitPair reports whether id has a paired v6 exit node in the merge set. +func HasV6ExitPair(id NetID, v6Merged map[NetID]struct{}) bool { + _, ok := v6Merged[NetID(string(id)+"-v6")] + return ok +}