From 95f4e90ae89d39032d4de71bb8f253504ed0c23f Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Mon, 30 Mar 2026 18:53:04 +0200 Subject: [PATCH 1/2] Add IPv6 dual-stack support for Android, iOS, and desktop UI --- client/android/client.go | 62 +++++++++++++++++- client/android/route_command.go | 7 ++- client/internal/peer/status.go | 6 +- client/ios/NetBirdSDK/client.go | 57 ++++++++++++++--- client/server/network.go | 54 +++++++++++++--- client/ui/network.go | 2 +- route/route.go | 30 +++++++++ route/route_test.go | 108 ++++++++++++++++++++++++++++++++ 8 files changed, 300 insertions(+), 26 deletions(-) create mode 100644 route/route_test.go diff --git a/client/android/client.go b/client/android/client.go index 995d6341a37..a8adc68be4f 100644 --- a/client/android/client.go +++ b/client/android/client.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "slices" + "strings" "sync" "golang.org/x/exp/maps" @@ -243,12 +244,35 @@ func (c *Client) Networks() *NetworkArray { } resolvedDomains := c.recorder.GetResolvedDomainsStates() + routesMap := routeManager.GetClientRoutesWithNetID() - for id, routes := range 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 { continue } + if _, ok := v6Merged[id]; ok { + continue + } + r := routes[0] domains := c.getNetworkDomainsFromRoute(r, resolvedDomains) netStr := r.Network.String() @@ -257,11 +281,12 @@ func (c *Client) Networks() *NetworkArray { netStr = r.Domains.SafeString() } - routePeer, err := c.recorder.GetPeer(routes[0].Peer) + routePeer, err := c.findBestRoutePeer(routes) if err != nil { - log.Errorf("could not get peer info for %s: %v", routes[0].Peer, err) + log.Errorf("could not get peer info for route %s: %v", id, err) continue } + network := Network{ Name: string(id), Network: netStr, @@ -270,11 +295,42 @@ func (c *Client) Networks() *NetworkArray { 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) } return networkArray } +// 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) { + netStr := routes[0].Network.String() + + fullStatus := c.recorder.GetFullStatus() + for _, p := range fullStatus.Peers { + if _, ok := p.GetRoutes()[netStr]; ok { + return p, nil + } + } + + for _, r := range routes { + p, err := c.recorder.GetPeer(r.Peer) + if err != nil { + continue + } + if p.ConnStatus == peer.StatusConnected { + return p, nil + } + } + return c.recorder.GetPeer(routes[0].Peer) +} + // OnUpdatedHostDNS update the DNS servers addresses for root zones func (c *Client) OnUpdatedHostDNS(list *DNSList) error { dnsServer, err := dns.GetServerDns() diff --git a/client/android/route_command.go b/client/android/route_command.go index b47d5ca6ce0..5e735733574 100644 --- a/client/android/route_command.go +++ b/client/android/route_command.go @@ -18,9 +18,12 @@ func executeRouteToggle(id string, manager routemanager.Manager, netID := route.NetID(id) routes := []route.NetID{netID} - log.Debugf("%s with id: %s", operationName, id) + routesMap := manager.GetClientRoutesWithNetID() + routes = route.ExpandV6ExitPairs(routes, routesMap) - if err := routeOperation(routes, maps.Keys(manager.GetClientRoutesWithNetID())); err != nil { + log.Debugf("%s with ids: %v", operationName, routes) + + if err := routeOperation(routes, maps.Keys(routesMap)); err != nil { log.Debugf("error when %s: %s", operationName, err) return fmt.Errorf("error %s: %w", operationName, err) } diff --git a/client/internal/peer/status.go b/client/internal/peer/status.go index fbf95de21e7..f4db95c8a0f 100644 --- a/client/internal/peer/status.go +++ b/client/internal/peer/status.go @@ -1055,7 +1055,11 @@ func (d *Status) notifyPeerListChanged() { } func (d *Status) notifyAddressChanged() { - d.notifier.localAddressChanged(d.localPeer.FQDN, d.localPeer.IP) + addr := d.localPeer.IP + if d.localPeer.IPv6 != "" { + addr = addr + "\n" + d.localPeer.IPv6 + } + d.notifier.localAddressChanged(d.localPeer.FQDN, addr) } func (d *Status) numOfPeers() int { diff --git a/client/ios/NetBirdSDK/client.go b/client/ios/NetBirdSDK/client.go index 990e03034f0..ddf0941686c 100644 --- a/client/ios/NetBirdSDK/client.go +++ b/client/ios/NetBirdSDK/client.go @@ -50,10 +50,11 @@ type CustomLogger interface { } type selectRoute struct { - NetID string - Network netip.Prefix - Domains domain.List - Selected bool + NetID string + Network netip.Prefix + Domains domain.List + Selected bool + extraNetworks []netip.Prefix } func init() { @@ -363,27 +364,54 @@ func (c *Client) GetRoutesSelectionDetails() (*RoutesSelectionDetails, error) { } routeManager := engine.GetRouteManager() - routesMap := routeManager.GetClientRoutesWithNetID() if routeManager == nil { return nil, fmt.Errorf("could not get route manager") } + routesMap := routeManager.GetClientRoutesWithNetID() routeSelector := routeManager.GetRouteSelector() if routeSelector == nil { 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{}{} + } + } + } + var routes []*selectRoute for id, rt := range routesMap { if len(rt) == 0 { continue } - route := &selectRoute{ + if _, ok := v6ExitMerged[id]; ok { + continue + } + + r := &selectRoute{ NetID: string(id), Network: rt[0].Network, Domains: rt[0].Domains, Selected: routeSelector.IsSelected(id), } - routes = append(routes, route) + + // 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} + } + + routes = append(routes, r) } sort.Slice(routes, func(i, j int) bool { @@ -425,10 +453,15 @@ func prepareRouteSelectionDetails(routes []*selectRoute, resolvedDomains map[dom } domainList = append(domainList, domainResp) } + rangeStr := r.Network.String() + for _, extra := range r.extraNetworks { + rangeStr += ", " + extra.String() + } + domainDetails := DomainDetails{items: domainList} routeSelection = append(routeSelection, RoutesSelectionInfo{ ID: r.NetID, - Network: r.Network.String(), + Network: rangeStr, Domains: &domainDetails, Selected: r.Selected, }) @@ -456,7 +489,9 @@ func (c *Client) SelectRoute(id string) error { } else { log.Debugf("select route with id: %s", id) routes := toNetIDs([]string{id}) - if err := routeSelector.SelectRoutes(routes, true, maps.Keys(routeManager.GetClientRoutesWithNetID())); err != nil { + routesMap := routeManager.GetClientRoutesWithNetID() + routes = route.ExpandV6ExitPairs(routes, routesMap) + if err := routeSelector.SelectRoutes(routes, true, maps.Keys(routesMap)); err != nil { log.Debugf("error when selecting routes: %s", err) return fmt.Errorf("select routes: %w", err) } @@ -483,7 +518,9 @@ func (c *Client) DeselectRoute(id string) error { } else { log.Debugf("deselect route with id: %s", id) routes := toNetIDs([]string{id}) - if err := routeSelector.DeselectRoutes(routes, maps.Keys(routeManager.GetClientRoutesWithNetID())); err != nil { + routesMap := routeManager.GetClientRoutesWithNetID() + routes = route.ExpandV6ExitPairs(routes, routesMap) + if err := routeSelector.DeselectRoutes(routes, maps.Keys(routesMap)); err != nil { log.Debugf("error when deselecting routes: %s", err) return fmt.Errorf("deselect routes: %w", err) } diff --git a/client/server/network.go b/client/server/network.go index bb1cce56c54..de7f0d3f541 100644 --- a/client/server/network.go +++ b/client/server/network.go @@ -16,10 +16,11 @@ import ( ) type selectRoute struct { - NetID route.NetID - Network netip.Prefix - Domains domain.List - Selected bool + NetID route.NetID + Network netip.Prefix + Domains domain.List + Selected bool + extraNetworks []netip.Prefix } // ListNetworks returns a list of all available networks. @@ -44,18 +45,45 @@ 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{}{} + } + } + } + var routes []*selectRoute for id, rt := range routesMap { if len(rt) == 0 { continue } - route := &selectRoute{ + // 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), } - routes = append(routes, route) + + // 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} + } + + routes = append(routes, r) } sort.Slice(routes, func(i, j int) bool { @@ -76,9 +104,13 @@ func (s *Server) ListNetworks(context.Context, *proto.ListNetworksRequest) (*pro 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: route.Network.String(), + Range: rangeStr, Domains: route.Domains.ToSafeStringList(), ResolvedIPs: map[string]*proto.IPList{}, Selected: route.Selected, @@ -137,7 +169,9 @@ func (s *Server) SelectNetworks(_ context.Context, req *proto.SelectNetworksRequ routeSelector.SelectAllRoutes() } else { routes := toNetIDs(req.GetNetworkIDs()) - netIdRoutes := maps.Keys(routeManager.GetClientRoutesWithNetID()) + 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) } @@ -183,7 +217,9 @@ func (s *Server) DeselectNetworks(_ context.Context, req *proto.SelectNetworksRe routeSelector.DeselectAllRoutes() } else { routes := toNetIDs(req.GetNetworkIDs()) - netIdRoutes := maps.Keys(routeManager.GetClientRoutesWithNetID()) + 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) } diff --git a/client/ui/network.go b/client/ui/network.go index 0173201b949..b9a23524150 100644 --- a/client/ui/network.go +++ b/client/ui/network.go @@ -195,7 +195,7 @@ func getOverlappingNetworks(routes []*proto.Network) []*proto.Network { func getExitNodeNetworks(routes []*proto.Network) []*proto.Network { var filteredRoutes []*proto.Network for _, route := range routes { - if route.Range == "0.0.0.0/0" || route.Range == "::/0" { + if strings.Contains(route.Range, "0.0.0.0/0") || route.Range == "::/0" { filteredRoutes = append(filteredRoutes, route) } } diff --git a/route/route.go b/route/route.go index c724e7c7d07..25a63dfbbc7 100644 --- a/route/route.go +++ b/route/route.go @@ -215,3 +215,33 @@ func ParseNetwork(networkString string) (NetworkType, netip.Prefix, error) { return IPv4Network, masked, nil } + +var ( + v4Default = netip.PrefixFrom(netip.IPv4Unspecified(), 0) + v6Default = netip.PrefixFrom(netip.IPv6Unspecified(), 0) +) + +// IsV4DefaultRoute reports whether p is the IPv4 default route (0.0.0.0/0). +func IsV4DefaultRoute(p netip.Prefix) bool { return p == v4Default } + +// IsV6DefaultRoute reports whether p is the IPv6 default route (::/0). +func IsV6DefaultRoute(p netip.Prefix) bool { return p == v6Default } + +// ExpandV6ExitPairs appends the paired "-v6" exit node NetID for any v4 exit +// node (0.0.0.0/0) in ids that has a matching v6 counterpart (::/0) in routesMap. +// It modifies and returns the input slice. +func ExpandV6ExitPairs(ids []NetID, routesMap map[NetID][]*Route) []NetID { + for _, id := range ids { + rt, ok := routesMap[id] + if !ok || len(rt) == 0 || !IsV4DefaultRoute(rt[0].Network) { + continue + } + v6ID := NetID(string(id) + "-v6") + if v6Rt, ok := routesMap[v6ID]; ok && len(v6Rt) > 0 && IsV6DefaultRoute(v6Rt[0].Network) { + if !slices.Contains(ids, v6ID) { + ids = append(ids, v6ID) + } + } + } + return ids +} diff --git a/route/route_test.go b/route/route_test.go new file mode 100644 index 00000000000..dab707ed35a --- /dev/null +++ b/route/route_test.go @@ -0,0 +1,108 @@ +package route + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestExpandV6ExitPairs(t *testing.T) { + v4ExitRoute := &Route{Network: netip.MustParsePrefix("0.0.0.0/0")} + v6ExitRoute := &Route{Network: netip.MustParsePrefix("::/0")} + regularRoute := &Route{Network: netip.MustParsePrefix("10.0.0.0/8")} + + tests := []struct { + name string + ids []NetID + routesMap map[NetID][]*Route + expected []NetID + }{ + { + name: "v4 exit node with matching v6 pair", + ids: []NetID{"exit-node"}, + routesMap: map[NetID][]*Route{ + "exit-node": {v4ExitRoute}, + "exit-node-v6": {v6ExitRoute}, + }, + expected: []NetID{"exit-node", "exit-node-v6"}, + }, + { + name: "v4 exit node without v6 pair", + ids: []NetID{"exit-node"}, + routesMap: map[NetID][]*Route{ + "exit-node": {v4ExitRoute}, + }, + expected: []NetID{"exit-node"}, + }, + { + name: "regular route is not expanded", + ids: []NetID{"office"}, + routesMap: map[NetID][]*Route{ + "office": {regularRoute}, + "office-v6": {v6ExitRoute}, + }, + expected: []NetID{"office"}, + }, + { + name: "v6 already included is not duplicated", + ids: []NetID{"exit-node", "exit-node-v6"}, + routesMap: map[NetID][]*Route{ + "exit-node": {v4ExitRoute}, + "exit-node-v6": {v6ExitRoute}, + }, + expected: []NetID{"exit-node", "exit-node-v6"}, + }, + { + name: "multiple exit nodes expanded independently", + ids: []NetID{"exit-a", "exit-b"}, + routesMap: map[NetID][]*Route{ + "exit-a": {v4ExitRoute}, + "exit-a-v6": {v6ExitRoute}, + "exit-b": {v4ExitRoute}, + "exit-b-v6": {v6ExitRoute}, + }, + expected: []NetID{"exit-a", "exit-b", "exit-a-v6", "exit-b-v6"}, + }, + { + name: "v6 suffix but not exit node network", + ids: []NetID{"office"}, + routesMap: map[NetID][]*Route{ + "office": {regularRoute}, + "office-v6": {regularRoute}, + }, + expected: []NetID{"office"}, + }, + { + name: "user-chosen name for exit node with v6 pair", + ids: []NetID{"my-exit"}, + routesMap: map[NetID][]*Route{ + "my-exit": {v4ExitRoute}, + "my-exit-v6": {v6ExitRoute}, + }, + expected: []NetID{"my-exit", "my-exit-v6"}, + }, + { + name: "real-world management-generated IDs", + ids: []NetID{"0.0.0.0/0"}, + routesMap: map[NetID][]*Route{ + "0.0.0.0/0": {v4ExitRoute}, + "0.0.0.0/0-v6": {v6ExitRoute}, + }, + expected: []NetID{"0.0.0.0/0", "0.0.0.0/0-v6"}, + }, + { + name: "empty input", + ids: []NetID{}, + routesMap: map[NetID][]*Route{}, + expected: []NetID{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ExpandV6ExitPairs(tt.ids, tt.routesMap) + assert.ElementsMatch(t, tt.expected, result) + }) + } +} From a457faa876262141b3d1e9da43085f5a00020e6a Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Mon, 30 Mar 2026 19:31:43 +0200 Subject: [PATCH 2/2] Extract v6 exit node pairing into shared helpers --- client/android/client.go | 83 ++++++++++++++------------------- client/ios/NetBirdSDK/client.go | 57 +++++++++------------- client/server/network.go | 16 +------ route/route.go | 33 ++++++++++++- 4 files changed, 89 insertions(+), 100 deletions(-) diff --git a/client/android/client.go b/client/android/client.go index a8adc68be4f..632742b5cbd 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,72 +238,58 @@ 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 } + networkArray.Add(*network) + } + return networkArray +} - 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 - } +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() + } - network := Network{ - Name: string(id), - Network: netStr, - Peer: routePeer.FQDN, - Status: routePeer.ConnStatus.String(), - IsSelected: routeSelector.IsSelected(id), - Domains: domains, - } + routePeer, err := c.findBestRoutePeer(routes) + if err != nil { + log.Errorf("could not get peer info for route %s: %v", id, err) + return nil + } - if route.IsV4DefaultRoute(r.Network) { - if _, ok := v6ExitByBase[id]; ok { - network.Network = "0.0.0.0/0, ::/0" - } - } + network := &Network{ + Name: string(id), + Network: netStr, + Peer: routePeer.FQDN, + Status: routePeer.ConnStatus.String(), + IsSelected: selected, + Domains: c.getNetworkDomainsFromRoute(r, resolvedDomains), + } - networkArray.Add(network) + if route.IsV4DefaultRoute(r.Network) && route.HasV6ExitPair(id, v6Merged) { + network.Network = "0.0.0.0/0, ::/0" } - return networkArray + + return network } // findBestRoutePeer returns the peer actively routing traffic for the given diff --git a/client/ios/NetBirdSDK/client.go b/client/ios/NetBirdSDK/client.go index ddf0941686c..c73a0dcd1a9 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 + } + iAddr, jAddr := routes[i].Network.Addr(), routes[j].Network.Addr() + if iAddr != jAddr { + return iAddr.Less(jAddr) } - return iPrefix < jPrefix + 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 de7f0d3f541..4a439d8cf39 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 25a63dfbbc7..97b9721f619 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 +}