policy/v2: recapture tester rows with failure detail

Re-runs keep SaaS's error data and validate answer. Tester compat checks
verdict, message, failure breakdown and node labels.
This commit is contained in:
Kristoffer Dalby committed 2026-11-02 16:09:00 +01:00
1 parent 1915feafd5
commit c69c47409a
96 files changed
+4764 -1110190

No files matched your search

+291 -76
View File
@@ -1,56 +1,66 @@
// Compatibility tests for the policy `tests` block, replaying captures
// recorded against a real Tailscale SaaS tailnet. The runner mirrors the
// pattern in tailscale_grants_compat_test.go: a single Glob over a
// testdata directory, one t.Run per file. Each capture is one of:
// Compatibility tests for the policy `tests` and `sshTests` blocks, replaying
// captures recorded against a real Tailscale SaaS tailnet. Each capture is
// checked in steps, see [runTesterCompat]:
//
// - APIResponseCode != 200 — the policy was rejected by the SaaS, the
// captured Message is the byte-exact body the user saw, and headscale
// must reject the same input with an error string that contains the
// same body (substring match, allowing wrapping like "test(s)
// failed:\n…").
// - APIResponseCode == 200 — the SaaS accepted the policy (its `tests`
// block passed); headscale's RunTests must also pass.
// - verdict: SaaS accepted the policy (200) exactly when headscale does;
// - message: a refusal's message is contained in headscale's error;
// - breakdown: the failures SaaS lists in its error data are the ones
// headscale's evaluators report, see [requireFailureBreakdown];
// - label: no node hostname appears in one report and not the other.
//
// Captures live in testdata/policytest_results/*.hujson. Scenarios in
// knownPolicyTesterDivergences are skipped with their tracking note —
// these are real Tailscale ↔ headscale divergences uncovered by the
// captures that need engine-level fixes in follow-up PRs.
// Captures live in testdata/policytest_results/*.hujson; the sshTests ones
// are replayed by sshtester_compat_test.go.
//
// Source format: github.com/juanfont/headscale/hscontrol/types/testcapture
package v2
import (
"encoding/json"
"fmt"
"net/http"
"path"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/types/testcapture"
"github.com/stretchr/testify/require"
tsapi "tailscale.com/client/tailscale/v2"
"tailscale.com/tailcfg"
)
// knownPolicyTesterDivergences lists scenarios where headscale's evaluator
// disagrees with Tailscale SaaS on whether the policy should be accepted.
// Each entry is a real bug to fix in a follow-up; documenting them here
// keeps the compat suite green and the divergence list visible.
var knownPolicyTesterDivergences = map[string]string{} //nolint:gosec // strings here are human-readable notes, not credentials
// 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 reports autogroup:tagged as an unknown tests source; 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,
}
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"
)
// TestPolicyTesterCompat replays every capture under
// testdata/policytest_results/ against the engine. With no captures the
// test is a no-op — committed early so the layout/wiring lands before
// the bulk import.
// testdata/policytest_results/ against the engine.
func TestPolicyTesterCompat(t *testing.T) {
t.Parallel()
files, err := filepath.Glob(filepath.Join("testdata", "policytest_results", "*.hujson"))
require.NoError(t, err, "failed to glob test files")
if len(files) == 0 {
t.Skip("no policytest captures yet")
}
users := setupSSHDataCompatUsers()
require.NotEmpty(t, files, "no policytest captures")
for _, file := range files {
c, err := testcapture.Read(file)
@@ -59,57 +69,250 @@ func TestPolicyTesterCompat(t *testing.T) {
t.Run(c.TestID, func(t *testing.T) {
t.Parallel()
if reason, skip := knownPolicyTesterDivergences[c.TestID]; skip {
t.Skip(reason)
}
// Each capture pins its own topology IPs; range and
// address tests mean nothing against other nodes.
nodes := buildGrantsNodesFromCapture(users, c)
policyJSON := []byte(c.Input.FullPolicy)
pm, parseErr := NewPolicyManager(policyJSON, users, nodes.ViewSlice())
// Tailscale validates and runs tests as one POST step:
// either failure mode produces the same 400. Headscale
// splits structural validation (parse) from test
// evaluation (SetPolicy). For the compat assertion, the
// two are equivalent — whichever surfaces first carries
// the captured body.
if c.Input.APIResponseCode == 200 {
require.NoError(t, parseErr, "tailscale accepted this policy; headscale must parse it")
_, setErr := pm.SetPolicy(policyJSON)
require.NoError(t, setErr, "tailscale accepted this policy; headscale tests should pass")
return
}
var got error
switch {
case parseErr != nil:
got = parseErr
default:
_, setErr := pm.SetPolicy(policyJSON)
got = setErr
}
require.Error(t, got, "tailscale rejected; headscale must reject too")
if c.Input.APIResponseBody == nil || c.Input.APIResponseBody.Message == "" {
return
}
want := c.Input.APIResponseBody.Message
if !strings.Contains(got.Error(), want) {
t.Errorf("error body mismatch\n tailscale wants: %q\n headscale got: %q", want, got.Error())
}
runTesterCompat(t, c, knownPolicyTesterDivergences)
})
}
}
// runTesterCompat replays one tests or sshTests capture. SaaS validates the
// policy and runs both blocks in one POST; headscale parses first and runs
// the blocks in SetPolicy, so an error from either step is its refusal.
func runTesterCompat(t *testing.T, c *testcapture.Capture, skips map[string]string) {
t.Helper()
users := setupSSHDataCompatUsers()
// Each capture pins its own topology IPs; range and address tests mean
// nothing against other nodes.
nodes := buildGrantsNodesFromCapture(users, c)
policyJSON := []byte(c.Input.FullPolicy)
pm, err := NewPolicyManager(policyJSON, users, nodes.ViewSlice())
if err == nil {
_, err = pm.SetPolicy(policyJSON)
} else {
pm = nil
}
var body testcapture.APIResponseBody
if c.Input.APIResponseBody != nil {
body = *c.Input.APIResponseBody
}
saasRefused := c.Input.APIResponseCode != http.StatusOK
key := c.TestID + "/"
compatCheck(t, skips, key+"verdict", func() string {
switch {
case saasRefused && err == nil:
return fmt.Sprintf("SaaS refused with %q; headscale accepted", body.Message)
case !saasRefused && err != nil:
return fmt.Sprintf("SaaS accepted; headscale refused: %v", err)
}
return ""
})
if !saasRefused || err == nil {
compatChecksNotRun(t, skips, key+"message", key+"breakdown", key+"label")
return
}
compatCheck(t, skips, key+"message", func() string {
if strings.Contains(err.Error(), body.Message) {
return ""
}
return fmt.Sprintf("tailscale wants: %q\nheadscale got: %q", body.Message, err.Error())
})
if len(body.Data) == 0 {
compatChecksNotRun(t, skips, key+"breakdown", key+"label")
return
}
requireFailureBreakdown(t, skips, key+"breakdown", body.Data, c.Input.FullPolicy, pm, err)
compatCheck(t, skips, key+"label", func() string {
return cmp.Diff(nodeLabels(c, fmt.Sprint(body.Data)), nodeLabels(c, err.Error()))
})
}
var (
// [acl test error]: address "tag:server:22" (protocol "tcp"): want: Accept, got: Drop.
saasTestFailRE = regexp.MustCompile(
`^\[acl test error\]: address "(.+)" \(protocol "[^"]*"\): want: (Accept|Drop), got: (?:Accept|Drop)$`)
// [ssh test error]: src "a" at "ip", dst "b" at "ip", ssh user "root": want: "accept" got: "deny".
saasSSHTestFailRE = regexp.MustCompile(
`^\[ssh test error\]: src ".*" at ".*", dst ".*" at ".*", ssh user "(.*)": want: "(accept|check|deny)" got: "[a-z]+"$`)
)
// requireFailureBreakdown compares the failures SaaS lists in its error data
// with the ones headscale's evaluators report, as sorted lines of
// "src -> dst: want" (tests), "src/user: want" (sshTests) and "src: error"
// for an entry that could not be evaluated. Repeats are kept. sshTests lines
// leave the destination out: SaaS names the alias, headscale each node, and
// the label check covers that.
func requireFailureBreakdown(
t *testing.T,
skips map[string]string,
key string,
data []tsapi.APIErrorData,
policy string,
pm *PolicyManager,
refusal error,
) {
t.Helper()
var want []string
for _, d := range data {
for _, line := range d.Errors {
if m := saasTestFailRE.FindStringSubmatch(line); m != nil {
want = append(want, fmt.Sprintf("%s -> %s: %s", d.User, m[1], testWant(m[2] == "Accept")))
continue
}
if m := saasSSHTestFailRE.FindStringSubmatch(line); m != nil {
want = append(want, fmt.Sprintf("%s/%s: %s", d.User, m[1], m[2]))
continue
}
want = append(want, d.User+": error")
}
}
slices.Sort(want)
compatCheck(t, skips, key, func() string {
if pm != nil {
return cmp.Diff(want, testerBreakdown(pm))
}
got, err := parseRefusalBreakdown(policy, refusal)
if err != nil {
return err.Error()
}
return cmp.Diff(want, got)
})
}
// parseRefusalEntryRE finds the entries a parse-time refusal names; the
// parser reports tests and sshTests entries by index.
var parseRefusalEntryRE = regexp.MustCompile(`(?m)^\s*(test|sshTest) (\d+)\b`)
// parseRefusalBreakdown renders a refusal headscale gave before running the
// tests: one "src: error" per entry it names.
func parseRefusalBreakdown(policy string, refusal error) ([]string, error) {
type entry struct {
Src string `json:"src"`
}
var raw struct {
Tests []entry `json:"tests"`
SSHTests []entry `json:"sshTests"`
}
err := json.Unmarshal([]byte(policy), &raw)
if err != nil {
return nil, fmt.Errorf("reading test entries: %w", err)
}
var out []string
for _, m := range parseRefusalEntryRE.FindAllStringSubmatch(refusal.Error(), -1) {
entries := raw.Tests
if m[1] == "sshTest" {
entries = raw.SSHTests
}
i, err := strconv.Atoi(m[2])
if err != nil || i >= len(entries) {
return nil, fmt.Errorf("refusal names %s %s, which the policy does not have: %w", m[1], m[2], refusal)
}
out = append(out, entries[i].Src+": error")
}
slices.Sort(out)
return out, nil
}
func testWant(accept bool) string {
if accept {
return "accept"
}
return "deny"
}
// testerBreakdown runs both blocks as the boot path does and renders the
// failures as [requireFailureBreakdown] does.
func testerBreakdown(pm *PolicyManager) []string {
var out []string
for _, res := range runPolicyTests(pm.pol, pm.filter, pm.users, pm.nodes).Results {
for range res.Errors {
out = append(out, res.Src+": error")
}
for _, dst := range res.AcceptFail {
out = append(out, fmt.Sprintf("%s -> %s: %s", res.Src, dst, testWant(true)))
}
for _, dst := range res.DenyFail {
out = append(out, fmt.Sprintf("%s -> %s: %s", res.Src, dst, testWant(false)))
}
}
cache := make(map[types.NodeID]*tailcfg.SSHPolicy)
for _, res := range runSSHPolicyTests(pm.pol, pm.users, pm.nodes, cache).Results {
for range res.Errors {
out = append(out, res.Src+": error")
}
for want, fails := range map[string]map[string][]string{
"accept": res.AcceptFail,
"deny": res.DenyFail,
"check": res.CheckFail,
} {
for user, dsts := range fails {
for range dsts {
out = append(out, fmt.Sprintf("%s/%s: %s", res.Src, user, want))
}
}
}
}
slices.Sort(out)
return out
}
// nodeLabels lists the capture's hostnames that text names while the policy
// does not, so they are there only because the report labels nodes.
func nodeLabels(c *testcapture.Capture, text string) []string {
var out []string
for _, n := range c.Topology.Nodes {
if !strings.Contains(c.Input.FullPolicy, n.Hostname) && strings.Contains(text, n.Hostname) {
out = append(out, n.Hostname)
}
}
slices.Sort(out)
return out
}
// compatCheck runs one check of a capture as a subtest named after the last
// element of key; mismatch returns "" when headscale matches SaaS. A key in
// skips is a known difference that must still differ, so the fix that makes
@@ -131,3 +334,15 @@ func compatCheck(t *testing.T, skips map[string]string, key string, mismatch fun
}
})
}
// compatChecksNotRun fails for a listed check that cannot run, since its
// entry could never go stale.
func compatChecksNotRun(t *testing.T, skips map[string]string, keys ...string) {
t.Helper()
for _, key := range keys {
if _, listed := skips[key]; listed {
t.Errorf("check %q does not run for this capture; delete its skip entry", key)
}
}
}
+18 -57
View File
@@ -1,35 +1,38 @@
// Replay golden HuJSON captures under testdata/sshtest_results/*.hujson:
// the 200 path requires headscale's evaluateSSHTests to pass; the
// non-200 path requires headscale to reject the same input with the
// captured error body as a substring. Divergences are listed in
// Replay golden HuJSON captures under testdata/sshtest_results/*.hujson with
// the checks of [runTesterCompat]. Divergences are listed in
// knownSSHTesterDivergences with the engine gap each represents.
package v2
import (
"path/filepath"
"strings"
"testing"
"github.com/juanfont/headscale/hscontrol/types/testcapture"
"github.com/stretchr/testify/require"
)
// knownSSHTesterDivergences names the engine gap for each capture where
// headscale and upstream disagree.
var knownSSHTesterDivergences = map[string]string{}
// knownSSHTesterDivergences names the engine gap for each check where
// headscale and upstream disagree, keyed <test_id>/<check>.
var knownSSHTesterDivergences = map[string]string{
"sshtest-accept-fail-no-ssh-rule/label": sshTestNodeLabel,
"sshtest-accept-fail-wrong-login-user/label": sshTestNodeLabel,
"sshtest-both-tests-pass-sshTests-fail/label": sshTestNodeLabel,
"sshtest-deny-fail-policy-allows/label": sshTestNodeLabel,
"sshtest-malformed-empty-user/label": sshTestNodeLabel,
"sshtest-mixed-acls-tcp22-allow-no-ssh-rule/label": sshTestNodeLabel,
"sshtest-user-localpart-domain-mismatch/label": sshTestNodeLabel,
"sshtest-user-nonroot-blocks-root/label": sshTestNodeLabel,
}
const sshTestNodeLabel = "SSHTEST_NODE_LABEL: headscale names each failing dst node by hostname; SaaS names the dst as written"
func TestSSHTesterCompat(t *testing.T) {
t.Parallel()
files, err := filepath.Glob(filepath.Join("testdata", "sshtest_results", "*.hujson"))
require.NoError(t, err, "failed to glob test files")
if len(files) == 0 {
t.Skip("no sshtest captures yet")
}
users := setupSSHDataCompatUsers()
require.NotEmpty(t, files, "no sshtest captures")
for _, file := range files {
c, err := testcapture.Read(file)
@@ -38,49 +41,7 @@ func TestSSHTesterCompat(t *testing.T) {
t.Run(c.TestID, func(t *testing.T) {
t.Parallel()
if reason, skip := knownSSHTesterDivergences[c.TestID]; skip {
t.Skip(reason)
}
// Each capture pins its own topology IPs; build nodes
// from the capture so host-alias dsts resolve.
nodes := buildGrantsNodesFromCapture(users, c)
policyJSON := []byte(c.Input.FullPolicy)
pm, parseErr := NewPolicyManager(policyJSON, users, nodes.ViewSlice())
if c.Input.APIResponseCode == 200 {
require.NoError(t, parseErr,
"tailscale accepted this policy; headscale must parse it")
_, setErr := pm.SetPolicy(policyJSON)
require.NoError(t, setErr,
"tailscale accepted this policy; headscale sshTests must pass")
return
}
var got error
switch {
case parseErr != nil:
got = parseErr
default:
_, setErr := pm.SetPolicy(policyJSON)
got = setErr
}
require.Error(t, got, "tailscale rejected; headscale must reject too")
if c.Input.APIResponseBody == nil || c.Input.APIResponseBody.Message == "" {
return
}
want := c.Input.APIResponseBody.Message
if !strings.Contains(got.Error(), want) {
t.Errorf("error body mismatch\n tailscale wants: %q\n headscale got: %q", want, got.Error())
}
runTesterCompat(t, c, knownSSHTesterDivergences)
})
}
}
@@ -69,6 +69,7 @@ func buildGrantsNodesFromCapture(
for _, nodeDef := range tf.Topology.Nodes {
node := &types.Node{
ID: types.NodeID(autoID), //nolint:gosec
Hostname: nodeDef.Hostname,
GivenName: nodeDef.Hostname,
IPv4: ptrAddr(nodeDef.IPv4),
IPv6: ptrAddr(nodeDef.IPv6),
@@ -437,7 +437,9 @@ func TestSSHCheckParamsMatchesCaptures(t *testing.T) {
for _, file := range files {
tf := loadSSHTestFile(t, file)
if tf.Input.APIResponseCode != 200 {
// Validation-only captures hold no node rules to compare.
if tf.Input.APIResponseCode != 200 || len(tf.Captures) == 0 {
continue
}
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
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
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,171 @@
// policytest-allpass-acls-only-tag-src-cidr-dst
//
// tests block all-pass: tag src to cidr dst, acls only, tcp
//
// Steps: 1
// Captured at: 2026-10-17T15:11:37Z
// tool version: tscap/4ef410b
// schema version: 1
{
"schema_version": 1,
"test_id": "policytest-allpass-acls-only-tag-src-cidr-dst",
"description": "tests block all-pass: tag src to cidr dst, acls only, tcp",
"category": "policytest",
"captured_at": "2026-10-17T15:11:37.637403038Z",
"tool_version": "tscap/4ef410b",
"tailnet": "odin@example.com",
"error": true,
"input": {
"api_response_code": 400,
"api_response_body": {"message": "test(s) failed", "data": [{"user": "tag:client", "errors": [
"[acl test error]: user or host is invalid: unknown user or host: \"10.0.0.0/8\""
]}]},
"tailnet": {
"dns": {"magic_dns": false, "nameservers": [], "search_paths": [], "split_dns": {}},
"settings": {}
},
"scenario_hujson": " \n \n \n \n \n \n \n \n \n \n{\n\t\"id\": \"policytest-allpass-acls-only-tag-src-cidr-dst\",\n\t\"category\": \"policytest\",\n\t\"description\": \"tests block all-pass: tag src to cidr dst, acls only, tcp\",\n\n\t\"topology\": \"../_topologies/acl.hujson\",\n\n\t\"tailnet\": {\n\t\t\"dns\": {\"magic_dns\": false, \"nameservers\": [], \"search_paths\": [], \"split_dns\": {}},\n\t\t\"settings\": {}\n\t},\n\n\t\"policy\": {\n\t\t\"groups\": {\n\t\t\t\"group:admins\": [\"odin@example.com\"],\n\t\t\t\"group:developers\": [\"thor@example.org\", \"odin@example.com\"],\n\t\t\t\"group:empty\": [],\n\t\t\t\"group:monitors\": [\"freya@example.com\"]\n\t\t},\n\t\t\"tagOwners\": {\n\t\t\t\"tag:client\": [\"odin@example.com\"],\n\t\t\t\"tag:exit\": [\"odin@example.com\"],\n\t\t\t\"tag:pidgey\": [\"odin@example.com\"],\n\t\t\t\"tag:pidgeotto\": [\"odin@example.com\"],\n\t\t\t\"tag:group-a\": [\"odin@example.com\"],\n\t\t\t\"tag:group-b\": [\"odin@example.com\"],\n\t\t\t\"tag:ha\": [\"odin@example.com\"],\n\t\t\t\"tag:prod\": [\"odin@example.com\"],\n\t\t\t\"tag:router\": [\"odin@example.com\"],\n\t\t\t\"tag:spearow\": [\"odin@example.com\"],\n\t\t\t\"tag:fearow\": [\"odin@example.com\"],\n\t\t\t\"tag:server\": [\"odin@example.com\"]\n\t\t},\n\t\t\"hosts\": {\n\t\t\t\"internal\": \"10.0.0.0/8\",\n\t\t\t\"prodbox\": \"100.103.8.15\",\n\t\t\t\"subnet24\": \"192.168.1.0/24\",\n\t\t\t\"webserver\": \"100.64.0.16\"\n\t\t},\n\t\t\"acls\": [\n\t\t\t{\"action\": \"accept\", \"src\": [\"tag:client\"], \"dst\": [\"10.0.0.0/8:22\"]}\n\t\t],\n\t\t\"tests\": [\n\t\t\t{\"src\": \"tag:client\", \"accept\": [\"10.0.0.0/8:22\"]}\n\t\t]\n\t}\n}\n",
"scenario_path": "scenarios/policytest/policytest-allpass-acls-only-tag-src-cidr-dst.hujson",
"full_policy": {
"acls": [{"action": "accept", "dst": ["10.0.0.0/8:22"], "src": ["tag:client"]}],
"groups": {
"group:admins": ["odin@example.com"],
"group:developers": ["thor@example.org", "odin@example.com"],
"group:empty": [],
"group:monitors": ["freya@example.com"]
},
"hosts": {
"internal": "10.0.0.0/8",
"prodbox": "100.103.8.15",
"subnet24": "192.168.1.0/24",
"webserver": "100.64.0.16"
},
"tagOwners": {
"tag:client": ["odin@example.com"],
"tag:exit": ["odin@example.com"],
"tag:pidgey": ["odin@example.com"],
"tag:pidgeotto": ["odin@example.com"],
"tag:group-a": ["odin@example.com"],
"tag:group-b": ["odin@example.com"],
"tag:ha": ["odin@example.com"],
"tag:prod": ["odin@example.com"],
"tag:router": ["odin@example.com"],
"tag:spearow": ["odin@example.com"],
"tag:fearow": ["odin@example.com"],
"tag:server": ["odin@example.com"]
},
"tests": [{"accept": ["10.0.0.0/8:22"], "src": "tag:client"}]
}
},
"topology": {"users": [
{"id": 1, "name": "odin", "email": "odin@example.com"},
{"id": 2, "name": "thor", "email": "thor@example.org"},
{"id": 3, "name": "freya", "email": "freya@example.com"}
], "nodes": {"beedrill": {
"hostname": "beedrill",
"tags": ["tag:server"],
"ipv4": "100.64.0.16",
"ipv6": "fd7a:115c:a1e0::10",
"routable_ips": [],
"approved_routes": []
}, "bulbasaur": {
"hostname": "bulbasaur",
"tags": [],
"ipv4": "100.64.0.19",
"ipv6": "fd7a:115c:a1e0::13",
"user": "odin",
"routable_ips": [],
"approved_routes": []
}, "charmander": {
"hostname": "charmander",
"tags": ["tag:exit"],
"ipv4": "100.64.0.4",
"ipv6": "fd7a:115c:a1e0::4",
"routable_ips": ["0.0.0.0/0", "::/0"],
"approved_routes": []
}, "ivysaur": {
"hostname": "ivysaur",
"tags": [],
"ipv4": "100.64.0.17",
"ipv6": "fd7a:115c:a1e0::11",
"user": "thor",
"routable_ips": [],
"approved_routes": []
}, "kakuna": {
"hostname": "kakuna",
"tags": ["tag:prod"],
"ipv4": "100.64.0.15",
"ipv6": "fd7a:115c:a1e0::f",
"routable_ips": [],
"approved_routes": []
}, "squirtle": {
"hostname": "squirtle",
"tags": ["tag:router"],
"ipv4": "100.64.0.13",
"ipv6": "fd7a:115c:a1e0::d",
"routable_ips": ["10.33.0.0/16"],
"approved_routes": []
}, "venusaur": {
"hostname": "venusaur",
"tags": [],
"ipv4": "100.64.0.18",
"ipv6": "fd7a:115c:a1e0::12",
"user": "freya",
"routable_ips": [],
"approved_routes": []
}, "weedle": {
"hostname": "weedle",
"tags": ["tag:client"],
"ipv4": "100.64.0.14",
"ipv6": "fd7a:115c:a1e0::e",
"routable_ips": [],
"approved_routes": []
}}},
"captures": {},
"steps": [{
"name": "validate",
"at": "2026-10-17T15:11:43.60650233Z",
"result": "observed",
"actions": [{
"kind": "api",
"principal": "admin",
"method": "POST",
"path": "/tailnet/-/acl/validate",
"header": {"Content-Type": "application/hujson"},
"body": {
"acls": [{"action": "accept", "dst": ["10.0.0.0/8:22"], "src": ["tag:client"]}],
"groups": {
"group:admins": ["odin@example.com"],
"group:developers": ["thor@example.org", "odin@example.com"],
"group:empty": [],
"group:monitors": ["freya@example.com"]
},
"hosts": {
"internal": "10.0.0.0/8",
"prodbox": "100.103.8.15",
"subnet24": "192.168.1.0/24",
"webserver": "100.64.0.16"
},
"tagOwners": {
"tag:client": ["odin@example.com"],
"tag:exit": ["odin@example.com"],
"tag:pidgey": ["odin@example.com"],
"tag:pidgeotto": ["odin@example.com"],
"tag:group-a": ["odin@example.com"],
"tag:group-b": ["odin@example.com"],
"tag:ha": ["odin@example.com"],
"tag:prod": ["odin@example.com"],
"tag:router": ["odin@example.com"],
"tag:spearow": ["odin@example.com"],
"tag:fearow": ["odin@example.com"],
"tag:server": ["odin@example.com"]
},
"tests": [{"accept": ["10.0.0.0/8:22"], "src": "tag:client"}]
},
"status": 200,
"response": {"data": [{"errors": [
"[acl test error]: user or host is invalid: unknown user or host: \"10.0.0.0/8\""
], "user": "tag:client"}], "message": "test(s) failed"}
}]
}]
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,171 @@
// policytest-allpass-grants-only-tag-src-cidr-dst-cgnat
//
// tests block all-pass: tag src to cgnat-cidr dst (contains topology nodes), grants only, tcp
//
// Steps: 1
// Captured at: 2026-10-17T15:11:57Z
// tool version: tscap/4ef410b
// schema version: 1
{
"schema_version": 1,
"test_id": "policytest-allpass-grants-only-tag-src-cidr-dst-cgnat",
"description": "tests block all-pass: tag src to cgnat-cidr dst (contains topology nodes), grants only, tcp",
"category": "policytest",
"captured_at": "2026-10-17T15:11:57.632457219Z",
"tool_version": "tscap/4ef410b",
"tailnet": "odin@example.com",
"error": true,
"input": {
"api_response_code": 400,
"api_response_body": {"message": "test(s) failed", "data": [{"user": "tag:client", "errors": [
"[acl test error]: user or host is invalid: unknown user or host: \"100.64.0.0/24\""
]}]},
"tailnet": {
"dns": {"magic_dns": false, "nameservers": [], "search_paths": [], "split_dns": {}},
"settings": {}
},
"scenario_hujson": " \n \n \n \n \n \n \n \n \n \n{\n\t\"id\": \"policytest-allpass-grants-only-tag-src-cidr-dst-cgnat\",\n\t\"category\": \"policytest\",\n\t\"description\": \"tests block all-pass: tag src to cgnat-cidr dst (contains topology nodes), grants only, tcp\",\n\n\t\"topology\": \"../_topologies/acl.hujson\",\n\n\t\"tailnet\": {\n\t\t\"dns\": {\"magic_dns\": false, \"nameservers\": [], \"search_paths\": [], \"split_dns\": {}},\n\t\t\"settings\": {}\n\t},\n\n\t\"policy\": {\n\t\t\"groups\": {\n\t\t\t\"group:admins\": [\"odin@example.com\"],\n\t\t\t\"group:developers\": [\"thor@example.org\", \"odin@example.com\"],\n\t\t\t\"group:empty\": [],\n\t\t\t\"group:monitors\": [\"freya@example.com\"]\n\t\t},\n\t\t\"tagOwners\": {\n\t\t\t\"tag:client\": [\"odin@example.com\"],\n\t\t\t\"tag:exit\": [\"odin@example.com\"],\n\t\t\t\"tag:pidgey\": [\"odin@example.com\"],\n\t\t\t\"tag:pidgeotto\": [\"odin@example.com\"],\n\t\t\t\"tag:group-a\": [\"odin@example.com\"],\n\t\t\t\"tag:group-b\": [\"odin@example.com\"],\n\t\t\t\"tag:ha\": [\"odin@example.com\"],\n\t\t\t\"tag:prod\": [\"odin@example.com\"],\n\t\t\t\"tag:router\": [\"odin@example.com\"],\n\t\t\t\"tag:spearow\": [\"odin@example.com\"],\n\t\t\t\"tag:fearow\": [\"odin@example.com\"],\n\t\t\t\"tag:server\": [\"odin@example.com\"]\n\t\t},\n\t\t\"hosts\": {\n\t\t\t\"internal\": \"10.0.0.0/8\",\n\t\t\t\"prodbox\": \"100.103.8.15\",\n\t\t\t\"subnet24\": \"192.168.1.0/24\",\n\t\t\t\"webserver\": \"100.64.0.16\"\n\t\t},\n\t\t\"grants\": [\n\t\t\t{\"src\": [\"tag:client\"], \"dst\": [\"100.64.0.0/24\"], \"ip\": [\"tcp:22\"]}\n\t\t],\n\t\t\"tests\": [\n\t\t\t{\"src\": \"tag:client\", \"accept\": [\"100.64.0.0/24:22\"]}\n\t\t]\n\t}\n}\n",
"scenario_path": "scenarios/policytest/policytest-allpass-grants-only-tag-src-cidr-dst-cgnat.hujson",
"full_policy": {
"grants": [{"dst": ["100.64.0.0/24"], "ip": ["tcp:22"], "src": ["tag:client"]}],
"groups": {
"group:admins": ["odin@example.com"],
"group:developers": ["thor@example.org", "odin@example.com"],
"group:empty": [],
"group:monitors": ["freya@example.com"]
},
"hosts": {
"internal": "10.0.0.0/8",
"prodbox": "100.103.8.15",
"subnet24": "192.168.1.0/24",
"webserver": "100.64.0.16"
},
"tagOwners": {
"tag:client": ["odin@example.com"],
"tag:exit": ["odin@example.com"],
"tag:pidgey": ["odin@example.com"],
"tag:pidgeotto": ["odin@example.com"],
"tag:group-a": ["odin@example.com"],
"tag:group-b": ["odin@example.com"],
"tag:ha": ["odin@example.com"],
"tag:prod": ["odin@example.com"],
"tag:router": ["odin@example.com"],
"tag:spearow": ["odin@example.com"],
"tag:fearow": ["odin@example.com"],
"tag:server": ["odin@example.com"]
},
"tests": [{"accept": ["100.64.0.0/24:22"], "src": "tag:client"}]
}
},
"topology": {"users": [
{"id": 1, "name": "odin", "email": "odin@example.com"},
{"id": 2, "name": "thor", "email": "thor@example.org"},
{"id": 3, "name": "freya", "email": "freya@example.com"}
], "nodes": {"beedrill": {
"hostname": "beedrill",
"tags": ["tag:server"],
"ipv4": "100.64.0.16",
"ipv6": "fd7a:115c:a1e0::10",
"routable_ips": [],
"approved_routes": []
}, "bulbasaur": {
"hostname": "bulbasaur",
"tags": [],
"ipv4": "100.64.0.19",
"ipv6": "fd7a:115c:a1e0::13",
"user": "odin",
"routable_ips": [],
"approved_routes": []
}, "charmander": {
"hostname": "charmander",
"tags": ["tag:exit"],
"ipv4": "100.64.0.4",
"ipv6": "fd7a:115c:a1e0::4",
"routable_ips": ["0.0.0.0/0", "::/0"],
"approved_routes": []
}, "ivysaur": {
"hostname": "ivysaur",
"tags": [],
"ipv4": "100.64.0.17",
"ipv6": "fd7a:115c:a1e0::11",
"user": "thor",
"routable_ips": [],
"approved_routes": []
}, "kakuna": {
"hostname": "kakuna",
"tags": ["tag:prod"],
"ipv4": "100.64.0.15",
"ipv6": "fd7a:115c:a1e0::f",
"routable_ips": [],
"approved_routes": []
}, "squirtle": {
"hostname": "squirtle",
"tags": ["tag:router"],
"ipv4": "100.64.0.13",
"ipv6": "fd7a:115c:a1e0::d",
"routable_ips": ["10.33.0.0/16"],
"approved_routes": []
}, "venusaur": {
"hostname": "venusaur",
"tags": [],
"ipv4": "100.64.0.18",
"ipv6": "fd7a:115c:a1e0::12",
"user": "freya",
"routable_ips": [],
"approved_routes": []
}, "weedle": {
"hostname": "weedle",
"tags": ["tag:client"],
"ipv4": "100.64.0.14",
"ipv6": "fd7a:115c:a1e0::e",
"routable_ips": [],
"approved_routes": []
}}},
"captures": {},
"steps": [{
"name": "validate",
"at": "2026-10-17T15:12:03.605506581Z",
"result": "observed",
"actions": [{
"kind": "api",
"principal": "admin",
"method": "POST",
"path": "/tailnet/-/acl/validate",
"header": {"Content-Type": "application/hujson"},
"body": {
"grants": [{"dst": ["100.64.0.0/24"], "ip": ["tcp:22"], "src": ["tag:client"]}],
"groups": {
"group:admins": ["odin@example.com"],
"group:developers": ["thor@example.org", "odin@example.com"],
"group:empty": [],
"group:monitors": ["freya@example.com"]
},
"hosts": {
"internal": "10.0.0.0/8",
"prodbox": "100.103.8.15",
"subnet24": "192.168.1.0/24",
"webserver": "100.64.0.16"
},
"tagOwners": {
"tag:client": ["odin@example.com"],
"tag:exit": ["odin@example.com"],
"tag:pidgey": ["odin@example.com"],
"tag:pidgeotto": ["odin@example.com"],
"tag:group-a": ["odin@example.com"],
"tag:group-b": ["odin@example.com"],
"tag:ha": ["odin@example.com"],
"tag:prod": ["odin@example.com"],
"tag:router": ["odin@example.com"],
"tag:spearow": ["odin@example.com"],
"tag:fearow": ["odin@example.com"],
"tag:server": ["odin@example.com"]
},
"tests": [{"accept": ["100.64.0.0/24:22"], "src": "tag:client"}]
},
"status": 200,
"response": {"data": [{"errors": [
"[acl test error]: user or host is invalid: unknown user or host: \"100.64.0.0/24\""
], "user": "tag:client"}], "message": "test(s) failed"}
}]
}]
}
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
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
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
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
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
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
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
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
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
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
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
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
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