servertest: cover via-grant exit-node visibility end-to-end

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
This commit is contained in:
Kristoffer Dalby 2026-04-30 11:40:43 +00:00
parent 2b7f15abaa
commit 76ee29352b

View File

@ -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 {