policy/v2: need every dst node for a tests accept, no range sources

An accept passes only when each node behind the dst is reached, as on
Tailscale SaaS; a deny keeps its rule. A CIDR, "*" or autogroup:tagged
src names no user or host, so its test fails.
This commit is contained in:
Kristoffer Dalby committed 2026-11-02 17:46:51 +00:00
1 parent 5527a30491
commit fa6b46bfd9
4 files changed
+124 -21

No files matched your search

@@ -38,27 +38,17 @@ import (
// knownPolicyTesterDivergences lists checks where headscale's evaluator
// differs from Tailscale SaaS, keyed <test_id>/<check>.
var knownPolicyTesterDivergences = map[string]string{ //nolint:gosec // strings here are human-readable notes, not credentials
"policytest-accept-fail-autogroup-tagged-src-tag-dst/breakdown": "TEST_SRC_SHAPE: SaaS refuses autogroup:tagged as a tests source as an unknown user or host; headscale evaluates it",
"policytest-accept-fail-proto-icmp-not-allowed/breakdown": testEntryErrors,
"policytest-proto-icmp/breakdown": testEntryErrors,
"policytest-dst-unknown-group/breakdown": testUnknownAlias,
"policytest-src-unknown-group/breakdown": testUnknownAlias,
"policytest-src-unknown-user-email/breakdown": testUnknownAlias,
"policytest-src-cidr-accept/verdict": testSrcShape,
"policytest-src-cidr-deny/breakdown": testSrcShape,
"policytest-src-wildcard-accept/breakdown": testSrcShape,
"policytest-src-wildcard-deny/verdict": testSrcShape,
"policytest-dst-multinode-partial-accept/verdict": "TEST_DST_EVERY_NODE: SaaS accepts only when every node of the dst alias is reached; headscale when one is",
}
const (
testEntryErrors = "TEST_ENTRY_ERRORS: SaaS reports the first shape error of a tests entry, headscale every one"
testUnknownAlias = "TEST_UNKNOWN_ALIAS: SaaS reports a test naming an unknown user or group as not reached, headscale as an evaluation error"
testSrcShape = "TEST_SRC_SHAPE: SaaS refuses a CIDR or \"*\" as a tests source as an unknown user or host; headscale evaluates it"
)
// TestPolicyTesterCompat replays every capture under
+77 -7
View File
@@ -10,6 +10,7 @@ import (
"github.com/go-json-experiment/json"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/util"
"go4.org/netipx"
"tailscale.com/tailcfg"
"tailscale.com/types/views"
)
@@ -32,6 +33,7 @@ var (
errPolicyTestsFailed = errors.New("test(s) failed")
errSSHPolicyTestsFailed = errors.New("test(s) failed")
errTestDestinationNoIP = errors.New("destination resolved to no IP addresses")
errTestSourceUnknown = errors.New("unknown user or host")
)
// PolicyTest is one entry in the policy's `tests` block.
@@ -394,7 +396,7 @@ func runPolicyTest(test PolicyTest, pol *Policy, filter []tailcfg.FilterRule, us
check := func(dsts []string, wantAllowed bool, ok, fail *[]string) {
for _, dst := range dsts {
allowed, err := evalReachability(srcPrefixes, dst, test.Proto, pol, filter, users, nodes)
allowed, err := evalReachability(srcPrefixes, dst, test.Proto, pol, filter, users, nodes, wantAllowed)
if err != nil {
res.Passed = false
res.Errors = append(res.Errors, fmt.Sprintf("error testing %q: %v", dst, err))
@@ -427,6 +429,21 @@ func resolveTestSource(src string, pol *Policy, users []types.User, nodes views.
return nil, fmt.Errorf("invalid alias: %w", err)
}
// Tailscale SaaS finds no user or host behind these, so a test from
// them fails, whether it accepts or denies.
unknown := isAddressRange(pol, alias)
switch a := alias.(type) {
case Asterix:
unknown = true
case *AutoGroup:
unknown = a.Is(AutoGroupTagged)
}
if unknown {
return nil, fmt.Errorf("%w: %q", errTestSourceUnknown, src)
}
// Rules tolerate unregistered users; a test naming one must still
// fail (SaaS: policytest-src-unknown-user-email).
if u, ok := alias.(*Username); ok {
@@ -448,13 +465,25 @@ func resolveTestSource(src string, pol *Policy, users []types.User, nodes views.
return addrs.Prefixes(), nil
}
// evalReachability reports whether traffic from any srcPrefix to dst (in
// `host:port` form) is allowed by filter for the requested protocol.
// evalReachability reports whether traffic from every srcPrefix to dst (in
// `host:port` form) is allowed by filter for the requested protocol. With
// everyNode, as an accept asks, each node behind dst must be reached, as on
// Tailscale SaaS; otherwise any address of dst will do, so a deny passes only
// when none is reached.
//
// Empty proto means the default set the client applies when proto is
// omitted (TCP/UDP/ICMP) — we accept a rule whose IPProto list contains
// any of those, or rules with no IPProto restriction at all.
func evalReachability(srcPrefixes []netip.Prefix, dst string, proto Protocol, pol *Policy, filter []tailcfg.FilterRule, users []types.User, nodes views.Slice[types.NodeView]) (bool, error) {
func evalReachability(
srcPrefixes []netip.Prefix,
dst string,
proto Protocol,
pol *Policy,
filter []tailcfg.FilterRule,
users []types.User,
nodes views.Slice[types.NodeView],
everyNode bool,
) (bool, error) {
awp, err := parseDestinationAlias(dst)
if err != nil {
return false, fmt.Errorf("invalid destination %q: %w", dst, err)
@@ -469,19 +498,60 @@ func evalReachability(srcPrefixes []netip.Prefix, dst string, proto Protocol, po
return false, fmt.Errorf("%w: %q", errTestDestinationNoIP, dst)
}
dstPrefixes := dstAddrs.Prefixes()
targets := [][]netip.Prefix{dstAddrs.Prefixes()}
if everyNode {
targets = nodeTargets(dstAddrs, nodes)
}
// Tailscale's tests semantics: ALL src prefixes must reach the dst for
// the test to consider it allowed. A partial allow is a fail.
for _, src := range srcPrefixes {
if !srcReachesDst(src, dstPrefixes, awp.Ports, proto, filter) {
return false, nil
for _, target := range targets {
if !srcReachesDst(src, target, awp.Ports, proto, filter) {
return false, nil
}
}
}
return true, nil
}
// nodeTargets splits dst into the addresses of each node it holds and,
// when any is left, the addresses no node holds.
func nodeTargets(dst ResolvedAddresses, nodes views.Slice[types.NodeView]) [][]netip.Prefix {
var (
out [][]netip.Prefix
rest netipx.IPSetBuilder
)
for _, p := range dst.Prefixes() {
rest.AddPrefix(p)
}
for _, n := range nodes.All() {
var own []netip.Prefix
for _, ip := range n.IPs() {
if dst.Contains(ip) {
own = append(own, netip.PrefixFrom(ip, ip.BitLen()))
rest.Remove(ip)
}
}
if len(own) > 0 {
out = append(out, own)
}
}
// The builder only holds valid prefixes, so it cannot fail.
set, _ := rest.IPSet()
if left := set.Prefixes(); len(left) > 0 {
out = append(out, left)
}
return out
}
// parseDestinationAlias is a thin wrapper over [AliasWithPorts.UnmarshalJSON]
// so callers can hand it a bare `"host:port"` string without re-implementing
// the parse logic.
+47
View File
@@ -103,6 +103,53 @@ func TestRunTests(t *testing.T) {
wantErrSub: []string{"bob@headscale.net", "tag:server:22", "expected ALLOWED"},
wantNoErrIs: errPolicyTestsFailed,
},
{
// An accept needs every node behind the dst; a deny fails
// as soon as one is reached.
name: "dst-alias-partly-reached",
policy: `{
"groups": { "group:people": ["alice@headscale.net", "bob@headscale.net"] },
"acls": [{
"action": "accept",
"src": ["alice@headscale.net"],
"dst": ["alice@headscale.net:22"]
}],
"tests": [{
"src": "alice@headscale.net",
"accept": ["group:people:22"],
"deny": ["group:people:22"]
}]
}`,
wantPass: false,
wantErrSub: []string{
"alice@headscale.net -> group:people:22: expected ALLOWED, got DENIED",
"alice@headscale.net -> group:people:22: expected DENIED, got ALLOWED",
},
wantNoErrIs: errPolicyTestsFailed,
},
{
name: "src-wildcard-not-reached",
policy: `{
"tagOwners": { "tag:server": ["alice@headscale.net"] },
"acls": [{
"action": "accept",
"src": ["alice@headscale.net"],
"dst": ["tag:server:22"]
}],
"tests": [
{ "src": "*", "deny": ["tag:server:80"] },
{ "src": "100.64.0.0/30", "deny": ["tag:server:80"] },
{ "src": "autogroup:tagged", "deny": ["tag:server:80"] }
]
}`,
wantPass: false,
wantErrSub: []string{
`unknown user or host: "*"`,
`unknown user or host: "100.64.0.0/30"`,
`unknown user or host: "autogroup:tagged"`,
},
wantNoErrIs: errPolicyTestsFailed,
},
{
name: "deny-fail-policy-allows-traffic",
policy: `{
-4
View File
@@ -323,10 +323,6 @@ var rejectedPolicySkipReasons = map[string]string{
"ssh-check-tag-src/refused": "SSH_CHECK_TAG_SRC: headscale accepts a check rule with a tag source",
"grant-dxp-comma-ports/refused": "GRANT_PORT_LIST: headscale accepts a comma port list in a grant ip entry",
"policytest-src-cidr-accept/refused": "TEST_SRC_SHAPE: headscale evaluates a CIDR tests source",
"policytest-src-wildcard-deny/refused": "TEST_SRC_SHAPE: headscale evaluates \"*\" as a tests source",
"policytest-dst-multinode-partial-accept/refused": "TEST_DST_EVERY_NODE: headscale accepts when one node of the dst alias is reached",
}
const noTailnetDomains = "NO_TAILNET_DOMAINS: headscale does not check user:*@domain or localpart:*@domain against the tailnet's domains"