From 1d11be02b38666c569a790224a9130f1e72b10ed Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 17 Oct 2026 12:03:14 +0000 Subject: [PATCH] testcapture: record multi-step captures and API error detail Optional Steps, NodeIDs, error data and login state; v1 files unchanged. Register log and raw map frame parsers; Write refuses auth keys and login URLs. (cherry picked from commit 1e397e8cd5815f2489fa7c1faeda503133329576, adapted: adds tailscale.com/client/tailscale/v2) --- flakehashes.json | 4 +- go.mod | 1 + go.sum | 2 + hscontrol/types/testcapture/header.go | 7 + hscontrol/types/testcapture/steps.go | 234 ++++++++++ hscontrol/types/testcapture/testcapture.go | 40 +- .../types/testcapture/testcapture_test.go | 407 ++++++++++++++++++ hscontrol/types/testcapture/write.go | 18 +- 8 files changed, 703 insertions(+), 10 deletions(-) create mode 100644 hscontrol/types/testcapture/steps.go diff --git a/flakehashes.json b/flakehashes.json index 8141bb9bb..1aed7ad19 100644 --- a/flakehashes.json +++ b/flakehashes.json @@ -1,6 +1,6 @@ { "vendor": { - "goModSum": "sha256-mYlV7k01FfMkHtlPGuk2WO+fSv18nCwXjxI9SIlep8g=", - "sri": "sha256-Q+y/yln9AlckH7/L2NItWxsOmN84OWPya/1gILyk4rE=" + "goModSum": "sha256-ZCJe3Dxz3jYjyz1hAb7QLMQJhrQLY78H2d+OaBpODbc=", + "sri": "sha256-Xzc8AYe5sJ+05IS9je/a8HjOqx1sXnIhewSCYqbQBQ4=" } } diff --git a/go.mod b/go.mod index fb83955c0..67abdf72f 100644 --- a/go.mod +++ b/go.mod @@ -74,6 +74,7 @@ require ( modernc.org/sqlite v1.52.0 pgregory.net/rapid v1.3.0 tailscale.com v1.104.4 + tailscale.com/client/tailscale/v2 v2.12.0 zombiezen.com/go/postgrestest v1.0.1 ) diff --git a/go.sum b/go.sum index 6f56e6cea..8d2bc7b89 100644 --- a/go.sum +++ b/go.sum @@ -682,5 +682,7 @@ software.sslmate.com/src/go-pkcs12 v0.7.3 h1:JBQD3FDqYjTeyDAeZQklj2ar88ykBLtALlo software.sslmate.com/src/go-pkcs12 v0.7.3/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= tailscale.com v1.104.4 h1:3UXjZjDdD3o3FTYcdFARD1ZLyHOHdhQYqdYOjvbHSpk= tailscale.com v1.104.4/go.mod h1:Hq4zB8UpaxMW1fvqO30CLVQo3A7Ith0t9MnvEyiuoSA= +tailscale.com/client/tailscale/v2 v2.12.0 h1:v7oMM/u0/vuchgLTwCdT0nJ6PyD0Mlpm2mWnoUKVyBo= +tailscale.com/client/tailscale/v2 v2.12.0/go.mod h1:FGjvGT3ThHelqo0gfdK3IN3k1dwNbRzYbQh2XO3C47U= zombiezen.com/go/postgrestest v1.0.1 h1:aXoADQAJmZDU3+xilYVut0pHhgc0sF8ZspPW9gFNwP4= zombiezen.com/go/postgrestest v1.0.1/go.mod h1:marlZezr+k2oSJrvXHnZUs1olHqpE9czlz8ZYkVxliQ= diff --git a/hscontrol/types/testcapture/header.go b/hscontrol/types/testcapture/header.go index 142937d5a..a3d461bff 100644 --- a/hscontrol/types/testcapture/header.go +++ b/hscontrol/types/testcapture/header.go @@ -15,6 +15,7 @@ import ( // // Nodes with filter rules: of ← for non-SSH captures // Nodes with SSH rules: of ← for SSH captures +// Steps: ← only when Steps is set // Captured at: // tool version: // schema version: @@ -42,6 +43,12 @@ func CommentHeader(c *Capture) string { } stats := captureStats(c) + // Only step files get the line, so rewriting a single-shot file keeps + // its header unchanged. + if len(c.Steps) > 0 { + stats = strings.TrimPrefix(fmt.Sprintf("%s\nSteps: %d", stats, len(c.Steps)), "\n") + } + if stats != "" { b.WriteByte('\n') b.WriteString(stats) diff --git a/hscontrol/types/testcapture/steps.go b/hscontrol/types/testcapture/steps.go new file mode 100644 index 000000000..6e130fb19 --- /dev/null +++ b/hscontrol/types/testcapture/steps.go @@ -0,0 +1,234 @@ +package testcapture + +import ( + "bytes" + "encoding/json" + "fmt" + "regexp" + "strings" + "time" + + "tailscale.com/tailcfg" +) + +// Step is one mutating action in a multi-step capture and what the tailnet +// showed once it settled. +type Step struct { + Name string `json:"name"` + + // At is the step mark. Key expiry seen in the step is judged against it. + At time.Time `json:"at"` + + Note string `json:"note,omitempty"` + + // Result is one of observed, expected-refusal, setup-failed, timeout, + // not-converged or capture-failed. Only observed and expected-refusal + // steps carry data a consumer may assert on. + Result string `json:"result"` + + Actions []Action `json:"actions,omitempty"` + + // Captures holds node state after the barrier, keyed by alias. + Captures map[string]Node `json:"captures,omitempty"` + + // Register and Frames are keyed by alias, in log order. + Register map[string][]RegisterAttempt `json:"register,omitempty"` + Frames map[string][]Frame `json:"frames,omitempty"` +} + +// Action is one thing the capture tool did in a step: a client command, an +// API exchange or a wait. Keys, OAuth clients, devices and auth URLs appear as +// labels, never as values. +type Action struct { + // Kind is join, relogin, logout, restart, kill, start, cli, api, token, + // policy, wait, retire and so on. + Kind string `json:"kind"` + Node string `json:"node,omitempty"` + + // Key and Client are the auth key and OAuth client labels used or created. + Key string `json:"key,omitempty"` + Client string `json:"client,omitempty"` + + // Principal is admin, user: or token:. + Principal string `json:"principal,omitempty"` + + Force bool `json:"force,omitempty"` + + // RequestTags is nil when no tags were sent and empty when an empty + // list was. + RequestTags *[]string `json:"request_tags,omitempty"` + + // Scope is a token request's scope field byte for byte, so order and + // repeats survive. It is nil when the form had none. + Scope *string `json:"scope,omitempty"` + + Routes []string `json:"routes,omitempty"` + Hostname string `json:"hostname,omitempty"` + Args []string `json:"args,omitempty"` + + // Path uses placeholders such as {device:x}, {key:K} and {client:C}. + Method string `json:"method,omitempty"` + Path string `json:"path,omitempty"` + Header map[string]string `json:"header,omitempty"` + + // Body and Response hold JSON as sent and received. Other bytes, such as + // HuJSON, are stored as a JSON string, and "" is a zero-length body. + // A token action never sets Body: the form carries the client secret, + // so Client, Scope and RequestTags stand for it. + Body json.RawMessage `json:"body,omitempty"` + Status int `json:"status,omitempty"` + RespHeader map[string]string `json:"resp_header,omitempty"` + Response json.RawMessage `json:"response,omitempty"` + + Exit *int `json:"exit,omitempty"` + Output string `json:"output,omitempty"` + + // OffsetMS is relative to [Step.At]. It only tells an action sent in the + // same tick from one sent after settling. + OffsetMS int64 `json:"offset_ms,omitempty"` +} + +// RegisterAttempt is one /machine/register exchange as tailscaled logs it, +// or a login error the client logged on its own. Node keys and auth URLs +// are labels in a written capture. +type RegisterAttempt struct { + NodeKey string `json:"node_key,omitempty"` + OldNodeKey string `json:"old_node_key,omitempty"` + Followup bool `json:"followup,omitempty"` + + // NodeKeyExpired and MachineAuthorized are nil until the client logs + // the answer, so an attempt still in flight reads as unanswered. + NodeKeyExpired *bool `json:"node_key_expired,omitempty"` + MachineAuthorized *bool `json:"machine_authorized,omitempty"` + AuthURL string `json:"auth_url,omitempty"` + + Err string `json:"err,omitempty"` + + // OffsetMS is relative to [Step.At]. + OffsetMS int64 `json:"offset_ms,omitempty"` +} + +// The leading [^"]* rejects tailscaled's JSON echoes of the same lines. +var ( + registerReqRE = regexp.MustCompile(`^[^"]*RegisterReq: onode=(\S*) node=(\S*) fup=(true|false) nks=(?:true|false)$`) + registerRespRE = regexp.MustCompile(`^[^"]*RegisterReq: got response; nodeKeyExpired=(true|false), machineAuthorized=(true|false); authURL=(?:true|false)$`) + authURLRE = regexp.MustCompile(`^[^"]*AuthURL is (\S+)$`) + loginErrRE = regexp.MustCompile(`^[^"]*(?:TryLogin|WaitLoginURL): (.+)$`) +) + +// AppendRegisterLine folds one tailscaled log line into attempts and reports +// whether it was a register line. A request line starts an attempt; the +// answer and the "AuthURL is" line complete the last one. A login error is +// its own entry, because it can come before any request is sent. Node keys +// and the URL come back raw; callers label them before writing. +func AppendRegisterLine(attempts []RegisterAttempt, line string) ([]RegisterAttempt, bool) { + line = strings.TrimRight(line, "\r\n") + + if m := registerReqRE.FindStringSubmatch(line); m != nil { + return append(attempts, RegisterAttempt{ + OldNodeKey: m[1], + NodeKey: m[2], + Followup: m[3] == "true", + }), true + } + + if m := registerRespRE.FindStringSubmatch(line); m != nil { + if n := len(attempts); n == 0 || attempts[n-1].MachineAuthorized != nil || attempts[n-1].Err != "" { + attempts = append(attempts, RegisterAttempt{}) + } + + last := &attempts[len(attempts)-1] + last.NodeKeyExpired = new(m[1] == "true") + last.MachineAuthorized = new(m[2] == "true") + + return attempts, true + } + + if m := authURLRE.FindStringSubmatch(line); m != nil { + if len(attempts) == 0 { + attempts = append(attempts, RegisterAttempt{}) + } + + attempts[len(attempts)-1].AuthURL = m[1] + + return attempts, true + } + + if m := loginErrRE.FindStringSubmatch(line); m != nil { + return append(attempts, RegisterAttempt{Err: m[1]}), true + } + + return attempts, false +} + +// Frame is one map message on a node's stream. +type Frame struct { + // OffsetMS is relative to [Step.At]. + OffsetMS int64 `json:"offset_ms"` + + // Request with Stream set marks the start of a stream. + Request *tailcfg.MapRequest `json:"request,omitempty"` + Response *tailcfg.MapResponse `json:"response,omitempty"` + + // Empty maps each key the raw response held as [], {} or null to that + // literal: top-level keys, and patch fields as + // "PeersChangedPatch[i]."; see [ParseMapResponse]. + Empty map[string]string `json:"empty,omitempty"` + + // Dropped names top-level fields cut for size, such as DERPMap. + Dropped []string `json:"dropped,omitempty"` +} + +// ParseMapResponse decodes a MapResponse body as the server sent it. Typed +// marshalling omits empty top-level slices and maps, yet an empty +// PacketFilter blocks everything while an absent one keeps the last, so +// [Frame.Empty] keeps what the raw body held. Patch fields count too: the +// client applies a patch's Endpoints: [] or CapMap: {}, but skips absent ones. +func ParseMapResponse(raw []byte) (Frame, error) { + var resp tailcfg.MapResponse + + err := json.Unmarshal(raw, &resp) + if err != nil { + return Frame{}, fmt.Errorf("testcapture: map response: %w", err) + } + + var top map[string]json.RawMessage + + err = json.Unmarshal(raw, &top) + if err != nil { + return Frame{}, fmt.Errorf("testcapture: map response keys: %w", err) + } + + var patches []map[string]json.RawMessage + + if v, ok := top["PeersChangedPatch"]; ok { + err = json.Unmarshal(v, &patches) + if err != nil { + return Frame{}, fmt.Errorf("testcapture: map response patches: %w", err) + } + } + + f := Frame{Response: &resp} + keep := func(k string, v json.RawMessage) { + switch s := string(bytes.Join(bytes.Fields(v), nil)); s { + case "[]", "{}", "null": + if f.Empty == nil { + f.Empty = make(map[string]string) + } + + f.Empty[k] = s + } + } + + for k, v := range top { + keep(k, v) + } + + for i, p := range patches { + for k, v := range p { + keep(fmt.Sprintf("PeersChangedPatch[%d].%s", i, k), v) + } + } + + return f, nil +} diff --git a/hscontrol/types/testcapture/testcapture.go b/hscontrol/types/testcapture/testcapture.go index 75e18ff86..5e715b50a 100644 --- a/hscontrol/types/testcapture/testcapture.go +++ b/hscontrol/types/testcapture/testcapture.go @@ -15,6 +15,9 @@ // All four capture types (acl, routes, grant, ssh) use the same [Capture] // shape. SSH scenarios populate Captures[name].SSHRules; the others // populate Captures[name].PacketFilterRules + Captures[name].Netmap. +// Multi-step captures add [Capture.Steps], which readers of single-shot +// captures ignore. API bodies in steps stay raw so that absent, null and +// empty values survive. package testcapture import ( @@ -23,6 +26,7 @@ import ( "time" "tailscale.com/client/tailscale/apitype" + tsapi "tailscale.com/client/tailscale/v2" "tailscale.com/tailcfg" "tailscale.com/types/netmap" "tailscale.com/wgengine/filter/filtertype" @@ -62,14 +66,18 @@ type Capture struct { // ToolVersion identifies the binary that produced the file. ToolVersion string `json:"tool_version"` + // ClientVersion is the tailscale.com version and capability version + // of the clients. A refused login sends no map request to show them. + ClientVersion string `json:"client_version,omitempty"` + // 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, [Capture.Captures] reflects - // the pre-push baseline (deny-all default) and [Input.APIResponseBody] - // is populated. + // the baseline still in force (the reset default has no acls or grants, + // so it allows all) and [Input.APIResponseBody] is populated. Error bool `json:"error,omitempty"` // CaptureError is set when the capture itself failed (timeout, @@ -86,9 +94,19 @@ type Capture struct { // capture time. Always populated by the capture tool. Topology Topology `json:"topology"` - // Captures holds the per-node captured data, keyed by node - // GivenName. + // Captures holds the per-node captured data, keyed by alias: the + // GivenName in single-shot captures, the probe's label in step files. + // In step files it is the baseline before the first step. Captures map[string]Node `json:"captures"` + + // NodeIDs maps each alias to its SaaS node ID, so that removals and + // patches resolve. A deleted node keeps its entry; a re-created one is + // "#2". + NodeIDs map[string]tailcfg.NodeID `json:"node_ids,omitempty"` + + // Steps is set by multi-step captures only. Consumers of steps require + // it to be non-empty. + Steps []Step `json:"steps,omitempty"` } // Input describes everything that was sent to the tailnet to produce @@ -207,6 +225,9 @@ func (i *Input) UnmarshalJSON(data []byte) error { // APIResponseBody is the (subset of) the SaaS API error response we keep. type APIResponseBody struct { Message string `json:"message,omitempty"` + + // Data carries per-user detail, such as failed policy tests. + Data []tsapi.APIErrorData `json:"data,omitempty"` } // TailnetInput captures tailnet-wide settings the capture tool applied before @@ -248,7 +269,7 @@ type Topology struct { // Users in the tailnet. Always populated by the capture tool. Users []TopologyUser `json:"users"` - // Nodes in the tailnet, keyed by GivenName. + // Nodes in the tailnet, keyed by alias (see [Capture.Captures]). Nodes map[string]TopologyNode `json:"nodes"` } @@ -280,8 +301,8 @@ type TopologyNode struct { ApprovedRoutes []string `json:"approved_routes"` } -// Node is the captured state for one node, keyed by GivenName in -// [Capture.Captures]. +// Node is the captured state for one node, keyed by alias in +// [Capture.Captures] and [Step.Captures]. // // All four capture types populate the same struct. Different fields are // used by different test types: @@ -329,4 +350,9 @@ type Node struct { // SSHRules is the SSH rules slice extracted from // [netmap.NetworkMap.SSHPolicy].Rules. Populated only for SSH scenarios. SSHRules []*tailcfg.SSHRule `json:"ssh_rules,omitempty"` + + // BackendState and ErrMessage are the client's IPN state and login + // error, so a refused login is recorded on a node with no netmap. + BackendState string `json:"backend_state,omitempty"` + ErrMessage string `json:"err_message,omitempty"` } diff --git a/hscontrol/types/testcapture/testcapture_test.go b/hscontrol/types/testcapture/testcapture_test.go index 7cc98351b..6b50e3f66 100644 --- a/hscontrol/types/testcapture/testcapture_test.go +++ b/hscontrol/types/testcapture/testcapture_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "github.com/google/go-cmp/cmp" "github.com/juanfont/headscale/hscontrol/types/testcapture" "tailscale.com/tailcfg" "tailscale.com/types/netmap" @@ -463,6 +464,412 @@ func TestInputUnmarshal_LegacyObjectForm(t *testing.T) { } } +// v1File is a single-shot capture in the form [testcapture.Write] gives it. +const v1File = `// acl-v1 +// +// refused policy, baseline kept +// +// Nodes with filter rules: 1 of 1 +// Captured at: 2026-04-07T12:34:56Z +// tool version: tscap/dev +// schema version: 1 +{ + "schema_version": 1, + "test_id": "acl-v1", + "description": "refused policy, baseline kept", + "category": "acl", + "captured_at": "2026-04-07T12:34:56Z", + "tool_version": "tscap/dev", + "tailnet": "example.com", + "error": true, + "input": { + "api_response_code": 400, + "api_response_body": {"message": "src=tag not found: \"tag:nonexistent\""}, + "tailnet": { + "dns": {"magic_dns": false, "nameservers": [], "search_paths": [], "split_dns": {}}, + "settings": {} + }, + "scenario_hujson": "{\"id\":\"acl-v1\"}", + "full_policy": { + "acls": [{"action": "accept", "src": ["tag:nonexistent"], "dst": ["*:*"]}] + } + }, + "topology": { + "users": [{"id": 1, "name": "odin", "email": "odin@example.com"}], + "nodes": {"user1": { + "hostname": "user1", + "tags": null, + "ipv4": "100.90.199.68", + "ipv6": "fd7a:115c:a1e0::2d01:c747", + "user": "odin", + "routable_ips": [], + "approved_routes": [] + }} + }, + "captures": {"user1": {"packet_filter_rules": [{ + "SrcIPs": ["100.64.0.0/10"], + "DstPorts": [{"IP": "*", "Ports": {"First": 0, "Last": 65535}}] + }]}} +} +` + +// TestWrite_V1FileUnchanged asserts that the step fields leave a single-shot +// file byte for byte as it was: no new keys and no Steps header line. +func TestWrite_V1FileUnchanged(t *testing.T) { + dir := t.TempDir() + in := filepath.Join(dir, "in.hujson") + out := filepath.Join(dir, "out.hujson") + + err := os.WriteFile(in, []byte(v1File), 0o600) + if err != nil { + t.Fatalf("WriteFile: %v", err) + } + + c, err := testcapture.Read(in) + if err != nil { + t.Fatalf("Read: %v", err) + } + + err = testcapture.Write(out, c) + if err != nil { + t.Fatalf("Write: %v", err) + } + + got, err := os.ReadFile(out) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + + if string(got) != v1File { + t.Errorf("v1 file changed on rewrite\n--- want ---\n%s\n--- got ---\n%s", v1File, got) + } +} + +func sampleStepsCapture(t *testing.T) *testcapture.Capture { + t.Helper() + + frame, err := testcapture.ParseMapResponse([]byte(`{ + "ControlTime": "2026-10-17T09:13:15Z", + "PacketFilter": [] +}`)) + if err != nil { + t.Fatalf("ParseMapResponse: %v", err) + } + + frame.OffsetMS = 1200 + + c := sampleACLCapture() + c.TestID = "route-approval" + c.ClientVersion = "1.102.5, capver 142" + c.NodeIDs = map[string]tailcfg.NodeID{"user1": 101, "tagged-server": 102, "tagged-server#2": 103} + c.Steps = []testcapture.Step{ + { + Name: "b0-join", + At: time.Date(2026, 4, 7, 12, 40, 0, 0, time.UTC), + Result: "observed", + Actions: []testcapture.Action{ + {Kind: "join", Node: "tagged-server", Key: "K-router", RequestTags: &[]string{}}, + {Kind: "cli", Node: "tagged-server", Args: []string{"set", "--advertise-routes=10.99.1.0/24"}, Exit: new(0)}, + { + Kind: "api", + Principal: "admin", + Method: "POST", + Path: "/device/{device:tagged-server}/routes", + Header: map[string]string{"Content-Type": "application/json"}, + Body: json.RawMessage(`{"routes":["10.99.1.0/24"]}`), + Status: 200, + Response: json.RawMessage(`{"advertisedRoutes":["10.99.1.0/24"],"enabledRoutes":[]}`), + OffsetMS: 15, + }, + { + Kind: "token", + Client: "C-read", + Scope: new("devices:routes:read devices:core:read devices:routes:read"), + Status: 200, + Response: json.RawMessage(`{"scope":"devices:routes:read devices:core:read","token_type":"Bearer"}`), + }, + {Kind: "token", Client: "C-read", Status: 200}, + }, + Captures: map[string]testcapture.Node{ + "tagged-server": {BackendState: "Running"}, + }, + Register: map[string][]testcapture.RegisterAttempt{ + "tagged-server": {{NodeKey: "nk-1", NodeKeyExpired: new(false), MachineAuthorized: new(true)}}, + }, + Frames: map[string][]testcapture.Frame{"user1": {frame}}, + }, + { + Name: "b1-policy", + At: time.Date(2026, 4, 7, 12, 41, 0, 0, time.UTC), + Result: "expected-refusal", + Actions: []testcapture.Action{ + { + Kind: "policy", + Method: "POST", + Path: "/acl", + Header: map[string]string{"If-Match": "etag-1"}, + Body: json.RawMessage(`""`), + Status: 400, + RespHeader: map[string]string{"ETag": "etag-1"}, + Response: json.RawMessage(`{"message":"empty policy","data":null}`), + }, + }, + Captures: map[string]testcapture.Node{ + "user1": {BackendState: "NeedsLogin", ErrMessage: "invalid key"}, + }, + }, + } + + return c +} + +func TestWriteReadRoundtrip_Steps(t *testing.T) { + path := filepath.Join(t.TempDir(), "route-approval.hujson") + in := sampleStepsCapture(t) + + 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) + + 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, "Steps: 2") { + t.Errorf("header missing steps line; got:\n%s", header) + } + + // Sent-empty tags and a zero-length body must stay apart from absent. + if tags := out.Steps[0].Actions[0].RequestTags; tags == nil || len(*tags) != 0 { + t.Errorf("RequestTags = %v, want sent empty", tags) + } + + if out.Steps[0].Actions[1].RequestTags != nil { + t.Errorf("RequestTags on cli action = %v, want nil", out.Steps[0].Actions[1].RequestTags) + } + + if got := string(out.Steps[1].Actions[0].Body); got != `""` { + t.Errorf("zero-length body = %s, want \"\"", got) + } + + // A token scope keeps order and repeats; an unsent one stays nil. + want := "devices:routes:read devices:core:read devices:routes:read" + if got := out.Steps[0].Actions[3].Scope; got == nil || *got != want { + t.Errorf("Scope = %v, want %q", got, want) + } + + if got := out.Steps[0].Actions[4].Scope; got != nil { + t.Errorf("Scope = %q, want nil when none was sent", *got) + } + + if out.ClientVersion != in.ClientVersion { + t.Errorf("ClientVersion = %q, want %q", out.ClientVersion, in.ClientVersion) + } +} + +func TestWrite_RefusesSecrets(t *testing.T) { + tests := []struct { + name string + edit func(c *testcapture.Capture) + }{ + { + name: "auth key in output", + edit: func(c *testcapture.Capture) { + c.Steps[0].Actions[1].Output = "joined with tskey-auth-k123CNTRL-abc" + }, + }, + { + name: "login URL in register attempt", + edit: func(c *testcapture.Capture) { + c.Steps[0].Register["tagged-server"][0].AuthURL = "https://login.tailscale.com/a/1a2b3c" + }, + }, + { + name: "auth key in description", + edit: func(c *testcapture.Capture) { + c.Description = "tskey-client-k123-abc" + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + c := sampleStepsCapture(t) + tt.edit(c) + + err := testcapture.Write(filepath.Join(dir, "x.hujson"), c) + if !errors.Is(err, testcapture.ErrSecret) { + t.Fatalf("Write = %v, want ErrSecret", err) + } + + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("ReadDir: %v", err) + } + + if len(entries) != 0 { + t.Errorf("refused Write left %d files behind", len(entries)) + } + }) + } +} + +// TestAppendRegisterLine feeds a stock client's log through the parser: an +// expired node key, the regenerated key's auth URL and the follow-up that +// completes once the login is approved. +func TestAppendRegisterLine(t *testing.T) { + lines := []string{ + // auto.go's report format; the client logged no login error here. + "[v1] TryLogin: context canceled", + "2026/10/17 09:12:42 control: doLogin(regen=false, hasUrl=false)", + "2026/10/17 09:12:43 control: RegisterReq: onode= node=[cCOGA] fup=false nks=false", + "2026/10/17 09:12:43 control: creating new noise client", + "2026/10/17 09:12:43 control: RegisterReq: got response; nodeKeyExpired=true, machineAuthorized=true; authURL=false", + "2026/10/17 09:12:43 control: server reports new node key [cCOGA] has expired", + "2026/10/17 09:12:43 control: doLogin(regen=true, hasUrl=false)", + "2026/10/17 09:12:43 control: Generating a new nodekey.", + "2026/10/17 09:12:43 control: RegisterReq: onode=[cCOGA] node=[ufHM+] fup=false nks=false", + "2026/10/17 09:12:43 control: RegisterReq: got response; nodeKeyExpired=false, machineAuthorized=false; authURL=true", + "2026/10/17 09:12:43 control: AuthURL is https://login.tailscale.com/a/REDACTED", + "2026/10/17 09:12:43 control: direct.WaitLoginURL", + "2026/10/17 09:12:43 control: RegisterReq: onode=[cCOGA] node=[ufHM+] fup=true nks=false", + "2026/10/17 09:12:43 Switching ipn state NoState -> NeedsLogin (WantRunning=true, nm=false)", + "2026/10/17 09:13:15 control: RegisterReq: got response; nodeKeyExpired=false, machineAuthorized=true; authURL=false\n", + "2026/10/17 09:13:15 control: No AuthURL", + `2026/10/17 09:13:17 RAW-STDERR: [{"logtail":{"client_time":"2026-10-17T09:13:15.108564694Z","proc_id":102272285,"proc_seq":87},"metrics":"N2anetmon_link_change_eqS0204N40controlclient_auto_status_queuedS0402N50gauge_localbackend_current_watch_ipn_busS0602N1eprofiles_switchS0802N28gauge_profiles_countS0a02N5agauge_dns_manager_linux_mode_systemd_resolvedS0c02","text":"control: RegisterReq: got response; nodeKeyExpired=false, machineAuthorized=true; authURL=false\n"}`, + } + + var ( + got []testcapture.RegisterAttempt + matched int + ) + + for _, l := range lines { + var ok bool + + got, ok = testcapture.AppendRegisterLine(got, l) + if ok { + matched++ + } + } + + want := []testcapture.RegisterAttempt{ + {Err: "context canceled"}, + {NodeKey: "[cCOGA]", NodeKeyExpired: new(true), MachineAuthorized: new(true)}, + { + NodeKey: "[ufHM+]", + OldNodeKey: "[cCOGA]", + NodeKeyExpired: new(false), + MachineAuthorized: new(false), + AuthURL: "https://login.tailscale.com/a/REDACTED", + }, + { + NodeKey: "[ufHM+]", + OldNodeKey: "[cCOGA]", + Followup: true, + NodeKeyExpired: new(false), + MachineAuthorized: new(true), + }, + } + + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("attempts (-want +got):\n%s", diff) + } + + if matched != 8 { + t.Errorf("matched %d lines, want 8", matched) + } + + // A follow-up still waiting for approval has no answer yet. + pending, _ := testcapture.AppendRegisterLine(nil, lines[12]) + if pending[0].MachineAuthorized != nil { + t.Errorf("unanswered attempt has MachineAuthorized = %v", *pending[0].MachineAuthorized) + } +} + +// TestParseMapResponse_KeepsEmpty asserts that a deny-all frame read back +// from a file stays distinct from one that leaves the filter unchanged: +// typed marshalling drops the empty PacketFilter, Empty keeps it. The same +// holds for patch fields, where an empty value clears and an absent one +// leaves the field. +func TestParseMapResponse_KeepsEmpty(t *testing.T) { + f, err := testcapture.ParseMapResponse([]byte(`{ + "ControlTime": "2026-10-17T09:13:15Z", + "PacketFilter": [], + "PeersRemoved": null, + "PeersChangedPatch": [ + {"NodeID": 101, "Endpoints": [], "CapMap": {}, "Online": null}, + {"NodeID": 102, "DERPRegion": 2} + ], + "Domain": "example.com" +}`)) + if err != nil { + t.Fatalf("ParseMapResponse: %v", err) + } + + unchanged, err := testcapture.ParseMapResponse([]byte(`{"ControlTime": "2026-10-17T09:13:15Z"}`)) + if err != nil { + t.Fatalf("ParseMapResponse: %v", err) + } + + path := filepath.Join(t.TempDir(), "frames.hujson") + + err = testcapture.Write(path, &testcapture.Capture{ + SchemaVersion: testcapture.SchemaVersion, + TestID: "frames", + Steps: []testcapture.Step{{ + Name: "s1", + Result: "observed", + Frames: map[string][]testcapture.Frame{"user1": {f, unchanged}}, + }}, + }) + if err != nil { + t.Fatalf("Write: %v", err) + } + + c, err := testcapture.Read(path) + if err != nil { + t.Fatalf("Read: %v", err) + } + + frames := c.Steps[0].Frames["user1"] + + got := frames[0] + if got.Response == nil || got.Response.PacketFilter != nil || got.Response.Domain != "example.com" { + t.Errorf("Response = %+v, want Domain set and PacketFilter dropped", got.Response) + } + + if p := got.Response.PeersChangedPatch; len(p) != 2 || p[0].Endpoints != nil || p[1].DERPRegion != 2 { + t.Errorf("PeersChangedPatch = %+v, want two patches, Endpoints dropped", p) + } + + want := map[string]string{ + "PacketFilter": "[]", + "PeersRemoved": "null", + "PeersChangedPatch[0].Endpoints": "[]", + "PeersChangedPatch[0].CapMap": "{}", + "PeersChangedPatch[0].Online": "null", + } + if diff := cmp.Diff(want, got.Empty); diff != "" { + t.Errorf("Empty (-want +got):\n%s", diff) + } + + if frames[1].Empty != nil { + t.Errorf("Empty = %v for a frame with no empty keys, want nil", frames[1].Empty) + } +} + // extractHeaderLines returns the leading "// ..." comment lines from a // slice of raw file lines, stripped of the "// " prefix. Stops at the // first non-comment line. diff --git a/hscontrol/types/testcapture/write.go b/hscontrol/types/testcapture/write.go index 13e2d08ba..c1630f576 100644 --- a/hscontrol/types/testcapture/write.go +++ b/hscontrol/types/testcapture/write.go @@ -1,6 +1,7 @@ package testcapture import ( + "bytes" "encoding/json" "errors" "fmt" @@ -14,6 +15,14 @@ import ( // ErrNilCapture is returned by [Write] when called with a nil [Capture]. var ErrNilCapture = errors.New("testcapture: nil capture") +// ErrSecret is returned by [Write] when the file would hold an auth key or +// an interactive login URL. +var ErrSecret = errors.New("testcapture: capture holds an auth key or login URL") + +// secretMarkers back up the capture tool's own stripping: a capture is +// committed, so it must never carry a credential. +var secretMarkers = [][]byte{[]byte("tskey-"), []byte("login.tailscale.com/a/")} + // 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 @@ -21,7 +30,8 @@ var ErrNilCapture = errors.New("testcapture: nil capture") // // The comment header is built by [CommentHeader] from c's [Capture.TestID], // [Capture.Description], and [Capture.Captures]. The file's parent directory must -// already exist; callers should [os.MkdirAll] first. +// already exist; callers should [os.MkdirAll] first. A capture holding an +// auth key or login URL is refused with [ErrSecret] and nothing is written. func Write(path string, c *Capture) error { if c == nil { return fmt.Errorf("testcapture: Write %s: %w", path, ErrNilCapture) @@ -36,6 +46,12 @@ func Write(path string, c *Capture) error { data := prependCommentHeader(body, header) + for _, m := range secretMarkers { + if bytes.Contains(data, m) { + return fmt.Errorf("testcapture: Write %s: %w", path, ErrSecret) + } + } + dir := filepath.Dir(path) base := filepath.Base(path)