From a739862c653dc3ca0fbb5e5f302c72ed58fca222 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sun, 22 Mar 2026 20:43:57 +0000 Subject: [PATCH] integration: add via grant route steering tests Add integration tests validating that via grants correctly steer routes to designated nodes per client group: - TestGrantViaSubnetSteering: two routers advertise the same subnet, via grants steer each client group to a specific router. Verifies per-client route visibility, curl reachability, and traceroute path. - TestGrantViaExitNodeSteering: two exit nodes, via grants steer each client group to a designated exit node. Verifies exit routes are withdrawn from non-designated nodes and the client rejects setting a non-designated exit node. - TestGrantViaMixedSteering: cross-steering where subnet routes and exit routes go to different servers per client group. Verifies subnet traffic uses the subnet-designated server while exit traffic uses the exit-designated server. Also add autogroupp helper for constructing AutoGroup aliases in grant policy configurations. Updates #2180 --- integration/helpers.go | 7 + integration/route_test.go | 1180 +++++++++++++++++++++++++++++++++++++ 2 files changed, 1187 insertions(+) diff --git a/integration/helpers.go b/integration/helpers.go index aec71a5a..ad72204b 100644 --- a/integration/helpers.go +++ b/integration/helpers.go @@ -1007,6 +1007,13 @@ func tagApprover(name string) policyv2.AutoApprover { return new(policyv2.Tag(name)) } +// autogroupp returns a pointer to an AutoGroup as an Alias for policy v2 configurations. +// Used in grant rules to reference autogroups like autogroup:self and autogroup:internet. +func autogroupp(name string) policyv2.Alias { + ag := policyv2.AutoGroup(name) + return &ag +} + // oidcMockUser creates a MockUser for OIDC authentication testing. // Generates consistent test user data with configurable email verification status // for validating OIDC integration flows in headscale authentication tests. diff --git a/integration/route_test.go b/integration/route_test.go index 9175f76f..40b61a80 100644 --- a/integration/route_test.go +++ b/integration/route_test.go @@ -20,6 +20,7 @@ import ( "github.com/juanfont/headscale/hscontrol/routes" "github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/util" + "github.com/juanfont/headscale/integration/dockertestutil" "github.com/juanfont/headscale/integration/hsic" "github.com/juanfont/headscale/integration/integrationutil" "github.com/juanfont/headscale/integration/tsic" @@ -3149,3 +3150,1182 @@ func TestSubnetRouteACLFiltering(t *testing.T) { assertTracerouteViaIPWithCollect(c, tr, ip) }, integrationutil.ScaledTimeout(60*time.Second), 200*time.Millisecond, "Verifying traceroute goes through router") } + +// TestGrantViaSubnetSteering validates that via grants steer different source +// groups through different tagged subnet routers to the same destination. +// Per Tailscale docs, via enables traffic steering: routing specific source +// groups through specific tagged intermediate nodes (subnet routers). +func TestGrantViaSubnetSteering(t *testing.T) { + IntegrationSkip(t) + + assertTimeout := 60 * time.Second + + spec := ScenarioSpec{ + NodesPerUser: 0, + Users: []string{"router", "client"}, + Networks: map[string][]string{ + "usernet1": {"router"}, + "usernet2": {"client"}, + }, + ExtraService: map[string][]extraServiceFunc{ + "usernet1": {Webservice}, + }, + Versions: []string{"head"}, + } + + scenario, err := NewScenario(spec) + + require.NoErrorf(t, err, "failed to create scenario: %s", err) + defer scenario.ShutdownAssertNoPanics(t) + + // Get the subnet for usernet1 before creating headscale + // (needed for policy construction). + route, err := scenario.SubnetOfNetwork("usernet1") + require.NoError(t, err) + + pol := &policyv2.Policy{ + TagOwners: policyv2.TagOwners{ + policyv2.Tag("tag:router-a"): policyv2.Owners{usernameOwner("router@")}, + policyv2.Tag("tag:router-b"): policyv2.Owners{usernameOwner("router@")}, + policyv2.Tag("tag:group-a"): policyv2.Owners{usernameOwner("client@")}, + policyv2.Tag("tag:group-b"): policyv2.Owners{usernameOwner("client@")}, + }, + Grants: []policyv2.Grant{ + // Allow all tagged nodes to communicate with each other (peer connectivity). + // Uses tag-based src/dst to avoid creating rules for the subnet prefix, + // so only via grants control subnet route visibility. + { + Sources: policyv2.Aliases{ + tagp("tag:router-a"), tagp("tag:router-b"), + tagp("tag:group-a"), tagp("tag:group-b"), + }, + Destinations: policyv2.Aliases{ + tagp("tag:router-a"), tagp("tag:router-b"), + tagp("tag:group-a"), tagp("tag:group-b"), + }, + InternetProtocols: []policyv2.ProtocolPort{ + {Protocol: "*", Ports: []tailcfg.PortRange{tailcfg.PortRangeAny}}, + }, + }, + // Via grant: steer tag:group-a traffic to usernet1 subnet through tag:router-a. + { + Sources: policyv2.Aliases{tagp("tag:group-a")}, + Destinations: policyv2.Aliases{prefixp(route.String())}, + InternetProtocols: []policyv2.ProtocolPort{ + {Protocol: "*", Ports: []tailcfg.PortRange{tailcfg.PortRangeAny}}, + }, + Via: []policyv2.Tag{policyv2.Tag("tag:router-a")}, + }, + // Via grant: steer tag:group-b traffic to usernet1 subnet through tag:router-b. + { + Sources: policyv2.Aliases{tagp("tag:group-b")}, + Destinations: policyv2.Aliases{prefixp(route.String())}, + InternetProtocols: []policyv2.ProtocolPort{ + {Protocol: "*", Ports: []tailcfg.PortRange{tailcfg.PortRangeAny}}, + }, + Via: []policyv2.Tag{policyv2.Tag("tag:router-b")}, + }, + }, + AutoApprovers: policyv2.AutoApproverPolicy{ + Routes: map[netip.Prefix]policyv2.AutoApprovers{ + *route: {tagApprover("tag:router-a"), tagApprover("tag:router-b")}, + }, + }, + } + + headscale, err := scenario.Headscale( + hsic.WithTestName("grantvia-subnet"), + hsic.WithACLPolicy(pol), + hsic.WithPolicyMode(types.PolicyModeDB), + ) + requireNoErrGetHeadscale(t, err) + + usernet1, err := scenario.Network("usernet1") + require.NoError(t, err) + usernet2, err := scenario.Network("usernet2") + require.NoError(t, err) + + // Create users on headscale server. + _, err = scenario.CreateUser("router") + require.NoError(t, err) + _, err = scenario.CreateUser("client") + require.NoError(t, err) + + userMap, err := headscale.MapUsers() + require.NoError(t, err) + + // Create Router A (tag:router-a) on usernet1. + routerA, err := scenario.CreateTailscaleNode("head", + tsic.WithNetwork(usernet1), + tsic.WithAcceptRoutes(), + ) + require.NoError(t, err) + + defer func() { _, _, _ = routerA.Shutdown() }() + + pakRouterA, err := scenario.CreatePreAuthKeyWithTags( + userMap["router"].GetId(), false, false, []string{"tag:router-a"}, + ) + require.NoError(t, err) + err = routerA.Login(headscale.GetEndpoint(), pakRouterA.GetKey()) + require.NoError(t, err) + err = routerA.WaitForRunning(30 * time.Second) + require.NoError(t, err) + + // Create Router B (tag:router-b) on usernet1. + routerB, err := scenario.CreateTailscaleNode("head", + tsic.WithNetwork(usernet1), + tsic.WithAcceptRoutes(), + ) + require.NoError(t, err) + + defer func() { _, _, _ = routerB.Shutdown() }() + + pakRouterB, err := scenario.CreatePreAuthKeyWithTags( + userMap["router"].GetId(), false, false, []string{"tag:router-b"}, + ) + require.NoError(t, err) + err = routerB.Login(headscale.GetEndpoint(), pakRouterB.GetKey()) + require.NoError(t, err) + err = routerB.WaitForRunning(30 * time.Second) + require.NoError(t, err) + + // Create Client A (tag:group-a) on usernet2. + clientA, err := scenario.CreateTailscaleNode("head", + tsic.WithNetwork(usernet2), + tsic.WithAcceptRoutes(), + ) + require.NoError(t, err) + + defer func() { _, _, _ = clientA.Shutdown() }() + + pakClientA, err := scenario.CreatePreAuthKeyWithTags( + userMap["client"].GetId(), false, false, []string{"tag:group-a"}, + ) + require.NoError(t, err) + err = clientA.Login(headscale.GetEndpoint(), pakClientA.GetKey()) + require.NoError(t, err) + err = clientA.WaitForRunning(30 * time.Second) + require.NoError(t, err) + + // Create Client B (tag:group-b) on usernet2. + clientB, err := scenario.CreateTailscaleNode("head", + tsic.WithNetwork(usernet2), + tsic.WithAcceptRoutes(), + ) + require.NoError(t, err) + + defer func() { _, _, _ = clientB.Shutdown() }() + + pakClientB, err := scenario.CreatePreAuthKeyWithTags( + userMap["client"].GetId(), false, false, []string{"tag:group-b"}, + ) + require.NoError(t, err) + err = clientB.Login(headscale.GetEndpoint(), pakClientB.GetKey()) + require.NoError(t, err) + err = clientB.WaitForRunning(30 * time.Second) + require.NoError(t, err) + + // Wait for all peers to see each other (4 nodes, each sees 3 peers). + allNodes := []TailscaleClient{routerA, routerB, clientA, clientB} + for _, node := range allNodes { + err = node.WaitForPeers(len(allNodes)-1, 60*time.Second, 1*time.Second) + require.NoErrorf(t, err, "node %s failed to see all peers", node.Hostname()) + } + + // Both routers advertise usernet1 subnet. + for _, router := range []TailscaleClient{routerA, routerB} { + command := []string{ + "tailscale", "set", + "--advertise-routes=" + route.String(), + } + _, _, err = router.Execute(command) + require.NoErrorf(t, err, "failed to advertise route on %s", router.Hostname()) + } + + // Wait for auto-approval on both routers. + // Only check announced and approved counts. SubnetRoutes (primary election) + // is a global single-primary-per-prefix model, so only one router wins. + // Via steering should override this per-client, which is what we test below. + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes() + assert.NoError(c, err) + + routerANode := MustFindNode(routerA.Hostname(), nodes) + t.Logf("Router A %s: announced=%v, approved=%v, subnet=%v", + routerANode.GetName(), + routerANode.GetAvailableRoutes(), + routerANode.GetApprovedRoutes(), + routerANode.GetSubnetRoutes()) + assert.Len(c, routerANode.GetAvailableRoutes(), 1, "Router A should have 1 announced route") + assert.Len(c, routerANode.GetApprovedRoutes(), 1, "Router A should have 1 approved route") + + routerBNode := MustFindNode(routerB.Hostname(), nodes) + t.Logf("Router B %s: announced=%v, approved=%v, subnet=%v", + routerBNode.GetName(), + routerBNode.GetAvailableRoutes(), + routerBNode.GetApprovedRoutes(), + routerBNode.GetSubnetRoutes()) + assert.Len(c, routerBNode.GetAvailableRoutes(), 1, "Router B should have 1 announced route") + assert.Len(c, routerBNode.GetApprovedRoutes(), 1, "Router B should have 1 approved route") + }, assertTimeout, 500*time.Millisecond, "Both routers should have auto-approved routes") + + // Get webservice info. + services, err := scenario.Services("usernet1") + require.NoError(t, err) + require.Len(t, services, 1) + + web := services[0] + webip := netip.MustParseAddr(web.GetIPInNetwork(usernet1)) + weburl := fmt.Sprintf("http://%s/etc/hostname", webip) + t.Logf("webservice: %s, %s", webip.String(), weburl) + + // Verify Client A sees only Router A's subnet route (via steering). + assert.EventuallyWithT(t, func(c *assert.CollectT) { + status, err := clientA.Status() + assert.NoError(c, err) + + routerAID := routerA.MustID() + routerBID := routerB.MustID() + + for _, peerKey := range status.Peers() { + peerStatus := status.Peer[peerKey] + + switch peerStatus.ID { + case routerAID.StableID(): + // Client A should see Router A's subnet route. + t.Logf("Client A sees Router A: AllowedIPs=%v, PrimaryRoutes=%v", + peerStatus.AllowedIPs, peerStatus.PrimaryRoutes) + requirePeerSubnetRoutesWithCollect(c, peerStatus, []netip.Prefix{*route}) + case routerBID.StableID(): + // Client A should NOT see Router B's subnet route. + t.Logf("Client A sees Router B: AllowedIPs=%v, PrimaryRoutes=%v", + peerStatus.AllowedIPs, peerStatus.PrimaryRoutes) + requirePeerSubnetRoutesWithCollect(c, peerStatus, nil) + } + } + }, assertTimeout, 500*time.Millisecond, "Client A should see only Router A's subnet route") + + // Verify Client B sees only Router B's subnet route (via steering). + assert.EventuallyWithT(t, func(c *assert.CollectT) { + status, err := clientB.Status() + assert.NoError(c, err) + + routerAID := routerA.MustID() + routerBID := routerB.MustID() + + for _, peerKey := range status.Peers() { + peerStatus := status.Peer[peerKey] + + switch peerStatus.ID { + case routerAID.StableID(): + // Client B should NOT see Router A's subnet route. + t.Logf("Client B sees Router A: AllowedIPs=%v, PrimaryRoutes=%v", + peerStatus.AllowedIPs, peerStatus.PrimaryRoutes) + requirePeerSubnetRoutesWithCollect(c, peerStatus, nil) + case routerBID.StableID(): + // Client B should see Router B's subnet route. + t.Logf("Client B sees Router B: AllowedIPs=%v, PrimaryRoutes=%v", + peerStatus.AllowedIPs, peerStatus.PrimaryRoutes) + requirePeerSubnetRoutesWithCollect(c, peerStatus, []netip.Prefix{*route}) + } + } + }, assertTimeout, 500*time.Millisecond, "Client B should see only Router B's subnet route") + + // Verify Client A can reach the webservice. + assert.EventuallyWithT(t, func(c *assert.CollectT) { + result, err := clientA.Curl(weburl) + assert.NoError(c, err) + assert.Len(c, result, 13) + }, assertTimeout, 200*time.Millisecond, "Client A should reach webservice") + + // Verify Client B can reach the webservice. + assert.EventuallyWithT(t, func(c *assert.CollectT) { + result, err := clientB.Curl(weburl) + assert.NoError(c, err) + assert.Len(c, result, 13) + }, assertTimeout, 200*time.Millisecond, "Client B should reach webservice") + + // Verify Client A's traffic goes through Router A. + assert.EventuallyWithT(t, func(c *assert.CollectT) { + tr, err := clientA.Traceroute(webip) + assert.NoError(c, err) + + ip, err := routerA.IPv4() + if !assert.NoError(c, err, "failed to get IPv4 for routerA") { + return + } + + assertTracerouteViaIPWithCollect(c, tr, ip) + }, assertTimeout, 200*time.Millisecond, "Client A traceroute should go through Router A") + + // Verify Client B's traffic goes through Router B. + assert.EventuallyWithT(t, func(c *assert.CollectT) { + tr, err := clientB.Traceroute(webip) + assert.NoError(c, err) + + ip, err := routerB.IPv4() + if !assert.NoError(c, err, "failed to get IPv4 for routerB") { + return + } + + assertTracerouteViaIPWithCollect(c, tr, ip) + }, assertTimeout, 200*time.Millisecond, "Client B traceroute should go through Router B") +} + +// TestGrantViaExitNodeSteering validates that via grants steer different +// source groups through different tagged exit nodes for internet traffic. +// Per Tailscale docs, via with autogroup:internet steers exit node traffic +// through specific tagged nodes. +func TestGrantViaExitNodeSteering(t *testing.T) { + IntegrationSkip(t) + + assertTimeout := 60 * time.Second + + spec := ScenarioSpec{ + NodesPerUser: 0, + Users: []string{"exit", "client"}, + Networks: map[string][]string{ + "usernet1": {"exit"}, + "usernet2": {"client"}, + }, + ExtraService: map[string][]extraServiceFunc{ + "usernet1": {Webservice}, + }, + Versions: []string{"head"}, + } + + scenario, err := NewScenario(spec) + + require.NoErrorf(t, err, "failed to create scenario: %s", err) + defer scenario.ShutdownAssertNoPanics(t) + + pol := &policyv2.Policy{ + TagOwners: policyv2.TagOwners{ + policyv2.Tag("tag:exit-a"): policyv2.Owners{usernameOwner("exit@")}, + policyv2.Tag("tag:exit-b"): policyv2.Owners{usernameOwner("exit@")}, + policyv2.Tag("tag:group-a"): policyv2.Owners{usernameOwner("client@")}, + policyv2.Tag("tag:group-b"): policyv2.Owners{usernameOwner("client@")}, + }, + Grants: []policyv2.Grant{ + // Allow all tagged nodes to communicate with each other (peer connectivity). + { + Sources: policyv2.Aliases{ + tagp("tag:exit-a"), tagp("tag:exit-b"), + tagp("tag:group-a"), tagp("tag:group-b"), + }, + Destinations: policyv2.Aliases{ + tagp("tag:exit-a"), tagp("tag:exit-b"), + tagp("tag:group-a"), tagp("tag:group-b"), + }, + InternetProtocols: []policyv2.ProtocolPort{ + {Protocol: "*", Ports: []tailcfg.PortRange{tailcfg.PortRangeAny}}, + }, + }, + // Via grant: steer tag:group-a internet traffic through tag:exit-a. + { + Sources: policyv2.Aliases{tagp("tag:group-a")}, + Destinations: policyv2.Aliases{autogroupp("autogroup:internet")}, + InternetProtocols: []policyv2.ProtocolPort{ + {Protocol: "*", Ports: []tailcfg.PortRange{tailcfg.PortRangeAny}}, + }, + Via: []policyv2.Tag{policyv2.Tag("tag:exit-a")}, + }, + // Via grant: steer tag:group-b internet traffic through tag:exit-b. + { + Sources: policyv2.Aliases{tagp("tag:group-b")}, + Destinations: policyv2.Aliases{autogroupp("autogroup:internet")}, + InternetProtocols: []policyv2.ProtocolPort{ + {Protocol: "*", Ports: []tailcfg.PortRange{tailcfg.PortRangeAny}}, + }, + Via: []policyv2.Tag{policyv2.Tag("tag:exit-b")}, + }, + }, + AutoApprovers: policyv2.AutoApproverPolicy{ + ExitNode: policyv2.AutoApprovers{ + tagApprover("tag:exit-a"), + tagApprover("tag:exit-b"), + }, + }, + } + + headscale, err := scenario.Headscale( + hsic.WithTestName("grantvia-exit"), + hsic.WithACLPolicy(pol), + hsic.WithPolicyMode(types.PolicyModeDB), + ) + requireNoErrGetHeadscale(t, err) + + usernet1, err := scenario.Network("usernet1") + require.NoError(t, err) + usernet2, err := scenario.Network("usernet2") + require.NoError(t, err) + + // Create users on headscale server. + _, err = scenario.CreateUser("exit") + require.NoError(t, err) + _, err = scenario.CreateUser("client") + require.NoError(t, err) + + userMap, err := headscale.MapUsers() + require.NoError(t, err) + + // Create Exit A (tag:exit-a) on usernet1. + exitA, err := scenario.CreateTailscaleNode("head", + tsic.WithNetwork(usernet1), + tsic.WithAcceptRoutes(), + ) + require.NoError(t, err) + + defer func() { _, _, _ = exitA.Shutdown() }() + + pakExitA, err := scenario.CreatePreAuthKeyWithTags( + userMap["exit"].GetId(), false, false, []string{"tag:exit-a"}, + ) + require.NoError(t, err) + err = exitA.Login(headscale.GetEndpoint(), pakExitA.GetKey()) + require.NoError(t, err) + err = exitA.WaitForRunning(30 * time.Second) + require.NoError(t, err) + + // Create Exit B (tag:exit-b) on usernet1. + exitB, err := scenario.CreateTailscaleNode("head", + tsic.WithNetwork(usernet1), + tsic.WithAcceptRoutes(), + ) + require.NoError(t, err) + + defer func() { _, _, _ = exitB.Shutdown() }() + + pakExitB, err := scenario.CreatePreAuthKeyWithTags( + userMap["exit"].GetId(), false, false, []string{"tag:exit-b"}, + ) + require.NoError(t, err) + err = exitB.Login(headscale.GetEndpoint(), pakExitB.GetKey()) + require.NoError(t, err) + err = exitB.WaitForRunning(30 * time.Second) + require.NoError(t, err) + + // Create Client A (tag:group-a) on usernet2. + clientA, err := scenario.CreateTailscaleNode("head", + tsic.WithNetwork(usernet2), + tsic.WithAcceptRoutes(), + ) + require.NoError(t, err) + + defer func() { _, _, _ = clientA.Shutdown() }() + + pakClientA, err := scenario.CreatePreAuthKeyWithTags( + userMap["client"].GetId(), false, false, []string{"tag:group-a"}, + ) + require.NoError(t, err) + err = clientA.Login(headscale.GetEndpoint(), pakClientA.GetKey()) + require.NoError(t, err) + err = clientA.WaitForRunning(30 * time.Second) + require.NoError(t, err) + + // Create Client B (tag:group-b) on usernet2. + clientB, err := scenario.CreateTailscaleNode("head", + tsic.WithNetwork(usernet2), + tsic.WithAcceptRoutes(), + ) + require.NoError(t, err) + + defer func() { _, _, _ = clientB.Shutdown() }() + + pakClientB, err := scenario.CreatePreAuthKeyWithTags( + userMap["client"].GetId(), false, false, []string{"tag:group-b"}, + ) + require.NoError(t, err) + err = clientB.Login(headscale.GetEndpoint(), pakClientB.GetKey()) + require.NoError(t, err) + err = clientB.WaitForRunning(30 * time.Second) + require.NoError(t, err) + + // Wait for all peers to see each other (4 nodes, each sees 3 peers). + allNodes := []TailscaleClient{exitA, exitB, clientA, clientB} + for _, node := range allNodes { + err = node.WaitForPeers(len(allNodes)-1, 60*time.Second, 1*time.Second) + require.NoErrorf(t, err, "node %s failed to see all peers", node.Hostname()) + } + + // Both exit nodes advertise exit routes. + for _, exitNode := range []TailscaleClient{exitA, exitB} { + command := []string{ + "tailscale", "set", + "--advertise-exit-node", + } + _, _, err = exitNode.Execute(command) + require.NoErrorf(t, err, "failed to advertise exit node on %s", exitNode.Hostname()) + } + + // Wait for auto-approval on both exit nodes. + // Exit routes = 2 per node (0.0.0.0/0 + ::/0). Only check announced/approved; + // primary election may only give one node the subnet designation. + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes() + assert.NoError(c, err) + + exitANode := MustFindNode(exitA.Hostname(), nodes) + t.Logf("Exit A %s: announced=%v, approved=%v, subnet=%v", + exitANode.GetName(), + exitANode.GetAvailableRoutes(), + exitANode.GetApprovedRoutes(), + exitANode.GetSubnetRoutes()) + assert.Len(c, exitANode.GetAvailableRoutes(), 2, "Exit A should have 2 announced routes") + assert.Len(c, exitANode.GetApprovedRoutes(), 2, "Exit A should have 2 approved routes") + + exitBNode := MustFindNode(exitB.Hostname(), nodes) + t.Logf("Exit B %s: announced=%v, approved=%v, subnet=%v", + exitBNode.GetName(), + exitBNode.GetAvailableRoutes(), + exitBNode.GetApprovedRoutes(), + exitBNode.GetSubnetRoutes()) + assert.Len(c, exitBNode.GetAvailableRoutes(), 2, "Exit B should have 2 announced routes") + assert.Len(c, exitBNode.GetApprovedRoutes(), 2, "Exit B should have 2 approved routes") + }, assertTimeout, 500*time.Millisecond, "Both exit nodes should have auto-approved exit routes") + + exitAID := exitA.MustID() + exitBID := exitB.MustID() + + // Via steering: Client A should see exit routes ONLY on Exit A (not Exit B). + assert.EventuallyWithT(t, func(c *assert.CollectT) { + status, err := clientA.Status() + assert.NoError(c, err) + + for _, peerKey := range status.Peers() { + peerStatus := status.Peer[peerKey] + + switch peerStatus.ID { + case exitAID.StableID(): + // Client A should see Exit A's exit routes. + t.Logf("Client A sees Exit A: AllowedIPs=%v, ExitNode=%v", + peerStatus.AllowedIPs, peerStatus.ExitNode) + got := filterNonRoutes(peerStatus) + assert.NotEmpty(c, got, "Client A should see Exit A's exit routes") + case exitBID.StableID(): + // Client A should NOT see Exit B's exit routes. + t.Logf("Client A sees Exit B: AllowedIPs=%v, ExitNode=%v", + peerStatus.AllowedIPs, peerStatus.ExitNode) + got := filterNonRoutes(peerStatus) + assert.Empty(c, got, "Client A should NOT see Exit B's exit routes") + } + } + }, assertTimeout, 500*time.Millisecond, "Client A should see exit routes only via Exit A") + + // Via steering: Client B should see exit routes ONLY on Exit B (not Exit A). + assert.EventuallyWithT(t, func(c *assert.CollectT) { + status, err := clientB.Status() + assert.NoError(c, err) + + for _, peerKey := range status.Peers() { + peerStatus := status.Peer[peerKey] + + switch peerStatus.ID { + case exitBID.StableID(): + // Client B should see Exit B's exit routes. + t.Logf("Client B sees Exit B: AllowedIPs=%v, ExitNode=%v", + peerStatus.AllowedIPs, peerStatus.ExitNode) + got := filterNonRoutes(peerStatus) + assert.NotEmpty(c, got, "Client B should see Exit B's exit routes") + case exitAID.StableID(): + // Client B should NOT see Exit A's exit routes. + t.Logf("Client B sees Exit A: AllowedIPs=%v, ExitNode=%v", + peerStatus.AllowedIPs, peerStatus.ExitNode) + got := filterNonRoutes(peerStatus) + assert.Empty(c, got, "Client B should NOT see Exit A's exit routes") + } + } + }, assertTimeout, 500*time.Millisecond, "Client B should see exit routes only via Exit B") + + services, err := scenario.Services("usernet1") + require.NoError(t, err) + require.Len(t, services, 1) + + web := services[0] + webip := netip.MustParseAddr(web.GetIPInNetwork(usernet1)) + weburl := fmt.Sprintf("http://%s/etc/hostname", webip) + t.Logf("webservice: %s, %s", webip.String(), weburl) + + // Negative test: Client A tries to set the WRONG exit node (Exit B, not designated by via). + // Via steering removes exit routes from non-designated exit nodes in the network map, + // so the Tailscale client itself rejects the command since Exit B has no exit routes + // from Client A's perspective. + exitBStatus := exitB.MustStatus() + wrongExitCmd := []string{ + "tailscale", "set", + "--exit-node=" + exitBStatus.Self.DNSName, + } + _, _, err = clientA.Execute(wrongExitCmd) + require.Error(t, err, "Client A should not be able to set non-designated Exit B as exit node") + + // Positive test: Each client selects the exit node designated by via. + exitAStatus := exitA.MustStatus() + commandA := []string{ + "tailscale", "set", + "--exit-node=" + exitAStatus.Self.DNSName, + } + _, _, err = clientA.Execute(commandA) + require.NoError(t, err, "Client A failed to set exit node to Exit A") + + commandB := []string{ + "tailscale", "set", + "--exit-node=" + exitBStatus.Self.DNSName, + } + _, _, err = clientB.Execute(commandB) + require.NoError(t, err, "Client B failed to set exit node to Exit B") + + // Verify traffic flows through the via-designated exit nodes. + assert.EventuallyWithT(t, func(c *assert.CollectT) { + result, err := clientA.Curl(weburl) + assert.NoError(c, err) + assert.Len(c, result, 13) + }, assertTimeout, 200*time.Millisecond, "Client A should reach webservice via Exit A") + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + tr, err := clientA.Traceroute(webip) + assert.NoError(c, err) + + ip, err := exitA.IPv4() + if !assert.NoError(c, err, "failed to get IPv4 for exitA") { + return + } + + assertTracerouteViaIPWithCollect(c, tr, ip) + }, assertTimeout, 200*time.Millisecond, "Client A traceroute should go through Exit A") + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + result, err := clientB.Curl(weburl) + assert.NoError(c, err) + assert.Len(c, result, 13) + }, assertTimeout, 200*time.Millisecond, "Client B should reach webservice via Exit B") + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + tr, err := clientB.Traceroute(webip) + assert.NoError(c, err) + + ip, err := exitB.IPv4() + if !assert.NoError(c, err, "failed to get IPv4 for exitB") { + return + } + + assertTracerouteViaIPWithCollect(c, tr, ip) + }, assertTimeout, 200*time.Millisecond, "Client B traceroute should go through Exit B") +} + +// TestGrantViaMixedSteering validates cross-steering when the same servers +// advertise both subnet routes and exit routes simultaneously. Via grants +// steer each client group through different servers for subnet vs exit traffic: +// - group-a uses server-a for subnet, server-b for exit +// - group-b uses server-b for subnet, server-a for exit +// +// Uses three networks: +// - usernet1: servers + webservice (subnet destination) +// - usernet2: clients +// - externet: webservice reachable only via exit nodes (servers also connected) +func TestGrantViaMixedSteering(t *testing.T) { + IntegrationSkip(t) + + assertTimeout := 60 * time.Second + + spec := ScenarioSpec{ + NodesPerUser: 0, + Users: []string{"server", "client"}, + Networks: map[string][]string{ + "usernet1": {"server"}, + "usernet2": {"client"}, + "externet": {}, + }, + ExtraService: map[string][]extraServiceFunc{ + "usernet1": {Webservice}, + "externet": {Webservice}, + }, + Versions: []string{"head"}, + } + + scenario, err := NewScenario(spec) + + require.NoErrorf(t, err, "failed to create scenario: %s", err) + defer scenario.ShutdownAssertNoPanics(t) + + route, err := scenario.SubnetOfNetwork("usernet1") + require.NoError(t, err) + + pol := &policyv2.Policy{ + TagOwners: policyv2.TagOwners{ + policyv2.Tag("tag:server-a"): policyv2.Owners{usernameOwner("server@")}, + policyv2.Tag("tag:server-b"): policyv2.Owners{usernameOwner("server@")}, + policyv2.Tag("tag:group-a"): policyv2.Owners{usernameOwner("client@")}, + policyv2.Tag("tag:group-b"): policyv2.Owners{usernameOwner("client@")}, + }, + Grants: []policyv2.Grant{ + // Allow all tagged nodes to communicate with each other (peer connectivity). + { + Sources: policyv2.Aliases{ + tagp("tag:server-a"), tagp("tag:server-b"), + tagp("tag:group-a"), tagp("tag:group-b"), + }, + Destinations: policyv2.Aliases{ + tagp("tag:server-a"), tagp("tag:server-b"), + tagp("tag:group-a"), tagp("tag:group-b"), + }, + InternetProtocols: []policyv2.ProtocolPort{ + {Protocol: "*", Ports: []tailcfg.PortRange{tailcfg.PortRangeAny}}, + }, + }, + // Subnet steering: group-a through server-a, group-b through server-b. + { + Sources: policyv2.Aliases{tagp("tag:group-a")}, + Destinations: policyv2.Aliases{prefixp(route.String())}, + InternetProtocols: []policyv2.ProtocolPort{ + {Protocol: "*", Ports: []tailcfg.PortRange{tailcfg.PortRangeAny}}, + }, + Via: []policyv2.Tag{policyv2.Tag("tag:server-a")}, + }, + { + Sources: policyv2.Aliases{tagp("tag:group-b")}, + Destinations: policyv2.Aliases{prefixp(route.String())}, + InternetProtocols: []policyv2.ProtocolPort{ + {Protocol: "*", Ports: []tailcfg.PortRange{tailcfg.PortRangeAny}}, + }, + Via: []policyv2.Tag{policyv2.Tag("tag:server-b")}, + }, + // Exit steering: CROSSED - group-a through server-b, group-b through server-a. + { + Sources: policyv2.Aliases{tagp("tag:group-a")}, + Destinations: policyv2.Aliases{autogroupp("autogroup:internet")}, + InternetProtocols: []policyv2.ProtocolPort{ + {Protocol: "*", Ports: []tailcfg.PortRange{tailcfg.PortRangeAny}}, + }, + Via: []policyv2.Tag{policyv2.Tag("tag:server-b")}, + }, + { + Sources: policyv2.Aliases{tagp("tag:group-b")}, + Destinations: policyv2.Aliases{autogroupp("autogroup:internet")}, + InternetProtocols: []policyv2.ProtocolPort{ + {Protocol: "*", Ports: []tailcfg.PortRange{tailcfg.PortRangeAny}}, + }, + Via: []policyv2.Tag{policyv2.Tag("tag:server-a")}, + }, + }, + AutoApprovers: policyv2.AutoApproverPolicy{ + Routes: map[netip.Prefix]policyv2.AutoApprovers{ + *route: {tagApprover("tag:server-a"), tagApprover("tag:server-b")}, + }, + ExitNode: policyv2.AutoApprovers{ + tagApprover("tag:server-a"), + tagApprover("tag:server-b"), + }, + }, + } + + headscale, err := scenario.Headscale( + hsic.WithTestName("grantvia-mixed"), + hsic.WithACLPolicy(pol), + hsic.WithPolicyMode(types.PolicyModeDB), + ) + requireNoErrGetHeadscale(t, err) + + usernet1, err := scenario.Network("usernet1") + require.NoError(t, err) + usernet2, err := scenario.Network("usernet2") + require.NoError(t, err) + externet, err := scenario.Network("externet") + require.NoError(t, err) + + // Create users. + _, err = scenario.CreateUser("server") + require.NoError(t, err) + _, err = scenario.CreateUser("client") + require.NoError(t, err) + + userMap, err := headscale.MapUsers() + require.NoError(t, err) + + // Create Server A (tag:server-a) on usernet1. + serverA, err := scenario.CreateTailscaleNode("head", + tsic.WithNetwork(usernet1), + tsic.WithAcceptRoutes(), + ) + require.NoError(t, err) + + defer func() { _, _, _ = serverA.Shutdown() }() + + pakServerA, err := scenario.CreatePreAuthKeyWithTags( + userMap["server"].GetId(), false, false, []string{"tag:server-a"}, + ) + require.NoError(t, err) + err = serverA.Login(headscale.GetEndpoint(), pakServerA.GetKey()) + require.NoError(t, err) + err = serverA.WaitForRunning(30 * time.Second) + require.NoError(t, err) + + // Connect Server A to externet AFTER login to avoid link change race + // (adding a network interface restarts tailscaled's connection). + err = dockertestutil.AddContainerToNetwork(scenario.Pool(), externet, serverA.Hostname()) + require.NoError(t, err, "failed to connect Server A to externet") + + // Create Server B (tag:server-b) on usernet1. + serverB, err := scenario.CreateTailscaleNode("head", + tsic.WithNetwork(usernet1), + tsic.WithAcceptRoutes(), + ) + require.NoError(t, err) + + defer func() { _, _, _ = serverB.Shutdown() }() + + pakServerB, err := scenario.CreatePreAuthKeyWithTags( + userMap["server"].GetId(), false, false, []string{"tag:server-b"}, + ) + require.NoError(t, err) + err = serverB.Login(headscale.GetEndpoint(), pakServerB.GetKey()) + require.NoError(t, err) + err = serverB.WaitForRunning(30 * time.Second) + require.NoError(t, err) + + // Connect Server B to externet AFTER login. + err = dockertestutil.AddContainerToNetwork(scenario.Pool(), externet, serverB.Hostname()) + require.NoError(t, err, "failed to connect Server B to externet") + + // Create Client A (tag:group-a) on usernet2. + clientA, err := scenario.CreateTailscaleNode("head", + tsic.WithNetwork(usernet2), + tsic.WithAcceptRoutes(), + ) + require.NoError(t, err) + + defer func() { _, _, _ = clientA.Shutdown() }() + + pakClientA, err := scenario.CreatePreAuthKeyWithTags( + userMap["client"].GetId(), false, false, []string{"tag:group-a"}, + ) + require.NoError(t, err) + err = clientA.Login(headscale.GetEndpoint(), pakClientA.GetKey()) + require.NoError(t, err) + err = clientA.WaitForRunning(30 * time.Second) + require.NoError(t, err) + + // Create Client B (tag:group-b) on usernet2. + clientB, err := scenario.CreateTailscaleNode("head", + tsic.WithNetwork(usernet2), + tsic.WithAcceptRoutes(), + ) + require.NoError(t, err) + + defer func() { _, _, _ = clientB.Shutdown() }() + + pakClientB, err := scenario.CreatePreAuthKeyWithTags( + userMap["client"].GetId(), false, false, []string{"tag:group-b"}, + ) + require.NoError(t, err) + err = clientB.Login(headscale.GetEndpoint(), pakClientB.GetKey()) + require.NoError(t, err) + err = clientB.WaitForRunning(30 * time.Second) + require.NoError(t, err) + + // Wait for all peers to see each other (4 nodes, each sees 3 peers). + allNodes := []TailscaleClient{serverA, serverB, clientA, clientB} + for _, node := range allNodes { + err = node.WaitForPeers(len(allNodes)-1, 60*time.Second, 1*time.Second) + require.NoErrorf(t, err, "node %s failed to see all peers", node.Hostname()) + } + + // Both servers advertise subnet + exit routes. + for _, server := range []TailscaleClient{serverA, serverB} { + command := []string{ + "tailscale", "set", + "--advertise-routes=" + route.String(), + "--advertise-exit-node", + } + _, _, err = server.Execute(command) + require.NoErrorf(t, err, "failed to advertise routes on %s", server.Hostname()) + } + + // Wait for auto-approval: 1 subnet + 2 exit = 3 announced, 3 approved per server. + // Primary election is global, so subnet count may differ between servers. + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes() + assert.NoError(c, err) + + serverANode := MustFindNode(serverA.Hostname(), nodes) + t.Logf("Server A %s: announced=%v, approved=%v, subnet=%v", + serverANode.GetName(), + serverANode.GetAvailableRoutes(), + serverANode.GetApprovedRoutes(), + serverANode.GetSubnetRoutes()) + assert.Len(c, serverANode.GetAvailableRoutes(), 3, "Server A should have 3 announced routes") + assert.Len(c, serverANode.GetApprovedRoutes(), 3, "Server A should have 3 approved routes") + + serverBNode := MustFindNode(serverB.Hostname(), nodes) + t.Logf("Server B %s: announced=%v, approved=%v, subnet=%v", + serverBNode.GetName(), + serverBNode.GetAvailableRoutes(), + serverBNode.GetApprovedRoutes(), + serverBNode.GetSubnetRoutes()) + assert.Len(c, serverBNode.GetAvailableRoutes(), 3, "Server B should have 3 announced routes") + assert.Len(c, serverBNode.GetApprovedRoutes(), 3, "Server B should have 3 approved routes") + }, assertTimeout, 500*time.Millisecond, "Both servers should have auto-approved subnet + exit routes") + + // Get webservice info. + services, err := scenario.Services("usernet1") + require.NoError(t, err) + require.Len(t, services, 1) + + web := services[0] + webip := netip.MustParseAddr(web.GetIPInNetwork(usernet1)) + weburl := fmt.Sprintf("http://%s/etc/hostname", webip) + t.Logf("webservice: %s, %s", webip.String(), weburl) + + // Get externet webservice (exit-only destination, not covered by subnet route). + externetServices, err := scenario.Services("externet") + require.NoError(t, err) + require.Len(t, externetServices, 1) + + extWeb := externetServices[0] + extWebIP := netip.MustParseAddr(extWeb.GetIPInNetwork(externet)) + extWebURL := fmt.Sprintf("http://%s/etc/hostname", extWebIP) + t.Logf("externet webservice: %s, %s", extWebIP.String(), extWebURL) + + serverAID := serverA.MustID() + serverBID := serverB.MustID() + + // Verify Client A sees Server A's subnet route but NOT Server B's. + // (subnet via steering: group-a -> server-a) + assert.EventuallyWithT(t, func(c *assert.CollectT) { + status, err := clientA.Status() + assert.NoError(c, err) + + for _, peerKey := range status.Peers() { + peerStatus := status.Peer[peerKey] + + switch peerStatus.ID { + case serverAID.StableID(): + t.Logf("Client A sees Server A: AllowedIPs=%v, PrimaryRoutes=%v", + peerStatus.AllowedIPs, peerStatus.PrimaryRoutes) + requirePeerSubnetRoutesWithCollect(c, peerStatus, []netip.Prefix{*route}) + case serverBID.StableID(): + t.Logf("Client A sees Server B: AllowedIPs=%v, PrimaryRoutes=%v", + peerStatus.AllowedIPs, peerStatus.PrimaryRoutes) + // Server B should only show exit routes to Client A, not the subnet. + got := filterNonRoutes(peerStatus) + for _, r := range got { + assert.True(c, tsaddr.IsExitRoute(r), + "Client A should not see Server B's subnet route, got %s", r) + } + } + } + }, assertTimeout, 500*time.Millisecond, "Client A should see subnet route only via Server A") + + // Verify Client B sees Server B's subnet route but NOT Server A's. + // (subnet via steering: group-b -> server-b) + assert.EventuallyWithT(t, func(c *assert.CollectT) { + status, err := clientB.Status() + assert.NoError(c, err) + + for _, peerKey := range status.Peers() { + peerStatus := status.Peer[peerKey] + + switch peerStatus.ID { + case serverBID.StableID(): + t.Logf("Client B sees Server B: AllowedIPs=%v, PrimaryRoutes=%v", + peerStatus.AllowedIPs, peerStatus.PrimaryRoutes) + requirePeerSubnetRoutesWithCollect(c, peerStatus, []netip.Prefix{*route}) + case serverAID.StableID(): + t.Logf("Client B sees Server A: AllowedIPs=%v, PrimaryRoutes=%v", + peerStatus.AllowedIPs, peerStatus.PrimaryRoutes) + // Server A should only show exit routes to Client B, not the subnet. + got := filterNonRoutes(peerStatus) + for _, r := range got { + assert.True(c, tsaddr.IsExitRoute(r), + "Client B should not see Server A's subnet route, got %s", r) + } + } + } + }, assertTimeout, 500*time.Millisecond, "Client B should see subnet route only via Server B") + + // Verify subnet route steering: Client A reaches webservice through Server A. + assert.EventuallyWithT(t, func(c *assert.CollectT) { + result, err := clientA.Curl(weburl) + assert.NoError(c, err) + assert.Len(c, result, 13) + }, assertTimeout, 200*time.Millisecond, "Client A should reach webservice") + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + tr, err := clientA.Traceroute(webip) + assert.NoError(c, err) + + ip, err := serverA.IPv4() + if !assert.NoError(c, err, "failed to get IPv4 for serverA") { + return + } + + assertTracerouteViaIPWithCollect(c, tr, ip) + }, assertTimeout, 200*time.Millisecond, "Client A subnet traceroute should go through Server A") + + // Verify subnet route steering: Client B reaches webservice through Server B. + assert.EventuallyWithT(t, func(c *assert.CollectT) { + result, err := clientB.Curl(weburl) + assert.NoError(c, err) + assert.Len(c, result, 13) + }, assertTimeout, 200*time.Millisecond, "Client B should reach webservice") + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + tr, err := clientB.Traceroute(webip) + assert.NoError(c, err) + + ip, err := serverB.IPv4() + if !assert.NoError(c, err, "failed to get IPv4 for serverB") { + return + } + + assertTracerouteViaIPWithCollect(c, tr, ip) + }, assertTimeout, 200*time.Millisecond, "Client B subnet traceroute should go through Server B") + + // Via exit steering: Client A should see exit routes ONLY on Server B (crossed). + assert.EventuallyWithT(t, func(c *assert.CollectT) { + status, err := clientA.Status() + assert.NoError(c, err) + + for _, peerKey := range status.Peers() { + peerStatus := status.Peer[peerKey] + + switch peerStatus.ID { + case serverBID.StableID(): + // Client A should see Server B's exit routes (via steers exit to server-b). + t.Logf("Client A sees Server B: AllowedIPs=%v", peerStatus.AllowedIPs) + got := filterNonRoutes(peerStatus) + hasExit := slices.ContainsFunc(got, tsaddr.IsExitRoute) + assert.True(c, hasExit, "Client A should see Server B's exit routes") + case serverAID.StableID(): + // Client A should NOT see Server A's exit routes (only subnet). + t.Logf("Client A sees Server A: AllowedIPs=%v", peerStatus.AllowedIPs) + + got := filterNonRoutes(peerStatus) + for _, r := range got { + assert.False(c, tsaddr.IsExitRoute(r), + "Client A should NOT see Server A's exit routes, got %s", r) + } + } + } + }, assertTimeout, 500*time.Millisecond, "Client A should see exit routes only via Server B") + + // Via exit steering: Client B should see exit routes ONLY on Server A (crossed). + assert.EventuallyWithT(t, func(c *assert.CollectT) { + status, err := clientB.Status() + assert.NoError(c, err) + + for _, peerKey := range status.Peers() { + peerStatus := status.Peer[peerKey] + + switch peerStatus.ID { + case serverAID.StableID(): + // Client B should see Server A's exit routes (via steers exit to server-a). + t.Logf("Client B sees Server A: AllowedIPs=%v", peerStatus.AllowedIPs) + got := filterNonRoutes(peerStatus) + hasExit := slices.ContainsFunc(got, tsaddr.IsExitRoute) + assert.True(c, hasExit, "Client B should see Server A's exit routes") + case serverBID.StableID(): + // Client B should NOT see Server B's exit routes (only subnet). + t.Logf("Client B sees Server B: AllowedIPs=%v", peerStatus.AllowedIPs) + + got := filterNonRoutes(peerStatus) + for _, r := range got { + assert.False(c, tsaddr.IsExitRoute(r), + "Client B should NOT see Server B's exit routes, got %s", r) + } + } + } + }, assertTimeout, 500*time.Millisecond, "Client B should see exit routes only via Server A") + + // Select the via-designated exit nodes, then validate traffic. + // Client A -> Server B (via steered), Client B -> Server A (via steered). + serverBStatus := serverB.MustStatus() + commandExitA := []string{ + "tailscale", "set", + "--exit-node=" + serverBStatus.Self.DNSName, + } + _, _, err = clientA.Execute(commandExitA) + require.NoError(t, err, "Client A failed to set exit node to Server B") + + serverAStatus := serverA.MustStatus() + commandExitB := []string{ + "tailscale", "set", + "--exit-node=" + serverAStatus.Self.DNSName, + } + _, _, err = clientB.Execute(commandExitB) + require.NoError(t, err, "Client B failed to set exit node to Server A") + + // Subnet traffic should still go through the subnet router (takes priority). + assert.EventuallyWithT(t, func(c *assert.CollectT) { + result, err := clientA.Curl(weburl) + assert.NoError(c, err) + assert.Len(c, result, 13) + }, assertTimeout, 200*time.Millisecond, "Client A should reach subnet webservice") + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + tr, err := clientA.Traceroute(webip) + assert.NoError(c, err) + + ip, err := serverA.IPv4() + if !assert.NoError(c, err, "failed to get IPv4 for serverA") { + return + } + + assertTracerouteViaIPWithCollect(c, tr, ip) + }, assertTimeout, 200*time.Millisecond, "Client A subnet traffic should go through Server A (not exit node)") + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + result, err := clientB.Curl(weburl) + assert.NoError(c, err) + assert.Len(c, result, 13) + }, assertTimeout, 200*time.Millisecond, "Client B should reach subnet webservice") + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + tr, err := clientB.Traceroute(webip) + assert.NoError(c, err) + + ip, err := serverB.IPv4() + if !assert.NoError(c, err, "failed to get IPv4 for serverB") { + return + } + + assertTracerouteViaIPWithCollect(c, tr, ip) + }, assertTimeout, 200*time.Millisecond, "Client B subnet traffic should go through Server B (not exit node)") + + // Exit traffic to externet (not covered by subnet route) goes through exit node. + assert.EventuallyWithT(t, func(c *assert.CollectT) { + result, err := clientA.Curl(extWebURL) + assert.NoError(c, err) + assert.Len(c, result, 13) + }, assertTimeout, 200*time.Millisecond, "Client A should reach externet via exit node") + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + tr, err := clientA.Traceroute(extWebIP) + assert.NoError(c, err) + + ip, err := serverB.IPv4() + if !assert.NoError(c, err, "failed to get IPv4 for serverB") { + return + } + + assertTracerouteViaIPWithCollect(c, tr, ip) + }, assertTimeout, 200*time.Millisecond, "Client A exit traffic should go through Server B") + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + result, err := clientB.Curl(extWebURL) + assert.NoError(c, err) + assert.Len(c, result, 13) + }, assertTimeout, 200*time.Millisecond, "Client B should reach externet via exit node") + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + tr, err := clientB.Traceroute(extWebIP) + assert.NoError(c, err) + + ip, err := serverA.IPv4() + if !assert.NoError(c, err, "failed to get IPv4 for serverA") { + return + } + + assertTracerouteViaIPWithCollect(c, tr, ip) + }, assertTimeout, 200*time.Millisecond, "Client B exit traffic should go through Server A") +}