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 filter rules: <X> of <Y> ← for non-SSH captures
|
||||||
// Nodes with SSH rules: <X> of <Y> ← for SSH captures
|
// Nodes with SSH rules: <X> of <Y> ← for SSH captures
|
||||||
|
// Steps: <N> ← only when Steps is set
|
||||||
// Captured at: <RFC3339 UTC>
|
// Captured at: <RFC3339 UTC>
|
||||||
// tool version: <ToolVersion>
|
// tool version: <ToolVersion>
|
||||||
// schema version: <SchemaVersion>
|
// schema version: <SchemaVersion>
|
||||||
@@ -42,6 +43,12 @@ func CommentHeader(c *Capture) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
stats := captureStats(c)
|
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 != "" {
|
if stats != "" {
|
||||||
b.WriteByte('\n')
|
b.WriteByte('\n')
|
||||||
b.WriteString(stats)
|
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]
|
// All four capture types (acl, routes, grant, ssh) use the same [Capture]
|
||||||
// shape. SSH scenarios populate Captures[name].SSHRules; the others
|
// shape. SSH scenarios populate Captures[name].SSHRules; the others
|
||||||
// populate Captures[name].PacketFilterRules + Captures[name].Netmap.
|
// 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
|
package testcapture
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -23,6 +26,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"tailscale.com/client/tailscale/apitype"
|
"tailscale.com/client/tailscale/apitype"
|
||||||
|
tsapi "tailscale.com/client/tailscale/v2"
|
||||||
"tailscale.com/tailcfg"
|
"tailscale.com/tailcfg"
|
||||||
"tailscale.com/types/netmap"
|
"tailscale.com/types/netmap"
|
||||||
"tailscale.com/wgengine/filter/filtertype"
|
"tailscale.com/wgengine/filter/filtertype"
|
||||||
@@ -62,14 +66,18 @@ type Capture struct {
|
|||||||
// ToolVersion identifies the binary that produced the file.
|
// ToolVersion identifies the binary that produced the file.
|
||||||
ToolVersion string `json:"tool_version"`
|
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
|
// Tailnet is the name of the SaaS tailnet the capture was taken
|
||||||
// against (e.g. "kratail2tid@passkey").
|
// against (e.g. "kratail2tid@passkey").
|
||||||
Tailnet string `json:"tailnet"`
|
Tailnet string `json:"tailnet"`
|
||||||
|
|
||||||
// Error is true when the SaaS API rejected the policy or when
|
// Error is true when the SaaS API rejected the policy or when
|
||||||
// capture itself failed. In the rejection case, [Capture.Captures] reflects
|
// capture itself failed. In the rejection case, [Capture.Captures] reflects
|
||||||
// the pre-push baseline (deny-all default) and [Input.APIResponseBody]
|
// the baseline still in force (the reset default has no acls or grants,
|
||||||
// is populated.
|
// so it allows all) and [Input.APIResponseBody] is populated.
|
||||||
Error bool `json:"error,omitempty"`
|
Error bool `json:"error,omitempty"`
|
||||||
|
|
||||||
// CaptureError is set when the capture itself failed (timeout,
|
// CaptureError is set when the capture itself failed (timeout,
|
||||||
@@ -86,9 +94,19 @@ type Capture struct {
|
|||||||
// capture time. Always populated by the capture tool.
|
// capture time. Always populated by the capture tool.
|
||||||
Topology Topology `json:"topology"`
|
Topology Topology `json:"topology"`
|
||||||
|
|
||||||
// Captures holds the per-node captured data, keyed by node
|
// Captures holds the per-node captured data, keyed by alias: the
|
||||||
// GivenName.
|
// 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"`
|
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
|
// 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.
|
// APIResponseBody is the (subset of) the SaaS API error response we keep.
|
||||||
type APIResponseBody struct {
|
type APIResponseBody struct {
|
||||||
Message string `json:"message,omitempty"`
|
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
|
// 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 in the tailnet. Always populated by the capture tool.
|
||||||
Users []TopologyUser `json:"users"`
|
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"`
|
Nodes map[string]TopologyNode `json:"nodes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,8 +301,8 @@ type TopologyNode struct {
|
|||||||
ApprovedRoutes []string `json:"approved_routes"`
|
ApprovedRoutes []string `json:"approved_routes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Node is the captured state for one node, keyed by GivenName in
|
// Node is the captured state for one node, keyed by alias in
|
||||||
// [Capture.Captures].
|
// [Capture.Captures] and [Step.Captures].
|
||||||
//
|
//
|
||||||
// All four capture types populate the same struct. Different fields are
|
// All four capture types populate the same struct. Different fields are
|
||||||
// used by different test types:
|
// used by different test types:
|
||||||
@@ -329,4 +350,9 @@ type Node struct {
|
|||||||
// SSHRules is the SSH rules slice extracted from
|
// SSHRules is the SSH rules slice extracted from
|
||||||
// [netmap.NetworkMap.SSHPolicy].Rules. Populated only for SSH scenarios.
|
// [netmap.NetworkMap.SSHPolicy].Rules. Populated only for SSH scenarios.
|
||||||
SSHRules []*tailcfg.SSHRule `json:"ssh_rules,omitempty"`
|
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"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/go-cmp/cmp"
|
||||||
"github.com/juanfont/headscale/hscontrol/types/testcapture"
|
"github.com/juanfont/headscale/hscontrol/types/testcapture"
|
||||||
"tailscale.com/tailcfg"
|
"tailscale.com/tailcfg"
|
||||||
"tailscale.com/types/netmap"
|
"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
|
// extractHeaderLines returns the leading "// ..." comment lines from a
|
||||||
// slice of raw file lines, stripped of the "// " prefix. Stops at the
|
// slice of raw file lines, stripped of the "// " prefix. Stops at the
|
||||||
// first non-comment line.
|
// first non-comment line.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package testcapture
|
package testcapture
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -14,6 +15,14 @@ import (
|
|||||||
// ErrNilCapture is returned by [Write] when called with a nil [Capture].
|
// ErrNilCapture is returned by [Write] when called with a nil [Capture].
|
||||||
var ErrNilCapture = errors.New("testcapture: 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 serializes c as a HuJSON file with a comment header. The
|
||||||
// write is atomic: body lands in a temp file in the target directory
|
// write is atomic: body lands in a temp file in the target directory
|
||||||
// and is then renamed into place, so concurrent regeneration cannot
|
// 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],
|
// The comment header is built by [CommentHeader] from c's [Capture.TestID],
|
||||||
// [Capture.Description], and [Capture.Captures]. The file's parent directory must
|
// [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 {
|
func Write(path string, c *Capture) error {
|
||||||
if c == nil {
|
if c == nil {
|
||||||
return fmt.Errorf("testcapture: Write %s: %w", path, ErrNilCapture)
|
return fmt.Errorf("testcapture: Write %s: %w", path, ErrNilCapture)
|
||||||
@@ -36,6 +46,12 @@ func Write(path string, c *Capture) error {
|
|||||||
|
|
||||||
data := prependCommentHeader(body, header)
|
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)
|
dir := filepath.Dir(path)
|
||||||
base := filepath.Base(path)
|
base := filepath.Base(path)
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user