mirror of
https://github.com/juanfont/headscale.git
synced 2026-10-31 10:44:07 +09:00
1124 lines
35 KiB
Go
1124 lines
35 KiB
Go
// This file replays re-authentication captures from Tailscale SaaS.
|
|
//
|
|
// 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.
|
|
// Source format: github.com/juanfont/headscale/hscontrol/types/testcapture
|
|
|
|
package servertest_test
|
|
|
|
import (
|
|
"cmp"
|
|
"encoding/json"
|
|
"fmt"
|
|
"maps"
|
|
"net/http"
|
|
"net/netip"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"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/require"
|
|
"golang.org/x/crypto/bcrypt"
|
|
"tailscale.com/tailcfg"
|
|
"tailscale.com/types/netmap"
|
|
"tailscale.com/wgengine/filter"
|
|
)
|
|
|
|
// reauthSkipReasons 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 reauthSkipReasons = map[string]string{
|
|
"owner-routes/r-expired-retag/expire/served": expiredRouterUnserved,
|
|
"owner-routes/r-expired-retag/expire/online": expiredPeerOffline,
|
|
|
|
"owner-routes/r-expired-retag/owner/node-key": expiredKeyAccepted,
|
|
|
|
"owner-routes/r-admin/owner/enabled": adminRetagNoApproval,
|
|
"owner-routes/r-admin/owner/served": adminRetagNoApproval,
|
|
"owner-routes/r-admin/owner/self-routes": adminRetagNoApproval,
|
|
|
|
"owner-routes/r-retag/withdraw/enabled": withdrawKeepsAutoApproval,
|
|
"owner-routes/r-expired-retag/withdraw/enabled": withdrawKeepsAutoApproval,
|
|
"owner-routes/u-convert/withdraw/enabled": withdrawKeepsAutoApproval,
|
|
|
|
"reauth/t-spent-relogin/t-spent/outcome": expiredKeyNotRotated,
|
|
"reauth/t-revoked-relogin/t-revoked/outcome": expiredKeyNotRotated,
|
|
"reauth/t-valid-relogin/t-valid/outcome": expiredKeyNotRotated,
|
|
"reauth/t-valid-relogin/t-valid/node-key": expiredKeyAccepted,
|
|
"reauth-user/a4-expired-spent-key/u-spent/outcome": expiredKeyNotRotated,
|
|
"reauth-user/a5-expired-valid-key/u-valid/outcome": expiredKeyNotRotated,
|
|
"reauth-user/a5-expired-valid-key/u-valid/node-key": expiredKeyAccepted,
|
|
|
|
"reauth-owner/u-renew-relogin/u-renew/expiry": sameKeyRenewsExpiry,
|
|
"reauth-user/a1-spent-key/u-spent/expiry": sameKeyRenewsExpiry,
|
|
"reauth-user/a2-valid-key/u-valid/expiry": sameKeyRenewsExpiry,
|
|
|
|
"reauth-foreign/t-reauth-foreign-relogin/t-reauth-foreign/devices": otherUserKeyReauthsTagged,
|
|
"reauth-foreign/t-reauth-foreign-relogin/t-reauth-foreign/owner": otherUserKeyReauthsTagged,
|
|
"reauth-foreign/t-reauth-foreign-relogin/t-reauth-foreign/node-key": otherUserKeyReauthsTagged,
|
|
"reauth-foreign/t-reauth-foreign-relogin/t-reauth-foreign/expiry": otherUserKeyReauthsTagged,
|
|
"reauth-foreign/t-reauth-foreign-relogin/t-reauth-foreign/ephemeral": otherUserKeyReauthsTagged,
|
|
"reauth-foreign/t-reauth-foreign-relogin/t-reauth-foreign/peer-obs": otherUserKeyReauthsTagged,
|
|
"reauth-foreign/t-reauth-foreign-relogin/obs/filter": otherUserKeyReauthsTagged,
|
|
"reauth-foreign/t-reauth-foreign-relogin/t-reauth-foreign/login-devices": loginTakesTaggedDevice,
|
|
"reauth-foreign/t-reauth-foreign-relogin/t-reauth-foreign/login-owner": loginTakesTaggedDevice,
|
|
"reauth-foreign/t-exp-foreign-relogin/t-exp-foreign/devices": otherUserKeyReauthsTagged,
|
|
"reauth-foreign/t-exp-foreign-relogin/t-exp-foreign/owner": otherUserKeyReauthsTagged,
|
|
"reauth-foreign/t-exp-foreign-relogin/t-exp-foreign/node-key": otherUserKeyReauthsTagged,
|
|
"reauth-foreign/t-exp-foreign-relogin/t-exp-foreign/expiry": otherUserKeyReauthsTagged,
|
|
"reauth-foreign/t-exp-foreign-relogin/t-exp-foreign/ephemeral": otherUserKeyReauthsTagged,
|
|
"reauth-foreign/t-exp-foreign-relogin/t-exp-foreign/peer-obs": otherUserKeyReauthsTagged,
|
|
"reauth-foreign/t-exp-foreign-relogin/obs/filter": otherUserKeyReauthsTagged,
|
|
"reauth-foreign/t-exp-foreign-relogin/t-exp-foreign/login-devices": loginTakesTaggedDevice,
|
|
"reauth-foreign/t-exp-foreign-relogin/t-exp-foreign/login-owner": loginTakesTaggedDevice,
|
|
|
|
"reauth-foreign/t-eph-spent-foreign-key/t-eph/ephemeral": spentKeyRebindsNode,
|
|
"reauth-foreign/t-eph-logout/t-eph/devices": spentKeyRebindsNode,
|
|
"reauth-foreign/t-eph-logout/t-eph/owner": spentKeyRebindsNode,
|
|
"reauth-foreign/t-eph-logout/t-eph/expiry": spentKeyRebindsNode,
|
|
"reauth-foreign/t-eph-logout/t-eph/ephemeral": spentKeyRebindsNode,
|
|
"reauth-foreign/t-eph-logout/t-eph/peer-obs": spentKeyRebindsNode,
|
|
|
|
"reauth-foreign/t-logout-logout/t-logout/expiry": taggedLogoutNotExpired,
|
|
"reauth-foreign/t-logout-logout/t-logout/peer-obs": taggedLogoutNotExpired,
|
|
"reauth-foreign/t-logout-relogin/t-logout/expiry": taggedLogoutNotExpired,
|
|
"reauth-foreign/t-logout-relogin/t-logout/peer-obs": taggedLogoutNotExpired,
|
|
|
|
"reauth-foreign/t-wake-foreign-key-tags/t-wake/outcome": wakeWithTagsRefused,
|
|
"identity/w-tagreq-relogin-tags/w-tagreq/owner": keyLoginTagsRefused,
|
|
"identity/w-tagreq-relogin-tags/w-tagreq/outcome": keyLoginTagsRefused,
|
|
"identity/w-tagreq-relogin-tags/w-tagreq/node-key": keyLoginTagsRefused,
|
|
"identity/w-tagreq-relogin-tags/w-tagreq/expiry": keyLoginTagsRefused,
|
|
"identity/w-tagreq-relogin-tags/obs/filter": keyLoginTagsRefused,
|
|
"identity/w-tagreq-new-join/w-tagreq-new/devices": keyLoginTagsRefused,
|
|
"identity/w-tagreq-new-join/w-tagreq-new/owner": keyLoginTagsRefused,
|
|
"identity/w-tagreq-new-join/w-tagreq-new/outcome": keyLoginTagsRefused,
|
|
"identity/w-tagreq-new-join/w-tagreq-new/expiry": keyLoginTagsRefused,
|
|
"identity/w-tagreq-new-join/w-tagreq-new/ephemeral": keyLoginTagsRefused,
|
|
"identity/w-tagreq-new-join/w-tagreq-new/peer-obs": keyLoginTagsRefused,
|
|
"identity/w-tagreq-new-join/w-tagreq-new/peer-key-obs": keyLoginTagsRefused,
|
|
|
|
"reauth-owner/u-tagged-reauth-relogin/u-tagged-reauth/owner": adminTagsKeptOnUserLogin,
|
|
"reauth-owner/u-tagged-reauth-relogin/u-tagged-reauth/expiry": adminTagsKeptOnUserLogin,
|
|
"reauth-owner/u-tagged-reauth-relogin/obs/filter": adminTagsKeptOnUserLogin,
|
|
|
|
"reauth/late-pak-spent-key/late-pak/text": refusalText,
|
|
"reauth/late-pak-spent-key/late-pak/legacy-text": refusalText,
|
|
}
|
|
|
|
const (
|
|
expiredKeyAccepted = "EXPIRED_KEY_ACCEPTED: headscale re-registers an expired node under its old node key; SaaS answers NodeKeyExpired, so the client rotates it"
|
|
adminRetagNoApproval = "ADMIN_RETAG_NO_APPROVAL: an admin tag change does not auto-approve the routes the new tags may approve; SaaS approves them at once"
|
|
withdrawKeepsAutoApproval = "WITHDRAW_KEEPS_AUTO_APPROVAL: headscale keeps an auto-approved route once it is withdrawn; SaaS drops it and keeps only admin-set approvals"
|
|
)
|
|
|
|
const (
|
|
expiredKeyNotRotated = "EXPIRED_KEY_NOT_ROTATED: headscale answers an expired node's key login without asking for a new node key; SaaS answers NodeKeyExpired first, so the client rotates it"
|
|
sameKeyRenewsExpiry = "SAME_KEY_LOGIN_RENEWS_EXPIRY: headscale renews a user node's expiry on a key login that keeps its node key; SaaS renews it only with a new node key"
|
|
otherUserKeyReauthsTagged = "OTHER_USER_KEY_REAUTHS_TAGGED: headscale re-registers a tagged device in place when another user's key logs in anew on its machine; SaaS keeps the tagged device and adds one for that user"
|
|
loginTakesTaggedDevice = "LOGIN_TAKES_TAGGED_DEVICE: headscale's interactive login gives another user the tagged device on the machine; SaaS's key login keeps it and adds a device for that user"
|
|
spentKeyRebindsNode = "SPENT_KEY_REBINDS_NODE: headscale binds a live node to the spent key it logs in with, so an ephemeral node stops being ephemeral and survives logout; SaaS keeps the node as it was"
|
|
taggedLogoutNotExpired = "TAGGED_LOGOUT_NOT_EXPIRED: headscale leaves a tagged node unexpired when it logs out; SaaS expires it"
|
|
wakeWithTagsRefused = "WAKE_WITH_TAGS_REFUSED: headscale refuses a key login that requests tags the key does not carry even when the node keeps its node key and tags; SaaS accepts it and changes nothing"
|
|
keyLoginTagsRefused = "KEY_LOGIN_TAGS_REFUSED: headscale refuses a user's key login that requests tags the user owns; SaaS tags the device, converting it in place or creating it tagged"
|
|
adminTagsKeptOnUserLogin = "ADMIN_TAGS_KEPT_ON_USER_LOGIN: headscale keeps an admin-tagged device tagged when its user's key logs in anew; SaaS turns it back into that user's device"
|
|
refusalText = "REFUSAL_TEXT: SaaS refuses a key by naming its ID; headscale says why the key is refused"
|
|
)
|
|
|
|
// reauthNotCompared lists, by key prefix, checks left out: SaaS's answer
|
|
// there is in doubt, so it is no reference.
|
|
var reauthNotCompared = []string{
|
|
"reauth-user/a8-convert/key:A-Kp",
|
|
"reauth-user/a8-convert/u-convert/expiry",
|
|
"reauth-user/a9-used-tagged-key/key:A-Kp",
|
|
"reauth-user/a9-used-tagged-key/u-loser-same/owner",
|
|
"reauth-user/a9-used-tagged-key/u-loser-same/outcome",
|
|
"reauth-user/a9-used-tagged-key/obs/filter",
|
|
"reauth-user/a9-used-tagged-key/t-server/filter",
|
|
"reauth-user/a10-used-tagged-key-force/u-loser-force/owner",
|
|
"reauth-user/a10-used-tagged-key-force/u-loser-force/outcome",
|
|
"reauth-user/a10-used-tagged-key-force/u-loser-force/node-key",
|
|
"reauth-user/a10-used-tagged-key-force/u-loser-force/expiry",
|
|
"reauth-user/a10-used-tagged-key-force/obs/filter",
|
|
"reauth-user/a10-used-tagged-key-force/t-server/filter",
|
|
}
|
|
|
|
// reauthCompatTests are the captures [runReauthCompat] replays.
|
|
var reauthCompatTests = []string{"reauth", "reauth-foreign", "reauth-owner", "identity", "reauth-user"}
|
|
|
|
// reauthObservers stay up through a capture: their view of each cell and
|
|
// their own filters are checked.
|
|
var reauthObservers = []string{"obs", "t-server"}
|
|
|
|
// TestReauthCompat replays re-authentication against SaaS. owner-routes:
|
|
// owner changes on routers with approved routes. The others: expired and
|
|
// live devices logging in again with spent, revoked, valid and other users'
|
|
// keys, logout, ephemeral devices, admin tagging, conversion either way, a
|
|
// second profile, and one single-use tagged key presented by several
|
|
// devices. via=login replays a user's key logins as interactive logins that
|
|
// user approves.
|
|
func TestReauthCompat(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("owner-routes", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
runRouteLifecycleCompat(t, "owner-routes", "obs", reauthSkipReasons)
|
|
})
|
|
|
|
for _, id := range reauthCompatTests {
|
|
for _, via := range []string{"key", "login"} {
|
|
t.Run(id+"/via="+via, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
runReauthCompat(t, id, via == "login")
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// runReauthCompat replays capture id step by step and checks each cell the
|
|
// step acted on against SaaS:
|
|
// - outcome: the login's register answers, see [registerOutcome];
|
|
// - devices, owner, node-key, expiry, ephemeral: every device on the
|
|
// cell's machine, in join order;
|
|
// - peer-<observer>, peer-key, disco: how the observers see them;
|
|
//
|
|
// and, per step, each observer's filter and each key SaaS read back. With
|
|
// viaLogin, a login SaaS accepted from a user's key is replayed as an
|
|
// interactive login that user approves, and only its devices and owner, and
|
|
// its expiry when SaaS ended tagged, are checked.
|
|
func runReauthCompat(t *testing.T, id string, viaLogin bool) {
|
|
t.Helper()
|
|
|
|
c := readStepCapture(t, id)
|
|
|
|
days := c.Input.Tailnet.Settings.DevicesKeyDurationDays
|
|
require.NotNil(t, days, "%s: no key duration", id)
|
|
|
|
run := &reauthRun{
|
|
c: c,
|
|
r: newStepReplay(t, c, servertest.WithNodeExpiry(time.Duration(*days)*24*time.Hour)),
|
|
viaLogin: viaLogin,
|
|
loggedOut: map[string]bool{},
|
|
saasFirst: map[string]deviceView{},
|
|
hsFirst: map[string]deviceView{},
|
|
saasPrev: map[string]deviceView{},
|
|
evaluated: map[string]bool{},
|
|
}
|
|
|
|
if viaLogin {
|
|
run.r.interactive = func(step testcapture.Step, a testcapture.Action) bool {
|
|
return loginReplayable(step, a, run.r.keyTags, run.loggedOut[a.Node])
|
|
}
|
|
}
|
|
|
|
for _, step := range c.Steps {
|
|
require.Contains(t, []string{"observed", "expected-refusal"}, step.Result,
|
|
"%s: the replay follows every step", step.Name)
|
|
|
|
run.step(t, step)
|
|
}
|
|
|
|
for k := range reauthSkipReasons {
|
|
check := k[strings.LastIndex(k, "/")+1:]
|
|
if !strings.HasPrefix(k, id+"/") || refusalCheck(check) ||
|
|
strings.HasPrefix(check, "login-") != viaLogin {
|
|
continue
|
|
}
|
|
|
|
if !run.evaluated[k] {
|
|
t.Errorf("skip entry %q names no check", k)
|
|
}
|
|
}
|
|
}
|
|
|
|
// reauthRun is one replay of a re-authentication capture and what earlier
|
|
// steps showed on each side.
|
|
type reauthRun struct {
|
|
c *testcapture.Capture
|
|
r *stepReplay
|
|
viaLogin bool
|
|
|
|
loggedOut map[string]bool
|
|
|
|
// saasFirst and hsFirst hold each cell's first device; saasPrev each
|
|
// SaaS device as last listed.
|
|
saasFirst, hsFirst map[string]deviceView
|
|
saasPrev map[string]deviceView
|
|
|
|
prevCaptures map[string]testcapture.Node
|
|
evaluated map[string]bool
|
|
}
|
|
|
|
// before is headscale's state for a step's cells just before it runs.
|
|
type before struct {
|
|
attempts map[string]int
|
|
views map[string]deviceView
|
|
disco map[string]string
|
|
}
|
|
|
|
func (run *reauthRun) step(t *testing.T, step testcapture.Step) {
|
|
t.Helper()
|
|
|
|
r := run.r
|
|
cells, logins := stepCells(step)
|
|
|
|
prev := before{attempts: map[string]int{}, views: map[string]deviceView{}, disco: map[string]string{}}
|
|
|
|
for _, cell := range cells {
|
|
if cl, ok := r.clients[cell]; ok {
|
|
prev.attempts[cell] = len(cl.RegisterAttempts())
|
|
}
|
|
|
|
for _, v := range r.hsViews(cell) {
|
|
prev.views[v.id] = v
|
|
|
|
for _, obs := range reauthObservers {
|
|
if cl, ok := r.clients[obs]; ok {
|
|
prev.disco[obs+"/"+v.id] = peerDisco(cl.Netmap(), v.nodeID)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
r.apply(t, step)
|
|
|
|
// After apply: a step's own mints decide its keys' tags.
|
|
replayed := map[string]bool{}
|
|
for node, a := range logins {
|
|
replayed[node] = loginReplayable(step, a, r.keyTags, run.loggedOut[node])
|
|
}
|
|
|
|
for _, a := range step.Actions {
|
|
switch a.Kind {
|
|
case "cli":
|
|
run.loggedOut[a.Node] = strings.Join(a.Args, " ") == "logout"
|
|
case "join", "relogin", "login", "switch_profile":
|
|
run.loggedOut[a.Node] = false
|
|
}
|
|
}
|
|
|
|
defer func() { run.prevCaptures = step.Captures }()
|
|
|
|
devices, readAt, ok := stepDeviceRead(step)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var checks []stepCheck
|
|
|
|
for _, cell := range cells {
|
|
if run.viaLogin && !replayed[cell] {
|
|
continue
|
|
}
|
|
|
|
login, isLogin := logins[cell]
|
|
checks = append(checks, run.cellChecks(step, cell, saasDeviceViews(run.c, devices, cell, readAt), prev, login, isLogin)...)
|
|
}
|
|
|
|
if !run.viaLogin {
|
|
checks = append(checks, r.keyChecks(step)...)
|
|
checks = append(checks, r.filterChecks(t, step, devices)...)
|
|
}
|
|
|
|
prefix := run.c.TestID + "/" + step.Name + "/"
|
|
checks = slices.DeleteFunc(checks, func(ch stepCheck) bool {
|
|
return notCompared(reauthNotCompared, prefix+ch.name)
|
|
})
|
|
|
|
// Marked here, so a run filtered to other steps does not call these
|
|
// entries stale.
|
|
for _, ch := range checks {
|
|
run.evaluated[prefix+ch.name] = true
|
|
}
|
|
|
|
if len(checks) > 0 {
|
|
t.Run(step.Name, func(t *testing.T) {
|
|
holdChecks(t, reauthSkipReasons, prefix, checks, run.evaluated)
|
|
})
|
|
}
|
|
|
|
for _, cell := range cells {
|
|
if want := saasDeviceViews(run.c, devices, cell, readAt); len(want) > 0 {
|
|
if _, ok := run.saasFirst[cell]; !ok {
|
|
run.saasFirst[cell] = want[0]
|
|
}
|
|
}
|
|
|
|
if got := r.hsViews(cell); len(got) > 0 {
|
|
if _, ok := run.hsFirst[cell]; !ok {
|
|
run.hsFirst[cell] = got[0]
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, d := range devices {
|
|
run.saasPrev[d.ID] = saasDeviceView(run.c, d, readAt)
|
|
}
|
|
}
|
|
|
|
// cellChecks builds one cell's checks for a step; want is SaaS's view of
|
|
// the cell's devices.
|
|
func (run *reauthRun) cellChecks(
|
|
step testcapture.Step,
|
|
cell string,
|
|
want []deviceView,
|
|
prev before,
|
|
login testcapture.Action,
|
|
isLogin bool,
|
|
) []stepCheck {
|
|
r := run.r
|
|
got := func() []deviceView { return r.hsViews(cell) }
|
|
|
|
var checks []stepCheck
|
|
|
|
check := func(name, want string, got func() string) {
|
|
if run.viaLogin {
|
|
name = "login-" + name
|
|
}
|
|
|
|
checks = append(checks, stepCheck{cell + "/" + name, want, got})
|
|
}
|
|
|
|
first, ok := run.saasFirst[cell]
|
|
if !ok && len(want) > 0 {
|
|
first = want[0]
|
|
}
|
|
|
|
check("devices", renderDevices(want, first), func() string {
|
|
views := got()
|
|
|
|
f, ok := run.hsFirst[cell]
|
|
if !ok && len(views) > 0 {
|
|
f = views[0]
|
|
}
|
|
|
|
return renderDevices(views, f)
|
|
})
|
|
check("owner", renderOwners(want), func() string { return renderOwners(got()) })
|
|
|
|
if run.viaLogin {
|
|
if len(want) > 0 && !slices.ContainsFunc(want, func(v deviceView) bool { return !v.tagged }) {
|
|
check("expiry", renderExpiry(want, run.saasPrev), func() string { return renderExpiry(got(), prev.views) })
|
|
}
|
|
|
|
return checks
|
|
}
|
|
|
|
if isLogin {
|
|
check("outcome", registerOutcome(step.Register[login.Node]), func() string {
|
|
return registerOutcome(r.clients[cell].RegisterAttempts()[prev.attempts[cell]:])
|
|
})
|
|
}
|
|
|
|
if slices.ContainsFunc(want, func(v deviceView) bool { _, ok := run.saasPrev[v.id]; return ok }) {
|
|
check("node-key", renderNodeKeys(want, run.saasPrev), func() string { return renderNodeKeys(got(), prev.views) })
|
|
}
|
|
|
|
check("expiry", renderExpiry(want, run.saasPrev), func() string { return renderExpiry(got(), prev.views) })
|
|
check("ephemeral", renderEphemeral(want), func() string { return renderEphemeral(got()) })
|
|
|
|
self := step.Captures[cell]
|
|
restarted := slices.ContainsFunc(step.Actions, func(a testcapture.Action) bool {
|
|
return a.Kind == "restart" && a.Node == cell
|
|
})
|
|
|
|
for _, obs := range reauthObservers {
|
|
view, captured := step.Captures[obs]
|
|
if !captured || obs == cell {
|
|
continue
|
|
}
|
|
|
|
check("peer-"+obs, renderPeers(want, view.Netmap), func() string {
|
|
return renderPeers(got(), r.clients[obs].Netmap())
|
|
})
|
|
|
|
if self.Netmap == nil {
|
|
continue
|
|
}
|
|
|
|
if self.BackendState == "Running" {
|
|
check("peer-key-"+obs, peerKeyState(view.Netmap, self.Netmap), func() string {
|
|
return peerKeyState(r.clients[obs].Netmap(), r.clients[cell].Netmap())
|
|
})
|
|
}
|
|
|
|
if restarted {
|
|
selfID := self.Netmap.SelfNode.ID()
|
|
saas := discoChange(peerDisco(run.prevCaptures[obs].Netmap, selfID), peerDisco(view.Netmap, selfID))
|
|
|
|
check("disco-"+obs, saas, func() string {
|
|
views := got()
|
|
if len(views) == 0 {
|
|
return "no device"
|
|
}
|
|
|
|
v := views[len(views)-1]
|
|
|
|
return discoChange(prev.disco[obs+"/"+v.id], peerDisco(r.clients[obs].Netmap(), v.nodeID))
|
|
})
|
|
}
|
|
}
|
|
|
|
return checks
|
|
}
|
|
|
|
// stepCells returns the nodes a step acts on, in order, and the last login
|
|
// each made. A step that names none, such as a key deletion, acts on every
|
|
// captured node that is not an observer.
|
|
func stepCells(step testcapture.Step) ([]string, map[string]testcapture.Action) {
|
|
var cells []string
|
|
|
|
logins := map[string]testcapture.Action{}
|
|
|
|
add := func(node string) {
|
|
if !slices.Contains(cells, node) {
|
|
cells = append(cells, node)
|
|
}
|
|
}
|
|
|
|
for _, a := range step.Actions {
|
|
switch a.Kind {
|
|
case "join", "relogin", "login", "switch_profile":
|
|
add(a.Node)
|
|
logins[a.Node] = a
|
|
case "restart", "cli", "logout":
|
|
add(a.Node)
|
|
case "api":
|
|
if strings.HasPrefix(a.Path, "/device/") {
|
|
alias, _, _ := strings.Cut(strings.TrimPrefix(a.Path, "/device/{device:"), "}")
|
|
add(alias)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(cells) == 0 {
|
|
for _, node := range slices.Sorted(maps.Keys(step.Captures)) {
|
|
if !slices.Contains(reauthObservers, node) {
|
|
add(node)
|
|
}
|
|
}
|
|
}
|
|
|
|
return cells, logins
|
|
}
|
|
|
|
// loginReplayable reports whether via=login replays a as an interactive
|
|
// login: a join, a new profile, or a relogin that starts a new login (forced,
|
|
// or after a logout), made with a user's untagged key and accepted by SaaS.
|
|
// A key's refusal says nothing about who may approve a login.
|
|
func loginReplayable(step testcapture.Step, a testcapture.Action, keyTags map[string][]string, loggedOut bool) bool {
|
|
switch a.Kind {
|
|
case "join", "login":
|
|
case "relogin":
|
|
if !a.Force && !loggedOut {
|
|
return false
|
|
}
|
|
default:
|
|
return false
|
|
}
|
|
|
|
tags, minted := keyTags[a.Key]
|
|
|
|
return minted && len(tags) == 0 && step.Result == "observed" &&
|
|
strings.HasSuffix(registerOutcome(step.Register[a.Node]), "accepted")
|
|
}
|
|
|
|
// registerOutcome reads a login's register exchanges up to the answer that
|
|
// ends it: "node-key-expired" for each answer asking for a new node key,
|
|
// then "accepted", "refused" or "auth-url". Errors of a login the client
|
|
// cancelled to start another are skipped.
|
|
func registerOutcome(attempts []testcapture.RegisterAttempt) string {
|
|
var out []string
|
|
|
|
for _, a := range attempts {
|
|
switch {
|
|
case strings.Contains(a.Err, "context canceled"):
|
|
case a.Err != "":
|
|
return strings.Join(append(out, "refused"), ", ")
|
|
case a.AuthURL != "":
|
|
return strings.Join(append(out, "auth-url"), ", ")
|
|
case a.NodeKeyExpired != nil && *a.NodeKeyExpired:
|
|
out = append(out, "node-key-expired")
|
|
case a.MachineAuthorized != nil && *a.MachineAuthorized:
|
|
return strings.Join(append(out, "accepted"), ", ")
|
|
}
|
|
}
|
|
|
|
return strings.Join(append(out, "unanswered"), ", ")
|
|
}
|
|
|
|
// stepDeviceRead returns the step's last device list and when SaaS read it.
|
|
func stepDeviceRead(step testcapture.Step) ([]saasDevice, time.Time, bool) {
|
|
for _, a := range slices.Backward(step.Actions) {
|
|
if a.Kind != "api" || a.Method != http.MethodGet || !strings.HasPrefix(a.Path, "/tailnet/-/devices") {
|
|
continue
|
|
}
|
|
|
|
var out struct {
|
|
Devices []saasDevice `json:"devices"`
|
|
}
|
|
|
|
if json.Unmarshal(a.Response, &out) != nil {
|
|
return nil, time.Time{}, false
|
|
}
|
|
|
|
return out.Devices, step.At.Add(time.Duration(a.OffsetMS) * time.Millisecond), true
|
|
}
|
|
|
|
return nil, time.Time{}, false
|
|
}
|
|
|
|
// deviceView is one device as the checks read it, from a SaaS device list
|
|
// or headscale's NodeStore.
|
|
type deviceView struct {
|
|
id string // SaaS label or headscale node ID
|
|
nodeID tailcfg.NodeID
|
|
tagged bool
|
|
owner string
|
|
expiry string // "disabled", "past" or "future"
|
|
expires string // the raw expiry, to tell a renewal from no change
|
|
ephemeral bool
|
|
nodeKey string
|
|
addrs string
|
|
ips []netip.Addr
|
|
}
|
|
|
|
// saasDeviceViews returns the devices on cell's machine in join order: the
|
|
// first device, then "#2" onward.
|
|
func saasDeviceViews(c *testcapture.Capture, devices []saasDevice, cell string, at time.Time) []deviceView {
|
|
var out []deviceView
|
|
|
|
for _, d := range devices {
|
|
if d.MachineKey == "{machine:"+cell+"}" {
|
|
out = append(out, saasDeviceView(c, d, at))
|
|
}
|
|
}
|
|
|
|
slices.SortFunc(out, func(a, b deviceView) int {
|
|
return cmp.Or(cmp.Compare(len(a.id), len(b.id)), strings.Compare(a.id, b.id))
|
|
})
|
|
|
|
return out
|
|
}
|
|
|
|
func saasDeviceView(c *testcapture.Capture, d saasDevice, at time.Time) deviceView {
|
|
expiry := "disabled"
|
|
|
|
exp, err := time.Parse(time.RFC3339, d.Expires)
|
|
if err == nil && !d.KeyExpiryDisabled {
|
|
expiry = "future"
|
|
if exp.Before(at) {
|
|
expiry = "past"
|
|
}
|
|
}
|
|
|
|
return deviceView{
|
|
id: d.ID,
|
|
nodeID: c.NodeIDs[deviceLabelAlias(d.ID)],
|
|
tagged: len(d.Tags) > 0,
|
|
owner: saasOwner(d),
|
|
expiry: expiry,
|
|
expires: d.Expires,
|
|
ephemeral: d.IsEphemeral,
|
|
nodeKey: d.NodeKey,
|
|
addrs: sortedStrings(d.Addresses),
|
|
}
|
|
}
|
|
|
|
// deviceLabelAlias turns "{device:x#2}" into "x#2".
|
|
func deviceLabelAlias(label string) string {
|
|
return strings.TrimSuffix(strings.TrimPrefix(label, "{device:"), "}")
|
|
}
|
|
|
|
// hsViews returns the nodes on alias's machine in join order.
|
|
func (r *stepReplay) hsViews(alias string) []deviceView {
|
|
nodes := r.machineNodes(alias)
|
|
slices.SortFunc(nodes, func(a, b types.NodeView) int { return cmp.Compare(a.ID(), b.ID()) })
|
|
|
|
out := make([]deviceView, 0, len(nodes))
|
|
|
|
for _, nv := range nodes {
|
|
v := deviceView{
|
|
id: nv.StringID(),
|
|
nodeID: nv.ID().NodeID(),
|
|
tagged: nv.IsTagged(),
|
|
expiry: "disabled",
|
|
ephemeral: nv.IsEphemeral(),
|
|
nodeKey: nv.NodeKey().String(),
|
|
addrs: sortedStrings(nv.IPsAsString()),
|
|
ips: nv.IPs(),
|
|
}
|
|
|
|
if v.tagged {
|
|
v.owner = "tags " + sortedStrings(nv.Tags().AsSlice())
|
|
} else {
|
|
v.owner = "user " + nv.User().Name()
|
|
}
|
|
|
|
if exp := nv.Expiry(); exp.Valid() && !exp.Get().IsZero() {
|
|
v.expires = exp.Get().String()
|
|
|
|
v.expiry = "future"
|
|
if exp.Get().Before(time.Now()) {
|
|
v.expiry = "past"
|
|
}
|
|
}
|
|
|
|
out = append(out, v)
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func renderDevices(views []deviceView, first deviceView) string {
|
|
kept := slices.ContainsFunc(views, func(v deviceView) bool {
|
|
return v.id == first.id && v.addrs == first.addrs
|
|
})
|
|
|
|
return fmt.Sprintf("devices %d, first kept %t", len(views), kept)
|
|
}
|
|
|
|
func renderOwners(views []deviceView) string {
|
|
return renderEach(views, func(v deviceView) string { return v.owner })
|
|
}
|
|
|
|
func renderNodeKeys(views []deviceView, prev map[string]deviceView) string {
|
|
return renderEach(views, func(v deviceView) string {
|
|
p, ok := prev[v.id]
|
|
|
|
switch {
|
|
case !ok:
|
|
return "new"
|
|
case p.nodeKey == v.nodeKey:
|
|
return "kept"
|
|
default:
|
|
return "rotated"
|
|
}
|
|
})
|
|
}
|
|
|
|
// renderExpiry gives each device's expiry class; a future expiry also says
|
|
// whether the step moved it.
|
|
func renderExpiry(views []deviceView, prev map[string]deviceView) string {
|
|
return renderEach(views, func(v deviceView) string {
|
|
p, ok := prev[v.id]
|
|
if v.expiry != "future" || !ok || p.expiry != "future" {
|
|
return v.expiry
|
|
}
|
|
|
|
if p.expires == v.expires {
|
|
return "future, unchanged"
|
|
}
|
|
|
|
return "future, renewed"
|
|
})
|
|
}
|
|
|
|
func renderEphemeral(views []deviceView) string {
|
|
return renderEach(views, func(v deviceView) string { return strconv.FormatBool(v.ephemeral) })
|
|
}
|
|
|
|
// renderPeers gives how an observer sees each device: absent, expired or
|
|
// valid.
|
|
func renderPeers(views []deviceView, nm *netmap.NetworkMap) string {
|
|
return renderEach(views, func(v deviceView) string {
|
|
p, missing := peerByID(nm, v.nodeID)
|
|
|
|
switch {
|
|
case missing != "":
|
|
return missing
|
|
case p.Expired():
|
|
return "expired"
|
|
default:
|
|
return "valid"
|
|
}
|
|
})
|
|
}
|
|
|
|
func renderEach(views []deviceView, f func(deviceView) string) string {
|
|
out := make([]string, len(views))
|
|
for i, v := range views {
|
|
out[i] = f(v)
|
|
}
|
|
|
|
return strings.Join(out, "; ")
|
|
}
|
|
|
|
// peerKeyState reports whether the observer's peer entry for a node holds
|
|
// the node key the node itself uses.
|
|
func peerKeyState(observer, self *netmap.NetworkMap) string {
|
|
if self == nil || !self.SelfNode.Valid() {
|
|
return "no self"
|
|
}
|
|
|
|
p, missing := peerByID(observer, self.SelfNode.ID())
|
|
if missing != "" {
|
|
return missing
|
|
}
|
|
|
|
if p.Key() == self.SelfNode.Key() {
|
|
return "current"
|
|
}
|
|
|
|
return "stale"
|
|
}
|
|
|
|
func peerDisco(nm *netmap.NetworkMap, id tailcfg.NodeID) string {
|
|
p, missing := peerByID(nm, id)
|
|
if missing != "" {
|
|
return missing
|
|
}
|
|
|
|
return p.DiscoKey().String()
|
|
}
|
|
|
|
func discoChange(before, after string) string {
|
|
switch {
|
|
case strings.HasPrefix(after, "discokey:") && before == after:
|
|
return "kept"
|
|
case strings.HasPrefix(after, "discokey:") && strings.HasPrefix(before, "discokey:"):
|
|
return "rotated"
|
|
default:
|
|
return before + " -> " + after
|
|
}
|
|
}
|
|
|
|
// keyChecks compares each key SaaS read back in the step: valid, invalid
|
|
// (spent, revoked or expired) or absent.
|
|
func (r *stepReplay) keyChecks(step testcapture.Step) []stepCheck {
|
|
var checks []stepCheck
|
|
|
|
for _, a := range step.Actions {
|
|
label, ok := strings.CutPrefix(a.Path, "/tailnet/-/keys/{key:")
|
|
if a.Kind != "api" || a.Method != http.MethodGet || !ok {
|
|
continue
|
|
}
|
|
|
|
label = strings.TrimSuffix(label, "}")
|
|
|
|
want := "absent"
|
|
|
|
if a.Status == http.StatusOK {
|
|
var k struct {
|
|
Invalid bool `json:"invalid"`
|
|
}
|
|
|
|
want = "valid"
|
|
if json.Unmarshal(a.Response, &k) == nil && k.Invalid {
|
|
want = "invalid"
|
|
}
|
|
}
|
|
|
|
checks = append(checks, stepCheck{"key:" + label, want, func() string {
|
|
pak, err := r.srv.State().GetPreAuthKeyByID(r.keyIDs[label])
|
|
|
|
switch {
|
|
case err != nil:
|
|
return "absent"
|
|
case pak.Validate() != nil:
|
|
return "invalid"
|
|
default:
|
|
return "valid"
|
|
}
|
|
}})
|
|
}
|
|
|
|
return checks
|
|
}
|
|
|
|
// filterChecks compares each captured observer's packet filter, as source
|
|
// devices and ports per rule. The observer is every rule's destination, so
|
|
// destinations are left out.
|
|
func (r *stepReplay) filterChecks(t *testing.T, step testcapture.Step, devices []saasDevice) []stepCheck {
|
|
t.Helper()
|
|
|
|
saasNames := map[netip.Addr]string{}
|
|
|
|
for _, d := range devices {
|
|
for _, s := range d.Addresses {
|
|
addr, err := netip.ParseAddr(s)
|
|
if err == nil {
|
|
saasNames[addr] = deviceLabelAlias(d.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
var checks []stepCheck
|
|
|
|
for _, obs := range reauthObservers {
|
|
view, ok := step.Captures[obs]
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
var want []string
|
|
|
|
for _, rule := range view.PacketFilterRules {
|
|
var srcs []netip.Prefix
|
|
|
|
for _, s := range rule.SrcIPs {
|
|
pfxs, err := parseDstPrefixes(s)
|
|
require.NoError(t, err, "%s: %s filter source", step.Name, obs)
|
|
|
|
srcs = append(srcs, pfxs...)
|
|
}
|
|
|
|
for _, dp := range rule.DstPorts {
|
|
want = append(want, filterLine(srcs, saasNames, dp.Ports.First, dp.Ports.Last))
|
|
}
|
|
}
|
|
|
|
checks = append(checks, stepCheck{obs + "/filter", filterLines(want), func() string {
|
|
nm := r.clients[obs].Netmap()
|
|
if nm == nil {
|
|
return "no netmap"
|
|
}
|
|
|
|
names := r.addrNames()
|
|
|
|
var got []string
|
|
|
|
for _, m := range nm.PacketFilter {
|
|
got = append(got, matchLines(m, names)...)
|
|
}
|
|
|
|
return filterLines(got)
|
|
}})
|
|
}
|
|
|
|
return checks
|
|
}
|
|
|
|
func matchLines(m filter.Match, names map[netip.Addr]string) []string {
|
|
out := make([]string, 0, len(m.Dsts))
|
|
for _, d := range m.Dsts {
|
|
out = append(out, filterLine(m.Srcs, names, d.Ports.First, d.Ports.Last))
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
// filterLine renders one rule as "sources => first-last".
|
|
func filterLine(srcs []netip.Prefix, names map[netip.Addr]string, first, last uint16) string {
|
|
set := map[string]bool{}
|
|
|
|
// Headscale merges adjacent addresses into prefixes, so a Tailscale
|
|
// prefix stands for every device inside it.
|
|
for _, p := range srcs {
|
|
matched := false
|
|
|
|
if p.Bits() == 0 {
|
|
set["*"], matched = true, true
|
|
} else if prefixInTailscaleRange(p) {
|
|
for addr, name := range names {
|
|
if p.Contains(addr) {
|
|
set[name], matched = true, true
|
|
}
|
|
}
|
|
}
|
|
|
|
if !matched {
|
|
set[p.String()] = true
|
|
}
|
|
}
|
|
|
|
return fmt.Sprintf("%s => %d-%d", strings.Join(slices.Sorted(maps.Keys(set)), ","), first, last)
|
|
}
|
|
|
|
func filterLines(lines []string) string {
|
|
slices.Sort(lines)
|
|
|
|
return strings.Join(slices.Compact(lines), "\n")
|
|
}
|
|
|
|
// addrNames names each headscale node by its machine's alias, with "#2"
|
|
// onward for later nodes on one machine, as SaaS labels devices.
|
|
func (r *stepReplay) addrNames() map[netip.Addr]string {
|
|
out := map[netip.Addr]string{}
|
|
|
|
for alias := range r.clients {
|
|
for i, v := range r.hsViews(alias) {
|
|
name := alias
|
|
if i > 0 {
|
|
name = fmt.Sprintf("%s#%d", alias, i+1)
|
|
}
|
|
|
|
for _, addr := range v.ips {
|
|
out[addr] = name
|
|
}
|
|
}
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
// authKeyRefusalRows are fresh machines logging in with a key whose state
|
|
// the capture shows: spent, 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"},
|
|
}
|
|
|
|
// refusalCheck reports whether a check name belongs to
|
|
// [TestAuthKeyRefusalCompat].
|
|
func refusalCheck(check string) bool {
|
|
switch strings.TrimPrefix(check, "legacy-") {
|
|
case "refused", "nodes", "text":
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// TestAuthKeyRefusalCompat replays a fresh machine's login with a key in
|
|
// the state SaaS shows, and checks that it is refused or accepted as on
|
|
// SaaS and that a refusal leaves no node. Each row runs again with the key
|
|
// stored under a legacy hash, the path that verifies it on a slower route.
|
|
func TestAuthKeyRefusalCompat(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, row := range authKeyRefusalRows {
|
|
for _, legacy := range []bool{false, true} {
|
|
name := row.id + "/" + row.step
|
|
if legacy {
|
|
name += "/legacy_hash"
|
|
}
|
|
|
|
t.Run(name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
runAuthKeyRefusalCompat(t, row.id, row.step, row.node, legacy)
|
|
})
|
|
}
|
|
}
|
|
|
|
t.Run("entries", func(t *testing.T) {
|
|
for k := range reauthSkipReasons {
|
|
check := k[strings.LastIndex(k, "/")+1:]
|
|
if !refusalCheck(check) {
|
|
continue
|
|
}
|
|
|
|
named := slices.ContainsFunc(authKeyRefusalRows, func(row struct{ id, step, node string }) bool {
|
|
return strings.HasPrefix(k, row.id+"/"+row.step+"/"+row.node+"/")
|
|
})
|
|
if !named {
|
|
t.Errorf("skip entry %q names no check", k)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
func runAuthKeyRefusalCompat(t *testing.T, id, stepName, node string, legacy bool) {
|
|
t.Helper()
|
|
|
|
c := readStepCapture(t, id)
|
|
r := newStepReplay(t, c)
|
|
|
|
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)
|
|
|
|
for _, step := range c.Steps[:i] {
|
|
r.apply(t, step)
|
|
}
|
|
|
|
step := c.Steps[i]
|
|
|
|
j := slices.IndexFunc(step.Actions, func(a testcapture.Action) bool { return a.Kind == "join" && a.Node == node })
|
|
require.GreaterOrEqual(t, j, 0, "%s: %s does not join", stepName, node)
|
|
|
|
// Keys minted in the step exist only once their action ran.
|
|
var refused map[string]error
|
|
|
|
if legacy {
|
|
label := step.Actions[j].Key
|
|
head, tail := step, step
|
|
head.Actions, tail.Actions = step.Actions[:j], step.Actions[j:]
|
|
r.apply(t, head)
|
|
r.storeLegacyHash(t, label)
|
|
refused = r.apply(t, tail)
|
|
|
|
// Verifying a legacy hash upgrades it, so the login took that path.
|
|
var hash string
|
|
require.NoError(t, r.srv.State().DB().DB.Raw(
|
|
"SELECT hash FROM credentials WHERE id = ?", r.keyIDs[label]).Scan(&hash).Error)
|
|
require.True(t, strings.HasPrefix(hash, "$sha256$"), "the login verified the legacy hash")
|
|
} else {
|
|
refused = r.apply(t, step)
|
|
}
|
|
|
|
devices, _, ok := stepDeviceRead(step)
|
|
require.True(t, ok, "%s: no device list", stepName)
|
|
|
|
saas := registerOutcome(step.Register[node])
|
|
saasErr := ""
|
|
|
|
for _, a := range step.Register[node] {
|
|
if a.Err != "" && !strings.Contains(a.Err, "context canceled") {
|
|
saasErr = a.Err
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
prefix := ""
|
|
if legacy {
|
|
prefix = "legacy-"
|
|
}
|
|
|
|
checks := make([]stepCheck, 0, 3)
|
|
checks = append(checks,
|
|
stepCheck{node + "/" + prefix + "refused", strconv.FormatBool(strings.HasSuffix(saas, "refused")), func() string {
|
|
return strconv.FormatBool(refused[node] != nil)
|
|
}},
|
|
stepCheck{node + "/" + prefix + "nodes", strconv.Itoa(len(saasDeviceViews(c, devices, node, step.At))), func() string {
|
|
return strconv.Itoa(len(r.machineNodes(node)))
|
|
}},
|
|
)
|
|
|
|
checks = append(checks, stepCheck{node + "/" + prefix + "text", saasErr, func() string {
|
|
if refused[node] == nil {
|
|
return ""
|
|
}
|
|
|
|
// The client wraps the server's message in its own context.
|
|
msg := refused[node].Error()
|
|
if i := strings.LastIndex(msg, ": "); i >= 0 {
|
|
msg = msg[i+2:]
|
|
}
|
|
|
|
return msg
|
|
}})
|
|
|
|
holdChecks(t, reauthSkipReasons, id+"/"+stepName+"/", checks, map[string]bool{})
|
|
}
|
|
|
|
// storeLegacyHash rewrites a key's stored secret as a legacy bcrypt hash,
|
|
// as keys created before the SHA-256 format hold it.
|
|
func (r *stepReplay) storeLegacyHash(t *testing.T, label string) {
|
|
t.Helper()
|
|
|
|
full, ok := r.keys[label]
|
|
require.True(t, ok, "no key %s", label)
|
|
|
|
secret := full[strings.LastIndex(full, "-")+1:]
|
|
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(secret), bcrypt.MinCost)
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, r.srv.State().DB().DB.Exec(
|
|
"UPDATE credentials SET hash = ? WHERE id = ?", hash, r.keyIDs[label],
|
|
).Error)
|
|
}
|