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) return i < len(ranges) && ranges[i].Overlaps(r)
} }
// DestsIsTheInternet reports whether the destination covers "the // DestsIsTheInternet reports whether any destination is in [util.TheInternet].
// internet" — the set represented by autogroup:internet, special-cased // It lets clients select exit nodes for that traffic.
// for exit nodes. See [util.CoversTheInternet].
func (m *Match) DestsIsTheInternet() bool { 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, want: false,
}, },
{ {
name: "single public IP is not the internet", name: "single public IPv4 destination needs exit routes",
dsts: []string{"8.8.8.8"}, 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, 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. // Resolved prefixes are valid, so the builder cannot fail.
set, _ := b.IPSet() set, _ := b.IPSet()
return util.CoversTheInternet(set) return util.HasInternetDestination(set)
} }
func (pm *PolicyManager) Version() int { 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/kakuna/filter": srcAddressForm,
"acl-self-plus-dsts/beedrill/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", "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 ( const (
allowAllForm = "ALLOW_ALL_FORM: SaaS writes the allow-all default's sources as the tailnet ranges, headscale as \"*\"" 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" 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 // 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/policy/policyutil"
"github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/types/testcapture" "github.com/juanfont/headscale/hscontrol/types/testcapture"
"github.com/juanfont/headscale/hscontrol/util"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go4.org/netipx" "go4.org/netipx"
@@ -372,7 +373,11 @@ func TestRoutesCompat(t *testing.T) {
) )
compatCheck(t, routesSkipReasons, tf.TestID+"/"+nodeName+"/filter", func() string { 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 // derivePeerPairsFromCaptures builds the set of expected peer pairs from
// golden file captures. For each node that receives filter rules from // golden file captures. For each node that receives filter rules from
// Tailscale SaaS, the SrcIPs identify subnets whose traffic will arrive // Tailscale SaaS, the SrcIPs identify subnets whose traffic will arrive
@@ -982,17 +1057,13 @@ func TestRoutesCompatReduceRoutes(t *testing.T) {
continue continue
} }
prefix, parseErr := netip.ParsePrefix(dp.IP) set, err := util.ParseIPSet(dp.IP, nil)
if parseErr != nil { require.NoErrorf(t, err, "golden DstPorts.IP %q unparseable", dp.IP)
addr, addrErr := netip.ParseAddr(dp.IP)
require.NoErrorf(t, addrErr,
"golden DstPorts.IP %q unparseable", dp.IP)
prefix = netip.PrefixFrom(addr, addr.BitLen()) for _, prefix := range set.Prefixes() {
} if !slices.Contains(dstPrefixes, prefix) {
dstPrefixes = append(dstPrefixes, prefix)
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-e1/refused": noTailnetDomains,
"ssh-e2/refused": noTailnetDomains, "ssh-e2/refused": noTailnetDomains,
"ssh-malformed-user-localpart-multi-glob/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 // TestRejectedPolicyLeavesLivePolicyCompat posts every policy SaaS refused to
// the v2 API over the baseline tscap's reset leaves: tagOwners only. // 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-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-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-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 // 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:]}, "from anywhere": {rule("*", "100.64.0.1"), routes[2:]},
"to everything": {rule("100.64.0.1", "*"), routes}, "to everything": {rule("100.64.0.1", "*"), routes},
"to the internet": {rule("100.64.0.1", internet...), routes[:2]}, "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}, "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) 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 return theInternetSet
}) })
// CoversTheInternet reports whether set covers [TheInternet]. Either // HasInternetDestination reports whether set contains any address in [TheInternet].
// family's /0 counts alone, because operators write it directly and it // Exit routes provide a path; packet filters still restrict the permitted traffic.
// still denotes the whole internet for that family. func HasInternetDestination(set *netipx.IPSet) bool {
func CoversTheInternet(set *netipx.IPSet) bool { return set.Overlaps(TheInternet())
return set.ContainsPrefix(tsaddr.AllIPv4()) ||
set.ContainsPrefix(tsaddr.AllIPv6()) ||
IPSetSubsetOf(TheInternet(), set)
} }
// IPSetSubsetOf reports whether every prefix of candidate is contained in // IPSetSubsetOf reports whether every prefix of candidate is contained in