Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions cmd/unbounded-net-node/main_update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"context"
"errors"
"reflect"
"slices"
"testing"

corev1 "k8s.io/api/core/v1"
Expand Down Expand Up @@ -816,3 +817,163 @@ func TestUpdateWireGuardFromSlices_ManageCniPluginFalseKeepsRemotePeeredMeshPeer
t.Fatalf("expected same-site worker to be skipped when manageCniPlugin=false, got peers %#v", gotPeers)
}
}

func TestUpdateWireGuardFromSlices_UnmanagedSiteGatewayWithoutPodCIDRIsTunnelReady(t *testing.T) {
mySiteName := "flex-site"
myPubKey := "pub-flex"
unmanaged := false

siteInformer := newInformerWithObjects(
toUnstructured(t, &unboundedv1alpha3.Site{ObjectMeta: metav1.ObjectMeta{Name: "flex-site"}}),
toUnstructured(t, &unboundedv1alpha3.Site{
ObjectMeta: metav1.ObjectMeta{Name: "cluster"},
Spec: unboundedv1alpha3.SiteSpec{ManageCniPlugin: &unmanaged},
}),
toUnstructured(t, &unboundedv1alpha3.Site{ObjectMeta: metav1.ObjectMeta{Name: "managed"}}),
)
sliceInformer := newInformerWithObjects(
toUnstructured(t, &unboundednetv1alpha1.SiteNodeSlice{
ObjectMeta: metav1.ObjectMeta{Name: "slice-flex-site"},
SiteName: mySiteName,
SliceIndex: 0,
Nodes: []unboundednetv1alpha1.NodeInfo{{
Name: "node-flex",
WireGuardPublicKey: myPubKey,
InternalIPs: []string{"10.3.0.10"},
PodCIDRs: []string{"10.244.3.0/24"},
}},
}),
)
gatewayPoolInformer := newInformerWithObjects(
toUnstructured(t, &unboundednetv1alpha1.GatewayPool{
ObjectMeta: metav1.ObjectMeta{Name: "pool-cluster"},
Spec: unboundednetv1alpha1.GatewayPoolSpec{
Type: "Internal",
RoutedCidrs: []string{"10.224.0.0/16"},
},
Status: unboundednetv1alpha1.GatewayPoolStatus{
Nodes: []unboundednetv1alpha1.GatewayNodeInfo{
{
Name: "gw-unmanaged",
SiteName: "cluster",
WireGuardPublicKey: "pub-gw-unmanaged",
GatewayWireguardPort: 51821,
InternalIPs: []string{"10.1.0.20"},
},
{
Name: "gw-managed-pending",
SiteName: "managed",
WireGuardPublicKey: "pub-gw-managed",
GatewayWireguardPort: 51822,
InternalIPs: []string{"10.2.0.20"},
},
},
},
}),
)
assignmentInformer := newInformerWithObjects(
toUnstructured(t, &unboundednetv1alpha1.SiteGatewayPoolAssignment{
ObjectMeta: metav1.ObjectMeta{Name: "assign-flex-site"},
Spec: unboundednetv1alpha1.SiteGatewayPoolAssignmentSpec{
Sites: []string{mySiteName},
GatewayPools: []string{"pool-cluster"},
},
}),
)

state := &wireGuardState{
clientset: fake.NewClientset(&corev1.Node{ObjectMeta: metav1.ObjectMeta{Name: "node-flex"}}),
nodeName: "node-flex",
}
cfg := &config{NodeName: "node-flex", WireGuardPort: 51820}

var gotGatewayPeers []gatewayPeerInfo

origConfigure := configureWireGuardFunc
configureWireGuardFunc = func(_ context.Context, _ *config, _ string, _ []meshPeerInfo, gatewayPeers []gatewayPeerInfo, _ string, _, _, _ map[string]bool, _, _, _, _, _ map[string]string, _, _, _, _, _ map[string]int, _ []unboundednetnetlink.DesiredRoute, _ map[string]bool, _ *wireGuardState, _ map[string]healthcheck.HealthCheckSettings) error {
gotGatewayPeers = append([]gatewayPeerInfo(nil), gatewayPeers...)
return nil
}

defer func() { configureWireGuardFunc = origConfigure }()

if err := updateWireGuardFromSlices(context.Background(), nil, siteInformer, sliceInformer, gatewayPoolInformer, newInformerWithObjects(), newInformerWithObjects(), assignmentInformer, newInformerWithObjects(), cfg, mySiteName, "priv", myPubKey, true, state); err != nil {
t.Fatalf("updateWireGuardFromSlices() error = %v", err)
}

peers := map[string]gatewayPeerInfo{}
for _, peer := range gotGatewayPeers {
peers[peer.Name] = peer
}

unmanagedGW, ok := peers["gw-unmanaged"]
if !ok {
t.Fatalf("expected gateway peer gw-unmanaged, got %#v", gotGatewayPeers)
}

if !unmanagedGW.UnmanagedCNISite {
t.Fatalf("expected gw-unmanaged to be marked as an unmanaged CNI site gateway, got %#v", unmanagedGW)
}

if !gatewayPeerReadyForTunnel(unmanagedGW) {
t.Fatalf("expected gateway in unmanaged site without podCIDRs to be tunnel ready, got %#v", unmanagedGW)
}

prefixes := gatewayBPFPrefixes(unmanagedGW, nil)
if !slices.Contains(prefixes, "10.224.0.0/16") || !slices.Contains(prefixes, "10.1.0.20/32") {
t.Fatalf("expected routed CIDR and gateway host CIDR in BPF prefixes, got %v", prefixes)
}

managedGW, ok := peers["gw-managed-pending"]
if !ok {
t.Fatalf("expected gateway peer gw-managed-pending, got %#v", gotGatewayPeers)
}

if managedGW.UnmanagedCNISite {
t.Fatalf("expected gw-managed-pending to stay a managed CNI site gateway, got %#v", managedGW)
}

if gatewayPeerReadyForTunnel(managedGW) {
t.Fatalf("expected managed-site gateway without podCIDRs to wait for podCIDR assignment, got %#v", managedGW)
}
}

