From c38667e10568431c3127c80007c7b0bd91c3a1c3 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 3 May 2025 23:00:22 +0200 Subject: [PATCH] integration: add route with filter acl integration test Signed-off-by: Kristoffer Dalby --- .github/workflows/test-integration.yaml | 1 + integration/route_test.go | 156 +++++++++++++++++++++++- 2 files changed, 153 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test-integration.yaml b/.github/workflows/test-integration.yaml index 58c5705a..3c8141c7 100644 --- a/.github/workflows/test-integration.yaml +++ b/.github/workflows/test-integration.yaml @@ -70,6 +70,7 @@ jobs: - TestSubnetRouterMultiNetwork - TestSubnetRouterMultiNetworkExitNode - TestAutoApproveMultiNetwork + - TestSubnetRouteACLFiltering - TestHeadscale - TestTailscaleNodesJoiningHeadcale - TestSSHOneUserToAll diff --git a/integration/route_test.go b/integration/route_test.go index e4b6239b..1d6178cc 100644 --- a/integration/route_test.go +++ b/integration/route_test.go @@ -1,6 +1,7 @@ package integration import ( + "encoding/json" "fmt" "net/netip" "sort" @@ -9,7 +10,7 @@ import ( "slices" - "github.com/google/go-cmp/cmp" + cmpdiff "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" v1 "github.com/juanfont/headscale/gen/go/headscale/v1" policyv1 "github.com/juanfont/headscale/hscontrol/policy/v1" @@ -940,7 +941,7 @@ func TestSubnetRouteACL(t *testing.T) { }, } - if diff := cmp.Diff(wantClientFilter, clientNm.PacketFilter, util.ViewSliceIPProtoComparer, util.PrefixComparer); diff != "" { + if diff := cmpdiff.Diff(wantClientFilter, clientNm.PacketFilter, util.ViewSliceIPProtoComparer, util.PrefixComparer); diff != "" { t.Errorf("Client (%s) filter, unexpected result (-want +got):\n%s", client.Hostname(), diff) } @@ -990,7 +991,7 @@ func TestSubnetRouteACL(t *testing.T) { }, } - if diff := cmp.Diff(wantSubnetFilter, subnetNm.PacketFilter, util.ViewSliceIPProtoComparer, util.PrefixComparer); diff != "" { + if diff := cmpdiff.Diff(wantSubnetFilter, subnetNm.PacketFilter, util.ViewSliceIPProtoComparer, util.PrefixComparer); diff != "" { t.Errorf("Subnet (%s) filter, unexpected result (-want +got):\n%s", subRouter1.Hostname(), diff) } } @@ -2007,7 +2008,7 @@ func requirePeerSubnetRoutes(t *testing.T, status *ipnstate.PeerStatus, expected return !slices.ContainsFunc(status.TailscaleIPs, p.Contains) }) - if diff := cmp.Diff(expected, got, util.PrefixComparer, cmpopts.EquateEmpty()); diff != "" { + if diff := cmpdiff.Diff(expected, got, util.PrefixComparer, cmpopts.EquateEmpty()); diff != "" { t.Fatalf("peer %s (%s) subnet routes, unexpected result (-want +got):\n%s", status.HostName, status.ID, diff) } } @@ -2018,3 +2019,150 @@ func requireNodeRouteCount(t *testing.T, node *v1.Node, announced, approved, sub require.Lenf(t, node.GetApprovedRoutes(), approved, "expected %q approved routes(%v) to have %d route, had %d", node.GetName(), node.GetApprovedRoutes(), approved, len(node.GetApprovedRoutes())) require.Lenf(t, node.GetSubnetRoutes(), subnet, "expected %q subnet routes(%v) to have %d route, had %d", node.GetName(), node.GetSubnetRoutes(), subnet, len(node.GetSubnetRoutes())) } + +// TestSubnetRouteACLFiltering tests that a node can only access subnet routes +// that are explicitly allowed in the ACL. +func TestSubnetRouteACLFiltering(t *testing.T) { + IntegrationSkip(t) + t.Parallel() + + // Use router and node users for better clarity + routerUser := "router" + nodeUser := "node" + + spec := ScenarioSpec{ + NodesPerUser: 1, + Users: []string{routerUser, nodeUser}, + } + + scenario, err := NewScenario(spec) + require.NoErrorf(t, err, "failed to create scenario: %s", err) + defer scenario.ShutdownAssertNoPanics(t) + + // Set up the ACL policy that allows the node to access only one of the subnet routes (10.10.10.0/24) + aclPolicyStr := fmt.Sprintf(`{ + "hosts": { + "router": "100.64.0.1/32", + "node": "100.64.0.2/32" + }, + "acls": [ + { + "action": "accept", + "src": [ + "*" + ], + "dst": [ + "router:0" + ] + }, + { + "action": "accept", + "src": [ + "node" + ], + "dst": [ + "10.10.10.0/24:*" + ] + } + ] + }`) + + // Create ACL policy + aclPolicy := &policyv1.ACLPolicy{} + err = json.Unmarshal([]byte(aclPolicyStr), aclPolicy) + require.NoError(t, err) + + err = scenario.CreateHeadscaleEnv([]tsic.Option{ + tsic.WithAcceptRoutes(), + }, hsic.WithTestName("routeaclfilter"), hsic.WithACLPolicy(aclPolicy)) + assertNoErrHeadscaleEnv(t, err) + + allClients, err := scenario.ListTailscaleClients() + assertNoErrListClients(t, err) + + err = scenario.WaitForTailscaleSync() + assertNoErrSync(t, err) + + headscale, err := scenario.Headscale() + assertNoErrGetHeadscale(t, err) + + // Sort clients by ID for consistent order + slices.SortFunc(allClients, func(a, b TailscaleClient) int { + return b.MustIPv4().Compare(a.MustIPv4()) + }) + + // Get the router and node clients + routerClient := allClients[0] + nodeClient := allClients[1] + + // Set up the subnet routes for the router + routes := []string{ + "10.10.10.0/24", // This should be accessible by the client + "10.10.11.0/24", // These should NOT be accessible + "10.10.12.0/24", + } + + routeArg := "--advertise-routes=" + routes[0] + "," + routes[1] + "," + routes[2] + command := []string{ + "tailscale", + "set", + routeArg, + } + + _, _, err = routerClient.Execute(command) + require.NoErrorf(t, err, "failed to advertise routes: %s", err) + + err = scenario.WaitForTailscaleSync() + assertNoErrSync(t, err) + + // List nodes and verify the router has 3 available routes + nodes, err := headscale.NodesByUser() + require.NoError(t, err) + require.Len(t, nodes, 2) + + // Find the router node + routerNode := nodes[routerUser][0] + nodeNode := nodes[nodeUser][0] + + require.NotNil(t, routerNode, "Router node not found") + require.NotNil(t, nodeNode, "Client node not found") + + // Check that the router has 3 routes available but not approved yet + requireNodeRouteCount(t, routerNode, 3, 0, 0) + requireNodeRouteCount(t, nodeNode, 0, 0, 0) + + // Approve all routes for the router + _, err = headscale.ApproveRoutes( + routerNode.GetId(), + util.MustStringsToPrefixes(routerNode.GetAvailableRoutes()), + ) + require.NoError(t, err) + + // Give some time for the routes to propagate + time.Sleep(5 * time.Second) + + // List nodes and verify the router has 3 available routes + nodes, err = headscale.NodesByUser() + require.NoError(t, err) + require.Len(t, nodes, 2) + + // Find the router node + routerNode = nodes[routerUser][0] + + // Check that the router has 3 routes now approved and available + requireNodeRouteCount(t, routerNode, 3, 3, 3) + + // Now check the client node status + nodeStatus, err := nodeClient.Status() + require.NoError(t, err) + + routerStatus, err := routerClient.Status() + require.NoError(t, err) + + // Check that the node can see the subnet routes from the router + routerPeerStatus := nodeStatus.Peer[routerStatus.Self.PublicKey] + + // The node should only have 1 subnet route (10.10.10.0/24) + expectedRoutes := []netip.Prefix{netip.MustParsePrefix("10.10.10.0/24")} + requirePeerSubnetRoutes(t, routerPeerStatus, expectedRoutes) +}