mirror of
https://github.com/juanfont/headscale.git
synced 2026-10-28 01:06:50 +09:00
policy/v2: preserve via exit steering across unrelated rules
Updates #3495
(cherry picked from commit 3417e4cb77)
This commit is contained in:
1 parent
52e01807db
commit
711d00d5ba
19 files changed
+152625
-101
No files matched your search
@@ -6,7 +6,6 @@ import (
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
"go4.org/netipx"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
@@ -20,7 +19,7 @@ import (
|
||||
func ReduceFilterRules(node types.NodeView, rules []tailcfg.FilterRule) []tailcfg.FilterRule {
|
||||
ret := []tailcfg.FilterRule{}
|
||||
subnetRoutes := node.SubnetRoutes()
|
||||
hasExitRoutes := node.IsExitNode()
|
||||
exitRoutes := node.ExitRoutes()
|
||||
|
||||
for _, rule := range rules {
|
||||
// Handle CapGrant rules separately — they use CapGrant[].Dsts
|
||||
@@ -54,19 +53,16 @@ func ReduceFilterRules(node types.NodeView, rules []tailcfg.FilterRule) []tailcf
|
||||
// [types.NodeView.SubnetRoutes] returns only approved,
|
||||
// non-exit routes — matching Tailscale SaaS behavior,
|
||||
// which does not generate filter rules for
|
||||
// advertised-but-unapproved routes. Exit routes
|
||||
// (0.0.0.0/0, ::/0) are excluded by
|
||||
// [types.NodeView.SubnetRoutes] and handled separately
|
||||
// via AllowedIPs/routing.
|
||||
// advertised-but-unapproved routes.
|
||||
if slices.ContainsFunc(subnetRoutes, expanded.OverlapsPrefix) {
|
||||
dests = append(dests, dest)
|
||||
continue
|
||||
}
|
||||
|
||||
// Exit-route advertisers need rules targeting the
|
||||
// public internet so the kernel filter accepts
|
||||
// traffic forwarded by autogroup:internet sources.
|
||||
if hasExitRoutes && ipSetSubsetOf(expanded, util.TheInternet()) {
|
||||
// Approved exit routes count like subnet routes. They
|
||||
// contain every destination, so Tailscale SaaS sends an
|
||||
// exit node every rule, not only internet ones.
|
||||
if slices.ContainsFunc(exitRoutes, expanded.OverlapsPrefix) {
|
||||
dests = append(dests, dest)
|
||||
}
|
||||
}
|
||||
@@ -83,20 +79,6 @@ func ReduceFilterRules(node types.NodeView, rules []tailcfg.FilterRule) []tailcf
|
||||
return ret
|
||||
}
|
||||
|
||||
func ipSetSubsetOf(candidate, container *netipx.IPSet) bool {
|
||||
if candidate == nil || container == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, pref := range candidate.Prefixes() {
|
||||
if !container.ContainsPrefix(pref) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// reduceCapGrantRule filters a [tailcfg.CapGrant] rule to only include
|
||||
// [tailcfg.CapGrant] entries whose Dsts match the given node's IPs. When a
|
||||
// broad prefix (e.g. 100.64.0.0/10 from dst:*) contains a node's IP, it is
|
||||
|
||||
@@ -887,6 +887,66 @@ func TestReduceFilterRulesPartialApproval(t *testing.T) {
|
||||
},
|
||||
wantCount: 0,
|
||||
},
|
||||
{
|
||||
// Tailscale SaaS delivers every rule to an approved exit
|
||||
// node: its exit routes contain every destination.
|
||||
name: "approved-exit-route-includes-tailnet-host",
|
||||
node: &types.Node{
|
||||
IPv4: ap("100.64.0.1"),
|
||||
IPv6: ap("fd7a:115c:a1e0::1"),
|
||||
Hostinfo: &tailcfg.Hostinfo{RoutableIPs: tsaddr.ExitRoutes()},
|
||||
|
||||
ApprovedRoutes: tsaddr.ExitRoutes(),
|
||||
},
|
||||
rules: []tailcfg.FilterRule{
|
||||
{
|
||||
SrcIPs: []string{"100.64.0.17"},
|
||||
DstPorts: []tailcfg.NetPortRange{
|
||||
{IP: "100.64.0.16", Ports: tailcfg.PortRange{First: 53, Last: 53}},
|
||||
{IP: "fd7a:115c:a1e0::10", Ports: tailcfg.PortRange{First: 53, Last: 53}},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantCount: 1,
|
||||
wantRoutes: []string{"100.64.0.16", "fd7a:115c:a1e0::10"},
|
||||
},
|
||||
{
|
||||
name: "approved-exit-route-includes-private-subnet",
|
||||
node: &types.Node{
|
||||
IPv4: ap("100.64.0.1"),
|
||||
IPv6: ap("fd7a:115c:a1e0::1"),
|
||||
Hostinfo: &tailcfg.Hostinfo{RoutableIPs: tsaddr.ExitRoutes()},
|
||||
|
||||
ApprovedRoutes: tsaddr.ExitRoutes(),
|
||||
},
|
||||
rules: []tailcfg.FilterRule{
|
||||
{
|
||||
SrcIPs: []string{"100.64.0.17"},
|
||||
DstPorts: []tailcfg.NetPortRange{
|
||||
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantCount: 1,
|
||||
wantRoutes: []string{"10.33.0.0/16"},
|
||||
},
|
||||
{
|
||||
name: "unapproved-exit-route-excluded",
|
||||
node: &types.Node{
|
||||
IPv4: ap("100.64.0.1"),
|
||||
IPv6: ap("fd7a:115c:a1e0::1"),
|
||||
Hostinfo: &tailcfg.Hostinfo{RoutableIPs: tsaddr.ExitRoutes()},
|
||||
},
|
||||
rules: []tailcfg.FilterRule{
|
||||
{
|
||||
SrcIPs: []string{"100.64.0.17"},
|
||||
DstPorts: []tailcfg.NetPortRange{
|
||||
{IP: "100.64.0.16", Ports: tailcfg.PortRangeAny},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantCount: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -2,6 +2,7 @@ package v2
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/netip"
|
||||
"slices"
|
||||
|
||||
@@ -712,15 +713,54 @@ func compileAutogroupSelf(
|
||||
node types.NodeView,
|
||||
userIdx userNodeIndex,
|
||||
) []tailcfg.FilterRule {
|
||||
if node.IsTagged() || cg.self == nil {
|
||||
if node.IsTagged() || cg.self == nil || !node.User().Valid() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !node.User().Valid() {
|
||||
return compileSelfForUser(cg, userIdx[node.User().ID()])
|
||||
}
|
||||
|
||||
// exitNodeSelfRules returns the autogroup:self rules of every user
|
||||
// other than the node's own for an exit node, whose exit routes contain
|
||||
// every self destination. Only the packet filter needs them: they never
|
||||
// make the exit node a peer, and expanding every user for peer matching
|
||||
// would cost O(users) per exit node on each peer map build.
|
||||
func exitNodeSelfRules(
|
||||
grants []compiledGrant,
|
||||
node types.NodeView,
|
||||
userIdx userNodeIndex,
|
||||
) []tailcfg.FilterRule {
|
||||
if !node.IsExitNode() {
|
||||
return nil
|
||||
}
|
||||
|
||||
sameUserNodes := userIdx[node.User().ID()]
|
||||
var rules []tailcfg.FilterRule
|
||||
|
||||
for i := range grants {
|
||||
cg := &grants[i]
|
||||
if cg.self == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, uid := range slices.Sorted(maps.Keys(userIdx)) {
|
||||
// compileAutogroupSelf already covers an untagged node's own user.
|
||||
if !node.IsTagged() && node.User().Valid() && node.User().ID() == uid {
|
||||
continue
|
||||
}
|
||||
|
||||
rules = append(rules, compileSelfForUser(cg, userIdx[uid])...)
|
||||
}
|
||||
}
|
||||
|
||||
return rules
|
||||
}
|
||||
|
||||
// compileSelfForUser produces the autogroup:self rules for one user's
|
||||
// untagged devices.
|
||||
func compileSelfForUser(
|
||||
cg *compiledGrant,
|
||||
sameUserNodes []types.NodeView,
|
||||
) []tailcfg.FilterRule {
|
||||
if len(sameUserNodes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -211,7 +211,10 @@ func (pol *Policy) compileFilterRulesForNode(
|
||||
grants := pol.compileGrants(users, nodes)
|
||||
userIdx := buildUserNodeIndex(nodes)
|
||||
|
||||
return filterRulesForNode(grants, node, userIdx)
|
||||
return append(
|
||||
filterRulesForNode(grants, node, userIdx),
|
||||
exitNodeSelfRules(grants, node, userIdx)...,
|
||||
)
|
||||
}
|
||||
|
||||
var sshAccept = tailcfg.SSHAction{
|
||||
|
||||
@@ -100,3 +100,109 @@ func TestIssue3233ViaInternetExitVisibility(t *testing.T) {
|
||||
"alice viewing tag:exit2 exit must Exclude ::/0 — strips it from AllowedIPs")
|
||||
})
|
||||
}
|
||||
|
||||
// TestViaInternetExitSteeringSurvivesUnrelatedRules checks that a
|
||||
// regular rule matching the viewer only lifts via exit steering when its
|
||||
// destination actually reaches the internet. Narrow destinations overlap
|
||||
// 0.0.0.0/0 but must not evict it from Exclude.
|
||||
func TestViaInternetExitSteeringSurvivesUnrelatedRules(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
users := types.Users{
|
||||
{Model: gorm.Model{ID: 1}, Name: "alice", Email: "alice@headscale.net"},
|
||||
{Model: gorm.Model{ID: 2}, Name: "bob", Email: "bob@headscale.net"},
|
||||
}
|
||||
|
||||
exitRoutes := []netip.Prefix{tsaddr.AllIPv4(), tsaddr.AllIPv6()}
|
||||
|
||||
aliceLaptop := node("alice-laptop", "100.64.0.10", "fd7a:115c:a1e0::a", users[0])
|
||||
aliceLaptop.ID = 1
|
||||
|
||||
exitA := node("exit-a", "100.64.0.11", "fd7a:115c:a1e0::b", users[0])
|
||||
exitA.ID = 2
|
||||
exitA.Tags = []string{"tag:exit-a"}
|
||||
exitA.Hostinfo = &tailcfg.Hostinfo{RoutableIPs: exitRoutes}
|
||||
exitA.ApprovedRoutes = exitRoutes
|
||||
|
||||
exitB := node("exit-b", "100.64.0.12", "fd7a:115c:a1e0::c", users[0])
|
||||
exitB.ID = 3
|
||||
exitB.Tags = []string{"tag:exit-b"}
|
||||
exitB.Hostinfo = &tailcfg.Hostinfo{RoutableIPs: exitRoutes}
|
||||
exitB.ApprovedRoutes = exitRoutes
|
||||
|
||||
nodes := types.Nodes{aliceLaptop, exitA, exitB}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
extra string
|
||||
// wantExcluded: exit-b's exit routes stay hidden from alice.
|
||||
wantExcluded bool
|
||||
}{
|
||||
{
|
||||
name: "via-grant-only",
|
||||
wantExcluded: true,
|
||||
},
|
||||
{
|
||||
name: "acl-autogroup-self",
|
||||
extra: `"acls": [{"action": "accept", "src": ["autogroup:member"], "dst": ["autogroup:self:*"]}],`,
|
||||
wantExcluded: true,
|
||||
},
|
||||
{
|
||||
name: "acl-single-host",
|
||||
extra: `"acls": [{"action": "accept", "src": ["autogroup:member"], "dst": ["100.64.0.15:53"]}],`,
|
||||
wantExcluded: true,
|
||||
},
|
||||
{
|
||||
name: "acl-private-subnet",
|
||||
extra: `"acls": [{"action": "accept", "src": ["autogroup:member"], "dst": ["10.0.0.0/8:*"]}],`,
|
||||
wantExcluded: true,
|
||||
},
|
||||
{
|
||||
name: "acl-other-exit-node",
|
||||
extra: `"acls": [{"action": "accept", "src": ["autogroup:member"], "dst": ["tag:exit-b:*"]}],`,
|
||||
wantExcluded: true,
|
||||
},
|
||||
{
|
||||
name: "acl-autogroup-internet",
|
||||
extra: `"acls": [{"action": "accept", "src": ["autogroup:member"], "dst": ["autogroup:internet:*"]}],`,
|
||||
wantExcluded: false,
|
||||
},
|
||||
{
|
||||
name: "acl-wildcard",
|
||||
extra: `"acls": [{"action": "accept", "src": ["autogroup:member"], "dst": ["*:*"]}],`,
|
||||
wantExcluded: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
policy := `{
|
||||
"tagOwners": {
|
||||
"tag:exit-a": ["alice@headscale.net"],
|
||||
"tag:exit-b": ["alice@headscale.net"]
|
||||
},
|
||||
` + tt.extra + `
|
||||
"grants": [{
|
||||
"src": ["autogroup:member"],
|
||||
"dst": ["autogroup:internet"],
|
||||
"ip": ["*"],
|
||||
"via": ["tag:exit-a"]
|
||||
}]
|
||||
}`
|
||||
|
||||
pm, err := NewPolicyManager([]byte(policy), users, nodes.ViewSlice())
|
||||
require.NoError(t, err)
|
||||
|
||||
viaPeer := pm.ViaRoutesForPeer(aliceLaptop.View(), exitA.View())
|
||||
require.Contains(t, viaPeer.Include, tsaddr.AllIPv4())
|
||||
|
||||
other := pm.ViaRoutesForPeer(aliceLaptop.View(), exitB.View())
|
||||
for _, p := range exitRoutes {
|
||||
require.Equal(t, tt.wantExcluded, slices.Contains(other.Exclude, p),
|
||||
"exit-b %s in Exclude: got %v", p, other.Exclude)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -738,7 +738,10 @@ func (pm *PolicyManager) filterForNodeLocked(
|
||||
if !pm.needsPerNodeFilter {
|
||||
unreduced = pm.filter
|
||||
} else {
|
||||
unreduced = pm.filterRulesForNodeLocked(node)
|
||||
unreduced = append(
|
||||
pm.filterRulesForNodeLocked(node),
|
||||
exitNodeSelfRules(pm.compiledGrants, node, pm.userNodeIdx)...,
|
||||
)
|
||||
}
|
||||
|
||||
reduced := policyutil.ReduceFilterRules(node, unreduced)
|
||||
@@ -1350,7 +1353,17 @@ func (pm *PolicyManager) ViaRoutesForPeer(viewer, peer types.NodeView) types.Via
|
||||
}
|
||||
}
|
||||
|
||||
result.Exclude = slices.DeleteFunc(result.Exclude, dstPrefix.Overlaps)
|
||||
// Every address overlaps an exit route, so overlap
|
||||
// only decides for subnet routes.
|
||||
result.Exclude = slices.DeleteFunc(result.Exclude, func(p netip.Prefix) bool {
|
||||
return !tsaddr.IsExitRoute(p) && dstPrefix.Overlaps(p)
|
||||
})
|
||||
}
|
||||
|
||||
// A regular grant to the internet lets the viewer use any
|
||||
// exit node, not just the via-tagged ones.
|
||||
if grantReachesInternet(grant) {
|
||||
result.Exclude = slices.DeleteFunc(result.Exclude, tsaddr.IsExitRoute)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1358,6 +1371,21 @@ func (pm *PolicyManager) ViaRoutesForPeer(viewer, peer types.NodeView) types.Via
|
||||
return result
|
||||
}
|
||||
|
||||
// grantReachesInternet reports whether a grant's destinations include
|
||||
// the internet. Neither the wildcard nor autogroup:internet resolves
|
||||
// to 0.0.0.0/0, so check the aliases themselves.
|
||||
func grantReachesInternet(grant Grant) bool {
|
||||
return slices.ContainsFunc(grant.Destinations, func(d Alias) bool {
|
||||
if ag, ok := d.(*AutoGroup); ok {
|
||||
return ag.Is(AutoGroupInternet)
|
||||
}
|
||||
|
||||
_, ok := d.(Asterix)
|
||||
|
||||
return ok
|
||||
})
|
||||
}
|
||||
|
||||
func (pm *PolicyManager) Version() int {
|
||||
return 2
|
||||
}
|
||||
|
||||
@@ -1446,6 +1446,92 @@ func TestAutogroupSelfCombinedWithTags(t *testing.T) {
|
||||
"web server should see admin phone (symmetric)")
|
||||
}
|
||||
|
||||
// TestAutogroupSelfRulesReachExitNodes checks that an approved exit
|
||||
// node receives every user's autogroup:self rules: its exit routes
|
||||
// contain every destination, and Tailscale SaaS delivers them.
|
||||
func TestAutogroupSelfRulesReachExitNodes(t *testing.T) {
|
||||
users := types.Users{
|
||||
{Model: gorm.Model{ID: 1}, Name: "alice", Email: "alice@example.com"},
|
||||
{Model: gorm.Model{ID: 2}, Name: "bob", Email: "bob@example.com"},
|
||||
}
|
||||
|
||||
alice := &types.Node{
|
||||
ID: 1,
|
||||
Hostname: "alice",
|
||||
User: new(users[0]),
|
||||
UserID: new(users[0].ID),
|
||||
IPv4: ap("100.64.0.1"),
|
||||
Hostinfo: &tailcfg.Hostinfo{},
|
||||
}
|
||||
|
||||
bob := &types.Node{
|
||||
ID: 2,
|
||||
Hostname: "bob",
|
||||
User: new(users[1]),
|
||||
UserID: new(users[1].ID),
|
||||
IPv4: ap("100.64.0.2"),
|
||||
Hostinfo: &tailcfg.Hostinfo{},
|
||||
}
|
||||
|
||||
exit := &types.Node{
|
||||
ID: 3,
|
||||
Hostname: "exit",
|
||||
User: new(users[0]),
|
||||
UserID: new(users[0].ID),
|
||||
IPv4: ap("100.64.0.3"),
|
||||
Tags: []string{"tag:exit"},
|
||||
Hostinfo: &tailcfg.Hostinfo{RoutableIPs: tsaddr.ExitRoutes()},
|
||||
ApprovedRoutes: tsaddr.ExitRoutes(),
|
||||
}
|
||||
|
||||
server := &types.Node{
|
||||
ID: 4,
|
||||
Hostname: "server",
|
||||
User: new(users[0]),
|
||||
UserID: new(users[0].ID),
|
||||
IPv4: ap("100.64.0.4"),
|
||||
Tags: []string{"tag:server"},
|
||||
Hostinfo: &tailcfg.Hostinfo{},
|
||||
}
|
||||
|
||||
nodes := types.Nodes{alice, bob, exit, server}
|
||||
|
||||
policy := `{
|
||||
"tagOwners": {
|
||||
"tag:exit": ["alice@example.com"],
|
||||
"tag:server": ["alice@example.com"]
|
||||
},
|
||||
"acls": [
|
||||
{"action": "accept", "src": ["autogroup:member"], "dst": ["autogroup:self:*"]}
|
||||
]
|
||||
}`
|
||||
|
||||
pm, err := NewPolicyManager([]byte(policy), users, nodes.ViewSlice())
|
||||
require.NoError(t, err)
|
||||
|
||||
dsts := func(n *types.Node) []string {
|
||||
rules, err := pm.FilterForNode(n.View())
|
||||
require.NoError(t, err)
|
||||
|
||||
var out []string
|
||||
|
||||
for _, r := range rules {
|
||||
for _, dp := range r.DstPorts {
|
||||
out = append(out, dp.IP)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
require.ElementsMatch(t, []string{"100.64.0.1", "100.64.0.2"}, dsts(exit),
|
||||
"exit node must get every user's self rule")
|
||||
require.Empty(t, dsts(server),
|
||||
"tagged node without exit routes gets no self rules")
|
||||
require.ElementsMatch(t, []string{"100.64.0.1"}, dsts(alice),
|
||||
"user device gets only its own user's self rule")
|
||||
}
|
||||
|
||||
// TestIssue2990SameUserTaggedDevice reproduces the exact scenario from issue #2990:
|
||||
// - One user (user1) who is in group:admin
|
||||
// - node1: user device (not tagged), belongs to user1
|
||||
|
||||
@@ -157,8 +157,18 @@ var grantSkipReasons = map[string]string{
|
||||
// SaaS lists contiguous source addresses one by one and writes
|
||||
// autogroup:internet as address ranges; headscale merges sources into
|
||||
// ranges and writes the internet as prefixes. The address sets match;
|
||||
// TestViaGrantMapCompat compares this capture's filters by address.
|
||||
// TestViaGrantMapCompat compares these captures' filters by address.
|
||||
"via-grant-v52": "FILTER_ADDRESS_FORM: same addresses, different wire form",
|
||||
"via-grant-v53": "FILTER_ADDRESS_FORM: same addresses, different wire form",
|
||||
"via-grant-v54": "FILTER_ADDRESS_FORM: same addresses, different wire form",
|
||||
"via-grant-v55": "FILTER_ADDRESS_FORM: same addresses, different wire form",
|
||||
"via-grant-v56": "FILTER_ADDRESS_FORM: same addresses, different wire form",
|
||||
"via-grant-v57": "FILTER_ADDRESS_FORM: same addresses, different wire form",
|
||||
"via-grant-v58": "FILTER_ADDRESS_FORM: same addresses, different wire form",
|
||||
"via-grant-v59": "FILTER_ADDRESS_FORM: same addresses, different wire form",
|
||||
"via-grant-v60": "FILTER_ADDRESS_FORM: same addresses, different wire form and rule order",
|
||||
"via-grant-v61": "FILTER_ADDRESS_FORM: same addresses, different wire form",
|
||||
"via-grant-v62": "FILTER_ADDRESS_FORM: same addresses, different wire form",
|
||||
}
|
||||
|
||||
// TestGrantsCompat is a data-driven test that loads all GRANT-*.json
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -9,6 +9,8 @@ package servertest_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/netip"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
@@ -37,6 +39,16 @@ var viaCompatTests = []struct {
|
||||
{"via-grant-v35", "via grant with unadvertised destination"},
|
||||
{"via-grant-v36", "full complex: peer connectivity + crossed subnet + crossed exit"},
|
||||
{"via-grant-v52", "members reach the internet only via tag:exit, admins reach everything"},
|
||||
{"via-grant-v53", "via exit steering plus a member rule to a tagged host port"},
|
||||
{"via-grant-v54", "via exit steering plus a member rule to autogroup:self"},
|
||||
{"via-grant-v55", "via exit steering plus a member rule to a private subnet"},
|
||||
{"via-grant-v56", "via exit steering plus a plain member rule to autogroup:internet"},
|
||||
{"via-grant-v57", "via exit steering plus a member rule to a non-via exit node"},
|
||||
{"via-grant-v58", "no via grant: approved exit nodes plus a member rule to a tagged host port"},
|
||||
{"via-grant-v59", "via exit steering for tagged clients; members have an unrelated rule"},
|
||||
{"via-grant-v60", "user-owned exit node outside the via tag; members have an autogroup:self rule"},
|
||||
{"via-grant-v61", "via exit steering plus a member capability grant to a tagged host"},
|
||||
{"via-grant-v62", "via exit steering plus a member rule to autogroup:internet on one port"},
|
||||
}
|
||||
|
||||
// TestViaGrantMapCompat loads golden captures from Tailscale SaaS and
|
||||
@@ -224,6 +236,8 @@ func runViaMapCompat(t *testing.T, c *testcapture.Capture) {
|
||||
})
|
||||
}
|
||||
|
||||
saasAddrs := saasAddrsByNode(c, clients)
|
||||
|
||||
// Compare each viewer's [tailcfg.MapResponse] against the golden [netmap.NetworkMap].
|
||||
for viewerName, cl := range clients {
|
||||
capture := c.Captures[viewerName]
|
||||
@@ -235,7 +249,7 @@ func runViaMapCompat(t *testing.T, c *testcapture.Capture) {
|
||||
nm := cl.Netmap()
|
||||
require.NotNil(t, nm, "netmap is nil")
|
||||
|
||||
compareNetmap(t, nm, capture, clients)
|
||||
compareNetmap(t, nm, capture, clients, saasAddrs)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -246,12 +260,13 @@ func runViaMapCompat(t *testing.T, c *testcapture.Capture) {
|
||||
// - Route prefixes in AllowedIPs (non-Tailscale-IP entries like 10.44.0.0/16)
|
||||
// - Number of Tailscale IPs per peer (should be 2: one v4 + one v6)
|
||||
// - PrimaryRoutes per peer
|
||||
// - PacketFilter rule count and non-Tailscale dst prefixes
|
||||
// - PacketFilter (source, destination, ports) triples
|
||||
func compareNetmap(
|
||||
t *testing.T,
|
||||
got *netmap.NetworkMap,
|
||||
want testcapture.Node,
|
||||
clients map[string]*servertest.TestClient,
|
||||
saasAddrs map[netip.Addr]string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
@@ -383,110 +398,131 @@ func compareNetmap(
|
||||
}
|
||||
}
|
||||
|
||||
// Compare PacketFilter rules (IP-independent).
|
||||
wantFilterRules := want.PacketFilterRules
|
||||
comparePacketFilter(t, got, want, clients, saasAddrs)
|
||||
}
|
||||
|
||||
if !assert.Lenf(t, got.PacketFilter, len(wantFilterRules),
|
||||
"PacketFilter rule count mismatch") {
|
||||
return
|
||||
}
|
||||
// comparePacketFilter compares PacketFilter rules as a set of (source,
|
||||
// destination, ports or capability) triples. Headscale merges rules
|
||||
// that share sources while SaaS keeps one rule per policy entry, so rule
|
||||
// count and order differ without changing what the filter allows.
|
||||
// Tailscale IPs differ between SaaS and headscale allocation, so both
|
||||
// sides are re-keyed by peer identity.
|
||||
func comparePacketFilter(
|
||||
t *testing.T,
|
||||
got *netmap.NetworkMap,
|
||||
want testcapture.Node,
|
||||
clients map[string]*servertest.TestClient,
|
||||
saasAddrs map[netip.Addr]string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
// Resolve SaaS IPs → peer name and HS IPs → peer name so we can
|
||||
// compare rule sources structurally. Tailscale IPs in SaaS vs HS
|
||||
// allocations never match literally, but each IP belongs to a
|
||||
// peer with a stable hostname.
|
||||
saasAddrs := saasAddrsByPeer(want, clients)
|
||||
hsAddrs := hsAddrsByPeer(clients)
|
||||
|
||||
// Compare destination prefixes per rule — subnet CIDRs like
|
||||
// 10.44.0.0/16 are stable between Tailscale SaaS and headscale.
|
||||
// Source IPs are re-keyed per peer identity before comparison.
|
||||
for i := range wantFilterRules {
|
||||
wantRule := wantFilterRules[i]
|
||||
gotMatch := got.PacketFilter[i]
|
||||
wantTriples := map[string]struct{}{}
|
||||
|
||||
wantSrcIdents := canonicaliseSrcStrings(t, wantRule.SrcIPs, saasAddrs, i)
|
||||
gotSrcIdents := canonicaliseSrcPrefixes(t, gotMatch.Srcs, hsAddrs, i)
|
||||
for i, rule := range want.PacketFilterRules {
|
||||
srcs := strings.Join(canonicaliseSrcStrings(t, rule.SrcIPs, saasAddrs, i), ",")
|
||||
|
||||
assert.Equalf(t, wantSrcIdents, gotSrcIdents,
|
||||
"PacketFilter[%d]: source peer identities mismatch", i)
|
||||
|
||||
// Destination prefixes: extract non-Tailscale-IP CIDRs
|
||||
// from both golden and headscale rules and compare.
|
||||
var wantDstPrefixes []string
|
||||
|
||||
for _, dp := range wantRule.DstPorts {
|
||||
for _, dp := range rule.DstPorts {
|
||||
pfxs, err := parseDstPrefixes(dp.IP)
|
||||
require.NoErrorf(t, err,
|
||||
"golden DstPorts[%d].IP %q should parse as prefix, addr or range", i, dp.IP)
|
||||
|
||||
for _, pfx := range pfxs {
|
||||
if !isTailscaleIP(pfx) {
|
||||
wantDstPrefixes = append(wantDstPrefixes, pfx.String())
|
||||
for _, dst := range peerIdents(t, pfx, saasAddrs, i) {
|
||||
wantTriples[filterTriple(srcs, dst, dp.Ports.First, dp.Ports.Last)] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var gotDstPrefixes []string
|
||||
for _, cg := range rule.CapGrant {
|
||||
caps := slices.Collect(maps.Keys(cg.CapMap))
|
||||
caps = append(caps, cg.Caps...)
|
||||
|
||||
for _, dst := range gotMatch.Dsts {
|
||||
pfx := dst.Net
|
||||
if !isTailscaleIP(pfx) {
|
||||
gotDstPrefixes = append(gotDstPrefixes, pfx.String())
|
||||
for _, pfx := range cg.Dsts {
|
||||
for _, dst := range peerIdents(t, pfx, saasAddrs, i) {
|
||||
for _, c := range caps {
|
||||
wantTriples[capTriple(srcs, dst, c)] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
gotTriples := map[string]struct{}{}
|
||||
|
||||
for i, match := range got.PacketFilter {
|
||||
srcs := strings.Join(canonicaliseSrcPrefixes(t, match.Srcs, hsAddrs, i), ",")
|
||||
|
||||
for _, dp := range match.Dsts {
|
||||
for _, dst := range peerIdents(t, dp.Net, hsAddrs, i) {
|
||||
gotTriples[filterTriple(srcs, dst, dp.Ports.First, dp.Ports.Last)] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
slices.Sort(wantDstPrefixes)
|
||||
slices.Sort(gotDstPrefixes)
|
||||
|
||||
assert.Equalf(t, wantDstPrefixes, gotDstPrefixes,
|
||||
"PacketFilter[%d]: non-Tailscale destination prefixes mismatch", i)
|
||||
for _, cm := range match.Caps {
|
||||
for _, dst := range peerIdents(t, cm.Dst, hsAddrs, i) {
|
||||
gotTriples[capTriple(srcs, dst, cm.Cap)] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.ElementsMatchf(t, sortedKeys(wantTriples), sortedKeys(gotTriples),
|
||||
"PacketFilter (source, destination, ports) mismatch")
|
||||
}
|
||||
|
||||
// saasAddrsByPeer builds a map from SaaS Tailscale address to peer
|
||||
// hostname using each capture's [tailcfg.NodeView.Addresses]. Peers not in
|
||||
// clients are skipped.
|
||||
func saasAddrsByPeer(
|
||||
want testcapture.Node,
|
||||
func filterTriple(srcs, dst string, first, last uint16) string {
|
||||
return fmt.Sprintf("%s => %s:%d-%d", srcs, dst, first, last)
|
||||
}
|
||||
|
||||
func capTriple(srcs, dst string, c tailcfg.PeerCapability) string {
|
||||
return fmt.Sprintf("%s => %s cap %s", srcs, dst, c)
|
||||
}
|
||||
|
||||
// peerIdents resolves a prefix into sorted canonical identity tokens,
|
||||
// see [addIdentsForSrc].
|
||||
func peerIdents(
|
||||
t *testing.T,
|
||||
pfx netip.Prefix,
|
||||
addrToPeer map[netip.Addr]string,
|
||||
ruleIndex int,
|
||||
) []string {
|
||||
t.Helper()
|
||||
|
||||
seen := map[string]struct{}{}
|
||||
addIdentsForSrc(t, pfx, addrToPeer, ruleIndex, seen)
|
||||
|
||||
return sortedKeys(seen)
|
||||
}
|
||||
|
||||
// saasAddrsByNode maps each SaaS Tailscale address to its node's
|
||||
// hostname using every captured node's own addresses. A rule may name
|
||||
// sources that are not the viewer's peers, so peer lists are not
|
||||
// enough. Nodes not in clients are skipped.
|
||||
func saasAddrsByNode(
|
||||
c *testcapture.Capture,
|
||||
clients map[string]*servertest.TestClient,
|
||||
) map[netip.Addr]string {
|
||||
out := map[netip.Addr]string{}
|
||||
|
||||
if want.Netmap == nil {
|
||||
return out
|
||||
}
|
||||
|
||||
// Walk peers listed in this [netmap.NetworkMap].
|
||||
for _, peer := range want.Netmap.Peers {
|
||||
name := extractHostname(peer.Name())
|
||||
for name, node := range c.Captures {
|
||||
if _, isOurs := clients[name]; !isOurs {
|
||||
continue
|
||||
}
|
||||
|
||||
for i := range peer.Addresses().Len() {
|
||||
pfx := peer.Addresses().At(i)
|
||||
if node.Netmap == nil || !node.Netmap.SelfNode.Valid() {
|
||||
continue
|
||||
}
|
||||
|
||||
addrs := node.Netmap.SelfNode.Addresses()
|
||||
for i := range addrs.Len() {
|
||||
pfx := addrs.At(i)
|
||||
if isTailscaleIP(pfx) {
|
||||
out[pfx.Addr()] = name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The viewer's own [tailcfg.NodeView] addresses also appear as possible src.
|
||||
if want.Netmap.SelfNode.Valid() {
|
||||
name := extractHostname(want.Netmap.SelfNode.Name())
|
||||
|
||||
if _, isOurs := clients[name]; isOurs {
|
||||
addrs := want.Netmap.SelfNode.Addresses()
|
||||
for i := range addrs.Len() {
|
||||
pfx := addrs.At(i)
|
||||
if isTailscaleIP(pfx) {
|
||||
out[pfx.Addr()] = name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user