func TestGatewayPeerReadyForTunnel(t *testing.T) {
for _, tt := range []struct {
name string
peer gatewayPeerInfo
want bool
}{
{
name: "managed gateway with podCIDRs",
peer: gatewayPeerInfo{InternalIPs: []string{"10.1.0.20"}, PodCIDRs: []string{"10.244.2.0/24"}},
want: true,
},
{
name: "managed gateway waiting for podCIDRs",
peer: gatewayPeerInfo{InternalIPs: []string{"10.1.0.20"}, RoutedCidrs: []string{"10.224.0.0/16"}},
want: false,
},
{
name: "unmanaged site gateway with routed CIDRs",
peer: gatewayPeerInfo{InternalIPs: []string{"10.1.0.20"}, RoutedCidrs: []string{"10.224.0.0/16"}, UnmanagedCNISite: true},
want: true,
},
{
name: "unmanaged site gateway without routed CIDRs",
peer: gatewayPeerInfo{InternalIPs: []string{"10.1.0.20"}, UnmanagedCNISite: true},
want: false,
},
{
name: "gateway without internal IPs",
peer: gatewayPeerInfo{PodCIDRs: []string{"10.244.2.0/24"}, RoutedCidrs: []string{"10.224.0.0/16"}, UnmanagedCNISite: true},
want: false,
},
} {
t.Run(tt.name, func(t *testing.T) {
if got := gatewayPeerReadyForTunnel(tt.peer); got != tt.want {
t.Fatalf("gatewayPeerReadyForTunnel(%#v) = %v, want %v", tt.peer, got, tt.want)
}
})
}
}
1 change: 1 addition & 0 deletions cmd/unbounded-net-node/node_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ type gatewayPeerInfo struct {
LearnedRoutes map[string]unboundednetv1alpha1.GatewayNodeRoute
PodCIDRs []string // Gateway node's podCIDRs for specific routing
SkipPodCIDRRoutes bool // Keep tunnel reachability host routes but skip explicit PodCIDR routes
UnmanagedCNISite bool // Gateway's Site has manageCniPlugin=false, so it never gets a podCIDR
TunnelProtocol string // Resolved tunnel protocol for this peer (WireGuard or GENEVE)
TunnelMTU int // Effective MTU after route, encapsulation, and CRD limits
}
Expand Down
6 changes: 3 additions & 3 deletions cmd/unbounded-net-node/peer_healthcheck.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ func registerPeersWithHealthCheck(

// Gateway peers.
for _, gwPeer := range gatewayPeers {
overlayIP := getHealthIPFromPodCIDRs(gwPeer.PodCIDRs)
overlayIP, viaIf := gatewayHealthTarget(gwPeer, state.nodePodCIDRs)
if overlayIP == "" {
continue
}
Expand Down Expand Up @@ -145,10 +145,10 @@ func registerPeersWithHealthCheck(
state.gatewayPeerHealthCheckEnabled[ifName] = true
state.mu.Unlock()

if err := state.healthCheckManager.AddPeer(gwPeer.Name, net.ParseIP(overlayIP), settings); err != nil {
if err := state.healthCheckManager.AddPeerVia(gwPeer.Name, net.ParseIP(overlayIP), viaIf, settings); err != nil {
registrationErrors = append(registrationErrors, fmt.Errorf("register gateway peer %s at %s: %w", gwPeer.Name, overlayIP, err))
} else {
klog.V(4).Infof("Healthcheck: registered gateway peer %s at %s (iface %s)", gwPeer.Name, overlayIP, ifName)
klog.V(4).Infof("Healthcheck: registered gateway peer %s at %s (iface %s, via %q)", gwPeer.Name, overlayIP, ifName, viaIf)
}
}

Expand Down
117 changes: 117 additions & 0 deletions cmd/unbounded-net-node/peer_healthcheck_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package main
import (
"context"
"errors"
"net/netip"
"testing"
"time"

Expand Down Expand Up @@ -246,3 +247,119 @@ func TestReconciliationPassesFreshHealthProfilesBeforeStateCommit(t *testing.T)
t.Fatalf("registration used stale state: %+v %v", got, err)
}
}

func TestGatewayHealthTarget(t *testing.T) {
routed := []string{"10.32.130.0/23"}
v4Overlay := []string{"192.168.20.0/25"}

cases := map[string]struct {
peer gatewayPeerInfo
local []string
wantIP string
wantViaDev string
}{
"overlay address from pod CIDR": {
peer: gatewayPeerInfo{InternalIPs: []string{"10.0.0.4"}, PodCIDRs: []string{"10.244.1.0/24"}},
wantIP: "10.244.1.1",
},
"unmanaged gateway probed at its IPv4 address through the tunnel": {
peer: gatewayPeerInfo{InternalIPs: []string{"fd00::4", "10.0.0.4"}, RoutedCidrs: routed, UnmanagedCNISite: true},
local: v4Overlay,
wantIP: "10.0.0.4",
wantViaDev: unbounded0DeviceName,
},
"unmanaged IPv6-only gateway is not probed": {
peer: gatewayPeerInfo{InternalIPs: []string{"fd00::4"}, RoutedCidrs: routed, UnmanagedCNISite: true},
local: v4Overlay,
},
"no IPv4 overlay address on this node to probe from": {
peer: gatewayPeerInfo{InternalIPs: []string{"10.0.0.4"}, RoutedCidrs: routed, UnmanagedCNISite: true},
local: []string{"fd10::/64"},
},
"managed gateway still waiting for its pod CIDR": {
peer: gatewayPeerInfo{InternalIPs: []string{"10.0.0.4"}, RoutedCidrs: routed},
local: v4Overlay,
},
"unmanaged gateway over WireGuard": {
peer: gatewayPeerInfo{InternalIPs: []string{"10.0.0.4"}, RoutedCidrs: routed, UnmanagedCNISite: true, TunnelProtocol: "WireGuard"},
local: v4Overlay,
},
"unmanaged gateway without routed CIDRs": {
peer: gatewayPeerInfo{InternalIPs: []string{"10.0.0.4"}, UnmanagedCNISite: true},
local: v4Overlay,
},
}

for name, tc := range cases {
t.Run(name, func(t *testing.T) {
ip, via := gatewayHealthTarget(tc.peer, tc.local)
if ip != tc.wantIP || via != tc.wantViaDev {
t.Fatalf("gatewayHealthTarget() = (%q, %q), want (%q, %q)", ip, via, tc.wantIP, tc.wantViaDev)
}
})
}
}

// TestGatewayProbePins checks that the probe target gets exactly one BPF
// entry: pinned when localCidrs exclude it, and not added a second time when
// gatewayBPFPrefixes already has it (two nexthops would be health-filtered).
func TestGatewayProbePins(t *testing.T) {
peer := gatewayPeerInfo{InternalIPs: []string{"10.0.0.4"}, RoutedCidrs: []string{"10.32.130.0/23"}, UnmanagedCNISite: true}
local := []string{"192.168.20.0/25"}

excluded := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/27")}
if got := gatewayProbePins(peer, excluded, local); len(got) != 1 || got[0] != "10.0.0.4/32" {
t.Fatalf("pins with the gateway IP in localCidrs = %v, want [10.0.0.4/32]", got)
}

if got := gatewayProbePins(peer, nil, local); len(got) != 0 {
t.Fatalf("pins without localCidrs = %v, want none (gatewayBPFPrefixes has the /32)", got)
}

managed := peer
managed.UnmanagedCNISite = false

if got := gatewayProbePins(managed, excluded, local); len(got) != 0 {
t.Fatalf("pins for a managed gateway = %v, want none", got)
}
}

// TestRegisterUnmanagedGatewayHealthCheck covers a gateway in a Site with
// manageCniPlugin=false: it has no pod CIDR, and used to be skipped, so its
// BPF entries were never health-checked or failed over.
func TestRegisterUnmanagedGatewayHealthCheck(t *testing.T) {
manager, err := healthcheck.NewManager("local", 0, nil)
if err != nil {
t.Fatal(err)
}
defer manager.Stop()

state := &wireGuardState{
healthCheckManager: manager,
meshPeerHealthCheckEnabled: make(map[string]bool),
gatewayPeerHealthCheckEnabled: make(map[string]bool),
nodePodCIDRs: []string{"192.168.20.0/25"},
}
gateways := []gatewayPeerInfo{{
Name: "gw",
PoolName: "pool",
InternalIPs: []string{"10.0.0.4"},
RoutedCidrs: []string{"10.32.130.0/23"},
UnmanagedCNISite: true,
}}

desired, err := registerPeersWithHealthCheck(nil, gateways, "remote", false, map[string]string{"remote": "site"}, nil, nil, nil, nil,
map[string]healthcheck.HealthCheckSettings{"site": healthcheck.DefaultSettings()}, state,
func(gatewayPeerInfo) string { return "geneve0" }, true)
if err != nil {
t.Fatal(err)
}

if !desired["gw"] {
t.Fatalf("unmanaged gateway was not registered for health checks: %v", desired)
}

if _, err := manager.GetPeerSettings("gw"); err != nil {
t.Fatalf("no health check session for the unmanaged gateway: %v", err)
}
}
1 change: 1 addition & 0 deletions cmd/unbounded-net-node/reconciliation_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,7 @@ func gatewayPeersEqual(a, b []gatewayPeerInfo) bool {
!intMapEqual(a[i].RouteDistances, b[i].RouteDistances) ||
!strSliceEqual(a[i].PodCIDRs, b[i].PodCIDRs) ||
a[i].SkipPodCIDRRoutes != b[i].SkipPodCIDRRoutes ||
a[i].UnmanagedCNISite != b[i].UnmanagedCNISite ||
a[i].TunnelProtocol != b[i].TunnelProtocol ||
a[i].TunnelMTU != b[i].TunnelMTU {
return false
Expand Down
6 changes: 6 additions & 0 deletions cmd/unbounded-net-node/site_watch_reconcile.go
Original file line number Diff line number Diff line change
Expand Up @@ -900,6 +900,11 @@ func getManageCniPluginFromCRDs(siteInformer cache.SharedIndexInformer, siteName
return true // Site not found, default to true
}

// siteHasUnmanagedCNI reports whether the site explicitly sets manageCniPlugin=false.
func siteHasUnmanagedCNI(site *unboundedv1alpha3.Site) bool {
return site != nil && site.Spec.ManageCniPlugin != nil && !*site.Spec.ManageCniPlugin
}

func getSiteTunnelMTUFromCRDs(siteInformer cache.SharedIndexInformer, siteName string) int {
if siteName == "" {
return 0
Expand Down Expand Up @@ -1760,6 +1765,7 @@ func updateWireGuardFromSlices(ctx context.Context, dynamicClient dynamic.Interf
LearnedRoutes: learnedRoutes,
PodCIDRs: gwNode.PodCIDRs,
SkipPodCIDRRoutes: !manageCniPlugin && !isGatewayNode && gwNode.SiteName == mySiteName,
UnmanagedCNISite: siteHasUnmanagedCNI(siteMap[gwNode.SiteName]),
}, siteRouting.localCIDRs))
}
}
Expand Down
Loading