mirror of
https://github.com/juanfont/headscale.git
synced 2026-04-10 19:17:25 +02:00
Introduce ResolvedAddresses type for structured IP set results. Refactor all Alias.Resolve() methods to return ResolvedAddresses instead of raw IPSets. Restrict identity-based aliases to matching address families, fix nil dereferences in partial resolution paths, and update test expectations for the new IP format (bare IPs, IP ranges instead of CIDR prefixes). Updates #2180
991 lines
42 KiB
Go
991 lines
42 KiB
Go
// This file is "generated" by Claude.
|
|
// It contains a data-driven test that reads 237 GRANT-*.json test files
|
|
// captured from Tailscale SaaS. Each file contains:
|
|
// - A policy with grants (and optionally ACLs)
|
|
// - The expected packet_filter_rules for each of 8 test nodes
|
|
// - Or an error response for invalid policies
|
|
//
|
|
// The test loads each JSON file, applies the policy through headscale's
|
|
// grants engine, and compares the output against Tailscale's actual behavior.
|
|
//
|
|
// Tests that are known to fail due to unimplemented features or known
|
|
// differences are skipped with a TODO comment explaining the root cause.
|
|
// As headscale's grants implementation improves, tests should be removed
|
|
// from the skip list.
|
|
//
|
|
// Test data source: testdata/grant_results/GRANT-*.json
|
|
// Captured from: Tailscale SaaS API + tailscale debug localapi
|
|
|
|
package v2
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
"github.com/google/go-cmp/cmp/cmpopts"
|
|
"github.com/juanfont/headscale/hscontrol/policy/policyutil"
|
|
"github.com/juanfont/headscale/hscontrol/types"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/gorm"
|
|
"tailscale.com/tailcfg"
|
|
)
|
|
|
|
// grantTestFile represents the JSON structure of a captured grant test file.
|
|
type grantTestFile struct {
|
|
TestID string `json:"test_id"`
|
|
Error bool `json:"error"`
|
|
Input struct {
|
|
FullPolicy json.RawMessage `json:"full_policy"`
|
|
APIResponseCode int `json:"api_response_code"`
|
|
APIResponseBody *struct {
|
|
Message string `json:"message"`
|
|
} `json:"api_response_body"`
|
|
} `json:"input"`
|
|
Topology struct {
|
|
Nodes map[string]struct {
|
|
Hostname string `json:"hostname"`
|
|
Tags []string `json:"tags"`
|
|
IPv4 string `json:"ipv4"`
|
|
IPv6 string `json:"ipv6"`
|
|
} `json:"nodes"`
|
|
} `json:"topology"`
|
|
Captures map[string]struct {
|
|
PacketFilterRules json.RawMessage `json:"packet_filter_rules"`
|
|
} `json:"captures"`
|
|
}
|
|
|
|
// setupGrantsCompatUsers returns the 3 test users for grants compatibility tests.
|
|
// Email addresses use @example.com domain, matching the converted Tailscale policy format.
|
|
func setupGrantsCompatUsers() types.Users {
|
|
return types.Users{
|
|
{Model: gorm.Model{ID: 1}, Name: "kratail2tid", Email: "kratail2tid@example.com"},
|
|
{Model: gorm.Model{ID: 2}, Name: "kristoffer", Email: "kristoffer@example.com"},
|
|
{Model: gorm.Model{ID: 3}, Name: "monitorpasskeykradalby", Email: "monitorpasskeykradalby@example.com"},
|
|
}
|
|
}
|
|
|
|
// setupGrantsCompatNodes returns the 8 test nodes for grants compatibility tests.
|
|
// The node configuration matches the Tailscale test environment:
|
|
// - 3 user-owned nodes (user1, user-kris, user-mon)
|
|
// - 5 tagged nodes (tagged-server, tagged-prod, tagged-client, subnet-router, exit-node)
|
|
func setupGrantsCompatNodes(users types.Users) types.Nodes {
|
|
nodeUser1 := &types.Node{
|
|
ID: 1,
|
|
GivenName: "user1",
|
|
User: &users[0],
|
|
UserID: &users[0].ID,
|
|
IPv4: ptrAddr("100.90.199.68"),
|
|
IPv6: ptrAddr("fd7a:115c:a1e0::2d01:c747"),
|
|
Hostinfo: &tailcfg.Hostinfo{},
|
|
}
|
|
|
|
nodeUserKris := &types.Node{
|
|
ID: 2,
|
|
GivenName: "user-kris",
|
|
User: &users[1],
|
|
UserID: &users[1].ID,
|
|
IPv4: ptrAddr("100.110.121.96"),
|
|
IPv6: ptrAddr("fd7a:115c:a1e0::1737:7960"),
|
|
Hostinfo: &tailcfg.Hostinfo{},
|
|
}
|
|
|
|
nodeUserMon := &types.Node{
|
|
ID: 3,
|
|
GivenName: "user-mon",
|
|
User: &users[2],
|
|
UserID: &users[2].ID,
|
|
IPv4: ptrAddr("100.103.90.82"),
|
|
IPv6: ptrAddr("fd7a:115c:a1e0::9e37:5a52"),
|
|
Hostinfo: &tailcfg.Hostinfo{},
|
|
}
|
|
|
|
nodeTaggedServer := &types.Node{
|
|
ID: 4,
|
|
GivenName: "tagged-server",
|
|
IPv4: ptrAddr("100.108.74.26"),
|
|
IPv6: ptrAddr("fd7a:115c:a1e0::b901:4a87"),
|
|
Tags: []string{"tag:server"},
|
|
Hostinfo: &tailcfg.Hostinfo{},
|
|
}
|
|
|
|
nodeTaggedProd := &types.Node{
|
|
ID: 5,
|
|
GivenName: "tagged-prod",
|
|
IPv4: ptrAddr("100.103.8.15"),
|
|
IPv6: ptrAddr("fd7a:115c:a1e0::5b37:80f"),
|
|
Tags: []string{"tag:prod"},
|
|
Hostinfo: &tailcfg.Hostinfo{},
|
|
}
|
|
|
|
nodeTaggedClient := &types.Node{
|
|
ID: 6,
|
|
GivenName: "tagged-client",
|
|
IPv4: ptrAddr("100.83.200.69"),
|
|
IPv6: ptrAddr("fd7a:115c:a1e0::c537:c845"),
|
|
Tags: []string{"tag:client"},
|
|
Hostinfo: &tailcfg.Hostinfo{},
|
|
}
|
|
|
|
nodeSubnetRouter := &types.Node{
|
|
ID: 7,
|
|
GivenName: "subnet-router",
|
|
IPv4: ptrAddr("100.92.142.61"),
|
|
IPv6: ptrAddr("fd7a:115c:a1e0::3e37:8e3d"),
|
|
Tags: []string{"tag:router"},
|
|
Hostinfo: &tailcfg.Hostinfo{},
|
|
}
|
|
|
|
nodeExitNode := &types.Node{
|
|
ID: 8,
|
|
GivenName: "exit-node",
|
|
IPv4: ptrAddr("100.85.66.106"),
|
|
IPv6: ptrAddr("fd7a:115c:a1e0::7c37:426a"),
|
|
Tags: []string{"tag:exit"},
|
|
Hostinfo: &tailcfg.Hostinfo{},
|
|
}
|
|
|
|
return types.Nodes{
|
|
nodeUser1,
|
|
nodeUserKris,
|
|
nodeUserMon,
|
|
nodeTaggedServer,
|
|
nodeTaggedProd,
|
|
nodeTaggedClient,
|
|
nodeSubnetRouter,
|
|
nodeExitNode,
|
|
}
|
|
}
|
|
|
|
// findGrantsNode finds a node by its GivenName in the grants test environment.
|
|
func findGrantsNode(nodes types.Nodes, name string) *types.Node {
|
|
for _, n := range nodes {
|
|
if n.GivenName == name {
|
|
return n
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// convertPolicyUserEmails converts Tailscale SaaS user email formats to
|
|
// headscale-compatible @example.com format in the raw policy JSON.
|
|
//
|
|
// Tailscale uses provider-specific email formats:
|
|
// - kratail2tid@passkey (passkey auth)
|
|
// - kristoffer@dalby.cc (email auth)
|
|
// - monitorpasskeykradalby@passkey (passkey auth)
|
|
//
|
|
// Headscale resolves users by Email field, so we convert all to @example.com.
|
|
func convertPolicyUserEmails(policyJSON []byte) []byte {
|
|
s := string(policyJSON)
|
|
s = strings.ReplaceAll(s, "kratail2tid@passkey", "kratail2tid@example.com")
|
|
s = strings.ReplaceAll(s, "kristoffer@dalby.cc", "kristoffer@example.com")
|
|
s = strings.ReplaceAll(s, "monitorpasskeykradalby@passkey", "monitorpasskeykradalby@example.com")
|
|
|
|
return []byte(s)
|
|
}
|
|
|
|
// loadGrantTestFile loads and parses a single grant test JSON file.
|
|
func loadGrantTestFile(t *testing.T, path string) grantTestFile {
|
|
t.Helper()
|
|
|
|
content, err := os.ReadFile(path)
|
|
require.NoError(t, err, "failed to read test file %s", path)
|
|
|
|
var tf grantTestFile
|
|
|
|
err = json.Unmarshal(content, &tf)
|
|
require.NoError(t, err, "failed to parse test file %s", path)
|
|
|
|
return tf
|
|
}
|
|
|
|
// Skip categories document WHY tests are expected to fail and WHAT needs to be
|
|
// implemented to fix them. Tests are grouped by root cause to identify high-impact
|
|
// changes.
|
|
//
|
|
// Impact summary (highest first):
|
|
//
|
|
// CAPGRANT_COMPILATION - 49 tests: Implement app->CapGrant FilterRule compilation
|
|
// ERROR_VALIDATION_GAP - 23 tests: Implement missing grant validation rules
|
|
// MISSING_IPV6_ADDRS - 90 tests: Include IPv6 for identity-based alias resolution
|
|
// CAPGRANT_COMPILATION_AND_SRCIPS - 11 tests: Both CapGrant compilation + SrcIPs format
|
|
// SUBNET_ROUTE_FILTER_RULES - 10 tests: Generate filter rules for subnet-routed CIDRs
|
|
// VIA_COMPILATION_AND_SRCIPS_FORMAT - 7 tests: Via route compilation + SrcIPs format
|
|
// AUTOGROUP_SELF_CIDR_FORMAT - 4 tests: DstPorts IPs get /32 or /128 suffix for autogroup:self
|
|
// VIA_COMPILATION - 3 tests: Via route compilation
|
|
// AUTOGROUP_DANGER_ALL - 3 tests: Implement autogroup:danger-all support
|
|
// USER_PASSKEY_WILDCARD - 2 tests: user:*@passkey wildcard pattern unresolvable
|
|
// VALIDATION_STRICTNESS - 2 tests: headscale too strict (rejects what Tailscale accepts)
|
|
// RAW_IPV6_ADDR_EXPANSION - 2 tests: Raw fd7a: IPv6 src/dst expanded to include IPv4
|
|
// SRCIPS_WILDCARD_NODE_DEDUP - 1 test: Wildcard+specific source node IP deduplication
|
|
//
|
|
// Total: 207 tests skipped, 30 tests expected to pass.
|
|
var grantSkipReasons = map[string]string{
|
|
// ========================================================================
|
|
// MISSING_IPV6_ADDRS (90 tests)
|
|
//
|
|
// TODO: Include IPv6 addresses when resolving identity-based aliases in
|
|
// filter rules.
|
|
//
|
|
// When compiling filter rules, headscale resolves identity-based aliases
|
|
// (tags, groups, users, autogroups) to only IPv4 addresses. Tailscale
|
|
// includes both IPv4 AND the corresponding fd7a:115c:a1e0:: IPv6 address
|
|
// in SrcIPs and DstPorts.
|
|
//
|
|
// IMPORTANT: This only applies to IDENTITY-based aliases. Address-based
|
|
// aliases (raw IPs like "100.108.74.26", host aliases like "webserver")
|
|
// correctly resolve to IPv4-only in both Tailscale and headscale.
|
|
//
|
|
// The rule (verified 100% across 790 node-IP references in test data):
|
|
// Identity aliases (tag:X, group:X, user@Y, autogroup:X, *)
|
|
// → include BOTH node.IPv4 and node.IPv6
|
|
// Address aliases (raw IPv4/IPv6, host alias names)
|
|
// → include ONLY the literal/resolved IP
|
|
//
|
|
// Example diff (tag:client src → tagged-server node):
|
|
// SrcIPs: headscale=["100.83.200.69"]
|
|
// SrcIPs: tailscale=["100.83.200.69", "fd7a:115c:a1e0::c537:c845"]
|
|
//
|
|
// Fix: When resolving an identity alias (tag, group, user, autogroup, *)
|
|
// to IPs, include both node.IPv4 and node.IPv6 addresses. When resolving
|
|
// an address alias (raw IP, host alias), keep only the literal IP.
|
|
// ========================================================================
|
|
|
|
// J-series: Protocol-specific IP grants with identity src/dst
|
|
"GRANT-J1": "MISSING_IPV6_ADDRS",
|
|
"GRANT-J2": "MISSING_IPV6_ADDRS",
|
|
"GRANT-J3": "MISSING_IPV6_ADDRS",
|
|
"GRANT-J4": "MISSING_IPV6_ADDRS",
|
|
"GRANT-J5": "MISSING_IPV6_ADDRS",
|
|
"GRANT-J6": "MISSING_IPV6_ADDRS",
|
|
|
|
// K-series: Various IP grant patterns with identity aliases
|
|
"GRANT-K4": "MISSING_IPV6_ADDRS",
|
|
"GRANT-K16": "MISSING_IPV6_ADDRS",
|
|
"GRANT-K17": "MISSING_IPV6_ADDRS",
|
|
"GRANT-K22": "MISSING_IPV6_ADDRS",
|
|
"GRANT-K26": "MISSING_IPV6_ADDRS",
|
|
|
|
// P02-series: Source targeting (user, group, tag)
|
|
"GRANT-P02_1": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P02_2": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P02_3": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P02_4": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P02_5_CORRECT": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P02_5_NAIVE": "MISSING_IPV6_ADDRS",
|
|
|
|
// P03-series: Destination targeting
|
|
"GRANT-P03_1": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P03_2": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P03_3": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P03_4": "MISSING_IPV6_ADDRS",
|
|
|
|
// P04-series: autogroup:member grants
|
|
"GRANT-P04_1": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P04_2": "MISSING_IPV6_ADDRS",
|
|
|
|
// P06-series: IP protocol grants
|
|
"GRANT-P06_1": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P06_2": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P06_3": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P06_4": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P06_5": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P06_6": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P06_7": "MISSING_IPV6_ADDRS",
|
|
|
|
// P08-series: Multiple grants / rule merging
|
|
"GRANT-P08_1": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P08_2": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P08_4": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P08_5": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P08_6": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P08_7": "MISSING_IPV6_ADDRS",
|
|
|
|
// P09-series: ACL-to-grant conversion equivalence tests
|
|
"GRANT-P09_1A": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P09_1B": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P09_1C": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P09_1D": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P09_1E": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P09_2A_CORRECT": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P09_2A_NAIVE": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P09_2B_CORRECT": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P09_2B_NAIVE": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P09_2C": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P09_3A": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P09_3B": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P09_3C": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P09_4A": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P09_4B": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P09_4C": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P09_4D": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P09_4F": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P09_4G": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P09_5A": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P09_5B": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P09_5C_NAIVE": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P09_6C": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P09_7B_NAIVE": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P09_7C": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P09_7D_NAIVE": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P09_8A": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P09_8B": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P09_8C": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P09_9A": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P09_9B": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P09_9C": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P09_10A": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P09_10B": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P09_10C": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P09_10D": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P09_11A": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P09_11B": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P09_11C_NAIVE": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P09_11D": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P09_12A": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P09_12B": "MISSING_IPV6_ADDRS + SUBNET_ROUTE_FILTER_RULES: tagged-server subtest missing IPv6; subnet-router subtest missing entire rule for 10.0.0.0/8",
|
|
"GRANT-P09_14A": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P09_14B": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P09_14C": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P09_14D": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P09_14E": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P09_14F": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P09_14G": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P09_14H": "MISSING_IPV6_ADDRS",
|
|
"GRANT-P09_14I": "MISSING_IPV6_ADDRS",
|
|
|
|
// P10-series: Host alias grants (only identity-src subtests fail)
|
|
"GRANT-P10_2": "MISSING_IPV6_ADDRS",
|
|
|
|
// P11-series: autogroup:tagged grants
|
|
"GRANT-P11_2": "MISSING_IPV6_ADDRS",
|
|
|
|
// P13-series: CIDR destination grants (identity-src subtests)
|
|
"GRANT-P13_4": "MISSING_IPV6_ADDRS",
|
|
|
|
// ========================================================================
|
|
// SUBNET_ROUTE_FILTER_RULES (10 tests)
|
|
//
|
|
// TODO: Generate filter rules for non-Tailscale CIDR destinations on
|
|
// subnet-router nodes.
|
|
//
|
|
// When a grant targets a non-Tailscale CIDR (e.g., 10.0.0.0/8,
|
|
// 10.33.0.0/16, 10.33.1.0/24), Tailscale generates FilterRules on the
|
|
// subnet-router node that advertises overlapping routes. headscale
|
|
// produces no rules for these destinations, resulting in empty output
|
|
// on the subnet-router node.
|
|
//
|
|
// Example (GRANT-P13_1, dst=10.33.0.0/16):
|
|
// tailscale produces on subnet-router:
|
|
// SrcIPs=["100.103.90.82","100.110.121.96","100.90.199.68", + IPv6s]
|
|
// DstPorts=[{IP:"10.33.0.0/16", Ports:"22"}]
|
|
// headscale produces: [] (empty)
|
|
//
|
|
// Fix: During filter rule compilation, check if a destination CIDR
|
|
// overlaps with any subnet route advertised by the current node, and
|
|
// if so, generate the appropriate FilterRule.
|
|
// ========================================================================
|
|
"GRANT-P08_8": "SUBNET_ROUTE_FILTER_RULES: dst=10.0.0.0/8 — subnet-router gets no rules",
|
|
"GRANT-P09_6D": "SUBNET_ROUTE_FILTER_RULES: dst=internal (host alias for 10.0.0.0/8) — subnet-router gets no rules",
|
|
"GRANT-P10_3": "SUBNET_ROUTE_FILTER_RULES: dst=host alias for 10.33.0.0/16 — subnet-router gets no rules",
|
|
"GRANT-P10_4": "SUBNET_ROUTE_FILTER_RULES: dst=host alias for 10.33.0.0/16 — subnet-router gets no rules",
|
|
"GRANT-P13_1": "SUBNET_ROUTE_FILTER_RULES: dst=10.33.0.0/16 port 22 — subnet-router gets no rules",
|
|
"GRANT-P13_2": "SUBNET_ROUTE_FILTER_RULES: dst=10.33.0.0/16 port 80-443 — subnet-router gets no rules",
|
|
"GRANT-P13_3": "SUBNET_ROUTE_FILTER_RULES: dst=10.33.0.0/16 ports 22,80,443 — subnet-router gets no rules",
|
|
"GRANT-P15_1": "SUBNET_ROUTE_FILTER_RULES: dst=10.33.1.0/24 port 22 — subnet-router gets no rules",
|
|
"GRANT-P15_3": "SUBNET_ROUTE_FILTER_RULES: dst=10.32.0.0/14 port 22 — subnet-router gets no rules",
|
|
// Note: GRANT-P09_12B also has a subnet-router subtest failure — listed under MISSING_IPV6_ADDRS above
|
|
|
|
// ========================================================================
|
|
// AUTOGROUP_SELF_CIDR_FORMAT (4 tests)
|
|
//
|
|
// TODO: Use bare IPs (not CIDR notation) in DstPorts for autogroup:self grants.
|
|
//
|
|
// When compiling autogroup:self grants, headscale appends /32 to IPv4
|
|
// and /128 to IPv6 DstPort IPs. Tailscale uses bare IPs without a CIDR
|
|
// suffix. These tests also have missing IPv6 in SrcIPs.
|
|
//
|
|
// Example diff (user1 node, autogroup:member -> autogroup:self):
|
|
// DstPorts: tailscale=[{IP:"100.90.199.68"}, {IP:"fd7a:...::2d01:c747"}]
|
|
// DstPorts: headscale=[{IP:"100.90.199.68/32"}, {IP:"fd7a:...::2d01:c747/128"}]
|
|
// SrcIPs: tailscale=["100.90.199.68", "fd7a:...::2d01:c747"]
|
|
// SrcIPs: headscale=["100.90.199.68"]
|
|
// ========================================================================
|
|
"GRANT-P09_4E": "AUTOGROUP_SELF_CIDR_FORMAT: autogroup:member -> autogroup:self — DstPorts IPs have /32 and /128 suffix + missing IPv6 in SrcIPs",
|
|
"GRANT-P09_13E": "AUTOGROUP_SELF_CIDR_FORMAT: autogroup:member -> autogroup:self with ip:[*] — DstPorts IPs have CIDR suffix + missing IPv6 in SrcIPs",
|
|
"GRANT-P09_13F": "AUTOGROUP_SELF_CIDR_FORMAT: single user -> autogroup:self with ip:[22] — DstPorts IPs have CIDR suffix + missing IPv6 in SrcIPs",
|
|
"GRANT-P09_13G": "AUTOGROUP_SELF_CIDR_FORMAT: single user -> autogroup:self with ip:[22,80,443] — DstPorts IPs have CIDR suffix + missing IPv6 in SrcIPs",
|
|
|
|
// ========================================================================
|
|
// USER_PASSKEY_WILDCARD (2 tests)
|
|
//
|
|
// TODO: Handle user:*@passkey wildcard pattern in grant src/dst.
|
|
//
|
|
// Tailscale SaaS policies can use user:*@passkey as a wildcard matching
|
|
// all passkey-authenticated users. headscale's convertPolicyUserEmails
|
|
// only converts specific user@passkey addresses (not the wildcard form),
|
|
// so the filter compiler logs "user not found: token user:*@passkey"
|
|
// and produces no rules.
|
|
//
|
|
// Fix: Either convert user:*@passkey to a headscale-compatible wildcard,
|
|
// or resolve it to all known users during filter compilation.
|
|
// ========================================================================
|
|
"GRANT-K20": "USER_PASSKEY_WILDCARD: src=user:*@passkey, dst=tag:server — source can't be resolved, no rules produced",
|
|
"GRANT-K21": "USER_PASSKEY_WILDCARD: src=*, dst=user:*@passkey — destination can't be resolved, no rules produced",
|
|
|
|
// ========================================================================
|
|
// RAW_IPV6_ADDR_EXPANSION (2 tests)
|
|
//
|
|
// TODO: Don't expand raw IPv6 addresses to include the matching node's IPv4.
|
|
//
|
|
// When a grant uses a raw fd7a: IPv6 address as src or dst, headscale
|
|
// resolves it to BOTH the IPv4 and IPv6 of the matching node. Tailscale
|
|
// keeps only the specific address that was referenced in the grant.
|
|
//
|
|
// Example (GRANT-K14, src=fd7a:115c:a1e0::c537:c845):
|
|
// SrcIPs: tailscale=["fd7a:115c:a1e0::c537:c845"]
|
|
// SrcIPs: headscale=["100.83.200.69", "fd7a:115c:a1e0::c537:c845"]
|
|
// Example (GRANT-K15, dst=fd7a:115c:a1e0::b901:4a87):
|
|
// DstPorts: tailscale=[{IP:"fd7a:...::b901:4a87"}]
|
|
// DstPorts: headscale=[{IP:"100.108.74.26"}, {IP:"fd7a:...::b901:4a87"}]
|
|
// ========================================================================
|
|
"GRANT-K14": "RAW_IPV6_ADDR_EXPANSION: src=fd7a:...::c537:c845 — headscale adds extra IPv4 SrcIP + missing IPv6 in DstPorts",
|
|
"GRANT-K15": "RAW_IPV6_ADDR_EXPANSION: dst=fd7a:...::b901:4a87 — headscale adds extra IPv4 DstPort entry",
|
|
|
|
// ========================================================================
|
|
// SRCIPS_WILDCARD_NODE_DEDUP (1 test)
|
|
//
|
|
// TODO: When src includes both * (wildcard) and specific identities,
|
|
// Tailscale unions individual node IPs with the wildcard CGNAT ranges.
|
|
// headscale only produces the wildcard ranges, omitting the individual
|
|
// node IPs that are technically covered by those ranges.
|
|
//
|
|
// Also has missing IPv6 in DstPorts.
|
|
//
|
|
// Example (GRANT-P09_7A, src=[*, autogroup:member, tag:client, ...]):
|
|
// SrcIPs: tailscale=[individual IPs + CGNAT ranges + IPv6s] (20 entries)
|
|
// SrcIPs: headscale=[10.33.0.0/16, CGNAT ranges, fd7a::/48] (4 entries)
|
|
// ========================================================================
|
|
"GRANT-P09_7A": "SRCIPS_WILDCARD_NODE_DEDUP: src=[*,...] — individual node IPs missing from SrcIPs + missing IPv6 in DstPorts",
|
|
|
|
// ========================================================================
|
|
// CAPGRANT_COMPILATION (49 tests)
|
|
//
|
|
// TODO: Implement app capability grant -> CapGrant FilterRule compilation.
|
|
//
|
|
// When a grant specifies an "app" field (application capabilities), it
|
|
// should produce a FilterRule with CapGrant entries instead of DstPorts.
|
|
// headscale currently does not compile app grants into CapGrant FilterRules,
|
|
// producing empty output where Tailscale produces CapGrant rules.
|
|
//
|
|
// Each CapGrant FilterRule contains:
|
|
// - SrcIPs: source IP ranges (same format as DstPorts rules)
|
|
// - CapGrant: []tailcfg.CapGrant with Dsts (destination IPs) and
|
|
// CapMap (capability name -> JSON values)
|
|
//
|
|
// Fixing CapGrant compilation would resolve all 41 tests in this category.
|
|
// ========================================================================
|
|
|
|
// A-series: Basic app capability grants
|
|
"GRANT-A1": "CAPGRANT_COMPILATION",
|
|
"GRANT-A3": "CAPGRANT_COMPILATION",
|
|
"GRANT-A4": "CAPGRANT_COMPILATION",
|
|
"GRANT-A6": "CAPGRANT_COMPILATION",
|
|
|
|
// B-series: Specific capability types (kubernetes, drive, etc.)
|
|
"GRANT-B1": "CAPGRANT_COMPILATION",
|
|
"GRANT-B2": "CAPGRANT_COMPILATION",
|
|
"GRANT-B3": "CAPGRANT_COMPILATION",
|
|
"GRANT-B4": "CAPGRANT_COMPILATION",
|
|
"GRANT-B5": "CAPGRANT_COMPILATION",
|
|
|
|
// C-series: Capability values and multiple caps
|
|
"GRANT-C1": "CAPGRANT_COMPILATION",
|
|
"GRANT-C2": "CAPGRANT_COMPILATION",
|
|
"GRANT-C3": "CAPGRANT_COMPILATION",
|
|
"GRANT-C4": "CAPGRANT_COMPILATION",
|
|
"GRANT-C5": "CAPGRANT_COMPILATION",
|
|
"GRANT-C6": "CAPGRANT_COMPILATION",
|
|
|
|
// D-series: Source targeting with app caps
|
|
"GRANT-D1": "CAPGRANT_COMPILATION",
|
|
"GRANT-D2": "CAPGRANT_COMPILATION",
|
|
"GRANT-D3": "CAPGRANT_COMPILATION",
|
|
"GRANT-D4": "CAPGRANT_COMPILATION",
|
|
"GRANT-D5": "CAPGRANT_COMPILATION",
|
|
"GRANT-D6": "CAPGRANT_COMPILATION",
|
|
"GRANT-D7": "CAPGRANT_COMPILATION",
|
|
|
|
// E-series: Destination targeting with app caps
|
|
"GRANT-E1": "CAPGRANT_COMPILATION",
|
|
"GRANT-E2": "CAPGRANT_COMPILATION",
|
|
"GRANT-E4": "CAPGRANT_COMPILATION",
|
|
"GRANT-E5": "CAPGRANT_COMPILATION",
|
|
"GRANT-E6": "CAPGRANT_COMPILATION",
|
|
"GRANT-E7": "CAPGRANT_COMPILATION",
|
|
"GRANT-E8": "CAPGRANT_COMPILATION",
|
|
|
|
// G-series: Group-based source with app caps (pure capgrant)
|
|
"GRANT-G1": "CAPGRANT_COMPILATION",
|
|
"GRANT-G2": "CAPGRANT_COMPILATION",
|
|
"GRANT-G3": "CAPGRANT_COMPILATION",
|
|
"GRANT-G6": "CAPGRANT_COMPILATION",
|
|
|
|
// H-series: Edge cases with app caps
|
|
"GRANT-H2": "CAPGRANT_COMPILATION",
|
|
"GRANT-H6": "CAPGRANT_COMPILATION",
|
|
|
|
// K-series: Various app cap patterns
|
|
"GRANT-K11": "CAPGRANT_COMPILATION",
|
|
"GRANT-K18": "CAPGRANT_COMPILATION",
|
|
"GRANT-K19": "CAPGRANT_COMPILATION",
|
|
"GRANT-K24": "CAPGRANT_COMPILATION",
|
|
"GRANT-K25": "CAPGRANT_COMPILATION",
|
|
"GRANT-K27": "CAPGRANT_COMPILATION",
|
|
|
|
// V-series: App caps on specific tags, drive cap, autogroup:self app
|
|
"GRANT-V02": "CAPGRANT_COMPILATION: app grant on tag:exit — CapGrant with exit-node IPs as Dsts not compiled",
|
|
"GRANT-V03": "CAPGRANT_COMPILATION: app grant on tag:router — CapGrant with router IPs as Dsts not compiled",
|
|
"GRANT-V06": "CAPGRANT_COMPILATION: multi-dst app grant on [tag:server, tag:exit] — per-node CapGrant not compiled",
|
|
"GRANT-V19": "CAPGRANT_COMPILATION: drive cap on tag:exit — drive CapGrant + reverse drive-sharer not compiled",
|
|
"GRANT-V20": "CAPGRANT_COMPILATION: kubernetes cap on tag:router — CapGrant not compiled",
|
|
"GRANT-V25": "CAPGRANT_COMPILATION: autogroup:self app grant — self-targeting CapGrant per member not compiled",
|
|
|
|
// ========================================================================
|
|
// CAPGRANT_COMPILATION_AND_SRCIPS_FORMAT (11 tests)
|
|
//
|
|
// TODO: These tests have BOTH DstPorts and CapGrant FilterRules.
|
|
// They require both CapGrant compilation AND SrcIPs format fixes.
|
|
// Grants with both "ip" and "app" fields produce two separate FilterRules:
|
|
// one with DstPorts (from "ip") and one with CapGrant (from "app").
|
|
//
|
|
// V09/V10: headscale currently rejects mixed ip+app grants with
|
|
// "grants cannot specify both 'ip' and 'app' fields", but Tailscale
|
|
// accepts them and produces two FilterRules per grant.
|
|
// ========================================================================
|
|
|
|
// F-series: Mixed ip+app grants
|
|
"GRANT-F1": "CAPGRANT_COMPILATION_AND_SRCIPS_FORMAT",
|
|
"GRANT-F2": "CAPGRANT_COMPILATION_AND_SRCIPS_FORMAT",
|
|
"GRANT-F3": "CAPGRANT_COMPILATION_AND_SRCIPS_FORMAT",
|
|
"GRANT-F4": "CAPGRANT_COMPILATION_AND_SRCIPS_FORMAT",
|
|
|
|
// G-series: Group-based mixed grants
|
|
"GRANT-G4": "CAPGRANT_COMPILATION_AND_SRCIPS_FORMAT",
|
|
"GRANT-G5": "CAPGRANT_COMPILATION_AND_SRCIPS_FORMAT",
|
|
|
|
// K-series: Mixed patterns
|
|
"GRANT-K3": "CAPGRANT_COMPILATION_AND_SRCIPS_FORMAT",
|
|
"GRANT-K5": "CAPGRANT_COMPILATION_AND_SRCIPS_FORMAT",
|
|
"GRANT-K28": "CAPGRANT_COMPILATION_AND_SRCIPS_FORMAT",
|
|
|
|
// V-series: Mixed ip+app on specific tags
|
|
"GRANT-V09": "CAPGRANT_COMPILATION_AND_SRCIPS_FORMAT: mixed ip+app on tag:exit — headscale rejects, Tailscale produces DstPorts + CapGrant",
|
|
"GRANT-V10": "CAPGRANT_COMPILATION_AND_SRCIPS_FORMAT: mixed ip+app on tag:router — headscale rejects, Tailscale produces DstPorts + CapGrant",
|
|
|
|
// ========================================================================
|
|
// VIA_COMPILATION (3 tests)
|
|
//
|
|
// TODO: Implement via route compilation in filter rules.
|
|
//
|
|
// Via routes with specific (non-wildcard) sources produce DstPorts rules
|
|
// with correctly restricted SrcIPs. These tests have no SrcIPs format
|
|
// issue because they use specific src identities (tags, groups, members).
|
|
// ========================================================================
|
|
"GRANT-V11": "VIA_COMPILATION: via tag:router + src:tag:client — SrcIPs = client IPs only",
|
|
"GRANT-V12": "VIA_COMPILATION: via tag:router + src:autogroup:member — SrcIPs = member IPs",
|
|
"GRANT-V13": "VIA_COMPILATION: via tag:router + src:group:developers + tcp:80,443 — group SrcIPs + specific ports",
|
|
|
|
// ========================================================================
|
|
// VIA_COMPILATION_AND_SRCIPS_FORMAT (7 tests)
|
|
//
|
|
// TODO: Implement via route compilation in filter rules.
|
|
//
|
|
// Via routes ("via" field in grants) specify that traffic to a destination
|
|
// CIDR should be routed through a specific tagged subnet router. The via
|
|
// field is currently parsed and validated but NOT compiled into FilterRules.
|
|
//
|
|
// These tests also have SrcIPs format differences (wildcard src expands
|
|
// to split CGNAT ranges).
|
|
// ========================================================================
|
|
"GRANT-I1": "VIA_COMPILATION_AND_SRCIPS_FORMAT",
|
|
"GRANT-I2": "VIA_COMPILATION_AND_SRCIPS_FORMAT",
|
|
"GRANT-I3": "VIA_COMPILATION_AND_SRCIPS_FORMAT",
|
|
"GRANT-K13": "VIA_COMPILATION_AND_SRCIPS_FORMAT",
|
|
"GRANT-V17": "VIA_COMPILATION_AND_SRCIPS_FORMAT: via tag:router + multi-dst — unadvertised subnets silently dropped",
|
|
"GRANT-V21": "VIA_COMPILATION_AND_SRCIPS_FORMAT: via [tag:router, tag:exit] — only advertising nodes get rules",
|
|
"GRANT-V23": "VIA_COMPILATION_AND_SRCIPS_FORMAT: via tag:router + tcp:22,80,443 — via + multiple ports",
|
|
|
|
// ========================================================================
|
|
// AUTOGROUP_DANGER_ALL (3 tests)
|
|
//
|
|
// TODO: Implement autogroup:danger-all support.
|
|
//
|
|
// autogroup:danger-all matches ALL IPs including non-Tailscale addresses.
|
|
// When used as a source, it should expand to 0.0.0.0/0 and ::/0.
|
|
// When used as a destination, Tailscale rejects it with an error.
|
|
//
|
|
// GRANT-K6: autogroup:danger-all as src (success test, produces rules)
|
|
// GRANT-K7: autogroup:danger-all as dst (error: "cannot use autogroup:danger-all as a dst")
|
|
// GRANT-K8: autogroup:danger-all as both src and dst (error: same message)
|
|
// ========================================================================
|
|
"GRANT-K6": "AUTOGROUP_DANGER_ALL",
|
|
"GRANT-K7": "AUTOGROUP_DANGER_ALL",
|
|
"GRANT-K8": "AUTOGROUP_DANGER_ALL",
|
|
|
|
// ========================================================================
|
|
// ERROR_VALIDATION_GAP (23 tests)
|
|
//
|
|
// TODO: Implement grant validation rules that Tailscale enforces but
|
|
// headscale does not yet.
|
|
//
|
|
// These are policies that Tailscale rejects (api_response_code=400) but
|
|
// headscale currently accepts without error. Each test documents the
|
|
// specific validation that needs to be added.
|
|
// ========================================================================
|
|
|
|
// Capability name format validation:
|
|
// Tailscale requires cap names to be {domain}/{path} without https:// prefix
|
|
// and rejects caps in the tailscale.com domain.
|
|
"GRANT-A2": "ERROR_VALIDATION_GAP: capability name must have the form {domain}/{path} — headscale should reject https:// prefix in cap names",
|
|
"GRANT-A5": "ERROR_VALIDATION_GAP: capability name must not be in the tailscale.com domain — headscale should reject tailscale.com/cap/relay-target",
|
|
"GRANT-K9": "ERROR_VALIDATION_GAP: capability name must not be in the tailscale.com domain — headscale should reject tailscale.com/cap/ingress",
|
|
"GRANT-K10": "ERROR_VALIDATION_GAP: capability name must not be in the tailscale.com domain — headscale should reject tailscale.com/cap/funnel",
|
|
|
|
// autogroup:self validation:
|
|
// Tailscale only allows autogroup:self as dst when src is a user, group,
|
|
// or supported autogroup (like autogroup:member). It rejects autogroup:self
|
|
// when src is "*" (which includes tags) or when src is a tag.
|
|
"GRANT-E3": "ERROR_VALIDATION_GAP: autogroup:self can only be used with users, groups, or supported autogroups — src=[*] includes tags",
|
|
"GRANT-H9": "ERROR_VALIDATION_GAP: autogroup:self can only be used with users, groups, or supported autogroups — src=[*] includes tags",
|
|
"GRANT-P04_3": "ERROR_VALIDATION_GAP: autogroup:self can only be used with users, groups, or supported autogroups — src=[*] with ip grant",
|
|
"GRANT-P09_13A": "ERROR_VALIDATION_GAP: autogroup:self can only be used with users, groups, or supported autogroups — src=[*] with ip:[*]",
|
|
"GRANT-P09_13B": "ERROR_VALIDATION_GAP: autogroup:self can only be used with users, groups, or supported autogroups — src=[*] with ip:[22]",
|
|
"GRANT-P09_13C": "ERROR_VALIDATION_GAP: autogroup:self can only be used with users, groups, or supported autogroups — src=[*] with ip:[22,80,443]",
|
|
"GRANT-P09_13D": "ERROR_VALIDATION_GAP: autogroup:self can only be used with users, groups, or supported autogroups — src=[*] with ip:[80-443]",
|
|
"GRANT-P09_13H_CORRECT": "ERROR_VALIDATION_GAP: autogroup:self can only be used with users, groups, or supported autogroups — multi-grant with self",
|
|
"GRANT-P09_13H_NAIVE": "ERROR_VALIDATION_GAP: autogroup:self can only be used with users, groups, or supported autogroups — naive multi-dst with self",
|
|
|
|
// Via route validation:
|
|
// Tailscale requires "via" to be a tag, rejects other values.
|
|
"GRANT-I4": "ERROR_VALIDATION_GAP: via can only be a tag — headscale should reject non-tag via values",
|
|
|
|
// autogroup:internet + app grants validation:
|
|
// Tailscale rejects app grants when dst includes autogroup:internet.
|
|
"GRANT-V01": "ERROR_VALIDATION_GAP: cannot use app grants with autogroup:internet — headscale does not reject",
|
|
"GRANT-V22": "ERROR_VALIDATION_GAP: cannot use app grants with autogroup:internet — headscale returns different error (rejects mixed ip+app instead)",
|
|
|
|
// Raw default route CIDR validation:
|
|
// Tailscale rejects 0.0.0.0/0 and ::/0 as grant dst, requiring "*" or
|
|
// "autogroup:internet" instead. This applies with or without via.
|
|
"GRANT-V04": "ERROR_VALIDATION_GAP: dst 0.0.0.0/0 rejected — headscale should reject raw default route CIDRs in grant dst",
|
|
"GRANT-V05": "ERROR_VALIDATION_GAP: dst ::/0 rejected — headscale should reject raw default route CIDRs in grant dst",
|
|
"GRANT-V08": "ERROR_VALIDATION_GAP: dst 0.0.0.0/0 with ip grant — same rejection as V04",
|
|
"GRANT-V14": "ERROR_VALIDATION_GAP: dst 0.0.0.0/0 with via — rejected even with via field",
|
|
"GRANT-V15": "ERROR_VALIDATION_GAP: dst ::/0 with via — rejected even with via field",
|
|
"GRANT-V16": "ERROR_VALIDATION_GAP: dst [0.0.0.0/0, ::/0] with via — both rejected",
|
|
"GRANT-V18": "ERROR_VALIDATION_GAP: dst 0.0.0.0/0 with via + app — rejected regardless of via or app",
|
|
|
|
// Empty src/dst validation difference:
|
|
// Tailscale ACCEPTS empty src/dst arrays (producing no filter rules),
|
|
// but headscale rejects them with "grant sources/destinations cannot be empty".
|
|
// headscale is stricter here — should match Tailscale and accept empty arrays.
|
|
"GRANT-H4": "VALIDATION_STRICTNESS: headscale rejects empty src=[] but Tailscale accepts it (producing no rules)",
|
|
"GRANT-H5": "VALIDATION_STRICTNESS: headscale rejects empty dst=[] but Tailscale accepts it (producing no rules)",
|
|
|
|
// ========================================================================
|
|
// NIL_VS_EMPTY_RULES (varies)
|
|
//
|
|
// TODO: headscale returns empty []FilterRule{} where Tailscale returns null.
|
|
//
|
|
// Some success tests have null packet_filter_rules for online nodes,
|
|
// meaning Tailscale determined no rules apply. headscale may still produce
|
|
// empty-but-non-nil results due to how filter compilation works.
|
|
// These are handled by cmpopts.EquateEmpty() in the comparison, so they
|
|
// should no longer fail. If they still fail, the specific test needs
|
|
// investigation.
|
|
// ========================================================================
|
|
}
|
|
|
|
// TestGrantsCompat is a data-driven test that loads all 237 GRANT-*.json
|
|
// test files captured from Tailscale SaaS and compares headscale's grants
|
|
// engine output against the real Tailscale behavior.
|
|
//
|
|
// Each JSON file contains:
|
|
// - A full policy (groups, tagOwners, hosts, autoApprovers, grants, optionally acls)
|
|
// - For success cases: expected packet_filter_rules per node (8 nodes)
|
|
// - For error cases: expected error message
|
|
//
|
|
// The test converts Tailscale user email formats (@passkey, @dalby.cc) to
|
|
// headscale format (@example.com) and runs the policy through unmarshalPolicy,
|
|
// validate, compileFilterRulesForNode, and ReduceFilterRules.
|
|
//
|
|
// Skip category impact summary (highest first):
|
|
//
|
|
// CAPGRANT_COMPILATION - 49 tests: Implement app->CapGrant FilterRule compilation
|
|
// ERROR_VALIDATION_GAP - 23 tests: Implement missing grant validation rules
|
|
// MISSING_IPV6_ADDRS - 90 tests: Include IPv6 for identity-based alias resolution
|
|
// CAPGRANT_COMPILATION_AND_SRCIPS - 11 tests: Both CapGrant compilation + SrcIPs format
|
|
// SUBNET_ROUTE_FILTER_RULES - 10 tests: Generate filter rules for subnet-routed CIDRs
|
|
// VIA_COMPILATION_AND_SRCIPS_FORMAT - 7 tests: Via route compilation + SrcIPs format
|
|
// AUTOGROUP_SELF_CIDR_FORMAT - 4 tests: DstPorts IPs get /32 or /128 suffix for autogroup:self
|
|
// VIA_COMPILATION - 3 tests: Via route compilation
|
|
// AUTOGROUP_DANGER_ALL - 3 tests: Implement autogroup:danger-all support
|
|
// USER_PASSKEY_WILDCARD - 2 tests: user:*@passkey wildcard pattern unresolvable
|
|
// VALIDATION_STRICTNESS - 2 tests: headscale too strict (rejects what Tailscale accepts)
|
|
// RAW_IPV6_ADDR_EXPANSION - 2 tests: Raw fd7a: IPv6 src/dst expanded to include IPv4
|
|
// SRCIPS_WILDCARD_NODE_DEDUP - 1 test: Wildcard+specific source node IP deduplication
|
|
//
|
|
// Total: 207 tests skipped, 30 tests expected to pass.
|
|
func TestGrantsCompat(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
files, err := filepath.Glob(filepath.Join("testdata", "grant_results", "GRANT-*.json"))
|
|
require.NoError(t, err, "failed to glob test files")
|
|
require.NotEmpty(t, files, "no GRANT-*.json test files found in testdata/grant_results/")
|
|
|
|
t.Logf("Loaded %d grant test files", len(files))
|
|
|
|
users := setupGrantsCompatUsers()
|
|
nodes := setupGrantsCompatNodes(users)
|
|
|
|
for _, file := range files {
|
|
tf := loadGrantTestFile(t, file)
|
|
|
|
t.Run(tf.TestID, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Check if this test is in the skip list
|
|
if reason, ok := grantSkipReasons[tf.TestID]; ok {
|
|
t.Skipf("TODO: %s — see grantSkipReasons comments for details", reason)
|
|
return
|
|
}
|
|
|
|
// Convert Tailscale user emails to headscale @example.com format
|
|
policyJSON := convertPolicyUserEmails(tf.Input.FullPolicy)
|
|
|
|
if tf.Input.APIResponseCode == 400 || tf.Error {
|
|
testGrantError(t, policyJSON, tf)
|
|
return
|
|
}
|
|
|
|
testGrantSuccess(t, policyJSON, tf, users, nodes)
|
|
})
|
|
}
|
|
}
|
|
|
|
// testGrantError verifies that an invalid policy produces the expected error.
|
|
func testGrantError(t *testing.T, policyJSON []byte, tf grantTestFile) {
|
|
t.Helper()
|
|
|
|
wantMsg := ""
|
|
if tf.Input.APIResponseBody != nil {
|
|
wantMsg = tf.Input.APIResponseBody.Message
|
|
}
|
|
|
|
pol, err := unmarshalPolicy(policyJSON)
|
|
if err != nil {
|
|
// Parse-time error
|
|
if wantMsg != "" {
|
|
assertGrantErrorContains(t, err, wantMsg, tf.TestID)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
err = pol.validate()
|
|
if err != nil {
|
|
// Validation error
|
|
if wantMsg != "" {
|
|
assertGrantErrorContains(t, err, wantMsg, tf.TestID)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
t.Errorf("%s: expected error (api_response_code=400) but policy parsed and validated successfully; want message: %q",
|
|
tf.TestID, wantMsg)
|
|
}
|
|
|
|
// grantErrorMessageMap maps Tailscale error messages to their headscale equivalents
|
|
// where the wording differs but the meaning is the same.
|
|
var grantErrorMessageMap = map[string]string{
|
|
// Tailscale says "ip and app can not both be empty",
|
|
// headscale says "grants must specify either 'ip' or 'app' field"
|
|
"ip and app can not both be empty": "grants must specify either",
|
|
}
|
|
|
|
// assertGrantErrorContains checks that an error message contains the expected
|
|
// Tailscale error message (or its headscale equivalent).
|
|
func assertGrantErrorContains(t *testing.T, err error, wantMsg string, testID string) {
|
|
t.Helper()
|
|
|
|
errStr := err.Error()
|
|
|
|
// First try direct substring match
|
|
if strings.Contains(errStr, wantMsg) {
|
|
return
|
|
}
|
|
|
|
// Try mapped equivalent
|
|
if mapped, ok := grantErrorMessageMap[wantMsg]; ok {
|
|
if strings.Contains(errStr, mapped) {
|
|
return
|
|
}
|
|
}
|
|
|
|
// Try matching key parts of the error message
|
|
// Extract the most distinctive part of the Tailscale message
|
|
keyParts := extractErrorKeyParts(wantMsg)
|
|
for _, part := range keyParts {
|
|
if strings.Contains(errStr, part) {
|
|
return
|
|
}
|
|
}
|
|
|
|
t.Errorf("%s: error message mismatch\n tailscale wants: %q\n headscale got: %q",
|
|
testID, wantMsg, errStr)
|
|
}
|
|
|
|
// extractErrorKeyParts extracts distinctive substrings from an error message
|
|
// that should appear in any equivalent error message.
|
|
func extractErrorKeyParts(msg string) []string {
|
|
var parts []string
|
|
|
|
// Common patterns to extract
|
|
if strings.Contains(msg, "tag:") {
|
|
// Extract tag references like tag:nonexistent
|
|
for word := range strings.FieldsSeq(msg) {
|
|
word = strings.Trim(word, `"'`)
|
|
if strings.HasPrefix(word, "tag:") {
|
|
parts = append(parts, word)
|
|
}
|
|
}
|
|
}
|
|
|
|
if strings.Contains(msg, "autogroup:") {
|
|
for word := range strings.FieldsSeq(msg) {
|
|
word = strings.Trim(word, `"'`)
|
|
if strings.HasPrefix(word, "autogroup:") {
|
|
parts = append(parts, word)
|
|
}
|
|
}
|
|
}
|
|
|
|
if strings.Contains(msg, "capability name") {
|
|
parts = append(parts, "capability")
|
|
}
|
|
|
|
if strings.Contains(msg, "port range") {
|
|
parts = append(parts, "port")
|
|
}
|
|
|
|
return parts
|
|
}
|
|
|
|
// testGrantSuccess verifies that a valid policy produces the expected
|
|
// packet filter rules for each node.
|
|
func testGrantSuccess(
|
|
t *testing.T,
|
|
policyJSON []byte,
|
|
tf grantTestFile,
|
|
users types.Users,
|
|
nodes types.Nodes,
|
|
) {
|
|
t.Helper()
|
|
|
|
pol, err := unmarshalPolicy(policyJSON)
|
|
require.NoError(t, err, "%s: policy should parse successfully", tf.TestID)
|
|
|
|
err = pol.validate()
|
|
require.NoError(t, err, "%s: policy should validate successfully", tf.TestID)
|
|
|
|
for nodeName, capture := range tf.Captures {
|
|
t.Run(nodeName, func(t *testing.T) {
|
|
// Check if this node was offline during capture.
|
|
// tagged-prod was frequently offline (132 of 188 success tests).
|
|
// When offline, packet_filter_rules is null and topology shows
|
|
// hostname="unknown" with empty tags.
|
|
captureIsNull := len(capture.PacketFilterRules) == 0 ||
|
|
string(capture.PacketFilterRules) == "null"
|
|
|
|
if captureIsNull {
|
|
topoNode, exists := tf.Topology.Nodes[nodeName]
|
|
if exists && (topoNode.Hostname == "unknown" || topoNode.Hostname == "") {
|
|
t.Skipf(
|
|
"node %s was offline during Tailscale capture (hostname=%q)",
|
|
nodeName,
|
|
topoNode.Hostname,
|
|
)
|
|
|
|
return
|
|
}
|
|
// Node was online but has null/empty rules — means Tailscale
|
|
// produced no rules. headscale should also produce no rules.
|
|
}
|
|
|
|
node := findGrantsNode(nodes, nodeName)
|
|
if node == nil {
|
|
t.Skipf(
|
|
"node %s not found in test setup (may be a test-specific node)",
|
|
nodeName,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
// Compile headscale filter rules for this node
|
|
gotRules, err := pol.compileFilterRulesForNode(
|
|
users,
|
|
node.View(),
|
|
nodes.ViewSlice(),
|
|
)
|
|
require.NoError(
|
|
t,
|
|
err,
|
|
"%s/%s: failed to compile filter rules",
|
|
tf.TestID,
|
|
nodeName,
|
|
)
|
|
|
|
gotRules = policyutil.ReduceFilterRules(node.View(), gotRules)
|
|
|
|
// Unmarshal Tailscale expected rules from JSON capture
|
|
var wantRules []tailcfg.FilterRule
|
|
if !captureIsNull {
|
|
err = json.Unmarshal(
|
|
[]byte(capture.PacketFilterRules),
|
|
&wantRules,
|
|
)
|
|
require.NoError(
|
|
t,
|
|
err,
|
|
"%s/%s: failed to unmarshal expected rules from JSON",
|
|
tf.TestID,
|
|
nodeName,
|
|
)
|
|
}
|
|
|
|
// Compare headscale output against Tailscale expected output.
|
|
// The diff labels show (-tailscale +headscale) to make clear
|
|
// which side produced which output.
|
|
// EquateEmpty treats nil and empty slices as equal since
|
|
// Tailscale's JSON null -> nil, headscale may return empty slice.
|
|
opts := append(cmpOptions(), cmpopts.EquateEmpty())
|
|
if diff := cmp.Diff(wantRules, gotRules, opts...); diff != "" {
|
|
t.Errorf(
|
|
"%s/%s: filter rules mismatch (-tailscale +headscale):\n%s",
|
|
tf.TestID,
|
|
nodeName,
|
|
diff,
|
|
)
|
|
}
|
|
})
|
|
}
|
|
}
|