mirror of
https://github.com/juanfont/headscale.git
synced 2026-11-04 12:39:25 +09:00
The client then rotates its node key and logs in again, so no key is judged under the expired one. The machine stays authorised.
629 lines
17 KiB
Go
629 lines
17 KiB
Go
// 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{}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// The map serves this capture alone, so every entry must name a check.
|
|
for k := range pendingLoginSkipReasons {
|
|
if !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 != "")
|
|
}
|