From 76ee29352b46659fc3ef16d8464bb50eb23ba497 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Thu, 30 Apr 2026 11:40:43 +0000 Subject: [PATCH] servertest: cover via-grant exit-node visibility end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TestGrantViaExitNodeInternetVisibility boots a server, applies a policy that scopes autogroup:internet to a tag, registers a tagged exit advertiser and a regular client, and asserts the client's netmap surfaces the exit node with 0.0.0.0/0 and ::/0 in AllowedIPs — the substrate the Tailscale client reads to populate `tailscale exit-node list`. TestGrantViaExitNodeNoFilterRules retains its assertion (literal /0 absent from the exit node's PacketFilter, matching SaaS PacketFilter encoding); only its docstring is updated to reflect that the exit node now does receive a TheInternet-shaped rule, just not the literal /0 form. Updates #3233 --- hscontrol/servertest/grants_test.go | 124 +++++++++++++++++++++++++--- 1 file changed, 114 insertions(+), 10 deletions(-) diff --git a/hscontrol/servertest/grants_test.go b/hscontrol/servertest/grants_test.go index c839da66..d6ac7df4 100644 --- a/hscontrol/servertest/grants_test.go +++ b/hscontrol/servertest/grants_test.go @@ -772,11 +772,12 @@ func TestGrantViaSubnetFilterRules(t *testing.T) { "without per-node filter compilation for via grants, these rules are missing") } -// TestGrantViaExitNodeNoFilterRules verifies that exit nodes with via grants -// for autogroup:internet do NOT receive PacketFilter rules for exit traffic. -// Tailscale SaaS handles exit traffic forwarding through the client's exit -// node selection mechanism, not through PacketFilter rules. Verified by -// golden captures GRANT-V14 through GRANT-V36. +// TestGrantViaExitNodeNoFilterRules verifies wire-format SaaS compat: +// the exit node's PacketFilter must not contain literal 0.0.0.0/0 or +// ::/0 destinations. Internally, autogroup:internet via grants emit a +// rule whose DstPorts enumerate util.TheInternet() prefixes; +// ReduceFilterRules preserves it on exit-route advertisers but it +// never surfaces as the literal /0 form. func TestGrantViaExitNodeNoFilterRules(t *testing.T) { t.Parallel() @@ -852,9 +853,11 @@ func TestGrantViaExitNodeNoFilterRules(t *testing.T) { return nm != nil }) - // The exit node's PacketFilter must NOT contain rules for exit traffic. - // The only rules should be from the peer connectivity grant (tag:exit-a - // and tag:group-a can talk to each other at their Tailscale IPs). + // The exit node's PacketFilter must not surface exit-route + // destinations as the literal /0 form. The autogroup:internet via + // grant emits a rule with DstPorts enumerating util.TheInternet() + // prefixes; literal /0 would diverge from SaaS PacketFilter + // encoding. exitNM := exitA.Netmap() require.NotNil(t, exitNM) @@ -862,12 +865,113 @@ func TestGrantViaExitNodeNoFilterRules(t *testing.T) { for _, dst := range m.Dsts { dstPrefix := netip.PrefixFrom(dst.Net.Addr(), dst.Net.Bits()) assert.Falsef(t, dstPrefix == exitRouteV4 || dstPrefix == exitRouteV6, - "exit node PacketFilter should NOT contain exit route destinations (0.0.0.0/0 or ::/0); "+ - "autogroup:internet via grants do not produce filter rules on exit nodes (verified against Tailscale SaaS)") + "exit node PacketFilter must not contain literal 0.0.0.0/0 or ::/0; "+ + "autogroup:internet via rules use util.TheInternet() prefixes") } } } +// TestGrantViaExitNodeInternetVisibility verifies that a via grant +// scoping autogroup:internet to a tag surfaces the via-tagged exit +// node as a peer in the source client's netmap with 0.0.0.0/0 and +// ::/0 in its AllowedIPs — the substrate the Tailscale client reads +// to populate `tailscale exit-node list`. +func TestGrantViaExitNodeInternetVisibility(t *testing.T) { + t.Parallel() + + srv := servertest.NewServer(t) + exitUser := srv.CreateUser(t, "exit-user") + clientUser := srv.CreateUser(t, "cl-user") + + exitRouteV4 := netip.MustParsePrefix("0.0.0.0/0") + exitRouteV6 := netip.MustParsePrefix("::/0") + + changed, err := srv.State().SetPolicy([]byte(`{ + "tagOwners": { + "tag:exit-a": ["exit-user@"], + "tag:group-a": ["cl-user@"] + }, + "grants": [ + { + "src": ["tag:exit-a", "tag:group-a"], + "dst": ["tag:exit-a", "tag:group-a"], + "ip": ["*"] + }, + { + "src": ["tag:group-a"], + "dst": ["autogroup:internet"], + "ip": ["*"], + "via": ["tag:exit-a"] + } + ], + "autoApprovers": { + "exitNode": ["tag:exit-a"] + } + }`)) + require.NoError(t, err) + + if changed { + changes, err := srv.State().ReloadPolicy() + require.NoError(t, err) + srv.App.Change(changes...) + } + + exitA := servertest.NewClient(t, srv, "exit-a", + servertest.WithUser(exitUser), + servertest.WithTags("tag:exit-a")) + clientA := servertest.NewClient(t, srv, "client-a", + servertest.WithUser(clientUser), + servertest.WithTags("tag:group-a")) + + exitA.WaitForPeers(t, 1, 15*time.Second) + clientA.WaitForPeers(t, 1, 15*time.Second) + + exitA.Direct().SetHostinfo(&tailcfg.Hostinfo{ + BackendLogID: "servertest-exit-a-internet", + Hostname: "exit-a", + RoutableIPs: []netip.Prefix{exitRouteV4, exitRouteV6}, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _ = exitA.Direct().SendUpdate(ctx) + + exitAID := findNodeID(t, srv, "exit-a") + _, routeChange, err := srv.State().SetApprovedRoutes( + exitAID, []netip.Prefix{exitRouteV4, exitRouteV6}) + require.NoError(t, err) + srv.App.Change(routeChange) + + clientA.WaitForCondition(t, "client-a sees exit-a with exit AllowedIPs", + 15*time.Second, + func(nm *netmap.NetworkMap) bool { + for _, p := range nm.Peers { + hi := p.Hostinfo() + if !hi.Valid() || hi.Hostname() != "exit-a" { + continue + } + + var sawV4, sawV6 bool + + for i := range p.AllowedIPs().Len() { + switch p.AllowedIPs().At(i) { + case exitRouteV4: + sawV4 = true + case exitRouteV6: + sawV6 = true + } + } + + if sawV4 && sawV6 { + return true + } + } + + return false + }) +} + // hasCapMatches returns true if any Match in the slice contains a // non-empty Caps (CapMatch) list. func hasCapMatches(matches []filtertype.Match) bool {