From f37401e9bc9ea1fe8c91fffe52dfb581d1b17b30 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Thu, 2 Apr 2026 13:25:34 +0000 Subject: [PATCH] integration: add subnet-to-subnet ACL routing test Add TestSubnetToSubnetACL which creates two subnet routers on separate Docker networks and verifies that ACL rules referencing only subnet CIDRs (not node IPs) as source and destination produce correct peer visibility, route distribution, and end-to-end connectivity. Updates #3157 Updates #3169 --- integration/route_test.go | 220 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) diff --git a/integration/route_test.go b/integration/route_test.go index 525d6c2b..0ce30573 100644 --- a/integration/route_test.go +++ b/integration/route_test.go @@ -3493,3 +3493,223 @@ func TestGrantViaSubnetSteering(t *testing.T) { assertTracerouteViaIPWithCollect(c, tr, ip) }, assertTimeout, 200*time.Millisecond, "Client B traceroute should go through Router B") } + +// TestSubnetToSubnetACL verifies that when ACL rules reference only subnet +// CIDRs as source and destination, the subnet routers for those subnets +// see each other as peers and exchange routes, enabling end-to-end +// connectivity between the two physical subnets. +// +// This is a regression test for https://github.com/juanfont/headscale/issues/3157 +func TestSubnetToSubnetACL(t *testing.T) { + IntegrationSkip(t) + + routerAUser := "routera" + routerBUser := "routerb" + + spec := ScenarioSpec{ + NodesPerUser: 1, + Users: []string{routerAUser, routerBUser}, + Networks: map[string]NetworkSpec{ + "usernet1": {Users: []string{routerAUser}}, + "usernet2": {Users: []string{routerBUser}}, + }, + ExtraService: map[string][]extraServiceFunc{ + "usernet1": {Webservice}, + "usernet2": {Webservice}, + }, + Versions: []string{"head"}, + } + + scenario, err := NewScenario(spec) + require.NoErrorf(t, err, "failed to create scenario: %s", err) + defer scenario.ShutdownAssertNoPanics(t) + + prefA, err := scenario.SubnetOfNetwork("usernet1") + require.NoError(t, err) + prefB, err := scenario.SubnetOfNetwork("usernet2") + require.NoError(t, err) + + // ACL: only subnet-A ↔ subnet-B traffic is allowed. + // No rules reference node IPs directly. + aclPolicy := &policyv2.Policy{ + ACLs: []policyv2.ACL{ + { + Action: "accept", + Sources: []policyv2.Alias{ + prefixp(prefA.String()), + }, + Destinations: []policyv2.AliasWithPorts{ + aliasWithPorts(prefixp(prefB.String()), tailcfg.PortRangeAny), + }, + }, + { + Action: "accept", + Sources: []policyv2.Alias{ + prefixp(prefB.String()), + }, + Destinations: []policyv2.AliasWithPorts{ + aliasWithPorts(prefixp(prefA.String()), tailcfg.PortRangeAny), + }, + }, + }, + } + + err = scenario.CreateHeadscaleEnv( + []tsic.Option{tsic.WithAcceptRoutes()}, + hsic.WithTestName("subnet2subnet"), + hsic.WithACLPolicy(aclPolicy), + ) + requireNoErrHeadscaleEnv(t, err) + + err = scenario.WaitForTailscaleSync() + requireNoErrSync(t, err) + + headscale, err := scenario.Headscale() + requireNoErrGetHeadscale(t, err) + + routerAClients, err := scenario.ListTailscaleClients(routerAUser) + require.NoError(t, err) + require.Len(t, routerAClients, 1) + routerA := routerAClients[0] + + routerBClients, err := scenario.ListTailscaleClients(routerBUser) + require.NoError(t, err) + require.Len(t, routerBClients, 1) + routerB := routerBClients[0] + + // Advertise subnet routes. + _, _, err = routerA.Execute([]string{ + "tailscale", "set", + "--advertise-routes=" + prefA.String(), + }) + require.NoErrorf(t, err, "failed to advertise route on router A") + + _, _, err = routerB.Execute([]string{ + "tailscale", "set", + "--advertise-routes=" + prefB.String(), + }) + require.NoErrorf(t, err, "failed to advertise route on router B") + + // Wait for advertisements to propagate. + var nodes []*v1.Node + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err = headscale.ListNodes() + assert.NoError(c, err) + assert.Len(c, nodes, 2) + + // Both should have 1 available route. + for _, n := range nodes { + requireNodeRouteCountWithCollect(c, n, 1, 0, 0) + } + }, integrationutil.ScaledTimeout(10*time.Second), 100*time.Millisecond, + "route advertisements should propagate") + + // Approve routes on both routers. + for _, n := range nodes { + _, err = headscale.ApproveRoutes( + n.GetId(), + util.MustStringsToPrefixes(n.GetAvailableRoutes()), + ) + require.NoError(t, err) + } + + // Wait for approvals to propagate. + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err = headscale.ListNodes() + assert.NoError(c, err) + assert.Len(c, nodes, 2) + + for _, n := range nodes { + requireNodeRouteCountWithCollect(c, n, 1, 1, 1) + } + }, integrationutil.ScaledTimeout(10*time.Second), 500*time.Millisecond, + "route approvals should propagate") + + // Verify both routers see each other's subnet routes. + assert.EventuallyWithT(t, func(c *assert.CollectT) { + statusA, err := routerA.Status() + assert.NoError(c, err) + + for _, peerKey := range statusA.Peers() { + peerStatus := statusA.Peer[peerKey] + requirePeerSubnetRoutesWithCollect(c, peerStatus, []netip.Prefix{*prefB}) + } + }, integrationutil.ScaledTimeout(10*time.Second), 500*time.Millisecond, + "Router A should see Router B's subnet route") + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + statusB, err := routerB.Status() + assert.NoError(c, err) + + for _, peerKey := range statusB.Peers() { + peerStatus := statusB.Peer[peerKey] + requirePeerSubnetRoutesWithCollect(c, peerStatus, []netip.Prefix{*prefA}) + } + }, integrationutil.ScaledTimeout(10*time.Second), 500*time.Millisecond, + "Router B should see Router A's subnet route") + + // Verify end-to-end connectivity: Router A → webservice on network B. + usernet2, err := scenario.Network("usernet2") + require.NoError(t, err) + + servicesB, err := scenario.Services("usernet2") + require.NoError(t, err) + require.Len(t, servicesB, 1) + + webB := servicesB[0] + webBIP := netip.MustParseAddr(webB.GetIPInNetwork(usernet2)) + urlB := fmt.Sprintf("http://%s/etc/hostname", webBIP) + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + result, err := routerA.Curl(urlB) + assert.NoError(c, err) + assert.Len(c, result, 13) + }, integrationutil.ScaledTimeout(10*time.Second), 200*time.Millisecond, + "Router A should reach webservice on network B") + + // Verify end-to-end connectivity: Router B → webservice on network A. + usernet1, err := scenario.Network("usernet1") + require.NoError(t, err) + + servicesA, err := scenario.Services("usernet1") + require.NoError(t, err) + require.Len(t, servicesA, 1) + + webA := servicesA[0] + webAIP := netip.MustParseAddr(webA.GetIPInNetwork(usernet1)) + urlA := fmt.Sprintf("http://%s/etc/hostname", webAIP) + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + result, err := routerB.Curl(urlA) + assert.NoError(c, err) + assert.Len(c, result, 13) + }, integrationutil.ScaledTimeout(10*time.Second), 200*time.Millisecond, + "Router B should reach webservice on network A") + + // Verify traceroute paths. + assert.EventuallyWithT(t, func(c *assert.CollectT) { + tr, err := routerA.Traceroute(webBIP) + assert.NoError(c, err) + + ip, err := routerB.IPv4() + if !assert.NoError(c, err, "failed to get IPv4 for routerB") { + return + } + + assertTracerouteViaIPWithCollect(c, tr, ip) + }, integrationutil.ScaledTimeout(10*time.Second), 200*time.Millisecond, + "Traceroute from Router A should go through Router B") + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + tr, err := routerB.Traceroute(webAIP) + assert.NoError(c, err) + + ip, err := routerA.IPv4() + if !assert.NoError(c, err, "failed to get IPv4 for routerA") { + return + } + + assertTracerouteViaIPWithCollect(c, tr, ip) + }, integrationutil.ScaledTimeout(10*time.Second), 200*time.Millisecond, + "Traceroute from Router B should go through Router A") +}