[management, client] Add IPv6 overlay support (#5631)

This commit is contained in:
Viktor Liu
2026-05-07 18:33:37 +09:00
committed by GitHub
parent f23aaa9ae7
commit 205ebcfda2
229 changed files with 10155 additions and 2816 deletions

View File

@@ -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 (
@@ -215,3 +218,61 @@ 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) + V6ExitSuffix)
if v6Rt, ok := routesMap[v6ID]; ok && len(v6Rt) > 0 && IsV6DefaultRoute(v6Rt[0].Network) {
if !slices.Contains(ids, v6ID) {
ids = append(ids, v6ID)
}
}
}
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
}

108
route/route_test.go Normal file
View File

@@ -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)
})
}
}