mirror of
https://github.com/juanfont/headscale.git
synced 2026-04-21 00:01:32 +02:00
hscontrol/policy/v2: add data-driven grants compatibility test
Add TestGrantsCompat, a data-driven test that validates headscale's grants implementation against 212 test cases captured from Tailscale SaaS. Each test case loads a GRANT-*.json file from testdata/, applies the policy through headscale's engine, and compares the resulting packet filter rules against Tailscale's actual output. Currently 19 tests pass and 193 are skipped with documented reasons: - SRCIPS_FORMAT (125): IP range formatting differences - CAPGRANT_COMPILATION (41): app capability grants not yet compiled - ERROR_VALIDATION_GAP (14): validation strictness differences - CAPGRANT_AND_SRCIPS_FORMAT (9): combined ip+app grant issues - VIA_AND_SRCIPS_FORMAT (4): via route compilation not implemented - AUTOGROUP_DANGER_ALL (3): autogroup:danger-all not supported - VALIDATION_STRICTNESS (2): empty src/dst array handling Updates #2180
This commit is contained in:
848
hscontrol/policy/v2/tailscale_grants_compat_test.go
Normal file
848
hscontrol/policy/v2/tailscale_grants_compat_test.go
Normal file
@@ -0,0 +1,848 @@
|
|||||||
|
// This file is "generated" by Claude.
|
||||||
|
// It contains a data-driven test that reads 212 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):
|
||||||
|
//
|
||||||
|
// SRCIPS_FORMAT - 125 tests: Fix SrcIPs to use CGNAT split ranges
|
||||||
|
// CAPGRANT_COMPILATION - 41 tests: Implement app->CapGrant FilterRule compilation
|
||||||
|
// ERROR_VALIDATION_GAP - 14 tests: Implement missing grant validation rules
|
||||||
|
// CAPGRANT_AND_SRCIPS_FORMAT - 9 tests: Both CapGrant compilation + SrcIPs format
|
||||||
|
// VIA_AND_SRCIPS_FORMAT - 4 tests: Via route compilation + SrcIPs format
|
||||||
|
// AUTOGROUP_DANGER_ALL - 3 tests: Implement autogroup:danger-all support
|
||||||
|
// VALIDATION_STRICTNESS - 2 tests: headscale too strict (rejects what Tailscale accepts)
|
||||||
|
//
|
||||||
|
// Total: 193 tests skipped, 19 tests expected to pass.
|
||||||
|
var grantSkipReasons = map[string]string{
|
||||||
|
// ========================================================================
|
||||||
|
// SRCIPS_FORMAT (125 tests)
|
||||||
|
//
|
||||||
|
// TODO: Implement CGNAT split range generation for SrcIPs.
|
||||||
|
//
|
||||||
|
// headscale currently generates ["100.64.0.0/10", "fd7a:115c:a1e0::/48"]
|
||||||
|
// for wildcard source matches. Tailscale generates split CGNAT ranges that
|
||||||
|
// exclude the ChromeOS VM range 100.115.92.0/23, and includes advertised
|
||||||
|
// subnet routes (e.g., "10.33.0.0/16") in the SrcIPs list.
|
||||||
|
//
|
||||||
|
// Additionally, headscale uses CIDR notation for host IPs in DstPorts
|
||||||
|
// (e.g., "100.108.74.26/32") while Tailscale uses bare IPs
|
||||||
|
// (e.g., "100.108.74.26").
|
||||||
|
//
|
||||||
|
// Fixing SrcIPs generation and DstPorts IP format would resolve all 125
|
||||||
|
// tests in this category.
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
// J-series: Protocol-specific IP grants
|
||||||
|
"GRANT-J1": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-J2": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-J3": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-J4": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-J5": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-J6": "SRCIPS_FORMAT",
|
||||||
|
|
||||||
|
// K-series: Various IP grant patterns
|
||||||
|
"GRANT-K1": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-K2": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-K4": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-K14": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-K15": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-K16": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-K17": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-K20": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-K21": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-K22": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-K26": "SRCIPS_FORMAT",
|
||||||
|
|
||||||
|
// P01-series: Wildcard and basic IP grants
|
||||||
|
"GRANT-P01_1": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P01_2": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P01_3": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P01_4": "SRCIPS_FORMAT",
|
||||||
|
|
||||||
|
// P02-series: Source targeting (user, group, tag)
|
||||||
|
"GRANT-P02_1": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P02_2": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P02_3": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P02_4": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P02_5_CORRECT": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P02_5_NAIVE": "SRCIPS_FORMAT",
|
||||||
|
|
||||||
|
// P03-series: Destination targeting
|
||||||
|
"GRANT-P03_1": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P03_2": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P03_3": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P03_4": "SRCIPS_FORMAT",
|
||||||
|
|
||||||
|
// P04-series: autogroup:member grants
|
||||||
|
"GRANT-P04_1": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P04_2": "SRCIPS_FORMAT",
|
||||||
|
|
||||||
|
// P05-series: Tag-to-tag grants
|
||||||
|
"GRANT-P05_1": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P05_2": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P05_3": "SRCIPS_FORMAT",
|
||||||
|
|
||||||
|
// P06-series: IP protocol grants
|
||||||
|
"GRANT-P06_1": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P06_2": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P06_3": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P06_4": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P06_5": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P06_6": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P06_7": "SRCIPS_FORMAT",
|
||||||
|
|
||||||
|
// P08-series: Multiple grants / rule merging
|
||||||
|
"GRANT-P08_1": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P08_2": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P08_4": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P08_5": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P08_6": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P08_7": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P08_8": "SRCIPS_FORMAT",
|
||||||
|
|
||||||
|
// P09-series: ACL-to-grant conversion equivalence tests
|
||||||
|
"GRANT-P09_1A": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_1B": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_1C": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_1D": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_1E": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_2A_CORRECT": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_2A_NAIVE": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_2B_CORRECT": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_2B_NAIVE": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_2C": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_3A": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_3B": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_3C": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_4A": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_4B": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_4C": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_4D": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_4E": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_4F": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_4G": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_5A": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_5B": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_5C_NAIVE": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_6A": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_6C": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_6D": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_7A": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_7B_NAIVE": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_7C": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_7D_NAIVE": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_8A": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_8B": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_8C": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_9A": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_9B": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_9C": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_10A": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_10B": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_10C": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_10D": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_11A": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_11B": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_11C_NAIVE": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_11D": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_12A": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_12B": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_13E": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_13F": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_13G": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_14A": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_14B": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_14C": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_14D": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_14E": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_14F": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_14G": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_14H": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P09_14I": "SRCIPS_FORMAT",
|
||||||
|
|
||||||
|
// P10-series: Host alias grants
|
||||||
|
"GRANT-P10_1": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P10_2": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P10_3": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P10_4": "SRCIPS_FORMAT",
|
||||||
|
|
||||||
|
// P11-series: autogroup:tagged grants
|
||||||
|
"GRANT-P11_1": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P11_2": "SRCIPS_FORMAT",
|
||||||
|
|
||||||
|
// P13-series: CIDR destination grants
|
||||||
|
"GRANT-P13_1": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P13_2": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P13_3": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P13_4": "SRCIPS_FORMAT",
|
||||||
|
|
||||||
|
// P15-series: Empty/no-match grants
|
||||||
|
"GRANT-P15_1": "SRCIPS_FORMAT",
|
||||||
|
"GRANT-P15_3": "SRCIPS_FORMAT",
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// CAPGRANT_COMPILATION (41 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",
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// CAPGRANT_COMPILATION_AND_SRCIPS_FORMAT (9 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").
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
// 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",
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// VIA_COMPILATION_AND_SRCIPS_FORMAT (4 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.
|
||||||
|
// ========================================================================
|
||||||
|
"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",
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 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 (12 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",
|
||||||
|
|
||||||
|
// 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 212 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):
|
||||||
|
//
|
||||||
|
// SRCIPS_FORMAT - 125 tests: Fix SrcIPs to use CGNAT split ranges
|
||||||
|
// CAPGRANT_COMPILATION - 41 tests: Implement app->CapGrant FilterRule compilation
|
||||||
|
// ERROR_VALIDATION_GAP - 14 tests: Implement missing grant validation rules
|
||||||
|
// CAPGRANT_AND_SRCIPS_FORMAT - 9 tests: Both CapGrant compilation + SrcIPs format
|
||||||
|
// VIA_AND_SRCIPS_FORMAT - 4 tests: Via route compilation + SrcIPs format
|
||||||
|
// AUTOGROUP_DANGER_ALL - 3 tests: Implement autogroup:danger-all support
|
||||||
|
// VALIDATION_STRICTNESS - 2 tests: headscale too strict (rejects what Tailscale accepts)
|
||||||
|
//
|
||||||
|
// Total: 193 tests skipped, 19 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user