mirror of
https://github.com/juanfont/headscale.git
synced 2026-10-31 02:34:07 +09:00
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.
This commit is contained in:
1 parent
46e374aa78
commit
1e397e8cd5
5 files changed
+698
-8
No files matched your search
@@ -15,6 +15,7 @@ import (
|
||||
//
|
||||
// Nodes with filter rules: <X> of <Y> ← for non-SSH captures
|
||||
// Nodes with SSH rules: <X> of <Y> ← for SSH captures
|
||||
// Steps: <N> ← only when Steps is set
|
||||
// Captured at: <RFC3339 UTC>
|
||||
// tool version: <ToolVersion>
|
||||
// schema version: <SchemaVersion>
|
||||
@@ -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)
|
||||
|
||||
@@ -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:<name> or token:<client>.
|
||||
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].<field>"; 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
|
||||
}
|
||||
@@ -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
|
||||
// "<alias>#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"`
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in new issue
Block a user