mirror of
https://github.com/juanfont/headscale.git
synced 2026-05-25 03:28:42 +09:00
The policy `tests` block lets entries omit `proto`. Tailscale's client
maps that to the default protocol set {TCP, UDP, ICMP, ICMPv6} — the
captured packet_filter_matches show all four IANA numbers explicitly
when no proto is set — and a rule restricted to any one of them
satisfies an empty-proto reachability test.
srcReachesDst was passing the empty Protocol through unchanged, which
landed an empty []int in ruleMatchesProto. The matcher then short-
circuited to "no match" for every rule with a non-empty IPProto
restriction, including TCP-only grants compiled from `ip: ["tcp:80"]`.
The bug surfaced in the captured allpass-acls-and-grants-mixed
scenario: the grant `tag:client → webserver:80` was reachable in the
compiled filter but the empty-proto test could not see it.
Expand the empty Protocol to the default set at the call site so
ruleMatchesProto's intersection check sees the right requested
protocols. Drop the now-dead empty-requestedProtos branch from the
matcher. The last divergence drops out of knownPolicyTesterDivergences
as a result.
Updates #1803
425 lines
11 KiB
Go
425 lines
11 KiB
Go
package v2
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/juanfont/headscale/hscontrol/types"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// policyTestUsers/policyTestNodes are reused across the test cases below to
|
|
// keep each table row focussed on the policy + tests under exercise.
|
|
func policyTestUsers() types.Users {
|
|
return types.Users{
|
|
{Model: gorm.Model{ID: 1}, Name: "alice", Email: "alice@headscale.net"},
|
|
{Model: gorm.Model{ID: 2}, Name: "bob", Email: "bob@headscale.net"},
|
|
}
|
|
}
|
|
|
|
func policyTestNodes(users types.Users) types.Nodes {
|
|
nodes := types.Nodes{
|
|
// alice's user-owned laptop
|
|
{
|
|
ID: 1,
|
|
Hostname: "alice-laptop",
|
|
IPv4: ap("100.64.0.1"),
|
|
IPv6: ap("fd7a:115c:a1e0::1"),
|
|
User: &users[0],
|
|
UserID: &users[0].ID,
|
|
},
|
|
// bob's user-owned laptop
|
|
{
|
|
ID: 2,
|
|
Hostname: "bob-laptop",
|
|
IPv4: ap("100.64.0.2"),
|
|
IPv6: ap("fd7a:115c:a1e0::2"),
|
|
User: &users[1],
|
|
UserID: &users[1].ID,
|
|
},
|
|
// tagged server (created via tagged preauth key from alice)
|
|
{
|
|
ID: 3,
|
|
Hostname: "server",
|
|
IPv4: ap("100.64.0.3"),
|
|
IPv6: ap("fd7a:115c:a1e0::3"),
|
|
User: &users[0],
|
|
UserID: &users[0].ID,
|
|
Tags: []string{"tag:server"},
|
|
},
|
|
}
|
|
|
|
return nodes
|
|
}
|
|
|
|
// TestRunTests covers the engine's per-test outcome reporting. Each row
|
|
// constructs a PolicyManager (which also runs SetPolicy's sandbox) and
|
|
// checks the resulting RunTests behaviour. SetPolicy gating is exercised
|
|
// separately in TestSetPolicyRejectsFailingTests.
|
|
func TestRunTests(t *testing.T) {
|
|
users := policyTestUsers()
|
|
nodes := policyTestNodes(users)
|
|
|
|
tests := []struct {
|
|
name string
|
|
policy string
|
|
wantPass bool
|
|
wantErrSub []string // substrings expected in the rendered error
|
|
wantNoErrIs error // sentinel the error must wrap
|
|
}{
|
|
{
|
|
name: "all-pass-user-to-tag",
|
|
policy: `{
|
|
"tagOwners": { "tag:server": ["alice@headscale.net"] },
|
|
"acls": [{
|
|
"action": "accept",
|
|
"src": ["alice@headscale.net"],
|
|
"dst": ["tag:server:22"]
|
|
}],
|
|
"tests": [{
|
|
"src": "alice@headscale.net",
|
|
"accept": ["tag:server:22"]
|
|
}]
|
|
}`,
|
|
wantPass: true,
|
|
},
|
|
{
|
|
name: "accept-fail-blocked-by-policy",
|
|
policy: `{
|
|
"tagOwners": { "tag:server": ["alice@headscale.net"] },
|
|
"acls": [{
|
|
"action": "accept",
|
|
"src": ["alice@headscale.net"],
|
|
"dst": ["tag:server:22"]
|
|
}],
|
|
"tests": [{
|
|
"src": "bob@headscale.net",
|
|
"accept": ["tag:server:22"]
|
|
}]
|
|
}`,
|
|
wantPass: false,
|
|
wantErrSub: []string{"bob@headscale.net", "tag:server:22", "expected ALLOWED"},
|
|
wantNoErrIs: errPolicyTestsFailed,
|
|
},
|
|
{
|
|
name: "deny-fail-policy-allows-traffic",
|
|
policy: `{
|
|
"tagOwners": { "tag:server": ["alice@headscale.net"] },
|
|
"acls": [{
|
|
"action": "accept",
|
|
"src": ["alice@headscale.net"],
|
|
"dst": ["tag:server:22"]
|
|
}],
|
|
"tests": [{
|
|
"src": "alice@headscale.net",
|
|
"deny": ["tag:server:22"]
|
|
}]
|
|
}`,
|
|
wantPass: false,
|
|
wantErrSub: []string{"alice@headscale.net", "tag:server:22", "expected DENIED"},
|
|
wantNoErrIs: errPolicyTestsFailed,
|
|
},
|
|
{
|
|
name: "unknown-src-user",
|
|
policy: `{
|
|
"acls": [{
|
|
"action": "accept",
|
|
"src": ["*"],
|
|
"dst": ["*:*"]
|
|
}],
|
|
"tests": [{
|
|
"src": "ghost@headscale.net",
|
|
"accept": ["alice-laptop:22"]
|
|
}]
|
|
}`,
|
|
wantPass: false,
|
|
wantErrSub: []string{"ghost@headscale.net", "failed to resolve source"},
|
|
wantNoErrIs: errPolicyTestsFailed,
|
|
},
|
|
// "malformed-dst-missing-port" used to live here; structural
|
|
// shape errors are now caught at parse by validateTests, so
|
|
// RunTests no longer sees them. The parse-side behaviour is
|
|
// covered by TestUnmarshalPolicy/tests-* in types_test.go.
|
|
{
|
|
name: "wildcard-src-passes",
|
|
policy: `{
|
|
"tagOwners": { "tag:server": ["alice@headscale.net"] },
|
|
"acls": [{
|
|
"action": "accept",
|
|
"src": ["*"],
|
|
"dst": ["tag:server:80"]
|
|
}],
|
|
"tests": [{
|
|
"src": "alice@headscale.net",
|
|
"accept": ["tag:server:80"]
|
|
}]
|
|
}`,
|
|
wantPass: true,
|
|
},
|
|
{
|
|
name: "proto-restrict-tcp-only",
|
|
policy: `{
|
|
"tagOwners": { "tag:server": ["alice@headscale.net"] },
|
|
"acls": [{
|
|
"action": "accept",
|
|
"proto": "tcp",
|
|
"src": ["alice@headscale.net"],
|
|
"dst": ["tag:server:22"]
|
|
}],
|
|
"tests": [
|
|
{
|
|
"src": "alice@headscale.net",
|
|
"proto": "tcp",
|
|
"accept": ["tag:server:22"]
|
|
},
|
|
{
|
|
"src": "alice@headscale.net",
|
|
"proto": "udp",
|
|
"deny": ["tag:server:22"]
|
|
}
|
|
]
|
|
}`,
|
|
wantPass: true,
|
|
},
|
|
{
|
|
name: "grants-only-policy-evaluated",
|
|
policy: `{
|
|
"tagOwners": { "tag:server": ["alice@headscale.net"] },
|
|
"grants": [{
|
|
"src": ["alice@headscale.net"],
|
|
"dst": ["tag:server"],
|
|
"ip": ["22"]
|
|
}],
|
|
"tests": [{
|
|
"src": "alice@headscale.net",
|
|
"accept": ["tag:server:22"]
|
|
}]
|
|
}`,
|
|
wantPass: true,
|
|
},
|
|
{
|
|
name: "mixed-pass-and-fail-reports-failure",
|
|
policy: `{
|
|
"tagOwners": { "tag:server": ["alice@headscale.net"] },
|
|
"acls": [{
|
|
"action": "accept",
|
|
"src": ["alice@headscale.net"],
|
|
"dst": ["tag:server:22"]
|
|
}],
|
|
"tests": [
|
|
{
|
|
"src": "alice@headscale.net",
|
|
"accept": ["tag:server:22"]
|
|
},
|
|
{
|
|
"src": "bob@headscale.net",
|
|
"accept": ["tag:server:22"]
|
|
}
|
|
]
|
|
}`,
|
|
wantPass: false,
|
|
wantErrSub: []string{"bob@headscale.net", "expected ALLOWED"},
|
|
wantNoErrIs: errPolicyTestsFailed,
|
|
},
|
|
{
|
|
name: "no-tests-block-is-no-op",
|
|
policy: `{
|
|
"acls": [{
|
|
"action": "accept",
|
|
"src": ["*"],
|
|
"dst": ["*:*"]
|
|
}]
|
|
}`,
|
|
wantPass: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
pm, err := NewPolicyManager([]byte(tt.policy), users, nodes.ViewSlice())
|
|
require.NoError(t, err, "policy must parse and compile")
|
|
|
|
runErr := pm.RunTests()
|
|
if tt.wantPass {
|
|
require.NoError(t, runErr, "tests should pass")
|
|
|
|
return
|
|
}
|
|
|
|
require.Error(t, runErr, "tests should fail")
|
|
require.ErrorIs(t, runErr, tt.wantNoErrIs, "error should wrap errPolicyTestsFailed")
|
|
|
|
for _, sub := range tt.wantErrSub {
|
|
assert.Contains(t, runErr.Error(), sub, "rendered error should mention %q", sub)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSetPolicyRejectsFailingTests asserts that SetPolicy is the user-write
|
|
// boundary: a policy whose tests fail must be rejected without mutating the
|
|
// live PolicyManager. NewPolicyManager (boot path) does not run tests.
|
|
func TestSetPolicyRejectsFailingTests(t *testing.T) {
|
|
users := policyTestUsers()
|
|
nodes := policyTestNodes(users)
|
|
|
|
good := `{
|
|
"tagOwners": { "tag:server": ["alice@headscale.net"] },
|
|
"acls": [{
|
|
"action": "accept",
|
|
"src": ["alice@headscale.net"],
|
|
"dst": ["tag:server:22"]
|
|
}],
|
|
"tests": [{
|
|
"src": "alice@headscale.net",
|
|
"accept": ["tag:server:22"]
|
|
}]
|
|
}`
|
|
|
|
bad := `{
|
|
"tagOwners": { "tag:server": ["alice@headscale.net"] },
|
|
"acls": [{
|
|
"action": "accept",
|
|
"src": ["alice@headscale.net"],
|
|
"dst": ["tag:server:22"]
|
|
}],
|
|
"tests": [{
|
|
"src": "bob@headscale.net",
|
|
"accept": ["tag:server:22"]
|
|
}]
|
|
}`
|
|
|
|
pm, err := NewPolicyManager([]byte(good), users, nodes.ViewSlice())
|
|
require.NoError(t, err)
|
|
|
|
beforeFilter, _ := pm.Filter()
|
|
|
|
changed, err := pm.SetPolicy([]byte(bad))
|
|
require.Error(t, err, "SetPolicy must reject a policy whose tests fail")
|
|
require.False(t, changed, "SetPolicy must report no change when rejected")
|
|
require.ErrorIs(t, err, errPolicyTestsFailed)
|
|
require.Contains(t, err.Error(), "expected ALLOWED")
|
|
|
|
afterFilter, _ := pm.Filter()
|
|
require.Len(t, afterFilter, len(beforeFilter), "live filter must not change after a rejected SetPolicy")
|
|
}
|
|
|
|
// TestNewPolicyManagerSkipsTests asserts the boot path does not evaluate
|
|
// tests, so a stale stored policy referencing a now-deleted user does not
|
|
// stop the server from booting.
|
|
func TestNewPolicyManagerSkipsTests(t *testing.T) {
|
|
users := policyTestUsers()
|
|
nodes := policyTestNodes(users)
|
|
|
|
// Tests reference "ghost@headscale.net" which doesn't exist. Boot
|
|
// must not error.
|
|
stale := `{
|
|
"acls": [{
|
|
"action": "accept",
|
|
"src": ["*"],
|
|
"dst": ["*:*"]
|
|
}],
|
|
"tests": [{
|
|
"src": "ghost@headscale.net",
|
|
"accept": ["alice-laptop:22"]
|
|
}]
|
|
}`
|
|
|
|
pm, err := NewPolicyManager([]byte(stale), users, nodes.ViewSlice())
|
|
require.NoError(t, err, "boot must not run tests")
|
|
require.NotNil(t, pm)
|
|
|
|
// And a subsequent SetPolicy of the same body must reject — that's
|
|
// the user-write path.
|
|
_, err = pm.SetPolicy([]byte(stale))
|
|
require.Error(t, err)
|
|
require.ErrorIs(t, err, errPolicyTestsFailed)
|
|
}
|
|
|
|
// TestRunTestsEmptyProtoMatchesDefaultProtocols captures the bug where a
|
|
// test entry with no `proto` field fails to match a filter rule whose
|
|
// IPProto is restricted to a default protocol (TCP, UDP, ICMP, ICMPv6).
|
|
// Tailscale's client default set is {6, 17, 1, 58} when proto is omitted,
|
|
// so a TCP-only rule must satisfy an empty-proto test.
|
|
//
|
|
// The capture
|
|
// testdata/policytest_results/policytest-allpass-acls-and-grants-mixed.hujson
|
|
// is the captured signal for this same bug (api_response_code 200, two
|
|
// passing tests including `tag:client → webserver:80` with no proto over
|
|
// a `ip: tcp:80` grant).
|
|
func TestRunTestsEmptyProtoMatchesDefaultProtocols(t *testing.T) {
|
|
users := types.Users{
|
|
{Model: gorm.Model{ID: 1}, Name: "odin", Email: "odin@example.com"},
|
|
}
|
|
nodes := types.Nodes{
|
|
{
|
|
ID: 1,
|
|
Hostname: "client",
|
|
IPv4: ap("100.64.0.10"),
|
|
IPv6: ap("fd7a:115c:a1e0::a"),
|
|
Tags: []string{"tag:client"},
|
|
},
|
|
{
|
|
ID: 2,
|
|
Hostname: "webserver",
|
|
IPv4: ap("100.64.0.16"),
|
|
IPv6: ap("fd7a:115c:a1e0::10"),
|
|
Tags: []string{"tag:server"},
|
|
},
|
|
}
|
|
|
|
policy := `{
|
|
"tagOwners": {
|
|
"tag:client": ["odin@example.com"],
|
|
"tag:server": ["odin@example.com"]
|
|
},
|
|
"hosts": {
|
|
"webserver": "100.64.0.16"
|
|
},
|
|
"grants": [
|
|
{"src": ["tag:client"], "dst": ["webserver"], "ip": ["tcp:80"]}
|
|
],
|
|
"tests": [
|
|
{"src": "tag:client", "accept": ["webserver:80"]}
|
|
]
|
|
}`
|
|
|
|
pm, err := NewPolicyManager([]byte(policy), users, nodes.ViewSlice())
|
|
require.NoError(t, err, "policy must parse and compile")
|
|
|
|
require.NoError(t, pm.RunTests(),
|
|
"empty-proto test must match a tcp-only grant rule (TCP is in the client default set)")
|
|
}
|
|
|
|
// TestPolicyTestResultsErrorsRendering checks the multi-line render layout
|
|
// since the body becomes the user-facing error.
|
|
func TestPolicyTestResultsErrorsRendering(t *testing.T) {
|
|
results := PolicyTestResults{
|
|
AllPassed: false,
|
|
Results: []PolicyTestResult{
|
|
{
|
|
Src: "alice@headscale.net",
|
|
AcceptFail: []string{"tag:server:22"},
|
|
},
|
|
{
|
|
Src: "bob@headscale.net",
|
|
Proto: "tcp",
|
|
DenyFail: []string{"tag:server:443"},
|
|
},
|
|
},
|
|
}
|
|
|
|
rendered := results.Errors()
|
|
for _, sub := range []string{
|
|
"alice@headscale.net -> tag:server:22: expected ALLOWED, got DENIED",
|
|
"bob@headscale.net -> tag:server:443 (tcp): expected DENIED, got ALLOWED",
|
|
} {
|
|
assert.Contains(t, rendered, sub)
|
|
}
|
|
|
|
// Lines should be newline-separated, not space-joined.
|
|
assert.Equal(t, 2, strings.Count(rendered, "\n")+1, "expected one line per failing assertion")
|
|
}
|