mirror of
https://github.com/juanfont/headscale.git
synced 2026-11-03 20:24:08 +09:00
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:
1 parent
f453502ef1
commit
4cb74dfe04
17 files changed
+12045
-27
No files matched your search
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+75
@@ -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"
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
}
|
||||||
+75
@@ -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\""
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
}
|
||||||
+2045
File diff suppressed because it is too large.
Load diff
+1962
File diff suppressed because it is too large.
Load diff
+1955
File diff suppressed because it is too large.
Load diff
+1924
File diff suppressed because it is too large.
Load diff
+1923
File diff suppressed because it is too large.
Load diff
+1960
File diff suppressed because it is too large.
Load diff
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user