policy, servertest: derive exit routes from allowed public destinations

A public IP permits exit defaults; private-only rules do not. Replay six
accepted and two refused SaaS captures with range-equivalence checks,
preserving provenance and the literal-default-route ACL validation gap.
This commit is contained in:
Kristoffer Dalby committed 2026-11-02 19:15:40 +01:00
1 parent f453502ef1
commit 4cb74dfe04
17 files changed
+12045 -27

No files matched your search

+3 -4
View File
@@ -151,9 +151,8 @@ func overlaps(ranges []netipx.IPRange, r netipx.IPRange) bool {
return i < len(ranges) && ranges[i].Overlaps(r)
}
// DestsIsTheInternet reports whether the destination covers "the
// internet" — the set represented by autogroup:internet, special-cased
// for exit nodes. See [util.CoversTheInternet].
// DestsIsTheInternet reports whether any destination is in [util.TheInternet].
// It lets clients select exit nodes for that traffic.
func (m *Match) DestsIsTheInternet() bool {
return util.CoversTheInternet(m.dests)
return util.HasInternetDestination(m.dests)
}
+16 -1
View File
@@ -523,8 +523,23 @@ func TestDestsIsTheInternet(t *testing.T) {
want: false,
},
{
name: "single public IP is not the internet",
name: "single public IPv4 destination needs exit routes",
dsts: []string{"8.8.8.8"},
want: true,
},
{
name: "single public IPv6 destination needs exit routes",
dsts: []string{"2001:db8::1"},
want: true,
},
{
name: "private IPv6 destination does not need exit routes",
dsts: []string{"fd00::1"},
want: false,
},
{
name: "link-local IPv4 destination does not need exit routes",
dsts: []string{"169.254.1.1"},
want: false,
},
{
+1 -1
View File
@@ -1865,7 +1865,7 @@ func grantReachesInternet(grant Grant, dsts []netip.Prefix) bool {
// Resolved prefixes are valid, so the builder cannot fail.
set, _ := b.IPSet()
return util.CoversTheInternet(set)
return util.HasInternetDestination(set)
}
func (pm *PolicyManager) Version() int {
@@ -216,11 +216,15 @@ var aclSkipReasons = map[string]string{
"acl-self-plus-dsts/kakuna/filter": srcAddressForm,
"acl-self-plus-dsts/beedrill/filter": srcAddressForm,
"acl-self-plus-dsts/bulbasaur/filter": "FILTER_ADDRESS_FORM+SELF_DST_REPEAT: sources listed one by one, the autogroup:self destination written once per source alias",
"routes-exit-family-ipv4/refused": aclDefaultRouteAccepted,
"routes-exit-family-ipv6/refused": aclDefaultRouteAccepted,
}
const (
allowAllForm = "ALLOW_ALL_FORM: SaaS writes the allow-all default's sources as the tailnet ranges, headscale as \"*\""
srcAddressForm = "FILTER_ADDRESS_FORM: SaaS lists contiguous source addresses one by one, headscale as a range"
allowAllForm = "ALLOW_ALL_FORM: SaaS writes the allow-all default's sources as the tailnet ranges, headscale as \"*\""
srcAddressForm = "FILTER_ADDRESS_FORM: SaaS lists contiguous source addresses one by one, headscale as a range"
aclDefaultRouteAccepted = "ACL_DEFAULT_ROUTE_ACCEPTED: headscale accepts literal 0.0.0.0/0 and ::/0 ACL destinations; SaaS rejects them and requires * or autogroup:internet"
)
// TestACLCompat is a data-driven test that loads all ACL-*.json test files
@@ -40,6 +40,7 @@ import (
"github.com/juanfont/headscale/hscontrol/policy/policyutil"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/types/testcapture"
"github.com/juanfont/headscale/hscontrol/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go4.org/netipx"
@@ -372,7 +373,11 @@ func TestRoutesCompat(t *testing.T) {
)
compatCheck(t, routesSkipReasons, tf.TestID+"/"+nodeName+"/filter", func() string {
return cmp.Diff(wantRules, gotRules, opts...)
return cmp.Diff(
expandFilterDestinationRanges(t, wantRules),
expandFilterDestinationRanges(t, gotRules),
opts...,
)
})
})
}
@@ -380,6 +385,76 @@ func TestRoutesCompat(t *testing.T) {
}
}
// Expand ranges per destination so ports, protocols, rule boundaries and duplicates stay intact.
func expandFilterDestinationRanges(t *testing.T, rules []tailcfg.FilterRule) []tailcfg.FilterRule {
t.Helper()
expanded := slices.Clone(rules)
for i, rule := range rules {
if !slices.ContainsFunc(rule.DstPorts, func(dst tailcfg.NetPortRange) bool {
return strings.Contains(dst.IP, "-")
}) {
continue
}
var dests []tailcfg.NetPortRange
for _, dst := range rule.DstPorts {
if !strings.Contains(dst.IP, "-") {
dests = append(dests, dst)
continue
}
set, err := util.ParseIPSet(dst.IP, nil)
require.NoErrorf(t, err, "golden DstPorts.IP %q unparseable", dst.IP)
for _, prefix := range set.Prefixes() {
dest := dst
dest.IP = filterIP(prefix)
dests = append(dests, dest)
}
}
expanded[i].DstPorts = dests
}
return expanded
}
func TestExpandFilterDestinationRanges(t *testing.T) {
rules := []tailcfg.FilterRule{{
SrcIPs: []string{"100.64.0.1"},
IPProto: []int{6, 17},
DstPorts: []tailcfg.NetPortRange{
{IP: "192.0.2.1-192.0.2.2", Ports: tailcfg.PortRange{First: 80, Last: 80}},
{IP: "192.0.2.1-192.0.2.2", Ports: tailcfg.PortRange{First: 443, Last: 443}},
{IP: "192.0.2.1-192.0.2.2", Ports: tailcfg.PortRange{First: 80, Last: 80}},
{IP: "203.0.113.4", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
}, {
SrcIPs: []string{"100.64.0.2"},
DstPorts: []tailcfg.NetPortRange{
{IP: "192.0.2.1", Ports: tailcfg.PortRangeAny},
},
}}
before := slices.Clone(rules[0].DstPorts)
want := slices.Clone(rules)
want[0].DstPorts = []tailcfg.NetPortRange{
{IP: "192.0.2.1", Ports: tailcfg.PortRange{First: 80, Last: 80}},
{IP: "192.0.2.2", Ports: tailcfg.PortRange{First: 80, Last: 80}},
{IP: "192.0.2.1", Ports: tailcfg.PortRange{First: 443, Last: 443}},
{IP: "192.0.2.2", Ports: tailcfg.PortRange{First: 443, Last: 443}},
{IP: "192.0.2.1", Ports: tailcfg.PortRange{First: 80, Last: 80}},
{IP: "192.0.2.2", Ports: tailcfg.PortRange{First: 80, Last: 80}},
{IP: "203.0.113.4", Ports: tailcfg.PortRange{First: 22, Last: 22}},
}
require.Equal(t, want, expandFilterDestinationRanges(t, rules))
require.Equal(t, before, rules[0].DstPorts)
}
// derivePeerPairsFromCaptures builds the set of expected peer pairs from
// golden file captures. For each node that receives filter rules from
// Tailscale SaaS, the SrcIPs identify subnets whose traffic will arrive
@@ -982,17 +1057,13 @@ func TestRoutesCompatReduceRoutes(t *testing.T) {
continue
}
prefix, parseErr := netip.ParsePrefix(dp.IP)
if parseErr != nil {
addr, addrErr := netip.ParseAddr(dp.IP)
require.NoErrorf(t, addrErr,
"golden DstPorts.IP %q unparseable", dp.IP)
set, err := util.ParseIPSet(dp.IP, nil)
require.NoErrorf(t, err, "golden DstPorts.IP %q unparseable", dp.IP)
prefix = netip.PrefixFrom(addr, addr.BitLen())
}
if !slices.Contains(dstPrefixes, prefix) {
dstPrefixes = append(dstPrefixes, prefix)
for _, prefix := range set.Prefixes() {
if !slices.Contains(dstPrefixes, prefix) {
dstPrefixes = append(dstPrefixes, prefix)
}
}
}
@@ -0,0 +1,75 @@
// routes-exit-family-ipv4
//
// Exit route families offered for 0.0.0.0/0 destination access
//
// Steps: 1
// Captured at: 2026-11-02T17:02:37Z
// tool version: tscap/5f80ee6150559e777612549d896d444b50768737
// schema version: 1
{
"schema_version": 1,
"test_id": "routes-exit-family-ipv4",
"description": "Exit route families offered for 0.0.0.0/0 destination access",
"category": "routes",
"captured_at": "2026-11-02T17:02:37.394824548Z",
"tool_version": "tscap/5f80ee6150559e777612549d896d444b50768737",
"tailnet": "odin@example.com",
"error": true,
"input": {
"api_response_code": 400,
"api_response_body": {
"message": "dst=\"0.0.0.0/0:*\": to allow all IP addresses, use *:port, not 0.0.0.0/0:port"
},
"tailnet": {
"dns": {"magic_dns": false, "nameservers": [], "search_paths": [], "split_dns": {}},
"settings": {}
},
"scenario_hujson": "{\n \"id\": \"routes-exit-family-ipv4\",\n \"category\": \"routes\",\n \"description\": \"Exit route families offered for 0.0.0.0/0 destination access\",\n \"topology\": {\n \"users\": [\n {\n \"id\": 1,\n \"name\": \"odin\",\n \"email\": \"odin@example.com\"\n }\n ],\n \"nodes\": {\n \"viewer\": {\n \"hostname\": \"viewer\",\n \"tags\": [\n \"tag:client\"\n ],\n \"ipv4\": \"100.64.0.1\",\n \"ipv6\": \"fd7a:115c:a1e0::1\",\n \"routable_ips\": [],\n \"approved_routes\": []\n },\n \"exit\": {\n \"hostname\": \"exit\",\n \"tags\": [\n \"tag:exit\"\n ],\n \"ipv4\": \"100.64.0.2\",\n \"ipv6\": \"fd7a:115c:a1e0::2\",\n \"routable_ips\": [\n \"0.0.0.0/0\",\n \"::/0\",\n \"10.40.7.0/24\"\n ],\n \"approved_routes\": []\n }\n }\n },\n \"tailnet\": {\n \"dns\": {\n \"magic_dns\": false,\n \"nameservers\": [],\n \"search_paths\": [],\n \"split_dns\": {}\n },\n \"settings\": {}\n },\n \"policy\": {\n \"tagOwners\": {\n \"tag:client\": [\n \"odin@example.com\"\n ],\n \"tag:exit\": [\n \"odin@example.com\"\n ]\n },\n \"autoApprovers\": {\n \"exitNode\": [\n \"tag:exit\"\n ],\n \"routes\": {\n \"10.40.7.0/24\": [\n \"tag:exit\"\n ]\n }\n },\n \"acls\": [\n {\n \"action\": \"accept\",\n \"src\": [\n \"tag:client\"\n ],\n \"dst\": [\n \"0.0.0.0/0:*\"\n ]\n }\n ]\n },\n \"options\": {\n \"max_wait_seconds\": 120,\n \"stable_window_seconds\": 5,\n \"capture_whois\": false,\n \"expect_api_error\": true\n }\n}\n",
"scenario_path": "/home/kradalby/.cache/tscap-pr3619/scenarios/routes-exit-family-ipv4.hujson",
"full_policy": {
"acls": [{"action": "accept", "dst": ["0.0.0.0/0:*"], "src": ["tag:client"]}],
"autoApprovers": {"exitNode": ["tag:exit"], "routes": {"10.40.7.0/24": ["tag:exit"]}},
"tagOwners": {"tag:client": ["odin@example.com"], "tag:exit": ["odin@example.com"]}
}
},
"topology": {
"users": [{"id": 1, "name": "odin", "email": "odin@example.com"}],
"nodes": {"exit": {
"hostname": "exit",
"tags": ["tag:exit"],
"ipv4": "100.64.0.2",
"ipv6": "fd7a:115c:a1e0::2",
"routable_ips": ["0.0.0.0/0", "::/0", "10.40.7.0/24"],
"approved_routes": []
}, "viewer": {
"hostname": "viewer",
"tags": ["tag:client"],
"ipv4": "100.64.0.1",
"ipv6": "fd7a:115c:a1e0::1",
"routable_ips": [],
"approved_routes": []
}}
},
"captures": {},
"steps": [{
"name": "validate",
"at": "2026-11-02T17:02:40.6764004Z",
"result": "observed",
"actions": [{
"kind": "api",
"principal": "admin",
"method": "POST",
"path": "/tailnet/-/acl/validate",
"header": {"Content-Type": "application/hujson"},
"body": {
"acls": [{"action": "accept", "dst": ["0.0.0.0/0:*"], "src": ["tag:client"]}],
"autoApprovers": {"exitNode": ["tag:exit"], "routes": {"10.40.7.0/24": ["tag:exit"]}},
"tagOwners": {"tag:client": ["odin@example.com"], "tag:exit": ["odin@example.com"]}
},
"status": 200,
"response": {
"message": "dst=\"0.0.0.0/0:*\": to allow all IP addresses, use *:port, not 0.0.0.0/0:port"
}
}]
}]
}
@@ -0,0 +1,75 @@
// routes-exit-family-ipv6
//
// Exit route families offered for ::/0 destination access
//
// Steps: 1
// Captured at: 2026-11-02T17:03:06Z
// tool version: tscap/5f80ee6150559e777612549d896d444b50768737
// schema version: 1
{
"schema_version": 1,
"test_id": "routes-exit-family-ipv6",
"description": "Exit route families offered for ::/0 destination access",
"category": "routes",
"captured_at": "2026-11-02T17:03:06.232744213Z",
"tool_version": "tscap/5f80ee6150559e777612549d896d444b50768737",
"tailnet": "odin@example.com",
"error": true,
"input": {
"api_response_code": 400,
"api_response_body": {
"message": "dst=\"::/0:*\": to allow all IP addresses, use \"*\" or \"autogroup:internet\""
},
"tailnet": {
"dns": {"magic_dns": false, "nameservers": [], "search_paths": [], "split_dns": {}},
"settings": {}
},
"scenario_hujson": "{\n \"id\": \"routes-exit-family-ipv6\",\n \"category\": \"routes\",\n \"description\": \"Exit route families offered for ::/0 destination access\",\n \"topology\": {\n \"users\": [\n {\n \"id\": 1,\n \"name\": \"odin\",\n \"email\": \"odin@example.com\"\n }\n ],\n \"nodes\": {\n \"viewer\": {\n \"hostname\": \"viewer\",\n \"tags\": [\n \"tag:client\"\n ],\n \"ipv4\": \"100.64.0.1\",\n \"ipv6\": \"fd7a:115c:a1e0::1\",\n \"routable_ips\": [],\n \"approved_routes\": []\n },\n \"exit\": {\n \"hostname\": \"exit\",\n \"tags\": [\n \"tag:exit\"\n ],\n \"ipv4\": \"100.64.0.2\",\n \"ipv6\": \"fd7a:115c:a1e0::2\",\n \"routable_ips\": [\n \"0.0.0.0/0\",\n \"::/0\",\n \"10.40.7.0/24\"\n ],\n \"approved_routes\": []\n }\n }\n },\n \"tailnet\": {\n \"dns\": {\n \"magic_dns\": false,\n \"nameservers\": [],\n \"search_paths\": [],\n \"split_dns\": {}\n },\n \"settings\": {}\n },\n \"policy\": {\n \"tagOwners\": {\n \"tag:client\": [\n \"odin@example.com\"\n ],\n \"tag:exit\": [\n \"odin@example.com\"\n ]\n },\n \"autoApprovers\": {\n \"exitNode\": [\n \"tag:exit\"\n ],\n \"routes\": {\n \"10.40.7.0/24\": [\n \"tag:exit\"\n ]\n }\n },\n \"acls\": [\n {\n \"action\": \"accept\",\n \"src\": [\n \"tag:client\"\n ],\n \"dst\": [\n \"::/0:*\"\n ]\n }\n ]\n },\n \"options\": {\n \"max_wait_seconds\": 120,\n \"stable_window_seconds\": 5,\n \"capture_whois\": false,\n \"expect_api_error\": true\n }\n}\n",
"scenario_path": "/home/kradalby/.cache/tscap-pr3619/scenarios/routes-exit-family-ipv6.hujson",
"full_policy": {
"acls": [{"action": "accept", "dst": ["::/0:*"], "src": ["tag:client"]}],
"autoApprovers": {"exitNode": ["tag:exit"], "routes": {"10.40.7.0/24": ["tag:exit"]}},
"tagOwners": {"tag:client": ["odin@example.com"], "tag:exit": ["odin@example.com"]}
}
},
"topology": {
"users": [{"id": 1, "name": "odin", "email": "odin@example.com"}],
"nodes": {"exit": {
"hostname": "exit",
"tags": ["tag:exit"],
"ipv4": "100.64.0.2",
"ipv6": "fd7a:115c:a1e0::2",
"routable_ips": ["0.0.0.0/0", "::/0", "10.40.7.0/24"],
"approved_routes": []
}, "viewer": {
"hostname": "viewer",
"tags": ["tag:client"],
"ipv4": "100.64.0.1",
"ipv6": "fd7a:115c:a1e0::1",
"routable_ips": [],
"approved_routes": []
}}
},
"captures": {},
"steps": [{
"name": "validate",
"at": "2026-11-02T17:03:09.537683602Z",
"result": "observed",
"actions": [{
"kind": "api",
"principal": "admin",
"method": "POST",
"path": "/tailnet/-/acl/validate",
"header": {"Content-Type": "application/hujson"},
"body": {
"acls": [{"action": "accept", "dst": ["::/0:*"], "src": ["tag:client"]}],
"autoApprovers": {"exitNode": ["tag:exit"], "routes": {"10.40.7.0/24": ["tag:exit"]}},
"tagOwners": {"tag:client": ["odin@example.com"], "tag:exit": ["odin@example.com"]}
},
"status": 200,
"response": {
"message": "dst=\"::/0:*\": to allow all IP addresses, use \"*\" or \"autogroup:internet\""
}
}]
}]
}
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
+7 -1
View File
@@ -316,9 +316,15 @@ var rejectedPolicySkipReasons = map[string]string{
"ssh-e1/refused": noTailnetDomains,
"ssh-e2/refused": noTailnetDomains,
"ssh-malformed-user-localpart-multi-glob/refused": noTailnetDomains,
"routes-exit-family-ipv4/refused": aclDefaultRouteAccepted,
"routes-exit-family-ipv6/refused": aclDefaultRouteAccepted,
}
const noTailnetDomains = "NO_TAILNET_DOMAINS: headscale does not check user:*@domain or localpart:*@domain against the tailnet's domains"
const (
noTailnetDomains = "NO_TAILNET_DOMAINS: headscale does not check user:*@domain or localpart:*@domain against the tailnet's domains"
aclDefaultRouteAccepted = "ACL_DEFAULT_ROUTE_ACCEPTED: headscale accepts literal 0.0.0.0/0 and ::/0 ACL destinations; SaaS rejects them and requires * or autogroup:internet"
)
// TestRejectedPolicyLeavesLivePolicyCompat posts every policy SaaS refused to
// the v2 API over the baseline tscap's reset leaves: tagOwners only.
+6
View File
@@ -99,6 +99,12 @@ var routesMapCompatTests = []struct {
{"routes-m4-member-self", "members to autogroup:self beside a multi-route router"},
{"routes-m5-wildcard-all", "allow-all with a multi-route router and an exit node"},
{"routes-m6-subnet-to-subnet-multi-route", "subnet of a multi-route router to a subnet of an exit node"},
{"routes-exit-family-internet", "internet destinations offer both exit route families"},
{"routes-exit-family-ipv4-split", "IPv4 destinations offer both exit route families"},
{"routes-exit-family-ipv6-split", "IPv6 destinations offer both exit route families"},
{"routes-exit-family-private", "private destinations keep the subnet without exit routes"},
{"routes-exit-family-public-ip", "one public destination offers both exit route families"},
{"routes-exit-family-wildcard", "wildcard destinations offer both exit route families"},
}
// viaCompatSkipReasons lists checks where headscale differs from SaaS, keyed
+1
View File
@@ -1252,6 +1252,7 @@ func TestCanAccessRoutesExitNeedsInternet(t *testing.T) {
"from anywhere": {rule("*", "100.64.0.1"), routes[2:]},
"to everything": {rule("100.64.0.1", "*"), routes},
"to the internet": {rule("100.64.0.1", internet...), routes[:2]},
"to a public IP": {rule("100.64.0.1", "8.8.8.8"), routes[:2]},
"to another viewer": {rule("100.64.0.1", "100.64.0.2"), nil},
} {
assert.Equalf(t, tt.want, viewer.CanAccessRoutes(tt.matchers, routes), "%s", name)
+4 -7
View File
@@ -63,13 +63,10 @@ var TheInternet = sync.OnceValue(func() *netipx.IPSet {
return theInternetSet
})
// CoversTheInternet reports whether set covers [TheInternet]. Either
// family's /0 counts alone, because operators write it directly and it
// still denotes the whole internet for that family.
func CoversTheInternet(set *netipx.IPSet) bool {
return set.ContainsPrefix(tsaddr.AllIPv4()) ||
set.ContainsPrefix(tsaddr.AllIPv6()) ||
IPSetSubsetOf(TheInternet(), set)
// HasInternetDestination reports whether set contains any address in [TheInternet].
// Exit routes provide a path; packet filters still restrict the permitted traffic.
func HasInternetDestination(set *netipx.IPSet) bool {
return set.Overlaps(TheInternet())
}
// IPSetSubsetOf reports whether every prefix of candidate is contained in