mirror of
https://github.com/juanfont/headscale
synced 2026-04-25 17:15:33 +02:00
testcapture: add typed capture format package
Typed Capture/Input/Node/Topology structs for golden SaaS captures. Schema drift between the tscap capture tool and headscale now becomes a compile error instead of a silent test pass. Updates #3157
This commit is contained in:
114
hscontrol/types/testcapture/header.go
Normal file
114
hscontrol/types/testcapture/header.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package testcapture
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CommentHeader returns the // comment header that gets prepended to
|
||||
// a Capture file when it is written. The header is purely
|
||||
// informational; consumers ignore it. Format:
|
||||
//
|
||||
// <TestID>
|
||||
//
|
||||
// <Description, possibly multi-line>
|
||||
//
|
||||
// Nodes with filter rules: <X> of <Y> ← for non-SSH corpora
|
||||
// Nodes with SSH rules: <X> of <Y> ← for SSH corpora
|
||||
// Captured at: <RFC3339 UTC>
|
||||
// tscap version: <ToolVersion>
|
||||
// schema version: <SchemaVersion>
|
||||
//
|
||||
// Both `tool_version` and `schema_version` are also stored as
|
||||
// first-class JSON fields on the Capture struct; the comment lines
|
||||
// exist purely so the values are visible at a glance without
|
||||
// parsing the file.
|
||||
//
|
||||
// The leading "// " on every line is added by the hujson writer.
|
||||
func CommentHeader(c *Capture) string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(c.TestID)
|
||||
b.WriteByte('\n')
|
||||
|
||||
if c.Description != "" {
|
||||
b.WriteByte('\n')
|
||||
b.WriteString(c.Description)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
|
||||
stats := captureStats(c)
|
||||
if stats != "" {
|
||||
b.WriteByte('\n')
|
||||
b.WriteString(stats)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
|
||||
if !c.CapturedAt.IsZero() {
|
||||
fmt.Fprintf(&b, "Captured at: %s\n", c.CapturedAt.UTC().Format("2006-01-02T15:04:05Z"))
|
||||
}
|
||||
|
||||
if c.ToolVersion != "" {
|
||||
fmt.Fprintf(&b, "tscap version: %s\n", c.ToolVersion)
|
||||
}
|
||||
|
||||
if c.SchemaVersion != 0 {
|
||||
fmt.Fprintf(&b, "schema version: %d\n", c.SchemaVersion)
|
||||
}
|
||||
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
// captureStats returns a one-line summary of how many nodes had
|
||||
// non-empty captured data, or the empty string if there are no
|
||||
// captures at all.
|
||||
//
|
||||
// The phrasing depends on which fields the corpus uses:
|
||||
// - SSH corpora populate SSHRules
|
||||
// - other corpora populate PacketFilterRules
|
||||
//
|
||||
// If both fields appear (mixed/unusual), filter rules wins.
|
||||
func captureStats(c *Capture) string {
|
||||
if len(c.Captures) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var (
|
||||
total = len(c.Captures)
|
||||
filterRules int
|
||||
sshRules int
|
||||
filterRulesSet bool
|
||||
sshRulesSet bool
|
||||
)
|
||||
|
||||
for _, n := range c.Captures {
|
||||
if n.PacketFilterRules != nil {
|
||||
filterRulesSet = true
|
||||
|
||||
if len(n.PacketFilterRules) > 0 {
|
||||
filterRules++
|
||||
}
|
||||
}
|
||||
|
||||
if n.SSHRules != nil {
|
||||
sshRulesSet = true
|
||||
|
||||
if len(n.SSHRules) > 0 {
|
||||
sshRules++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case filterRulesSet:
|
||||
return fmt.Sprintf("Nodes with filter rules: %d of %d", filterRules, total)
|
||||
case sshRulesSet:
|
||||
return fmt.Sprintf("Nodes with SSH rules: %d of %d", sshRules, total)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
62
hscontrol/types/testcapture/read.go
Normal file
62
hscontrol/types/testcapture/read.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package testcapture
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/tailscale/hujson"
|
||||
)
|
||||
|
||||
// ErrUnsupportedSchemaVersion is returned by Read when a capture
|
||||
// advertises a SchemaVersion newer than the current binary supports.
|
||||
var ErrUnsupportedSchemaVersion = errors.New("testcapture: unsupported schema version")
|
||||
|
||||
// Read parses a HuJSON capture file from disk into a Capture.
|
||||
//
|
||||
// Comments and trailing commas in the file are stripped before
|
||||
// unmarshaling. Files advertising a SchemaVersion newer than the
|
||||
// current binary's are rejected with ErrUnsupportedSchemaVersion;
|
||||
// SchemaVersion == 0 (pre-versioning) is accepted for backwards compat.
|
||||
// The returned Capture's CapturedAt is the value recorded in the file
|
||||
// (not "now").
|
||||
func Read(path string) (*Capture, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("testcapture: read %s: %w", path, err)
|
||||
}
|
||||
|
||||
var c Capture
|
||||
|
||||
err = unmarshalHuJSON(data, &c)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("testcapture: %s: %w", path, err)
|
||||
}
|
||||
|
||||
if c.SchemaVersion > SchemaVersion {
|
||||
return nil, fmt.Errorf("%w: %s has version %d, binary supports %d",
|
||||
ErrUnsupportedSchemaVersion, path, c.SchemaVersion, SchemaVersion)
|
||||
}
|
||||
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// unmarshalHuJSON parses HuJSON bytes (JSON with comments / trailing
|
||||
// commas) into v. Comments are stripped via hujson.Standardize before
|
||||
// json.Unmarshal is called.
|
||||
func unmarshalHuJSON(data []byte, v any) error {
|
||||
ast, err := hujson.Parse(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hujson parse: %w", err)
|
||||
}
|
||||
|
||||
ast.Standardize()
|
||||
|
||||
err = json.Unmarshal(ast.Pack(), v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("json unmarshal: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
325
hscontrol/types/testcapture/testcapture.go
Normal file
325
hscontrol/types/testcapture/testcapture.go
Normal file
@@ -0,0 +1,325 @@
|
||||
// Package testcapture defines the on-disk format used by Headscale's
|
||||
// policy v2 compatibility tests for golden data captured from
|
||||
// Tailscale SaaS by the tscap tool.
|
||||
//
|
||||
// Files are HuJSON. Wire-format Tailscale data (filter rules, netmap,
|
||||
// whois, SSH rules) is stored as proper tailcfg/netmap/filtertype/
|
||||
// apitype values rather than json.RawMessage so that schema drift
|
||||
// between tscap and headscale becomes a compile error rather than a
|
||||
// silent test failure, and so that consumers don't have to repeat
|
||||
// json.Unmarshal at every read site. Storing data as json.RawMessage
|
||||
// previously hid a serious capture-pipeline bug (the IPN bus initial
|
||||
// notification returns a stale Peers slice — see the comment on
|
||||
// Node.Netmap below) for months.
|
||||
//
|
||||
// All four corpora (acl, routes, grant, ssh) use the same Capture
|
||||
// shape. SSH scenarios populate Captures[name].SSHRules; the others
|
||||
// populate Captures[name].PacketFilterRules + Captures[name].Netmap.
|
||||
package testcapture
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/netmap"
|
||||
"tailscale.com/wgengine/filter/filtertype"
|
||||
)
|
||||
|
||||
// SchemaVersion identifies the on-disk format. Bumped on breaking changes.
|
||||
//
|
||||
// Files written before SchemaVersion existed do not have this field; new
|
||||
// captures from tscap always set it to the current value.
|
||||
const SchemaVersion = 1
|
||||
|
||||
// Capture is one captured run of one scenario.
|
||||
//
|
||||
// All four corpora (acl, routes, grant, ssh) use this same shape.
|
||||
// SSH scenarios populate Captures[name].SSHRules; the others populate
|
||||
// Captures[name].PacketFilterRules + Captures[name].Netmap.
|
||||
type Capture struct {
|
||||
// SchemaVersion identifies the on-disk format version. Always set
|
||||
// to testcapture.SchemaVersion when written by tscap.
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
|
||||
// TestID is the stable identifier of the scenario, derived from
|
||||
// its filename. Used as the test name in Go tests.
|
||||
TestID string `json:"test_id"`
|
||||
|
||||
// Description is free-form text copied from the scenario file.
|
||||
// Rendered in the comment header at the top of the file.
|
||||
Description string `json:"description,omitempty"`
|
||||
|
||||
// Category is an optional grouping label (e.g. "routes",
|
||||
// "grant", "ssh").
|
||||
Category string `json:"category,omitempty"`
|
||||
|
||||
// CapturedAt is the UTC timestamp of when the capture was taken.
|
||||
CapturedAt time.Time `json:"captured_at"`
|
||||
|
||||
// ToolVersion identifies the binary that produced the file.
|
||||
ToolVersion string `json:"tool_version"`
|
||||
|
||||
// Tailnet is the name of the SaaS tailnet the capture was taken
|
||||
// against (e.g. "kratail2tid@passkey").
|
||||
Tailnet string `json:"tailnet"`
|
||||
|
||||
// Error is true when the SaaS API rejected the policy or when
|
||||
// capture itself failed. In the rejection case, Captures reflects
|
||||
// the pre-push baseline (deny-all default) and Input.APIResponseBody
|
||||
// is populated.
|
||||
Error bool `json:"error,omitempty"`
|
||||
|
||||
// CaptureError is set when the capture itself failed (timeout,
|
||||
// missing data, etc.). The partially-captured Captures map is
|
||||
// still included for post-mortem. Distinct from
|
||||
// Input.APIResponseBody which describes a SaaS API rejection.
|
||||
CaptureError string `json:"capture_error,omitempty"`
|
||||
|
||||
// Input is everything that was sent to the tailnet to produce
|
||||
// the captured state.
|
||||
Input Input `json:"input"`
|
||||
|
||||
// Topology is the users and nodes present in the tailnet at
|
||||
// capture time. Always populated by tscap.
|
||||
Topology Topology `json:"topology"`
|
||||
|
||||
// Captures holds the per-node captured data, keyed by node
|
||||
// GivenName.
|
||||
Captures map[string]Node `json:"captures"`
|
||||
}
|
||||
|
||||
// Input describes everything that was sent to the tailnet to produce
|
||||
// the captured state.
|
||||
//
|
||||
// Input has a custom UnmarshalJSON to accept both the new on-disk
|
||||
// shape (where full_policy is a JSON-encoded string) and the legacy
|
||||
// shape (where full_policy is a JSON object). The legacy shape is
|
||||
// re-marshaled to a string at load time so consumers see the typed
|
||||
// field uniformly.
|
||||
type Input struct {
|
||||
// FullPolicy is the verbatim policy that was POSTed to the SaaS
|
||||
// API. Stored as a string because it is opaque JSON that round-
|
||||
// trips losslessly without parsing — headscale's policy parser
|
||||
// reads it on demand.
|
||||
FullPolicy string `json:"full_policy"`
|
||||
|
||||
// APIResponseCode is the HTTP status code of the policy POST.
|
||||
APIResponseCode int `json:"api_response_code"`
|
||||
|
||||
// APIResponseBody is only populated when APIResponseCode != 200.
|
||||
APIResponseBody *APIResponseBody `json:"api_response_body,omitempty"`
|
||||
|
||||
// Tailnet describes the tailnet-wide settings tscap applied
|
||||
// before pushing the policy.
|
||||
Tailnet TailnetInput `json:"tailnet"`
|
||||
|
||||
// ScenarioHuJSON is the verbatim contents of the scenario file
|
||||
// (HuJSON). Reading this back is enough to re-run the exact
|
||||
// same scenario.
|
||||
ScenarioHuJSON string `json:"scenario_hujson"`
|
||||
|
||||
// ScenarioPath is the path the scenario was loaded from,
|
||||
// relative to the captures directory. Informational only.
|
||||
ScenarioPath string `json:"scenario_path,omitempty"`
|
||||
}
|
||||
|
||||
// MarshalJSON writes FullPolicy as a raw JSON object rather than a
|
||||
// double-quoted string. Consumers (including via_compat_test.go which
|
||||
// uses its own local types) expect to parse full_policy as a JSON
|
||||
// object, not a JSON string. The UnmarshalJSON below accepts both
|
||||
// forms on read so old and new captures are interchangeable.
|
||||
func (i Input) MarshalJSON() ([]byte, error) {
|
||||
type alias Input
|
||||
|
||||
raw := struct {
|
||||
alias
|
||||
|
||||
FullPolicy json.RawMessage `json:"full_policy"`
|
||||
}{
|
||||
alias: alias(i),
|
||||
}
|
||||
|
||||
if i.FullPolicy != "" {
|
||||
raw.FullPolicy = json.RawMessage(i.FullPolicy)
|
||||
}
|
||||
|
||||
return json.Marshal(raw)
|
||||
}
|
||||
|
||||
// UnmarshalJSON handles both the current on-disk shape (full_policy
|
||||
// as a JSON-encoded string) and the legacy shape (full_policy as a
|
||||
// JSON object). Legacy objects are re-marshaled into a string at
|
||||
// load time so consumers see the typed field uniformly. New captures
|
||||
// always write the object form via the custom MarshalJSON above.
|
||||
func (i *Input) UnmarshalJSON(data []byte) error {
|
||||
type alias Input
|
||||
|
||||
var raw struct {
|
||||
alias
|
||||
|
||||
FullPolicy json.RawMessage `json:"full_policy"`
|
||||
}
|
||||
|
||||
err := json.Unmarshal(data, &raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*i = Input(raw.alias)
|
||||
// raw.FullPolicy might be a JSON-encoded string ("...") or a JSON
|
||||
// object/array/null. Try string first; on failure use the raw bytes
|
||||
// verbatim, normalised to compact form.
|
||||
if len(raw.FullPolicy) == 0 || string(raw.FullPolicy) == "null" {
|
||||
i.FullPolicy = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
if raw.FullPolicy[0] == '"' {
|
||||
var s string
|
||||
|
||||
err := json.Unmarshal(raw.FullPolicy, &s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
i.FullPolicy = s
|
||||
|
||||
return nil
|
||||
}
|
||||
// Legacy (and new MarshalJSON output): full_policy is a raw JSON
|
||||
// object. Compact whitespace but preserve key ordering so the
|
||||
// round-trip is stable.
|
||||
var buf bytes.Buffer
|
||||
|
||||
err = json.Compact(&buf, raw.FullPolicy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
i.FullPolicy = buf.String()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// APIResponseBody is the (subset of) the SaaS API error response we keep.
|
||||
type APIResponseBody struct {
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// TailnetInput captures tailnet-wide settings tscap applied before
|
||||
// pushing the policy.
|
||||
type TailnetInput struct {
|
||||
DNS DNSInput `json:"dns"`
|
||||
Settings SettingsInput `json:"settings"`
|
||||
}
|
||||
|
||||
// DNSInput describes the DNS configuration applied to the tailnet.
|
||||
type DNSInput struct {
|
||||
MagicDNS bool `json:"magic_dns"`
|
||||
Nameservers []string `json:"nameservers"`
|
||||
SearchPaths []string `json:"search_paths"`
|
||||
SplitDNS map[string][]string `json:"split_dns"`
|
||||
}
|
||||
|
||||
// SettingsInput describes tailnet settings applied via the API.
|
||||
//
|
||||
// Pointer fields are nil when the scenario does not override the
|
||||
// reset default for that setting.
|
||||
type SettingsInput struct {
|
||||
DevicesApprovalOn *bool `json:"devices_approval_on,omitempty"`
|
||||
DevicesAutoUpdatesOn *bool `json:"devices_auto_updates_on,omitempty"`
|
||||
DevicesKeyDurationDays *int `json:"devices_key_duration_days,omitempty"`
|
||||
}
|
||||
|
||||
// Topology describes the users and nodes present in the tailnet at
|
||||
// capture time. Headscale's compat tests use this to construct
|
||||
// equivalent types.User and types.Node objects.
|
||||
type Topology struct {
|
||||
// Users in the tailnet. Always populated by tscap.
|
||||
Users []TopologyUser `json:"users"`
|
||||
|
||||
// Nodes in the tailnet, keyed by GivenName.
|
||||
Nodes map[string]TopologyNode `json:"nodes"`
|
||||
}
|
||||
|
||||
// TopologyUser identifies one user account in the tailnet.
|
||||
type TopologyUser struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// TopologyNode is one node in the tailnet topology.
|
||||
type TopologyNode struct {
|
||||
Hostname string `json:"hostname"`
|
||||
Tags []string `json:"tags"`
|
||||
IPv4 string `json:"ipv4"`
|
||||
IPv6 string `json:"ipv6"`
|
||||
|
||||
// User is the TopologyUser.Name for user-owned nodes. Empty for
|
||||
// tagged nodes.
|
||||
User string `json:"user,omitempty"`
|
||||
|
||||
// RoutableIPs is what the node advertised
|
||||
// (Hostinfo.RoutableIPs in its own netmap.SelfNode).
|
||||
// May include 0.0.0.0/0 + ::/0 for exit nodes.
|
||||
RoutableIPs []string `json:"routable_ips"`
|
||||
|
||||
// ApprovedRoutes is the subset of RoutableIPs the tailnet has
|
||||
// approved. Used by Headscale's NodeCanApproveRoute test.
|
||||
ApprovedRoutes []string `json:"approved_routes"`
|
||||
}
|
||||
|
||||
// Node is the captured state for one node, keyed by GivenName in
|
||||
// Capture.Captures.
|
||||
//
|
||||
// All four corpora populate the same struct. Different fields are
|
||||
// used by different test types:
|
||||
//
|
||||
// - acl, routes, grant: PacketFilterRules + PacketFilterMatches + Netmap
|
||||
// - grant (with capture_whois): + Whois
|
||||
// - ssh: SSHRules
|
||||
//
|
||||
// Whichever fields are set in the file is what the consumer reads.
|
||||
type Node struct {
|
||||
// PacketFilterRules is the wire-format filter rules as returned
|
||||
// by tailscaled localapi /debug-packet-filter-rules. The single
|
||||
// most important field for ACL/routes/grant tests.
|
||||
PacketFilterRules []tailcfg.FilterRule `json:"packet_filter_rules,omitempty"`
|
||||
|
||||
// PacketFilterMatches is the compiled filter matches (with
|
||||
// CapMatch) returned by tailscaled localapi
|
||||
// /debug-packet-filter-matches. Captured alongside
|
||||
// PacketFilterRules; useful for grant tests that want the
|
||||
// compiled form.
|
||||
PacketFilterMatches []filtertype.Match `json:"packet_filter_matches,omitempty"`
|
||||
|
||||
// Netmap is the full netmap as observed by the local tailscaled.
|
||||
// NEVER trimmed. Consumers extract whatever fields they need.
|
||||
//
|
||||
// IMPORTANT: tscap captures this by waiting for the IPN bus to
|
||||
// settle on a fresh delta-triggered notification, NOT by reading
|
||||
// the WatchIPNBus(NotifyInitialNetMap) initial notification.
|
||||
// The initial notification carries cn.NetMap() which returns
|
||||
// nb.netMap as-is — the netmap.NetworkMap whose Peers slice was
|
||||
// set at full-sync time and never re-synchronized from the
|
||||
// authoritative nb.peers map. tscap previously used the initial
|
||||
// notification and silently captured netmaps with mostly-empty
|
||||
// Peers, which corrupted every via-grant compat test against the
|
||||
// stale data. See tscap/tsdaemon/capture.go:NetMap for the
|
||||
// stability-wait pattern, and tailscale.com/ipn/ipnlocal/c2n.go
|
||||
// :handleC2NDebugNetMap which uses netMapWithPeers() for the
|
||||
// same reason.
|
||||
Netmap *netmap.NetworkMap `json:"netmap,omitempty"`
|
||||
|
||||
// Whois is per-peer whois lookups, keyed by peer IP. Captured
|
||||
// only when scenario.options.capture_whois is true.
|
||||
Whois map[string]*apitype.WhoIsResponse `json:"whois,omitempty"`
|
||||
|
||||
// SSHRules is the SSH rules slice extracted from
|
||||
// netmap.SSHPolicy.Rules. Populated only for SSH scenarios.
|
||||
SSHRules []*tailcfg.SSHRule `json:"ssh_rules,omitempty"`
|
||||
}
|
||||
484
hscontrol/types/testcapture/testcapture_test.go
Normal file
484
hscontrol/types/testcapture/testcapture_test.go
Normal file
@@ -0,0 +1,484 @@
|
||||
package testcapture_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types/testcapture"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/netmap"
|
||||
)
|
||||
|
||||
func sampleACLCapture() *testcapture.Capture {
|
||||
rules := []tailcfg.FilterRule{
|
||||
{
|
||||
SrcIPs: []string{"*"},
|
||||
DstPorts: []tailcfg.NetPortRange{
|
||||
{IP: "*", Ports: tailcfg.PortRangeAny},
|
||||
},
|
||||
},
|
||||
}
|
||||
nm := &netmap.NetworkMap{
|
||||
SelfNode: (&tailcfg.Node{Name: "user1.tail.example.com."}).View(),
|
||||
}
|
||||
|
||||
return &testcapture.Capture{
|
||||
SchemaVersion: testcapture.SchemaVersion,
|
||||
TestID: "ACL-A01",
|
||||
Description: "wildcard ACL: every node sees every other node",
|
||||
Category: "acl",
|
||||
CapturedAt: time.Date(2026, 4, 7, 12, 34, 56, 0, time.UTC),
|
||||
ToolVersion: "tscap-test-0.0.0",
|
||||
Tailnet: "kratail2tid@passkey",
|
||||
Input: testcapture.Input{
|
||||
FullPolicy: `{"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}`,
|
||||
APIResponseCode: 200,
|
||||
Tailnet: testcapture.TailnetInput{
|
||||
DNS: testcapture.DNSInput{
|
||||
MagicDNS: false,
|
||||
Nameservers: []string{},
|
||||
SearchPaths: []string{},
|
||||
SplitDNS: map[string][]string{},
|
||||
},
|
||||
},
|
||||
ScenarioHuJSON: `{"id":"acl-a01","policy":{}}`,
|
||||
ScenarioPath: "scenarios/acl/acl-a01.hujson",
|
||||
},
|
||||
Topology: testcapture.Topology{
|
||||
Users: []testcapture.TopologyUser{
|
||||
{ID: 1, Name: "kratail2tid", Email: "kratail2tid@passkey"},
|
||||
{ID: 2, Name: "kristoffer", Email: "kristoffer@dalby.cc"},
|
||||
},
|
||||
Nodes: map[string]testcapture.TopologyNode{
|
||||
"user1": {
|
||||
Hostname: "user1",
|
||||
IPv4: "100.90.199.68",
|
||||
IPv6: "fd7a:115c:a1e0::2d01:c747",
|
||||
User: "kratail2tid",
|
||||
RoutableIPs: []string{},
|
||||
ApprovedRoutes: []string{},
|
||||
},
|
||||
"tagged-server": {
|
||||
Hostname: "tagged-server",
|
||||
Tags: []string{"tag:server"},
|
||||
IPv4: "100.108.74.26",
|
||||
IPv6: "fd7a:115c:a1e0::b901:4a87",
|
||||
RoutableIPs: []string{},
|
||||
ApprovedRoutes: []string{},
|
||||
},
|
||||
},
|
||||
},
|
||||
Captures: map[string]testcapture.Node{
|
||||
"user1": {
|
||||
PacketFilterRules: rules,
|
||||
Netmap: nm,
|
||||
},
|
||||
"tagged-server": {
|
||||
PacketFilterRules: rules,
|
||||
Netmap: nm,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func sampleSSHCapture() *testcapture.Capture {
|
||||
sshRules := []*tailcfg.SSHRule{
|
||||
{
|
||||
Action: &tailcfg.SSHAction{Accept: true},
|
||||
Principals: []*tailcfg.SSHPrincipal{{NodeIP: "100.90.199.68"}},
|
||||
SSHUsers: map[string]string{"root": "root"},
|
||||
},
|
||||
}
|
||||
|
||||
return &testcapture.Capture{
|
||||
SchemaVersion: testcapture.SchemaVersion,
|
||||
TestID: "SSH-A01",
|
||||
Description: "ssh accept autogroup:member to autogroup:self",
|
||||
Category: "ssh",
|
||||
CapturedAt: time.Date(2026, 4, 7, 13, 0, 0, 0, time.UTC),
|
||||
ToolVersion: "tscap-test-0.0.0",
|
||||
Tailnet: "kratail2tid@passkey",
|
||||
Input: testcapture.Input{
|
||||
FullPolicy: `{"ssh":[{"action":"accept","src":["autogroup:member"],"dst":["autogroup:self"],"users":["root"]}]}`,
|
||||
APIResponseCode: 200,
|
||||
Tailnet: testcapture.TailnetInput{
|
||||
DNS: testcapture.DNSInput{
|
||||
Nameservers: []string{},
|
||||
SearchPaths: []string{},
|
||||
SplitDNS: map[string][]string{},
|
||||
},
|
||||
},
|
||||
ScenarioHuJSON: `{"id":"ssh-a01"}`,
|
||||
},
|
||||
Topology: testcapture.Topology{
|
||||
Users: []testcapture.TopologyUser{
|
||||
{ID: 1, Name: "kratail2tid", Email: "kratail2tid@passkey"},
|
||||
},
|
||||
Nodes: map[string]testcapture.TopologyNode{
|
||||
"user1": {
|
||||
Hostname: "user1",
|
||||
IPv4: "100.90.199.68",
|
||||
IPv6: "fd7a:115c:a1e0::2d01:c747",
|
||||
User: "kratail2tid",
|
||||
RoutableIPs: []string{},
|
||||
ApprovedRoutes: []string{},
|
||||
},
|
||||
},
|
||||
},
|
||||
Captures: map[string]testcapture.Node{
|
||||
"user1": {SSHRules: sshRules},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// equalViaJSON compares two captures by JSON-marshaling them and
|
||||
// comparing the bytes. The Capture struct embeds tailcfg view types
|
||||
// with unexported pointer fields that go-cmp can't traverse, so a
|
||||
// JSON round-trip is the simplest way to verify Write+Read produced
|
||||
// equivalent values.
|
||||
func equalViaJSON(t *testing.T, want, got *testcapture.Capture) {
|
||||
t.Helper()
|
||||
|
||||
wantJSON, err := json.MarshalIndent(want, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal want: %v", err)
|
||||
}
|
||||
|
||||
gotJSON, err := json.MarshalIndent(got, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal got: %v", err)
|
||||
}
|
||||
|
||||
if string(wantJSON) != string(gotJSON) {
|
||||
t.Errorf("roundtrip mismatch\n--- want ---\n%s\n--- got ---\n%s",
|
||||
string(wantJSON), string(gotJSON))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteReadRoundtrip_ACL(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "ACL-A01.hujson")
|
||||
|
||||
in := sampleACLCapture()
|
||||
|
||||
err := testcapture.Write(path, in)
|
||||
if err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
|
||||
out, err := testcapture.Read(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Read: %v", err)
|
||||
}
|
||||
|
||||
equalViaJSON(t, in, out)
|
||||
}
|
||||
|
||||
func TestWriteReadRoundtrip_SSH(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "SSH-A01.hujson")
|
||||
|
||||
in := sampleSSHCapture()
|
||||
|
||||
err := testcapture.Write(path, in)
|
||||
if err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
|
||||
out, err := testcapture.Read(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Read: %v", err)
|
||||
}
|
||||
|
||||
equalViaJSON(t, in, out)
|
||||
}
|
||||
|
||||
func TestWrite_ProducesCommentHeader(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "ACL-A01.hujson")
|
||||
|
||||
c := sampleACLCapture()
|
||||
|
||||
err := testcapture.Write(path, c)
|
||||
if err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
|
||||
lines := strings.Split(string(raw), "\n")
|
||||
if len(lines) == 0 {
|
||||
t.Fatal("file is empty")
|
||||
}
|
||||
|
||||
// First line should be the test ID prefixed with "// ".
|
||||
if want := "// ACL-A01"; lines[0] != want {
|
||||
t.Errorf("first line: want %q, got %q", want, lines[0])
|
||||
}
|
||||
|
||||
header := strings.Join(extractHeaderLines(lines), "\n")
|
||||
if !strings.Contains(header, "wildcard ACL") {
|
||||
t.Errorf("header missing description; got:\n%s", header)
|
||||
}
|
||||
|
||||
if !strings.Contains(header, "Nodes with filter rules: 2 of 2") {
|
||||
t.Errorf("header missing stats line; got:\n%s", header)
|
||||
}
|
||||
|
||||
if !strings.Contains(header, "Captured at:") || !strings.Contains(header, "2026-04-07T12:34:56Z") {
|
||||
t.Errorf("header missing capture timestamp; got:\n%s", header)
|
||||
}
|
||||
|
||||
if !strings.Contains(header, "tscap version:") || !strings.Contains(header, "tscap-test-0.0.0") {
|
||||
t.Errorf("header missing tool version; got:\n%s", header)
|
||||
}
|
||||
|
||||
if !strings.Contains(header, "schema version: 1") {
|
||||
t.Errorf("header missing schema version; got:\n%s", header)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrite_SSH_StatsUseSSHRules(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "SSH-A01.hujson")
|
||||
|
||||
c := sampleSSHCapture()
|
||||
|
||||
err := testcapture.Write(path, c)
|
||||
if err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
|
||||
header := strings.Join(extractHeaderLines(strings.Split(string(raw), "\n")), "\n")
|
||||
if !strings.Contains(header, "Nodes with SSH rules: 1 of 1") {
|
||||
t.Errorf("ssh stats line missing; got:\n%s", header)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRead_HuJSONWithComments(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "manual.hujson")
|
||||
|
||||
const content = `// hand-written
|
||||
// comments + trailing commas
|
||||
{
|
||||
"schema_version": 1,
|
||||
"test_id": "MANUAL",
|
||||
"captured_at": "2026-04-07T12:00:00Z",
|
||||
"tool_version": "test",
|
||||
"tailnet": "example.com",
|
||||
"input": {
|
||||
"full_policy": "{}",
|
||||
"api_response_code": 200,
|
||||
"tailnet": {
|
||||
"dns": {
|
||||
"magic_dns": false,
|
||||
"nameservers": [],
|
||||
"search_paths": [],
|
||||
"split_dns": {},
|
||||
},
|
||||
"settings": {},
|
||||
},
|
||||
"scenario_hujson": "",
|
||||
},
|
||||
"topology": {
|
||||
"users": [],
|
||||
"nodes": {},
|
||||
},
|
||||
"captures": {},
|
||||
}
|
||||
`
|
||||
|
||||
err := os.WriteFile(path, []byte(content), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
c, err := testcapture.Read(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Read: %v", err)
|
||||
}
|
||||
|
||||
if c.TestID != "MANUAL" {
|
||||
t.Errorf("TestID = %q, want MANUAL", c.TestID)
|
||||
}
|
||||
|
||||
if c.SchemaVersion != testcapture.SchemaVersion {
|
||||
t.Errorf("SchemaVersion = %d, want %d", c.SchemaVersion, testcapture.SchemaVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRead_RejectsNewerSchemaVersion(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "future.hujson")
|
||||
|
||||
content := fmt.Sprintf(`{
|
||||
"schema_version": %d,
|
||||
"test_id": "FUTURE",
|
||||
"captured_at": "2099-01-01T00:00:00Z",
|
||||
"tool_version": "future",
|
||||
"tailnet": "example.com",
|
||||
"input": {
|
||||
"full_policy": "{}",
|
||||
"api_response_code": 200,
|
||||
"tailnet": {
|
||||
"dns": {"magic_dns": false, "nameservers": [], "search_paths": [], "split_dns": {}},
|
||||
"settings": {}
|
||||
},
|
||||
"scenario_hujson": ""
|
||||
},
|
||||
"topology": {"users": [], "nodes": {}},
|
||||
"captures": {}
|
||||
}`, testcapture.SchemaVersion+1)
|
||||
|
||||
err := os.WriteFile(path, []byte(content), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
_, err = testcapture.Read(path)
|
||||
if !errors.Is(err, testcapture.ErrUnsupportedSchemaVersion) {
|
||||
t.Fatalf("Read(future-schema) = %v, want ErrUnsupportedSchemaVersion", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrite_NilCapture(t *testing.T) {
|
||||
err := testcapture.Write(filepath.Join(t.TempDir(), "x.hujson"), nil)
|
||||
if err == nil {
|
||||
t.Fatal("Write(nil) returned nil error, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommentHeader_NilSafe(t *testing.T) {
|
||||
if got := testcapture.CommentHeader(nil); got != "" {
|
||||
t.Errorf("CommentHeader(nil) = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommentHeader_ZeroTime(t *testing.T) {
|
||||
c := &testcapture.Capture{TestID: "ZERO"}
|
||||
got := testcapture.CommentHeader(c)
|
||||
|
||||
if strings.Contains(got, "Captured at") {
|
||||
t.Errorf("zero time should not produce 'Captured at': %q", got)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(got, "ZERO") {
|
||||
t.Errorf("header should start with TestID: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommentHeader_NoStatsForEmptyCaptures(t *testing.T) {
|
||||
c := &testcapture.Capture{TestID: "EMPTY"}
|
||||
|
||||
header := testcapture.CommentHeader(c)
|
||||
if strings.Contains(header, "filter rules") || strings.Contains(header, "SSH rules") {
|
||||
t.Errorf("empty captures should not produce stats line: %q", header)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommentHeader_EmptyFilterRulesCountAsEmpty(t *testing.T) {
|
||||
// Mixed: nil, empty slice, and one populated rule. Only the
|
||||
// populated entry should be counted in the "filter rules" stat.
|
||||
c := &testcapture.Capture{
|
||||
TestID: "NULLS",
|
||||
Captures: map[string]testcapture.Node{
|
||||
"a": {PacketFilterRules: nil},
|
||||
"b": {PacketFilterRules: []tailcfg.FilterRule{}},
|
||||
"c": {PacketFilterRules: []tailcfg.FilterRule{{SrcIPs: []string{"*"}}}},
|
||||
},
|
||||
}
|
||||
|
||||
header := testcapture.CommentHeader(c)
|
||||
// Only "b" and "c" are non-nil, so the corpus is detected as
|
||||
// "filter rules" — and only "c" actually has rules. With the new
|
||||
// typed semantics, b's empty slice still counts as "set" (not
|
||||
// nil), so the denominator is 2 of 3 capture entries that have
|
||||
// any filter-rules slice at all, and 1 of those is populated.
|
||||
if !strings.Contains(header, "Nodes with filter rules: 1 of 3") {
|
||||
t.Errorf("expected '1 of 3' in header; got:\n%s", header)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInputUnmarshal_LegacyObjectForm asserts that a legacy capture
|
||||
// file written with full_policy as a raw JSON object (not a
|
||||
// JSON-encoded string) still deserialises into a valid Input, with
|
||||
// the policy re-marshaled to a compact string so downstream consumers
|
||||
// see a uniform typed field.
|
||||
func TestInputUnmarshal_LegacyObjectForm(t *testing.T) {
|
||||
legacy := []byte(`{
|
||||
"full_policy": {"tagOwners": {"tag:ops": ["user@example.com"]}},
|
||||
"api_response_code": 200,
|
||||
"tailnet": {"name": "corp", "dnsConfig": {}},
|
||||
"scenario_hujson": "",
|
||||
"scenario_path": ""
|
||||
}`)
|
||||
|
||||
var got testcapture.Input
|
||||
|
||||
err := json.Unmarshal(legacy, &got)
|
||||
if err != nil {
|
||||
t.Fatalf("legacy unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if got.APIResponseCode != 200 {
|
||||
t.Errorf("APIResponseCode: got %d, want 200", got.APIResponseCode)
|
||||
}
|
||||
|
||||
want := `{"tagOwners":{"tag:ops":["user@example.com"]}}`
|
||||
if got.FullPolicy != want {
|
||||
t.Errorf("FullPolicy:\n got %q\nwant %q", got.FullPolicy, want)
|
||||
}
|
||||
|
||||
// Round-trip: the new MarshalJSON must emit the object form so
|
||||
// UnmarshalJSON re-reads it identically.
|
||||
out, err := json.Marshal(got)
|
||||
if err != nil {
|
||||
t.Fatalf("re-marshal: %v", err)
|
||||
}
|
||||
|
||||
var back testcapture.Input
|
||||
|
||||
err = json.Unmarshal(out, &back)
|
||||
if err != nil {
|
||||
t.Fatalf("re-unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if back.FullPolicy != want {
|
||||
t.Errorf("round-trip FullPolicy drift:\n got %q\nwant %q", back.FullPolicy, want)
|
||||
}
|
||||
}
|
||||
|
||||
// extractHeaderLines returns the leading "// ..." comment lines from a
|
||||
// slice of raw file lines, stripped of the "// " prefix. Stops at the
|
||||
// first non-comment line.
|
||||
func extractHeaderLines(lines []string) []string {
|
||||
var out []string
|
||||
|
||||
for _, l := range lines {
|
||||
switch {
|
||||
case strings.HasPrefix(l, "// "):
|
||||
out = append(out, strings.TrimPrefix(l, "// "))
|
||||
case l == "//":
|
||||
out = append(out, "")
|
||||
default:
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
137
hscontrol/types/testcapture/write.go
Normal file
137
hscontrol/types/testcapture/write.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package testcapture
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/tailscale/hujson"
|
||||
)
|
||||
|
||||
// ErrNilCapture is returned by Write when called with a nil Capture.
|
||||
var ErrNilCapture = errors.New("testcapture: nil capture")
|
||||
|
||||
// Write serializes c as a HuJSON file with a comment header. The
|
||||
// write is atomic: body lands in a temp file in the target directory
|
||||
// and is then renamed into place, so concurrent regeneration cannot
|
||||
// leave a half-written file behind.
|
||||
//
|
||||
// The comment header is built by CommentHeader from c's TestID,
|
||||
// Description, and Captures. The file's parent directory must
|
||||
// already exist; callers should MkdirAll first.
|
||||
func Write(path string, c *Capture) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("testcapture: Write %s: %w", path, ErrNilCapture)
|
||||
}
|
||||
|
||||
header := CommentHeader(c)
|
||||
|
||||
body, err := marshalHuJSON(c)
|
||||
if err != nil {
|
||||
return fmt.Errorf("testcapture: marshal %s: %w", path, err)
|
||||
}
|
||||
|
||||
data := prependCommentHeader(body, header)
|
||||
|
||||
dir := filepath.Dir(path)
|
||||
base := filepath.Base(path)
|
||||
|
||||
tmp, err := os.CreateTemp(dir, base+".*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("testcapture: tempfile %s: %w", path, err)
|
||||
}
|
||||
|
||||
tmpName := tmp.Name()
|
||||
|
||||
cleanup := func() {
|
||||
_ = os.Remove(tmpName)
|
||||
}
|
||||
|
||||
_, err = tmp.Write(data)
|
||||
if err != nil {
|
||||
_ = tmp.Close()
|
||||
|
||||
cleanup()
|
||||
|
||||
return fmt.Errorf("testcapture: write %s: %w", path, err)
|
||||
}
|
||||
|
||||
err = tmp.Chmod(0o600)
|
||||
if err != nil {
|
||||
_ = tmp.Close()
|
||||
|
||||
cleanup()
|
||||
|
||||
return fmt.Errorf("testcapture: chmod %s: %w", path, err)
|
||||
}
|
||||
|
||||
err = tmp.Close()
|
||||
if err != nil {
|
||||
cleanup()
|
||||
|
||||
return fmt.Errorf("testcapture: close %s: %w", path, err)
|
||||
}
|
||||
|
||||
err = os.Rename(tmpName, path)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
|
||||
return fmt.Errorf("testcapture: rename %s: %w", path, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// marshalHuJSON serializes v as HuJSON-formatted bytes. It is
|
||||
// standard JSON encoding followed by hujson.Format which produces
|
||||
// consistent indentation/whitespace.
|
||||
func marshalHuJSON(v any) ([]byte, error) {
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("json marshal: %w", err)
|
||||
}
|
||||
|
||||
formatted, err := hujson.Format(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hujson format: %w", err)
|
||||
}
|
||||
|
||||
return formatted, nil
|
||||
}
|
||||
|
||||
// prependCommentHeader emits header as // comment lines at the top of
|
||||
// body. Empty lines in header become "//" alone (no trailing space).
|
||||
// The returned bytes always end with a single trailing newline.
|
||||
func prependCommentHeader(body []byte, header string) []byte {
|
||||
if header == "" {
|
||||
if len(body) == 0 || body[len(body)-1] != '\n' {
|
||||
body = append(body, '\n')
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
var buf strings.Builder
|
||||
|
||||
for line := range strings.SplitSeq(header, "\n") {
|
||||
if line == "" {
|
||||
buf.WriteString("//\n")
|
||||
continue
|
||||
}
|
||||
|
||||
buf.WriteString("// ")
|
||||
buf.WriteString(line)
|
||||
buf.WriteByte('\n')
|
||||
}
|
||||
|
||||
buf.Write(body)
|
||||
|
||||
if !strings.HasSuffix(buf.String(), "\n") {
|
||||
buf.WriteByte('\n')
|
||||
}
|
||||
|
||||
return []byte(buf.String())
|
||||
}
|
||||
Reference in New Issue
Block a user