servertest, hscontrol: add ephemeral, expiry, pending login golden data

Ephemeral devices through logout, deletes, re-auth and collection;
expired routers and nodes, a client's own expiry and logout; a login
waiting across restarts, replayed exchange by exchange.
This commit is contained in:
Kristoffer Dalby committed 2026-10-28 19:06:02 +00:00
1 parent dc52fe1b66
commit c5a057f350
8 files changed
+109005 -51

No files matched your search

+632
View File
@@ -0,0 +1,632 @@
// This file replays pending interactive logins captured from Tailscale SaaS
// through the register handler, one client exchange at a time.
//
// Test data source: servertest/testdata/steps/pending-login.hujson, loaded
// by name.
// Source format: github.com/juanfont/headscale/hscontrol/types/testcapture
package hscontrol
import (
"context"
"encoding/json"
"fmt"
"maps"
"net/http"
"path/filepath"
"slices"
"strings"
"testing"
"time"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/types/testcapture"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
)
// pendingLoginSkipReasons lists checks where headscale differs from SaaS,
// keyed <test_id>/<step>/<node>/<check>. A listed check that matches fails,
// so a fix must delete its entry.
var pendingLoginSkipReasons = map[string]string{
"pending-login/PL-E2-expire-restart/pl-laptop/exchange-0": expiredRestartUnauthorized,
"pending-login/PL-E3-restart/pl-laptop/exchange-1": expiredRestartUnauthorized,
}
const expiredRestartUnauthorized = "EXPIRED_RESTART_UNAUTHORIZED: headscale answers an expired node's restart with MachineAuthorized false; SaaS answers true, and both say the node key expired"
// pendingLoginNotCompared lists, by key prefix, checks left out: SaaS's
// answer there is in doubt, so it is no reference.
var pendingLoginNotCompared = []string{
"pending-login/PL-E4-restore/pl-laptop/exchange-0",
"pending-login/PL-E4-restore/pl-laptop/devices",
}
// TestPendingLoginCompat replays a fresh machine's interactive login across
// a restart and a stop, and an expired device restarted twice while its
// login waits, then restored. Each exchange the client logged is sent as the
// client sent it: same machine and node keys, follow-ups to the URL the
// server gave. It compares each answer's flags, whether an auth URL is new or
// repeats an earlier one, and the devices on each machine with their node
// key.
func TestPendingLoginCompat(t *testing.T) {
t.Parallel()
runPendingLoginCompat(t, "pending-login")
}
func runPendingLoginCompat(t *testing.T, id string) {
t.Helper()
c, err := testcapture.Read(filepath.Join("servertest", "testdata", "steps", id+".hujson"))
require.NoError(t, err)
require.NotEmpty(t, c.Steps, "%s: not a step capture", id)
r := newPendingReplay(t, c)
for _, step := range c.Steps {
require.Equal(t, "observed", step.Result, "%s: the replay follows every step", step.Name)
r.step(t, step)
}
for k := range pendingLoginSkipReasons {
if strings.HasPrefix(k, id+"/") && !r.evaluated[k] {
t.Errorf("skip entry %q names no check", k)
}
}
}
// pendingReplay drives the register handler as the capture's clients did.
// SaaS labels for node keys and auth URLs map to the keys the replay makes
// and the auth IDs headscale returns.
type pendingReplay struct {
c *testcapture.Capture
app *Headscale
users map[string]*types.User
owner *types.User
machines map[string]key.MachinePrivate
nodeKeys map[string]key.NodePrivate
keyLabels map[key.NodePublic]string
authKeys map[string]string
// urls holds each auth URL in the order it was given: SaaS's label
// and headscale's auth ID.
urls []pendingURL
// held is the auth key each client still holds in memory, lastURL the
// auth URL label it was last given, waiting its follow-up in flight.
held map[string]string
hostname map[string]string
lastURL map[string]types.AuthID
waiting map[string]*pendingFollowup
evaluated map[string]bool
}
type pendingURL struct {
label string
authID types.AuthID
}
// pendingFollowup is a follow-up parked in the register handler.
type pendingFollowup struct {
nodeKey string
cancel context.CancelFunc
done chan struct{}
answer string
}
func newPendingReplay(t *testing.T, c *testcapture.Capture) *pendingReplay {
t.Helper()
r := &pendingReplay{
c: c,
app: createTestApp(t),
users: map[string]*types.User{},
machines: map[string]key.MachinePrivate{},
nodeKeys: map[string]key.NodePrivate{},
keyLabels: map[key.NodePublic]string{},
authKeys: map[string]string{},
held: map[string]string{},
hostname: map[string]string{},
lastURL: map[string]types.AuthID{},
waiting: map[string]*pendingFollowup{},
evaluated: map[string]bool{},
}
for _, u := range c.Topology.Users {
r.users[u.Name] = r.app.state.CreateUserForTest(u.Name)
if u.Email == c.Tailnet {
r.owner = r.users[u.Name]
}
}
require.NotNil(t, r.owner, "%s: tailnet %q is not a capture user", c.TestID, c.Tailnet)
t.Cleanup(func() {
for _, f := range r.waiting {
f.cancel()
<-f.done
}
})
return r
}
// pendingCheck is one rendering of an outcome: SaaS's, and how to read
// headscale's.
type pendingCheck struct {
name string
want string
got func() string
}
// step applies the step's actions, then sends its register exchanges, then
// compares the outcome.
func (r *pendingReplay) step(t *testing.T, step testcapture.Step) {
t.Helper()
r.apply(t, step)
var checks []pendingCheck
for _, node := range slices.Sorted(maps.Keys(step.Register)) {
for i, a := range step.Register[node] {
name := fmt.Sprintf("%s/exchange-%d", node, i)
switch {
case strings.Contains(a.Err, "context canceled"):
// The client gave up on a request when it restarted.
continue
case a.Err != "":
t.Fatalf("%s: %s: no replay for a refusal: %s", step.Name, name, a.Err)
case a.Followup && a.OffsetMS < 0:
// Sent in an earlier step and answered in this one.
f := r.waiting[node]
require.NotNil(t, f, "%s: %s answers no follow-up", step.Name, name)
require.Equal(t, a.NodeKey, f.nodeKey, "%s: %s", step.Name, name)
checks = append(checks, pendingCheck{name, registerFlags(a), f.state})
case a.Followup:
f := r.followup(t, step, node, a)
checks = append(checks, pendingCheck{name, registerFlags(a), f.state})
default:
got := r.register(t, step, node, a)
checks = append(checks, pendingCheck{name, registerFlags(a), func() string { return got }})
if a.AuthURL != "" {
checks = append(checks, r.authURLCheck(name))
}
}
}
}
checks = append(checks, r.deviceChecks(t, step)...)
r.hold(t, r.c.TestID+"/"+step.Name+"/", checks)
}
// apply runs the step's actions.
func (r *pendingReplay) apply(t *testing.T, step testcapture.Step) {
t.Helper()
for _, a := range step.Actions {
switch a.Kind {
case "join", "relogin":
r.held[a.Node] = a.Key
r.hostname[a.Node] = a.Hostname
case "restart", "kill":
// A new process: the follow-up dies and the key held in memory
// is gone.
r.stopWaiting(a.Node)
r.held[a.Node] = ""
case "retire":
r.stopWaiting(a.Node)
for _, nv := range r.nodes(a.Node) {
changes, err := r.app.state.DeleteNode(nv)
require.NoError(t, err, "%s: retire %s", step.Name, a.Node)
r.app.Change(changes...)
}
case "wait":
case "api":
r.api(t, step, a)
default:
t.Fatalf("%s: no replay for action %q", step.Name, a.Kind)
}
}
}
// api replays one admin exchange: key mints, expiry and the key expiry
// setting. Reads are what the checks compare.
func (r *pendingReplay) api(t *testing.T, step testcapture.Step, a testcapture.Action) {
t.Helper()
switch {
case a.Method == http.MethodGet:
case a.Method == http.MethodPost && a.Path == "/tailnet/-/keys":
var body struct {
Capabilities struct {
Devices struct {
Create struct {
Reusable bool `json:"reusable"`
Ephemeral bool `json:"ephemeral"`
Tags []string `json:"tags"`
} `json:"create"`
} `json:"devices"`
} `json:"capabilities"`
ExpirySeconds int `json:"expirySeconds"`
}
require.NoError(t, json.Unmarshal(a.Body, &body), "%s: key body", step.Name)
create := body.Capabilities.Devices.Create
expiry := time.Now().Add(time.Duration(body.ExpirySeconds) * time.Second)
uid := types.UserID(r.owner.ID)
pak, err := r.app.state.CreatePreAuthKey(&uid, create.Reusable, create.Ephemeral, &expiry, create.Tags)
require.NoError(t, err, "%s: mint %s", step.Name, a.Key)
r.authKeys[a.Key] = pak.Key
case a.Method == http.MethodPost && strings.HasSuffix(a.Path, "/expire"):
now := time.Now()
r.setExpiry(t, step, a, &now)
case a.Method == http.MethodPost && strings.HasSuffix(a.Path, "/key"):
var body struct {
KeyExpiryDisabled bool `json:"keyExpiryDisabled"`
}
require.NoError(t, json.Unmarshal(a.Body, &body), "%s: key body", step.Name)
// As the v2 API: only turning expiry off maps onto headscale.
if body.KeyExpiryDisabled {
r.setExpiry(t, step, a, nil)
}
default:
t.Fatalf("%s: no replay for %s %s", step.Name, a.Method, a.Path)
}
}
func (r *pendingReplay) setExpiry(t *testing.T, step testcapture.Step, a testcapture.Action, expiry *time.Time) {
t.Helper()
alias, _, _ := strings.Cut(strings.TrimPrefix(a.Path, "/device/{device:"), "}")
nodes := r.nodes(alias)
require.Len(t, nodes, 1, "%s: %s", step.Name, a.Path)
_, changes, err := r.app.state.SetNodeExpiry(nodes[0].ID(), expiry)
require.NoError(t, err, "%s: %s", step.Name, a.Path)
r.app.Change(changes)
}
// request builds the register request a client sent: its node keys, the
// key it holds, and its hostname.
func (r *pendingReplay) request(node string, a testcapture.RegisterAttempt) tailcfg.RegisterRequest {
req := tailcfg.RegisterRequest{
NodeKey: r.nodeKey(a.NodeKey).Public(),
Hostinfo: &tailcfg.Hostinfo{Hostname: r.hostname[node]},
}
if a.OldNodeKey != "" {
req.OldNodeKey = r.nodeKey(a.OldNodeKey).Public()
}
if label := r.held[node]; label != "" {
req.Auth = &tailcfg.RegisterResponseAuth{AuthKey: r.authKeys[label]}
}
return req
}
// register sends one exchange and returns its answer's flags. An auth URL
// is mapped to SaaS's label, so a follow-up can go to it.
func (r *pendingReplay) register(t *testing.T, step testcapture.Step, node string, a testcapture.RegisterAttempt) string {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
resp, err := r.app.handleRegister(ctx, r.request(node, a), r.machine(node).Public())
got := responseFlags(resp, err)
if a.AuthURL != "" && resp != nil && resp.AuthURL != "" {
authID, err := extractRegistrationIDFromAuthURL(resp.AuthURL)
require.NoError(t, err, "%s: %s's auth URL", step.Name, node)
r.urls = append(r.urls, pendingURL{label: a.AuthURL, authID: authID})
r.lastURL[node] = authID
}
return got
}
// followup parks a follow-up to the auth URL node was last given, as
// tailscaled does while the login waits.
func (r *pendingReplay) followup(t *testing.T, step testcapture.Step, node string, a testcapture.RegisterAttempt) *pendingFollowup {
t.Helper()
authID, ok := r.lastURL[node]
require.True(t, ok, "%s: %s follows up on no auth URL", step.Name, node)
r.stopWaiting(node)
req := r.request(node, a)
req.Followup = r.app.authProvider.RegisterURL(authID)
ctx, cancel := context.WithCancel(context.Background())
f := &pendingFollowup{nodeKey: a.NodeKey, cancel: cancel, done: make(chan struct{})}
machine := r.machine(node).Public()
go func() {
defer close(f.done)
resp, err := r.app.handleRegister(ctx, req, machine)
if ctx.Err() == nil {
f.answer = responseFlags(resp, err)
}
}()
r.waiting[node] = f
return f
}
// state is the follow-up's answer, or "unanswered" while it waits.
func (f *pendingFollowup) state() string {
select {
case <-f.done:
if f.answer == "" {
return "cancelled"
}
return f.answer
default:
return "unanswered"
}
}
func (r *pendingReplay) stopWaiting(node string) {
if f, ok := r.waiting[node]; ok {
f.cancel()
<-f.done
delete(r.waiting, node)
}
}
// authURLCheck says whether the last auth URL given is new or repeats an
// earlier one, on SaaS by label and on headscale by auth ID.
func (r *pendingReplay) authURLCheck(name string) pendingCheck {
last := r.urls[len(r.urls)-1]
want, got := "new", "new"
for _, earlier := range r.urls[:len(r.urls)-1] {
if earlier.label == last.label {
want = "same as " + earlier.label
}
if earlier.authID == last.authID {
got = "same as " + earlier.label
}
}
return pendingCheck{name + "-auth-url", want, func() string { return got }}
}
// deviceChecks compares the devices on each machine after the step and
// their node keys, when SaaS read them.
func (r *pendingReplay) deviceChecks(t *testing.T, step testcapture.Step) []pendingCheck {
t.Helper()
type device struct {
MachineKey string `json:"machineKey"`
NodeKey string `json:"nodeKey"`
}
var (
devices []device
read bool
single = map[string]*device{}
)
for _, a := range step.Actions {
if a.Kind != "api" || a.Method != http.MethodGet {
continue
}
switch {
case strings.HasPrefix(a.Path, "/tailnet/-/devices"):
var out struct {
Devices []device `json:"devices"`
}
require.NoError(t, json.Unmarshal(a.Response, &out), "%s: %s", step.Name, a.Path)
devices, read = out.Devices, true
case strings.HasPrefix(a.Path, "/device/{device:"):
// The last read of each device.
alias, _, _ := strings.Cut(strings.TrimPrefix(a.Path, "/device/{device:"), "}")
single[alias] = nil
if a.Status == http.StatusOK {
var d device
require.NoError(t, json.Unmarshal(a.Response, &d), "%s: %s", step.Name, a.Path)
single[alias] = &d
}
}
}
if !read {
for _, d := range single {
if d != nil {
devices = append(devices, *d)
}
}
}
var aliases []string
for alias := range r.machines {
if _, ok := single[alias]; read || ok {
aliases = append(aliases, alias)
}
}
slices.Sort(aliases)
checks := make([]pendingCheck, 0, len(aliases))
for _, alias := range aliases {
var keys []string
for _, d := range devices {
if d.MachineKey == "{machine:"+alias+"}" {
keys = append(keys, d.NodeKey)
}
}
checks = append(checks, pendingCheck{alias + "/devices", renderNodeKeys(keys), func() string {
var got []string
for _, nv := range r.nodes(alias) {
label, ok := r.keyLabels[nv.NodeKey()]
if !ok {
label = "unknown node key"
}
got = append(got, label)
}
return renderNodeKeys(got)
}})
}
return checks
}
func renderNodeKeys(keys []string) string {
slices.Sort(keys)
return fmt.Sprintf("devices %d %v", len(keys), keys)
}
// hold polls the checks until, for several ticks in a row, each one listed
// in pendingLoginSkipReasons differs from SaaS and every other one matches,
// then reports each as a subtest. A listed check that matches fails.
func (r *pendingReplay) hold(t *testing.T, prefix string, checks []pendingCheck) {
t.Helper()
checks = slices.DeleteFunc(checks, func(c pendingCheck) bool {
return notCompared(pendingLoginNotCompared, prefix+c.name)
})
const holdTicks = 5
got := make([]string, len(checks))
held := 0
tick := time.NewTicker(100 * time.Millisecond)
defer tick.Stop()
for deadline := time.Now().Add(10 * time.Second); held < holdTicks && time.Now().Before(deadline); <-tick.C {
settled := true
for i, c := range checks {
got[i] = c.got()
if _, listed := pendingLoginSkipReasons[prefix+c.name]; (got[i] == c.want) == listed {
settled = false
}
}
if settled {
held++
} else {
held = 0
}
}
for i, c := range checks {
k := prefix + c.name
r.evaluated[k] = true
t.Run(strings.TrimPrefix(k, r.c.TestID+"/"), func(t *testing.T) {
reason, listed := pendingLoginSkipReasons[k]
switch {
case listed && got[i] == c.want:
t.Errorf("matches SaaS; delete the skip entry %q (%s)", k, reason)
case listed:
t.Skip(reason)
default:
assert.Equal(t, c.want, got[i])
}
})
}
}
func (r *pendingReplay) machine(node string) key.MachinePrivate {
mk, ok := r.machines[node]
if !ok {
mk = key.NewMachine()
r.machines[node] = mk
}
return mk
}
func (r *pendingReplay) nodeKey(label string) key.NodePrivate {
nk, ok := r.nodeKeys[label]
if !ok {
nk = key.NewNode()
r.nodeKeys[label] = nk
r.keyLabels[nk.Public()] = label
}
return nk
}
func (r *pendingReplay) nodes(alias string) []types.NodeView {
mk, ok := r.machines[alias]
if !ok {
return nil
}
return slices.Collect(maps.Values(r.app.state.GetNodesByMachineKeyAllUsers(mk.Public())))
}
// registerFlags renders an answer as the client logged it: its
// NodeKeyExpired and MachineAuthorized flags and whether it carried an auth
// URL, or "unanswered" for a request still waiting.
func registerFlags(a testcapture.RegisterAttempt) string {
if a.NodeKeyExpired == nil || a.MachineAuthorized == nil {
return "unanswered"
}
return fmt.Sprintf("expired=%t authorized=%t url=%t", *a.NodeKeyExpired, *a.MachineAuthorized, a.AuthURL != "")
}
// responseFlags renders headscale's answer the way [registerFlags] renders
// SaaS's; a refusal is "error".
func responseFlags(resp *tailcfg.RegisterResponse, err error) string {
if err != nil || resp == nil || resp.Error != "" {
return "error"
}
return fmt.Sprintf("expired=%t authorized=%t url=%t", resp.NodeKeyExpired, resp.MachineAuthorized, resp.AuthURL != "")
}
+690
View File
@@ -0,0 +1,690 @@
// This file replays key expiry captures from Tailscale SaaS: a router whose
// key expires beside a standby, a client's own expiry request and logout,
// and an expired node that keeps its control session while its peers,
// routes, DNS, policy and SSH change.
//
// Test data source: testdata/steps/expiry.hujson, loaded by name.
// Source format: github.com/juanfont/headscale/hscontrol/types/testcapture
package servertest_test
import (
"encoding/json"
"fmt"
"maps"
"net/http"
"slices"
"strings"
"testing"
"time"
"github.com/juanfont/headscale/hscontrol/servertest"
"github.com/juanfont/headscale/hscontrol/types/testcapture"
"github.com/stretchr/testify/require"
"tailscale.com/tailcfg"
"tailscale.com/types/netmap"
)
// expirySkipReasons lists checks where headscale differs from SaaS, keyed
// <test_id>/<step>/<node>/<check>. A listed check that matches fails, so a
// fix must delete its entry.
var expirySkipReasons = map[string]string{
"expiry/C2-expire-r1/r1/online-observer": expiredPeerOffline,
"expiry/C2-expire-r1/r1/online-r2": expiredPeerOffline,
"expiry/C2-expire-r1/r1/paths-observer": expiredPeerPaths,
"expiry/C2-expire-r1/r1/paths-r2": expiredPeerPaths,
"expiry/C2-expire-r1/r1/self": selfExpiredFlags,
"expiry/C3-restore-r1/r1/routes-observer": restoredRouterTakesRoute,
"expiry/C3-restore-r1/r1/routes-r2": restoredRouterTakesRoute,
"expiry/C3-restore-r1/r2/routes-observer": restoredRouterTakesRoute,
"expiry/C3-restore-r1/r2/routes-r1": restoredRouterTakesRoute,
"expiry/C4-expire-r2/r2/online-observer": expiredPeerOffline,
"expiry/C4-expire-r2/r2/online-r1": expiredPeerOffline,
"expiry/C4-expire-r2/r2/paths-observer": expiredPeerPaths,
"expiry/C4-expire-r2/r2/paths-r1": expiredPeerPaths,
"expiry/C4-expire-r2/r2/self": selfExpiredFlags,
"expiry/D2-set-expire/subject/key-expiry": clientExpiryRefused,
"expiry/D2-set-expire/subject/outcome": clientExpiryRefused,
"expiry/D3-past-expiry/subject/expired-observer": clientExpiryRefused,
"expiry/D3-past-expiry/subject/key-expiry": clientExpiryRefused,
"expiry/D3-past-expiry/subject/paths-observer": clientExpiryRefused,
"expiry/D3-past-expiry/subject/self": clientExpiryRefused,
"expiry/D5-logout-tagged/tagged/key-expiry": taggedLogoutNotExpired,
"expiry/D5-logout-tagged/tagged/outcome": taggedLogoutNotExpired,
"expiry/A2-expire/laptop/online-observer": expiredPeerOffline,
"expiry/A2-expire/laptop/online-peer1": expiredPeerOffline,
"expiry/A2-expire/laptop/paths-observer": expiredPeerPaths,
"expiry/A2-expire/laptop/paths-peer1": expiredPeerPaths,
"expiry/A2-expire/laptop/self": selfExpiredFlags,
"expiry/A3-peer-change/laptop/online-observer": expiredPeerOffline,
"expiry/A3-peer-change/laptop/online-peer1": expiredPeerOffline,
"expiry/A3-peer-change/laptop/online-peer3": expiredPeerOffline,
"expiry/A3-peer-change/laptop/paths-observer": expiredPeerPaths,
"expiry/A3-peer-change/laptop/paths-peer1": expiredPeerPaths,
"expiry/A3-peer-change/laptop/paths-peer3": expiredPeerPaths,
"expiry/A3-peer-change/laptop/self": selfExpiredFlags,
"expiry/A4-rename/laptop/online-observer": expiredPeerOffline,
"expiry/A4-rename/laptop/paths-observer": expiredPeerPaths,
"expiry/A4-rename/laptop/self": selfExpiredFlags,
"expiry/A5-advertise/laptop/online-observer": expiredPeerOffline,
"expiry/A5-advertise/laptop/paths-observer": expiredPeerPaths,
"expiry/A5-advertise/laptop/routes-observer": expiredRouterUnserved,
"expiry/A5-advertise/laptop/self": selfExpiredFlags,
"expiry/A7-dns/laptop/dns": nameserversFromConfig,
"expiry/A7-dns/laptop/online-observer": expiredPeerOffline,
"expiry/A7-dns/laptop/paths-observer": expiredPeerPaths,
"expiry/A7-dns/laptop/routes-observer": expiredRouterUnserved,
"expiry/A7-dns/laptop/self": selfExpiredFlags,
"expiry/A7-dns/observer/dns": nameserversFromConfig,
"expiry/A8-policy/laptop/online-observer": expiredPeerOffline,
"expiry/A8-policy/laptop/paths-observer": expiredPeerPaths,
"expiry/A8-policy/laptop/routes-observer": expiredRouterUnserved,
"expiry/A8-policy/laptop/self": selfExpiredFlags,
"expiry/A9-ssh/laptop/online-observer": expiredPeerOffline,
"expiry/A9-ssh/laptop/paths-observer": expiredPeerPaths,
"expiry/A9-ssh/laptop/routes-observer": expiredRouterUnserved,
"expiry/A9-ssh/laptop/self": selfExpiredFlags,
"expiry/A10-reconnect/laptop/online-observer": expiredPeerOffline,
"expiry/A10-reconnect/laptop/paths-observer": expiredPeerPaths,
"expiry/A10-reconnect/laptop/routes-observer": expiredRouterUnserved,
"expiry/A10-reconnect/laptop/self": selfExpiredFlags,
"expiry/A6a-expire-again/laptop/online-observer": expiredPeerOffline,
"expiry/A6a-expire-again/laptop/online-peer1": expiredPeerOffline,
"expiry/A6a-expire-again/laptop/paths-observer": expiredPeerPaths,
"expiry/A6a-expire-again/laptop/paths-peer1": expiredPeerPaths,
"expiry/A6a-expire-again/laptop/routes-observer": expiredRouterUnserved,
"expiry/A6a-expire-again/laptop/routes-peer1": expiredRouterUnserved,
"expiry/A6a-expire-again/laptop/self": selfExpiredFlags,
"expiry/A6b-rotate-disco/laptop/online-observer": expiredPeerOffline,
"expiry/A6b-rotate-disco/laptop/online-peer1": expiredPeerOffline,
"expiry/A6b-rotate-disco/laptop/online-peer3": expiredPeerOffline,
"expiry/A6b-rotate-disco/laptop/paths-observer": expiredPeerPaths,
"expiry/A6b-rotate-disco/laptop/paths-peer1": expiredPeerPaths,
"expiry/A6b-rotate-disco/laptop/paths-peer3": expiredPeerPaths,
"expiry/A6b-rotate-disco/laptop/routes-observer": expiredRouterUnserved,
"expiry/A6b-rotate-disco/laptop/routes-peer1": expiredRouterUnserved,
"expiry/A6b-rotate-disco/laptop/routes-peer3": expiredRouterUnserved,
"expiry/A6b-rotate-disco/laptop/self": selfExpiredFlags,
}
const (
selfExpiredFlags = "SELF_EXPIRED_FLAGS: headscale sends an expired node its own entry with MachineAuthorized false and Expired set; SaaS keeps MachineAuthorized true and leaves Expired out"
expiredPeerPaths = "EXPIRED_PEER_PATHS: headscale sends peers an expired node's endpoints and home DERP; SaaS leaves both out"
clientExpiryRefused = "CLIENT_EXPIRY_REFUSED: headscale refuses a client's request for an earlier key expiry; SaaS stores it, and the node expires then"
nameserversFromConfig = "NAMESERVERS_FROM_CONFIG: headscale reads nameservers from its config and cannot change them while running; SaaS pushes a change to every node, an expired one too"
restoredRouterTakesRoute = "RESTORED_ROUTER_TAKES_ROUTE: SaaS hands a route back to a router whose key expiry is lifted, taking it from the standby that served it meanwhile; headscale keeps the standby"
)
// TestNodeExpiryCompat replays key expiry against SaaS: an expired router
// beside a standby, restored, then the standby expired; a client asking for
// an earlier expiry, waiting past it and logging out, and a tagged node
// logging out; an expired node whose peers rename and join, which renames
// itself, advertises a route, and keeps its stream while DNS, the policy and
// SSH change, reconnects, is restored, expired again and rotates its disco
// key. Each step compares every captured node's own view, every captured
// node's view of the others, its register answers, the routes API and the
// device's key expiry.
func TestNodeExpiryCompat(t *testing.T) {
t.Parallel()
t.Run("expiry", func(t *testing.T) {
t.Parallel()
runNodeExpiryCompat(t, "expiry", "observer")
})
}
func runNodeExpiryCompat(t *testing.T, id, observer string) {
t.Helper()
c := readStepCapture(t, id)
base, ok := c.Captures[observer]
require.True(t, ok && base.Netmap != nil, "%s: no baseline for %s", id, observer)
opts := []servertest.ServerOption{
servertest.WithNodeExpiry(captureKeyDuration(t, c)),
servertest.WithDNSResolvers(c.Input.Tailnet.DNS.Nameservers...),
}
if c.Input.Tailnet.DNS.MagicDNS && len(base.Netmap.DNS.Domains) > 0 {
opts = append(opts, servertest.WithMagicDNS(base.Netmap.DNS.Domains[0]))
}
r := newStepReplay(t, c, opts...)
r.paths = true
// A change is judged against the step before only, so both sides
// compare the same span.
prevCaps := c.Captures
saasPrev, hsPrev := map[string]deviceView{}, map[string]deviceView{}
evaluated := map[string]bool{}
for _, step := range c.Steps {
require.Equal(t, "observed", step.Result, "%s: the replay follows every step", step.Name)
start := map[string]*netmap.NetworkMap{}
historyStart, attempts := map[string]int{}, map[string]int{}
for alias, cl := range r.clients {
start[alias] = cl.Netmap()
historyStart[alias] = max(len(cl.History())-1, 0)
attempts[alias] = len(cl.RegisterAttempts())
if v := r.hsViews(alias); len(v) == 1 {
hsPrev[alias] = v[0]
}
}
r.apply(t, step)
e := &expiryStep{
r: r, c: c, step: step, at: stepCaptureTime(step),
prevCaps: prevCaps, start: start, historyStart: historyStart,
}
var checks []stepCheck
captured := slices.Sorted(maps.Keys(step.Captures))
for _, x := range captured {
if step.Captures[x].Netmap == nil || r.clients[x] == nil {
continue
}
checks = append(checks, e.selfChecks(x)...)
for _, v := range captured {
if v != x && step.Captures[v].Netmap != nil && r.clients[v] != nil {
checks = append(checks, e.viewChecks(x, v)...)
}
}
}
for _, node := range slices.Sorted(maps.Keys(step.Register)) {
if cl, ok := r.clients[node]; ok {
checks = append(checks, stepCheck{node + "/outcome", registerFlags(step.Register[node]), func() string {
return registerFlags(cl.RegisterAttempts()[attempts[node]:])
}})
}
}
checks = append(checks, e.apiChecks(t, saasPrev, hsPrev)...)
prefix := id + "/" + step.Name + "/"
for _, ch := range checks {
evaluated[prefix+ch.name] = true
}
if len(checks) > 0 {
t.Run(step.Name, func(t *testing.T) {
holdChecks(t, expirySkipReasons, prefix, checks, evaluated)
})
}
prevCaps = step.Captures
}
for k := range expirySkipReasons {
if strings.HasPrefix(k, id+"/") && !evaluated[k] {
t.Errorf("skip entry %q names no check", k)
}
}
}
// expiryStep is one replayed step and what both sides showed before it.
type expiryStep struct {
r *stepReplay
c *testcapture.Capture
step testcapture.Step
// at is when SaaS's captures were taken.
at time.Time
// prevCaps holds SaaS's captures of the step before; start and
// historyStart headscale's netmaps when the step began.
prevCaps map[string]testcapture.Node
start map[string]*netmap.NetworkMap
historyStart map[string]int
}
// selfChecks compares x's own view: its key expiry flags and its peers among
// the capture's nodes and, when the step before captured x too, whether it
// lost any during the step and whether its DNS, packet filter and SSH policy
// changed.
func (e *expiryStep) selfChecks(x string) []stepCheck {
saas := e.step.Captures[x].Netmap
cl := e.r.clients[x]
checks := []stepCheck{
{x + "/self", renderSelfExpiry(saas, e.at), func() string { return renderSelfExpiry(cl.Netmap(), time.Now()) }},
{x + "/peers", e.saasPeers(saas), func() string { return e.hsPeers(cl.Netmap()) }},
}
prev := e.prevCaps[x].Netmap
if prev == nil || e.start[x] == nil {
return checks
}
if frames := e.step.Frames[x]; len(frames) > 0 {
checks = append(checks, stepCheck{x + "/removed", e.saasRemovedPeers(prev, frames), func() string {
return e.hsRemovedPeers(x)
}})
}
for _, part := range []struct {
name string
render func(*netmap.NetworkMap) string
}{
{"dns", renderResolvers},
{"filter", renderFilterRules},
{"ssh", renderSSHPolicy},
} {
checks = append(checks, stepCheck{x + "/" + part.name, changed(part.render(prev), part.render(saas)), func() string {
return changed(part.render(e.start[x]), part.render(cl.Netmap()))
}})
}
return checks
}
// viewChecks compares how v sees x: expired, online, whether it has paths
// to it, the routes it is served through it, its name and, when the step
// before captured v too, whether its disco key changed.
func (e *expiryStep) viewChecks(x, v string) []stepCheck {
saasView := e.step.Captures[v].Netmap
saasID := e.c.NodeIDs[x]
saasPeer, saasMissing := peerByID(saasView, saasID)
hsPeer := func() (tailcfg.NodeView, string) {
nodes := e.r.machineNodes(x)
if len(nodes) != 1 {
return tailcfg.NodeView{}, fmt.Sprintf("devices %d", len(nodes))
}
return peerByID(e.r.clients[v].Netmap(), nodes[0].ID().NodeID())
}
checks := make([]stepCheck, 0, 6)
checks = append(checks,
stepCheck{x + "/expired-" + v, peerExpired(saasPeer, saasMissing), func() string { return peerExpired(hsPeer()) }},
stepCheck{x + "/online-" + v, peerOnline(saasPeer, saasMissing), func() string { return peerOnline(hsPeer()) }},
stepCheck{x + "/paths-" + v, peerPaths(saasPeer, saasMissing), func() string { return peerPaths(hsPeer()) }},
stepCheck{x + "/routes-" + v, servedRoutes(saasPeer, saasMissing), func() string { return servedRoutes(hsPeer()) }},
stepCheck{x + "/name-" + v, peerName(saasPeer, saasMissing), func() string { return peerName(hsPeer()) }},
)
prev := e.prevCaps[v].Netmap
nodes := e.r.machineNodes(x)
if prev == nil || e.start[v] == nil || len(nodes) != 1 {
return checks
}
hsBefore := peerDisco(e.start[v], nodes[0].ID().NodeID())
return append(checks, stepCheck{x + "/disco-" + v, discoState(peerDisco(prev, saasID), peerDisco(saasView, saasID)), func() string {
p, missing := hsPeer()
if missing != "" {
return discoState(hsBefore, missing)
}
return discoState(hsBefore, p.DiscoKey().String())
}})
}
// apiChecks compares the step's last routes read and last device read of
// each device: advertised and enabled routes, and the key expiry, which for
// a future expiry also says whether the step moved it.
func (e *expiryStep) apiChecks(t *testing.T, saasPrev, hsPrev map[string]deviceView) []stepCheck {
t.Helper()
routes := map[string]testcapture.Action{}
device := map[string]testcapture.Action{}
for _, a := range e.step.Actions {
if a.Kind != "api" || a.Method != http.MethodGet || !strings.HasPrefix(a.Path, "/device/{device:") {
continue
}
alias := deviceAlias(t, a.Path)
switch {
case strings.HasSuffix(a.Path, "/routes"):
routes[alias] = a
case strings.HasSuffix(a.Path, "?fields=all"):
device[alias] = a
}
}
var checks []stepCheck
for _, alias := range slices.Sorted(maps.Keys(routes)) {
a := routes[alias]
require.Equal(t, http.StatusOK, a.Status, "%s: %s", e.step.Name, a.Path)
var want deviceRoutes
require.NoError(t, json.Unmarshal(a.Response, &want), "%s: %s", e.step.Name, a.Path)
got := func() (deviceRoutes, bool) {
path, ok := e.r.v2Path(a.Path)
if !ok {
return deviceRoutes{}, false
}
status, body := e.r.call(t, http.MethodGet, path, nil)
var out deviceRoutes
return out, status == http.StatusOK && json.Unmarshal(body, &out) == nil
}
checks = append(checks,
stepCheck{alias + "/advertised", sortedStrings(want.Advertised), func() string {
out, ok := got()
if !ok {
return "no device"
}
return sortedStrings(out.Advertised)
}},
stepCheck{alias + "/enabled", sortedStrings(want.Enabled), func() string {
out, ok := got()
if !ok {
return "no device"
}
return sortedStrings(out.Enabled)
}},
)
}
for _, alias := range slices.Sorted(maps.Keys(device)) {
a := device[alias]
require.Equal(t, http.StatusOK, a.Status, "%s: %s", e.step.Name, a.Path)
var d saasDevice
require.NoError(t, json.Unmarshal(a.Response, &d), "%s: %s", e.step.Name, a.Path)
want := saasDeviceView(e.c, d, e.step.At.Add(time.Duration(a.OffsetMS)*time.Millisecond))
prevWant, hadWant := saasPrev[alias]
prevGot, hadGot := hsPrev[alias]
saasPrev[alias] = want
checks = append(checks, stepCheck{alias + "/key-expiry", renderKeyExpiry(want, prevWant, hadWant), func() string {
views := e.r.hsViews(alias)
if len(views) != 1 {
return fmt.Sprintf("devices %d", len(views))
}
return renderKeyExpiry(views[0], prevGot, hadGot)
}})
}
return checks
}
// saasPeers names the capture's nodes among SaaS's peers in nm.
func (e *expiryStep) saasPeers(nm *netmap.NetworkMap) string {
names := map[tailcfg.NodeID]string{}
for alias, id := range e.c.NodeIDs {
names[id] = alias
}
var out []string
for _, p := range nm.Peers {
if alias, ok := names[p.ID()]; ok {
out = append(out, alias)
}
}
return sortedStrings(out)
}
// hsPeers names the replay's nodes among headscale's peers in nm.
func (e *expiryStep) hsPeers(nm *netmap.NetworkMap) string {
if nm == nil {
return "no netmap"
}
var out []string
for _, p := range nm.Peers {
if alias, ok := e.hsAlias(p.ID()); ok {
out = append(out, alias)
}
}
return sortedStrings(out)
}
// hsAlias is the alias whose machine holds node id.
func (e *expiryStep) hsAlias(id tailcfg.NodeID) (string, bool) {
for alias := range e.r.clients {
for _, nv := range e.r.machineNodes(alias) {
if nv.ID().NodeID() == id {
return alias, true
}
}
}
return "", false
}
// saasRemovedPeers lists the capture's nodes x had as peers before the step
// and lost during it, or "none".
func (e *expiryStep) saasRemovedPeers(prev *netmap.NetworkMap, frames []testcapture.Frame) string {
var out []string
for alias, id := range e.c.NodeIDs {
if _, missing := peerByID(prev, id); missing == "" && saasRemoved(prev, frames, id) == "removed" {
out = append(out, alias)
}
}
return removedList(out)
}
func (e *expiryStep) hsRemovedPeers(x string) string {
cl := e.r.clients[x]
history := cl.History()[e.historyStart[x]:]
var out []string
if before := e.start[x]; before != nil {
for _, p := range before.Peers {
if alias, ok := e.hsAlias(p.ID()); ok && hsRemoved(history, p.ID()) == "removed" {
out = append(out, alias)
}
}
}
return removedList(out)
}
func removedList(aliases []string) string {
if len(aliases) == 0 {
return "none"
}
return sortedStrings(aliases)
}
// stepCaptureTime is when the probe took the step's captures: after its last
// action, a wait counted to its end.
func stepCaptureTime(step testcapture.Step) time.Time {
end := step.At
for _, a := range step.Actions {
at := step.At.Add(time.Duration(a.OffsetMS) * time.Millisecond)
if a.Kind == "wait" && len(a.Args) == 1 {
d, err := time.ParseDuration(a.Args[0])
if err == nil {
at = at.Add(d)
}
}
if at.After(end) {
end = at
}
}
return end
}
// captureKeyDuration is the tailnet's key lifetime, read off the first
// device that holds its key expiry.
func captureKeyDuration(t *testing.T, c *testcapture.Capture) time.Duration {
t.Helper()
for _, step := range c.Steps {
for _, a := range step.Actions {
if a.Kind != "api" || a.Method != http.MethodGet || !strings.HasSuffix(a.Path, "?fields=all") || a.Status != http.StatusOK {
continue
}
var d struct {
Created time.Time `json:"created"`
Expires time.Time `json:"expires"`
KeyExpiryDisabled bool `json:"keyExpiryDisabled"`
Tags []string `json:"tags"`
}
if json.Unmarshal(a.Response, &d) == nil && !d.KeyExpiryDisabled && len(d.Tags) == 0 {
return d.Expires.Sub(d.Created)
}
}
}
t.Fatalf("%s: no device shows the key lifetime", c.TestID)
return 0
}
// renderSelfExpiry gives a node's own entry: its key expiry relative to now,
// and its MachineAuthorized and Expired flags.
func renderSelfExpiry(nm *netmap.NetworkMap, now time.Time) string {
if nm == nil || !nm.SelfNode.Valid() {
return "no netmap"
}
self := nm.SelfNode
expiry := "zero"
if ke := self.KeyExpiry(); !ke.IsZero() {
expiry = "future"
if ke.Before(now) {
expiry = "past"
}
}
return fmt.Sprintf("key expiry %s, authorized %t, expired %t", expiry, self.MachineAuthorized(), self.Expired())
}
// renderKeyExpiry gives a device's key expiry class; a future expiry also
// says whether it moved since prev.
func renderKeyExpiry(v, prev deviceView, hadPrev bool) string {
switch {
case v.expiry != "future" || !hadPrev || prev.expiry != "future":
return v.expiry
case prev.expires == v.expires:
return "future, unchanged"
default:
return "future, moved"
}
}
func peerPaths(p tailcfg.NodeView, missing string) string {
if missing != "" {
return missing
}
return fmt.Sprintf("endpoints %t, derp %t", p.Endpoints().Len() > 0, p.HomeDERP() != 0)
}
// peerName is the first label of the peer's MagicDNS name.
func peerName(p tailcfg.NodeView, missing string) string {
if missing != "" {
return missing
}
label, _, _ := strings.Cut(p.Name(), ".")
return label
}
// discoState says whether a peer's disco key was kept or rotated, or that
// the peer appeared or is missing.
func discoState(before, after string) string {
switch {
case !strings.HasPrefix(after, "discokey:"):
return after
case !strings.HasPrefix(before, "discokey:"):
return "appeared"
case before == after:
return "kept"
default:
return "rotated"
}
}
func changed(before, after string) string {
if before == after {
return "unchanged"
}
return "changed"
}
func renderResolvers(nm *netmap.NetworkMap) string {
if nm == nil {
return "no netmap"
}
out := make([]string, 0, len(nm.DNS.Resolvers))
for _, r := range nm.DNS.Resolvers {
out = append(out, r.Addr)
}
return sortedStrings(out)
}
func renderFilterRules(nm *netmap.NetworkMap) string {
if nm == nil {
return "no netmap"
}
b, err := json.Marshal(nm.PacketFilterRules)
if err != nil {
return err.Error()
}
return string(b)
}
func renderSSHPolicy(nm *netmap.NetworkMap) string {
if nm == nil {
return "no netmap"
}
b, err := json.Marshal(nm.SSHPolicy)
if err != nil {
return err.Error()
}
return string(b)
}
+453 -4
View File
@@ -1,8 +1,10 @@
// This file replays multi-step lifecycle captures from Tailscale SaaS and
// compares each step's outcome with what SaaS showed.
//
// Test data source: testdata/steps/{route-approval,owner-routes}.hujson,
// loaded by name. owner-routes is consumed by TestReauthCompat.
// Test data source: testdata/steps/{route-approval,owner-routes,
// ephemeral-lifecycle}.hujson, loaded by name. owner-routes is consumed by
// TestReauthCompat; the refused logins of ephemeral-lifecycle are rows of
// TestAuthKeyRefusalCompat.
// Source format: github.com/juanfont/headscale/hscontrol/types/testcapture
package servertest_test
@@ -10,6 +12,7 @@ package servertest_test
import (
"encoding/json"
"fmt"
"maps"
"net/http"
"slices"
"strconv"
@@ -17,6 +20,7 @@ import (
"testing"
"time"
"github.com/juanfont/headscale/hscontrol/servertest"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/types/testcapture"
"github.com/stretchr/testify/assert"
@@ -27,8 +31,8 @@ import (
)
// lifecycleSkipReasons lists checks where headscale differs from SaaS, keyed
// <test_id>/<step>/<check>. A listed check that matches fails, so a fix must
// delete its entry.
// <test_id>/<step>[/<node or action index>]/<check>. A listed check that
// matches fails, so a fix must delete its entry.
var lifecycleSkipReasons = map[string]string{
"route-approval/b6/enabled": approvalOnHostinfo,
"route-approval/b6/served": approvalOnHostinfo,
@@ -46,6 +50,22 @@ var lifecycleSkipReasons = map[string]string{
"route-approval/b9/self-routes": approvalOnSameKeyReauth,
"route-approval/r1/served": loggedOutRouterUnserved,
"ephemeral-lifecycle/E1-logout/eph-logout/outcome": logoutAnswerUnauthorized,
"ephemeral-lifecycle/E1-logout/eph-logout-user/outcome": logoutAnswerUnauthorized,
"ephemeral-lifecycle/E3-key-delete/2/keys": deletedKeyListed,
"ephemeral-lifecycle/E3-key-delete/3/keys": deletedKeyListed,
"ephemeral-lifecycle/E3-key-delete/4/status": deletedKeyDeleteNotFound,
"ephemeral-lifecycle/E4-reauth-other-key/eph-to-perm/ephemeral": ephemeralFollowsKey,
"ephemeral-lifecycle/E4-reauth-other-key/perm-to-eph/ephemeral": ephemeralFollowsKey,
"ephemeral-lifecycle/E6-poll-14/eph-to-perm/left": ephemeralFollowsKey,
"ephemeral-lifecycle/E6-poll-14/perm-to-eph/left": ephemeralFollowsKey,
"ephemeral-lifecycle/E5-mem-shutdown/mem/expiry": taggedLogoutNotExpired,
"ephemeral-lifecycle/E5-mem-shutdown/mem/outcome": taggedLogoutNotExpired,
"ephemeral-lifecycle/E5-mem-shutdown/mem/peer-observer": taggedLogoutNotExpired,
}
// SaaS runs auto-approval when a router's announced routes change and on a
@@ -57,6 +77,13 @@ const (
loggedOutRouterUnserved = "LOGGED_OUT_ROUTER_UNSERVED: headscale stops serving a logged-out router's routes; SaaS keeps them on the expired, offline peer"
)
const (
logoutAnswerUnauthorized = "LOGOUT_ANSWER_UNAUTHORIZED: headscale answers the logout that deletes an ephemeral node with MachineAuthorized false; SaaS answers true"
deletedKeyListed = "DELETED_KEY_LISTED: headscale lists a deleted auth key; SaaS leaves it out of both key lists, while a read by ID still answers"
deletedKeyDeleteNotFound = "DELETED_KEY_DELETE_NOT_FOUND: headscale answers 404 to a second delete of an auth key; SaaS answers 200"
ephemeralFollowsKey = "EPHEMERAL_FOLLOWS_KEY: headscale takes a node's ephemeral flag from the key it last logged in with; SaaS keeps the flag the device was created with, so it is collected, or kept, as created"
)
// Used by reauthSkipReasons.
const (
expiredRouterUnserved = "EXPIRED_ROUTER_UNSERVED: headscale stops serving an expired router's routes; SaaS keeps them in the peer's AllowedIPs and PrimaryRoutes"
@@ -544,3 +571,425 @@ func hsRemoved(history []*netmap.NetworkMap, id tailcfg.NodeID) string {
return "kept"
}
// TestEphemeralLifecycleCompat replays ephemeral devices through logout, a
// rejoin, a device deleted twice, a deleted key and a restart after it,
// re-auth with a key of the other kind, a shutdown with in-memory state and
// a kill of several devices. Each step compares with SaaS: the status of
// every admin exchange, key validity and listing, register answers, the
// devices on each machine, their ephemeral flag, node key, address and
// expiry, the observer's view and removals, and at the last read which
// devices are left once the killed ones could be collected.
func TestEphemeralLifecycleCompat(t *testing.T) {
t.Parallel()
t.Run("ephemeral-lifecycle", func(t *testing.T) {
t.Parallel()
runEphemeralLifecycleCompat(t, "ephemeral-lifecycle", "observer")
})
}
func runEphemeralLifecycleCompat(t *testing.T, id, observer string) {
t.Helper()
c := readStepCapture(t, id)
// Short, so the killed devices can be collected while the last read is
// checked; collection timing is not compared.
r := newStepReplay(t, c, servertest.WithEphemeralTimeout(2*time.Second))
r.readAPI = true
lastRead := -1
for i, step := range c.Steps {
if _, _, ok := stepDeviceRead(step); ok {
lastRead = i
}
}
saasFirst, hsFirst := map[string]deviceView{}, map[string]deviceView{}
saasPrev := map[string][]deviceView{}
down := map[string]bool{}
evaluated := map[string]bool{}
prevObs := c.Captures[observer].Netmap
for i, step := range c.Steps {
require.Contains(t, []string{"observed", "expected-refusal"}, step.Result,
"%s: the replay follows every step", step.Name)
cells := lifecycleCells(step)
hsPrev := map[string][]deviceView{}
attempts := map[string]int{}
for _, cell := range cells {
hsPrev[cell] = r.hsViews(cell)
if cl, ok := r.clients[cell]; ok {
attempts[cell] = len(cl.RegisterAttempts())
}
}
historyStart := 0
if obs, ok := r.clients[observer]; ok {
historyStart = max(len(obs.History())-1, 0)
}
r.apply(t, step)
markDown(step, down)
checks := r.answerChecks(step)
checks = append(checks, r.keyChecks(step)...)
checks = append(checks, r.keyListChecks(t, step)...)
devices, readAt, listed := stepDeviceRead(step)
r.firstDevices(c, step, saasFirst, hsFirst)
for _, cell := range cells {
// A device killed in the step is judged at the last read only:
// when it is collected is timing.
want, ok := saasCellDevices(c, step, cell)
if down[cell] || !ok {
continue
}
checks = append(checks, ephemeralCellChecks(r, step, cell, observer, cellState{
want: want, saasPrev: saasPrev[cell], hsPrev: hsPrev[cell],
saasFirst: saasFirst[cell], hsFirst: hsFirst[cell],
attempts: attempts[cell], prevObs: prevObs, historyStart: historyStart,
saasAll: devices, listed: listed,
})...)
}
if i == lastRead {
checks = append(checks, r.leftChecks(c, step)...)
}
prefix := id + "/" + step.Name + "/"
for _, ch := range checks {
evaluated[prefix+ch.name] = true
}
if len(checks) > 0 {
t.Run(step.Name, func(t *testing.T) {
holdChecks(t, lifecycleSkipReasons, prefix, checks, evaluated)
})
}
// A device list shows every machine; a device read only its own.
if listed {
for alias := range r.clients {
saasPrev[alias] = saasDeviceViews(c, devices, alias, readAt)
}
}
for _, cell := range cells {
if want, ok := saasCellDevices(c, step, cell); ok {
saasPrev[cell] = want
}
}
if obs, ok := step.Captures[observer]; ok && obs.Netmap != nil {
prevObs = obs.Netmap
}
}
for k := range lifecycleSkipReasons {
if strings.HasPrefix(k, id+"/") && !evaluated[k] {
t.Errorf("skip entry %q names no check", k)
}
}
}
// markDown records the nodes a step killed, and those it brought back.
func markDown(step testcapture.Step, down map[string]bool) {
for _, a := range step.Actions {
switch a.Kind {
case "kill":
down[a.Node] = true
case "start", "join", "relogin", "restart":
down[a.Node] = false
}
}
}
// firstDevices records each machine's device as it first joined: on SaaS
// from the first device list that shows it.
func (r *stepReplay) firstDevices(c *testcapture.Capture, step testcapture.Step, saasFirst, hsFirst map[string]deviceView) {
devices, at, listed := stepDeviceRead(step)
for alias := range r.clients {
if _, seen := saasFirst[alias]; !seen && listed {
if views := saasDeviceViews(c, devices, alias, at); len(views) > 0 {
saasFirst[alias] = views[0]
}
}
if _, seen := hsFirst[alias]; !seen {
if views := r.hsViews(alias); len(views) > 0 {
hsFirst[alias] = views[0]
}
}
}
}
// leftChecks compares how many devices each machine holds at the last read.
// A device the polls still saw when they gave up is not observed, not kept.
func (r *stepReplay) leftChecks(c *testcapture.Capture, step testcapture.Step) []stepCheck {
devices, at, _ := stepDeviceRead(step)
_, unseen, _ := strings.Cut(step.Note, "not observed: ")
notObserved := strings.Split(unseen, ", ")
checks := make([]stepCheck, 0, len(r.clients))
for _, alias := range slices.Sorted(maps.Keys(r.clients)) {
if slices.Contains(notObserved, alias) {
continue
}
checks = append(checks, stepCheck{alias + "/left", strconv.Itoa(len(saasDeviceViews(c, devices, alias, at))), func() string {
return strconv.Itoa(len(r.machineNodes(alias)))
}})
}
return checks
}
// cellState is what a cell's checks compare against: SaaS's devices for the
// cell in the step and both sides' devices before it.
type cellState struct {
want []deviceView
saasPrev, hsPrev []deviceView
saasFirst, hsFirst deviceView
attempts int
prevObs *netmap.NetworkMap
historyStart int
saasAll []saasDevice
listed bool
}
// ephemeralCellChecks compares one cell after a step: its devices, their
// ephemeral flag, node key, address and expiry, its register answers, and the
// observer's view of it and whether the observer lost it.
func ephemeralCellChecks(r *stepReplay, step testcapture.Step, cell, observer string, st cellState) []stepCheck {
got := func() []deviceView { return r.hsViews(cell) }
byID := func(views []deviceView) map[string]deviceView {
out := make(map[string]deviceView, len(views))
for _, v := range views {
out[v.id] = v
}
return out
}
checks := []stepCheck{
{cell + "/devices", renderDevices(st.want, st.saasFirst), func() string { return renderDevices(got(), st.hsFirst) }},
{cell + "/ephemeral", renderEphemeral(st.want), func() string { return renderEphemeral(got()) }},
{cell + "/expiry", renderExpiry(st.want, byID(st.saasPrev)), func() string { return renderExpiry(got(), byID(st.hsPrev)) }},
}
if len(st.saasPrev) > 0 {
checks = append(checks, stepCheck{cell + "/node-key", renderNodeKeys(st.want, byID(st.saasPrev)), func() string {
return renderNodeKeys(got(), byID(st.hsPrev))
}})
}
if st.listed {
saasAddrs := map[string]int{}
for _, d := range st.saasAll {
saasAddrs[sortedStrings(d.Addresses)]++
}
checks = append(checks, stepCheck{cell + "/address", renderAddresses(st.want, st.saasFirst, saasAddrs), func() string {
hsAddrs := map[string]int{}
for _, nv := range r.srv.State().ListNodes().All() {
hsAddrs[sortedStrings(nv.IPsAsString())]++
}
return renderAddresses(got(), st.hsFirst, hsAddrs)
}})
}
if saas := step.Register[cell]; len(saas) > 0 {
checks = append(checks, stepCheck{cell + "/outcome", registerFlags(saas), func() string {
return registerFlags(r.clients[cell].RegisterAttempts()[st.attempts:])
}})
}
if view, ok := step.Captures[observer]; ok && cell != observer {
checks = append(checks, stepCheck{cell + "/peer-" + observer, renderPeers(st.want, view.Netmap), func() string {
return renderPeers(got(), r.clients[observer].Netmap())
}})
}
if frames := step.Frames[observer]; len(frames) > 0 && cell != observer {
saas := make([]string, 0, len(st.saasPrev))
for _, v := range st.saasPrev {
saas = append(saas, saasRemoved(st.prevObs, frames, v.nodeID))
}
checks = append(checks, stepCheck{cell + "/removed-" + observer, strings.Join(saas, "; "), func() string {
history := r.clients[observer].History()[st.historyStart:]
hs := make([]string, 0, len(st.hsPrev))
for _, v := range st.hsPrev {
hs = append(hs, hsRemoved(history, v.nodeID))
}
return strings.Join(hs, "; ")
}})
}
return checks
}
// lifecycleCells returns the nodes a step's actions name, in order: the
// node of a client action and the device of an API path.
func lifecycleCells(step testcapture.Step) []string {
var cells []string
for _, a := range step.Actions {
node := a.Node
if rest, ok := strings.CutPrefix(a.Path, "/device/{device:"); ok {
node, _, _ = strings.Cut(rest, "}")
}
if node != "" && !slices.Contains(cells, node) {
cells = append(cells, node)
}
}
return cells
}
// saasCellDevices returns SaaS's devices on cell's machine after the step:
// from its last device list, else from its last read of cell's device. It
// reports false when the step read neither.
func saasCellDevices(c *testcapture.Capture, step testcapture.Step, cell string) ([]deviceView, bool) {
if devices, at, ok := stepDeviceRead(step); ok {
return saasDeviceViews(c, devices, cell, at), true
}
for _, a := range slices.Backward(step.Actions) {
if a.Kind != "api" || a.Method != http.MethodGet || !strings.HasPrefix(a.Path, "/device/{device:"+cell+"}?") {
continue
}
if a.Status != http.StatusOK {
return nil, true
}
var d saasDevice
if json.Unmarshal(a.Response, &d) != nil {
return nil, false
}
return []deviceView{saasDeviceView(c, d, step.At.Add(time.Duration(a.OffsetMS)*time.Millisecond))}, true
}
return nil, false
}
// renderAddresses says, per device, whether it holds the first device's
// addresses and whether no other device holds them.
func renderAddresses(views []deviceView, first deviceView, holders map[string]int) string {
return renderEach(views, func(v deviceView) string {
return fmt.Sprintf("first's %t, unique %t", v.addrs == first.addrs, holders[v.addrs] == 1)
})
}
// registerFlags renders each register answer the client logged: its
// NodeKeyExpired and MachineAuthorized flags and whether it carried an auth
// URL, or "error" for a refusal and "unanswered" for a request still waiting.
// Requests the client cancelled are left out.
func registerFlags(attempts []testcapture.RegisterAttempt) string {
out := make([]string, 0, len(attempts))
for _, a := range attempts {
switch {
case strings.Contains(a.Err, "context canceled"):
case a.Err != "":
out = append(out, "error")
case a.MachineAuthorized == nil || a.NodeKeyExpired == nil:
out = append(out, "unanswered")
default:
out = append(out, fmt.Sprintf("expired=%t authorized=%t url=%t",
*a.NodeKeyExpired, *a.MachineAuthorized, a.AuthURL != ""))
}
}
return strings.Join(out, "; ")
}
// answerChecks compares the status of each admin exchange headscale
// answered in the step, keyed by the action's index.
func (r *stepReplay) answerChecks(step testcapture.Step) []stepCheck {
checks := make([]stepCheck, 0, len(r.answers))
for _, i := range slices.Sorted(maps.Keys(r.answers)) {
got := strconv.Itoa(r.answers[i].status)
checks = append(checks, stepCheck{strconv.Itoa(i) + "/status", strconv.Itoa(step.Actions[i].Status), func() string {
return got
}})
}
return checks
}
// keyListChecks compares which of the replay's keys each key listing in the
// step names.
func (r *stepReplay) keyListChecks(t *testing.T, step testcapture.Step) []stepCheck {
t.Helper()
labels := map[string]string{}
for label, id := range r.keyIDs {
labels[strconv.FormatUint(id, 10)] = label
}
listedIn := func(body []byte, label func(string) (string, bool)) string {
var out struct {
Keys []struct {
ID string `json:"id"`
} `json:"keys"`
}
require.NoError(t, json.Unmarshal(body, &out), "%s: key list", step.Name)
var got []string
for _, k := range out.Keys {
if l, ok := label(k.ID); ok {
got = append(got, l)
}
}
return sortedStrings(got)
}
var checks []stepCheck
for i, a := range step.Actions {
ans, ok := r.answers[i]
if !ok || a.Method != http.MethodGet || !strings.HasPrefix(a.Path, "/tailnet/-/keys") || strings.Contains(a.Path, "{key:") {
continue
}
want := listedIn(a.Response, func(id string) (string, bool) {
l, ok := strings.CutPrefix(id, "{key:")
l = strings.TrimSuffix(l, "}")
_, minted := r.keyIDs[l]
return l, ok && minted
})
got := ""
if ans.status == http.StatusOK {
got = listedIn(ans.body, func(id string) (string, bool) { l, ok := labels[id]; return l, ok })
}
checks = append(checks, stepCheck{strconv.Itoa(i) + "/keys", want, func() string { return got }})
}
return checks
}
+14 -2
View File
@@ -3,7 +3,8 @@
// Test data source: testdata/steps/{owner-routes,reauth,reauth-foreign,
// reauth-owner,identity,reauth-user}.hujson, loaded by name. owner-routes is
// checked by runRouteLifecycleCompat in lifecycle_compat_test.go. The tag
// owner verdicts of tag-owner-chain.hujson are checked in ../policy/v2.
// owner verdicts of tag-owner-chain.hujson are checked in ../policy/v2. The
// refused logins of ephemeral-lifecycle.hujson are rows here too.
// Source format: github.com/juanfont/headscale/hscontrol/types/testcapture
package servertest_test
@@ -81,6 +82,11 @@ var reauthSkipReasons = map[string]string{
"reauth/late-pak-spent-key/late-pak/text": refusalText,
"reauth/late-pak-spent-key/late-pak/legacy-text": refusalText,
"ephemeral-lifecycle/late-revoked/late/text": refusalText,
"ephemeral-lifecycle/late-revoked/late/legacy-text": refusalText,
"ephemeral-lifecycle/late-expired/late-exp/text": refusalText,
"ephemeral-lifecycle/late-expired/late-exp/legacy-text": refusalText,
}
const (
@@ -928,12 +934,15 @@ func (r *stepReplay) addrNames() map[netip.Addr]string {
}
// authKeyRefusalRows are fresh machines logging in with a key whose state
// the capture shows: spent, and a valid control row.
// the capture shows: spent, revoked by a delete, expired, and a valid
// control row.
var authKeyRefusalRows = []struct {
id, step, node string
}{
{"reauth", "late-pak-spent-key", "late-pak"},
{"reauth", "t-valid-join", "t-valid"},
{"ephemeral-lifecycle", "late-revoked", "late"},
{"ephemeral-lifecycle", "late-expired", "late-exp"},
}
// refusalCheck reports whether a check name belongs to
@@ -991,6 +1000,9 @@ func runAuthKeyRefusalCompat(t *testing.T, id, stepName, node string, legacy boo
c := readStepCapture(t, id)
r := newStepReplay(t, c)
// Statuses are compared by the capture's own test; here a difference
// must not stop the replay.
r.readAPI = true
i := slices.IndexFunc(c.Steps, func(s testcapture.Step) bool { return s.Name == stepName })
require.GreaterOrEqual(t, i, 0, "%s: no step %s", id, stepName)
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+222 -45
View File
@@ -478,9 +478,9 @@ func readStepCapture(t *testing.T, id string) *testcapture.Capture {
// stepReplay drives a step capture's actions against headscale: policy
// pushes, key mints and revokes, joins, relogins, profile switches,
// restarts, route announcements, logouts, retires and admin calls, the
// last through the v2 API with the bodies SaaS received. Nothing is seeded
// from the capture's end state.
// restarts, kills, shutdowns, route announcements, logouts, retires and admin
// calls, the last through the v2 API with the bodies SaaS received. Nothing
// is seeded from the capture's end state.
type stepReplay struct {
c *testcapture.Capture
srv *servertest.TestServer
@@ -500,6 +500,27 @@ type stepReplay struct {
// profiles holds the login a node switched away from.
profiles map[string]persist.Persist
// keyExpires holds each minted key's SaaS expiry until the replay has
// expired it; see [stepReplay.expireDueKeys].
keyExpires map[string]time.Time
// lastIDs holds each machine's last single node, so a deleted device can
// still be addressed.
lastIDs map[string]string
// memState lists the nodes whose tailscaled ran with --state=mem:.
memState map[string]bool
// paths, when set, gives each joining client a home DERP and an
// endpoint, as a running tailscaled reports them.
paths bool
// readAPI, when set, replays reads too and keeps headscale's answer to
// each admin exchange in answers, by action index, instead of failing on
// a status that differs from SaaS's.
readAPI bool
answers map[int]apiAnswer
// interactive, when set, picks the joins and logins replayed as a login
// approved by the key's user instead of with the key.
interactive func(step testcapture.Step, a testcapture.Action) bool
@@ -529,11 +550,28 @@ func newStepReplay(t *testing.T, c *testcapture.Capture, opts ...servertest.Serv
keyTags: map[string][]string{},
keyUsers: map[string]*types.User{},
profiles: map[string]persist.Persist{},
keyExpires: map[string]time.Time{},
lastIDs: map[string]string{},
memState: memStateNodes[c.TestID],
}
}
// memStateNodes lists, per capture, the nodes the probe ran with
// --state=mem:, which a join action does not record.
var memStateNodes = map[string]map[string]bool{
"ephemeral-lifecycle": {"mem": true, "mem-kill": true},
}
// apiAnswer is headscale's answer to one replayed admin exchange.
type apiAnswer struct {
status int
body []byte
}
// apply runs the step's actions in order and returns the logins headscale
// refused, by node. Reads are skipped: they are what consumers compare.
// refused, by node. Reads are what consumers compare, so they are skipped
// unless readAPI is set.
func (r *stepReplay) apply(t *testing.T, step testcapture.Step) map[string]error {
t.Helper()
@@ -542,7 +580,13 @@ func (r *stepReplay) apply(t *testing.T, step testcapture.Step) map[string]error
refused := map[string]error{}
for _, a := range step.Actions {
r.expireDueKeys(t, step.At)
if r.readAPI {
r.answers = map[int]apiAnswer{}
}
for i, a := range step.Actions {
switch a.Kind {
case "policy":
require.Equal(t, http.StatusOK, a.Status, "%s: replay of a refused policy", step.Name)
@@ -556,7 +600,7 @@ func (r *stepReplay) apply(t *testing.T, step testcapture.Step) map[string]error
r.setPolicy(t, step.Name, policy)
case "api":
r.api(t, step.Name, a)
r.api(t, step.Name, i, a)
case "join":
hi := &tailcfg.Hostinfo{
@@ -566,7 +610,22 @@ func (r *stepReplay) apply(t *testing.T, step testcapture.Step) map[string]error
}
r.hostinfo[a.Node] = hi
opts := []servertest.ClientOption{servertest.WithUser(r.admin), servertest.WithHostinfo(hi.Clone())}
opts := []servertest.ClientOption{servertest.WithUser(r.admin)}
if r.memState[a.Node] {
opts = append(opts, servertest.WithLoginFlags(controlclient.LoginEphemeral))
}
if r.paths {
hi.NetInfo = &tailcfg.NetInfo{PreferredDERP: 1}
opts = append(opts, servertest.WithEndpoints(tailcfg.Endpoint{
Addr: netip.AddrPortFrom(netip.MustParseAddr("192.0.2.1"), uint16(41641+len(r.clients))), //nolint:gosec // a handful of clients
Type: tailcfg.EndpointExplicitConf,
}))
}
opts = append(opts, servertest.WithHostinfo(hi.Clone()))
if r.interactive != nil && r.interactive(step, a) {
opts = append(opts, servertest.WithInteractiveLogin(r.approver(ctx, t, a.Key)))
} else {
@@ -587,6 +646,8 @@ func (r *stepReplay) apply(t *testing.T, step testcapture.Step) map[string]error
r.announce(ctx, t, a.Node, a.Routes)
}
r.remember(a.Node)
case "relogin":
// The probe restates hostname and routes on every login.
hi := r.hostinfo[a.Node].Clone()
@@ -609,6 +670,8 @@ func (r *stepReplay) apply(t *testing.T, step testcapture.Step) map[string]error
refused[a.Node] = err
}
r.remember(a.Node)
case "switch_profile":
cl := r.clients[a.Node]
@@ -644,28 +707,9 @@ func (r *stepReplay) apply(t *testing.T, step testcapture.Step) map[string]error
refused[a.Node] = err
}
case "restart":
require.NoError(t, r.clients[a.Node].Restart(ctx, r.hostinfo[a.Node].Clone()), "%s: restart", step.Name)
case "advertise":
r.announce(ctx, t, a.Node, a.Routes)
case "cli":
args := strings.Join(a.Args, " ")
if args == "logout" {
require.NoError(t, r.clients[a.Node].LogoutAndDisconnect(ctx), "%s: logout", step.Name)
continue
}
hostname, ok := strings.CutPrefix(args, "set --hostname=")
require.True(t, ok, "%s: no replay for cli %q", step.Name, a.Args)
r.hostinfo[a.Node].Hostname = hostname
r.sendHostinfo(ctx, t, a.Node)
case "logout":
require.NoError(t, r.clients[a.Node].LogoutAndDisconnect(ctx), "%s: logout", step.Name)
case "restart", "start", "kill", "shutdown", "logout", "advertise", "set_hostname",
"break_tcp_conns", "rotate_disco_key", "cli":
r.daemon(ctx, t, step.Name, a)
case "retire":
// SaaS stops the daemon and deletes its devices.
@@ -688,6 +732,110 @@ func (r *stepReplay) apply(t *testing.T, step testcapture.Step) map[string]error
return refused
}
// daemon replays what a node's tailscaled or its CLI did.
func (r *stepReplay) daemon(ctx context.Context, t *testing.T, stepName string, a testcapture.Action) {
t.Helper()
cl := r.clients[a.Node]
args := strings.Join(a.Args, " ")
switch {
case a.Kind == "restart" || a.Kind == "start":
// start follows a kill: the daemon comes back on its state.
require.NoError(t, cl.Restart(ctx, r.hostinfo[a.Node].Clone()), "%s: %s", stepName, a.Kind)
case a.Kind == "kill", a.Kind == "shutdown" && !r.memState[a.Node]:
cl.Disconnect(t)
case a.Kind == "shutdown", a.Kind == "logout", a.Kind == "cli" && args == "logout":
// tailscaled logs out on shutdown only with in-memory state.
require.NoError(t, cl.LogoutAndDisconnect(ctx), "%s: %s", stepName, a.Kind)
case a.Kind == "advertise":
r.announce(ctx, t, a.Node, a.Routes)
case a.Kind == "set_hostname":
r.hostinfo[a.Node].Hostname = a.Hostname
r.sendHostinfo(ctx, t, a.Node)
case a.Kind == "break_tcp_conns":
// The client polls again under the same key.
require.NoError(t, cl.RestartPoll(ctx), "%s: reconnect", stepName)
case a.Kind == "rotate_disco_key":
require.NoError(t, cl.RotateDisco(ctx), "%s: disco rotation", stepName)
case strings.HasPrefix(args, "debug set-expire --in="):
d, err := time.ParseDuration(strings.TrimPrefix(args, "debug set-expire --in="))
require.NoError(t, err, "%s: set-expire duration", stepName)
// shortcut: the replay asks for at most setExpireLifetime, so the
// wait that follows need not be real; a check of the exact expiry
// would need the full duration.
// A refusal is in the client's register attempts.
_ = cl.SetExpirySooner(ctx, time.Now().Add(min(d, setExpireLifetime)))
case strings.HasPrefix(args, "set --hostname="):
r.hostinfo[a.Node].Hostname = strings.TrimPrefix(args, "set --hostname=")
r.sendHostinfo(ctx, t, a.Node)
default:
t.Fatalf("%s: no replay for %s %q", stepName, a.Kind, a.Args)
}
}
// setExpireLifetime bounds the key lifetime a replayed
// `tailscale debug set-expire` asks for.
const setExpireLifetime = 5 * time.Second
// expireDueKeys expires each minted key whose SaaS expiry passed before at,
// so a step taken after a key's lifetime finds it expired, as SaaS did,
// without the replay waiting.
func (r *stepReplay) expireDueKeys(t *testing.T, at time.Time) {
t.Helper()
for label, exp := range r.keyExpires {
if exp.Before(at) {
require.NoError(t, r.srv.State().ExpirePreAuthKey(r.keyIDs[label]), "expire key %s", label)
delete(r.keyExpires, label)
}
}
}
// remember records alias's node while its machine holds exactly one.
func (r *stepReplay) remember(alias string) {
if nodes := r.machineNodes(alias); len(nodes) == 1 {
r.lastIDs[alias] = nodes[0].StringID()
}
}
// v2Path maps a SaaS API path onto headscale's v2 API, and its device and
// key labels onto the replay's IDs. A device is its machine's one node, or
// the last one it held. It reports false for a label the replay never
// created and for a machine holding several nodes.
func (r *stepReplay) v2Path(path string) (string, bool) {
if rest, ok := strings.CutPrefix(path, "/device/{device:"); ok {
alias, tail, _ := strings.Cut(rest, "}")
if len(r.machineNodes(alias)) > 1 {
return "", false
}
r.remember(alias)
id, ok := r.lastIDs[alias]
return "/api/v2/device/" + id + tail, ok
}
if rest, ok := strings.CutPrefix(path, "/tailnet/-/keys/{key:"); ok {
label, tail, _ := strings.Cut(rest, "}")
id, ok := r.keyIDs[label]
return "/api/v2/tailnet/-/keys/" + strconv.FormatUint(id, 10) + tail, ok
}
return "/api/v2" + path, true
}
// requestTags is the Hostinfo.RequestTags a login sends: the action's when
// it set them, else the ones the node sent last.
func requestTags(a testcapture.Action, last []string) []string {
@@ -724,15 +872,26 @@ func (r *stepReplay) setPolicy(t *testing.T, stepName, policy string) {
}
}
// api replays one admin exchange. Device and key deletes go to the v2 API;
// key mints and expiry, which the v2 API lacks or would answer with
// secrets, go to State.
func (r *stepReplay) api(t *testing.T, stepName string, a testcapture.Action) {
// api replays one admin exchange, the i-th action of its step. Device and
// key deletes go to the v2 API; key mints and expiry, which the v2 API lacks
// or would answer with secrets, go to State. Reads are replayed only with
// readAPI.
func (r *stepReplay) api(t *testing.T, stepName string, i int, a testcapture.Action) {
t.Helper()
switch {
case a.Method == http.MethodGet:
return
if !r.readAPI {
return
}
if path, ok := r.v2Path(a.Path); ok {
r.exchange(t, stepName, i, a, path)
}
case a.Method == http.MethodPost && strings.HasPrefix(a.Path, "/tailnet/-/dns/"):
// Headscale reads DNS from its config, which a consumer sets from
// the capture's input; it has no API to change it.
case a.Method == http.MethodPost && a.Path == "/tailnet/-/acl":
require.Equal(t, http.StatusOK, a.Status, "%s: replay of a refused policy", stepName)
@@ -775,13 +934,18 @@ func (r *stepReplay) api(t *testing.T, stepName string, a testcapture.Action) {
r.keyTags[a.Key] = create.Tags
r.keyUsers[a.Key] = user
case a.Method == http.MethodDelete && strings.HasPrefix(a.Path, "/tailnet/-/keys/{key:"):
label := strings.TrimSuffix(strings.TrimPrefix(a.Path, "/tailnet/-/keys/{key:"), "}")
id, ok := r.keyIDs[label]
require.True(t, ok, "%s: delete of unknown key %q", stepName, label)
var minted struct {
Expires time.Time `json:"expires"`
}
if json.Unmarshal(a.Response, &minted) == nil && !minted.Expires.IsZero() {
r.keyExpires[a.Key] = minted.Expires
}
status, resp := r.call(t, a.Method, "/api/v2/tailnet/-/keys/"+strconv.FormatUint(id, 10), nil)
require.Equal(t, a.Status, status, "%s: %s %s: %s", stepName, a.Method, a.Path, resp)
case a.Method == http.MethodDelete && strings.HasPrefix(a.Path, "/tailnet/-/keys/{key:"):
path, ok := r.v2Path(a.Path)
require.True(t, ok, "%s: delete of unknown key %q", stepName, a.Path)
r.exchange(t, stepName, i, a, path)
case a.Method == http.MethodPost && strings.HasSuffix(a.Path, "/expire"):
now := time.Now()
@@ -791,18 +955,31 @@ func (r *stepReplay) api(t *testing.T, stepName string, a testcapture.Action) {
r.srv.App.Change(c)
case strings.HasPrefix(a.Path, "/device/"):
alias := deviceAlias(t, a.Path)
path := "/api/v2/device/" + r.node(t, alias).StringID() +
strings.TrimPrefix(a.Path, "/device/{device:"+alias+"}")
path, ok := r.v2Path(a.Path)
require.True(t, ok, "%s: %s names no single device", stepName, a.Path)
status, resp := r.call(t, a.Method, path, a.Body)
require.Equal(t, a.Status, status, "%s: %s %s: %s", stepName, a.Method, a.Path, resp)
r.exchange(t, stepName, i, a, path)
default:
t.Fatalf("%s: no replay for %s %s", stepName, a.Method, a.Path)
}
}
// exchange sends a's request to headscale at path. With readAPI the answer
// is kept for the consumer; otherwise its status must be SaaS's.
func (r *stepReplay) exchange(t *testing.T, stepName string, i int, a testcapture.Action, path string) {
t.Helper()
status, resp := r.call(t, a.Method, path, a.Body)
if r.readAPI {
r.answers[i] = apiAnswer{status: status, body: resp}
return
}
require.Equal(t, a.Status, status, "%s: %s %s: %s", stepName, a.Method, a.Path, resp)
}
// call sends one v2 API request as the admin.
func (r *stepReplay) call(t *testing.T, method, path string, body []byte) (int, []byte) {
t.Helper()