servertest: add login, profile, restart and register log helpers

TryNewClient returns a refused login; Login covers profile switches and
interactive logins approved through ApproveAuth; RegisterAttempts reads
the client's register log.
This commit is contained in:
Kristoffer Dalby committed 2026-10-29 14:00:02 +01:00
1 parent b18bbf4313
commit 5c2cf6ed6f
3 files changed
+279 -36

No files matched your search

+174 -36
View File
@@ -3,12 +3,14 @@ package servertest
import (
"context"
"fmt"
"path"
"slices"
"sync"
"testing"
"time"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/types/testcapture"
"tailscale.com/control/controlclient"
"tailscale.com/health"
"tailscale.com/net/netmon"
@@ -36,12 +38,15 @@ type TestClient struct {
authKey string
user *types.User
// machineKey and discoKey survive [TestClient.Relogin], as they survive
// a tailscaled login.
// machineKey survives every login and discoKey every login but
// [TestClient.Restart], as with tailscaled.
machineKey key.MachinePrivate
discoKey key.DiscoPublic
logf logger.Logf
// approve completes an interactive login; see [WithInteractiveLogin].
approve func(authID string) error
// Connection lifecycle.
pollCtx context.Context //nolint:containedctx // test-only; context stored for cancel control
pollCancel context.CancelFunc
@@ -58,6 +63,9 @@ type TestClient struct {
deltaUpdates bool
deltaRemoved []tailcfg.NodeID
// registers: see [TestClient.RegisterAttempts].
registers []testcapture.RegisterAttempt
// updates is a buffered channel that receives a signal
// each time a new [netmap.NetworkMap] arrives.
updates chan *netmap.NetworkMap
@@ -78,6 +86,8 @@ type clientConfig struct {
authKey string
deltaUpdates bool
noHistory bool
hostinfo *tailcfg.Hostinfo
approve func(authID string) error
}
// WithoutNetmapHistory retains only the latest netmap and one queued update.
@@ -115,6 +125,20 @@ func WithAuthKey(key string) ClientOption {
return func(c *clientConfig) { c.authKey = key }
}
// WithHostinfo registers with hi instead of a Hostinfo holding only the
// client's name, so a client can request tags or announce routes at
// registration.
func WithHostinfo(hi *tailcfg.Hostinfo) ClientOption {
return func(c *clientConfig) { c.hostinfo = hi }
}
// WithInteractiveLogin registers without an auth key, as `tailscale up`
// does: the server answers with an auth URL and approve, given its auth ID,
// completes the login, as `headscale auth register` would.
func WithInteractiveLogin(approve func(authID string) error) ClientOption {
return func(c *clientConfig) { c.approve = approve }
}
// WithUser sets the user for the client. If not set, the harness
// creates a default user.
func WithUser(user *types.User) ClientOption {
@@ -126,6 +150,20 @@ func WithUser(user *types.User) ClientOption {
func NewClient(tb testing.TB, server *TestServer, name string, opts ...ClientOption) *TestClient {
tb.Helper()
tc, err := TryNewClient(tb, server, name, opts...)
if err != nil {
tb.Fatalf("servertest: NewClient(%s): %v", name, err)
}
return tc
}
// TryNewClient is [NewClient] for a registration that may be refused: it
// returns the login error, and a client that holds no node and does not
// poll.
func TryNewClient(tb testing.TB, server *TestServer, name string, opts ...ClientOption) (*TestClient, error) {
tb.Helper()
cc := &clientConfig{
hostname: name,
}
@@ -146,6 +184,8 @@ func NewClient(tb testing.TB, server *TestServer, name string, opts ...ClientOpt
var authKey string
switch {
case cc.approve != nil:
// Interactive: no key.
case cc.authKey != "":
authKey = cc.authKey
case cc.ephemeral:
@@ -178,7 +218,7 @@ func NewClient(tb testing.TB, server *TestServer, name string, opts ...ClientOpt
user: user,
machineKey: key.NewMachine(),
discoKey: key.NewDisco().Public(),
logf: tb.Logf,
approve: cc.approve,
updates: make(chan *netmap.NetworkMap, updateBuffer),
bus: bus,
dialer: dialer,
@@ -188,10 +228,24 @@ func NewClient(tb testing.TB, server *TestServer, name string, opts ...ClientOpt
noHistory: cc.noHistory,
}
direct, err := tc.newDirect(persist.Persist{}, authKey, &tailcfg.Hostinfo{
BackendLogID: "servertest-" + name,
Hostname: cc.hostname,
})
tc.logf = func(format string, args ...any) {
line := fmt.Sprintf(format, args...)
tb.Logf("%s", line)
tc.mu.Lock()
tc.registers, _ = testcapture.AppendRegisterLine(tc.registers, line)
tc.mu.Unlock()
}
hi := cc.hostinfo
if hi == nil {
hi = &tailcfg.Hostinfo{
BackendLogID: "servertest-" + name,
Hostname: cc.hostname,
}
}
direct, err := tc.newDirect(persist.Persist{}, authKey, hi)
if err != nil {
tb.Fatalf("servertest: NewDirect(%s): %v", name, err)
}
@@ -202,13 +256,18 @@ func NewClient(tb testing.TB, server *TestServer, name string, opts ...ClientOpt
tc.cleanup()
})
// Register with the server.
tc.register(tb)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
err = tc.tryLogin(ctx, controlclient.LoginDefault, tc.approve)
if err != nil {
return tc, err
}
// Start long-polling in the background.
tc.startPoll(tb)
return tc
return tc, nil
}
// newDirect builds a [controlclient.Direct] for the client's machine.
@@ -238,14 +297,64 @@ func (c *TestClient) register(tb testing.TB) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
url, err := c.direct.TryLogin(ctx, controlclient.LoginDefault)
err := c.tryLogin(ctx, controlclient.LoginDefault, c.approve)
if err != nil {
tb.Fatalf("servertest: TryLogin(%s): %v", c.Name, err)
}
}
if url != "" {
tb.Fatalf("servertest: TryLogin(%s): unexpected auth URL: %s (expected auto-auth with preauth key)", c.Name, url)
// tryLogin logs the current Direct in. An auth URL is completed through
// approve, and is an error without it. A failed login is recorded in
// [TestClient.RegisterAttempts], as tailscaled logs it.
func (c *TestClient) tryLogin(ctx context.Context, flags controlclient.LoginFlags, approve func(string) error) error {
url, err := c.direct.TryLogin(ctx, flags)
switch {
case err != nil:
case url != "" && approve != nil:
err = c.waitLogin(ctx, url, approve)
case url != "":
err = fmt.Errorf("unexpected auth URL %q (expected auto-auth with preauth key)", url) //nolint:err113
}
if err != nil {
c.mu.Lock()
c.registers = append(c.registers, testcapture.RegisterAttempt{Err: err.Error()})
c.mu.Unlock()
}
return err
}
// waitLogin waits on the auth URL while approve completes the login it names.
func (c *TestClient) waitLogin(ctx context.Context, url string, approve func(string) error) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
done := make(chan error, 1)
go func() {
_, err := c.direct.WaitLoginURL(ctx, url)
done <- err
}()
// The URL ends in the auth ID: /register/<id>.
err := approve(path.Base(url))
if err != nil {
return err
}
return <-done
}
// RegisterAttempts returns the client's /machine/register exchanges as
// tailscaled logs them, with each failed login as its own entry; see
// [testcapture.AppendRegisterLine]. Node keys are short forms, not labels.
func (c *TestClient) RegisterAttempts() []testcapture.RegisterAttempt {
c.mu.RLock()
defer c.mu.RUnlock()
return slices.Clone(c.registers)
}
// startPoll begins the long-poll [tailcfg.MapRequest] loop.
@@ -466,50 +575,60 @@ func (c *TestClient) ReloginAndPoll(ctx context.Context) error {
}
// Relogin logs the client in again with authKey, as tailscaled does for
// `tailscale up --auth-key` (force: `--force-reauth`). Like
// LocalBackend.Start it builds a new [controlclient.Direct] that keeps the
// machine key and the persisted node key, so the first request presents the
// current node key unless force rotates it. hi replaces the Hostinfo, so
// routes can be restated. The poll restarts once the login succeeds.
// `tailscale up --auth-key` (force: `--force-reauth`): see [TestClient.Login],
// with the current login. The first request presents the current node key
// unless force rotates it. hi replaces the Hostinfo, so routes can be
// restated.
//
// Safe to call from non-test goroutines.
func (c *TestClient) Relogin(ctx context.Context, authKey string, force bool, hi *tailcfg.Hostinfo) error {
flags := controlclient.LoginDefault
if force {
flags = controlclient.LoginInteractive
}
return c.Login(ctx, c.Persist(), authKey, flags, hi, nil)
}
// Login logs the machine in from p, as LocalBackend.Start does: a new
// [controlclient.Direct] keeps the machine key and presents p's node key,
// unless flags ask for a new one. p is the current login ([TestClient.Persist])
// for a relogin or restart, another profile's for a profile switch, and
// empty for a new profile. With approve set and no authKey the login is
// interactive, see [WithInteractiveLogin]. The poll restarts once the login
// succeeds; after a failed one the client does not poll.
//
// Safe to call from non-test goroutines.
func (c *TestClient) Login(
ctx context.Context,
p persist.Persist,
authKey string,
flags controlclient.LoginFlags,
hi *tailcfg.Hostinfo,
approve func(authID string) error,
) error {
if c.pollCancel != nil {
c.pollCancel()
select {
case <-c.pollDone:
case <-ctx.Done():
return fmt.Errorf("servertest: Relogin(%s): poll did not exit: %w", c.Name, ctx.Err())
return fmt.Errorf("servertest: Login(%s): poll did not exit: %w", c.Name, ctx.Err())
}
}
var p persist.Persist
if v := c.direct.GetPersist(); v.Valid() {
p = *v.AsStruct()
}
direct, err := c.newDirect(p, authKey, hi)
if err != nil {
return fmt.Errorf("servertest: Relogin(%s): NewDirect: %w", c.Name, err)
return fmt.Errorf("servertest: Login(%s): NewDirect: %w", c.Name, err)
}
c.direct.Close()
c.direct = direct
c.authKey = authKey
flags := controlclient.LoginDefault
if force {
flags = controlclient.LoginInteractive
}
url, err := direct.TryLogin(ctx, flags)
err = c.tryLogin(ctx, flags, approve)
if err != nil {
return fmt.Errorf("servertest: Relogin(%s): %w", c.Name, err)
}
if url != "" {
return fmt.Errorf("servertest: Relogin(%s): unexpected auth URL %q", c.Name, url) //nolint:err113
return fmt.Errorf("servertest: Login(%s): %w", c.Name, err)
}
c.resetNetmapState()
@@ -518,6 +637,25 @@ func (c *TestClient) Relogin(ctx context.Context, authKey string, force bool, hi
return nil
}
// Restart logs the machine in again with a new disco key and no auth key,
// as a restarted tailscaled does: its login is kept, its disco key is not.
//
// Safe to call from non-test goroutines.
func (c *TestClient) Restart(ctx context.Context, hi *tailcfg.Hostinfo) error {
c.discoKey = key.NewDisco().Public()
return c.Login(ctx, c.Persist(), "", controlclient.LoginDefault, hi, nil)
}
// Persist returns the client's current login: its node keys and user.
func (c *TestClient) Persist() persist.Persist {
if v := c.direct.GetPersist(); v.Valid() {
return *v.AsStruct()
}
return persist.Persist{}
}
// RestartPoll tears down the current long-poll session and immediately
// starts a new one without re-registering, the way tailscaled restarts
// its map poll on state-machine transitions (pause/unpause around
+63
View File
@@ -17,6 +17,7 @@ import (
"tailscale.com/control/controlclient"
"tailscale.com/tailcfg"
"tailscale.com/types/netmap"
"tailscale.com/types/persist"
)
// TestConnectionLifecycle exercises the core node lifecycle:
@@ -400,6 +401,68 @@ func TestReloginKeepsNodeKeyUnlessForced(t *testing.T) {
func(nm *netmap.NetworkMap) bool { return nm.SelfNode.ID() == id.NodeID() })
}
// TestLoginProfilesAndRestart pins the shapes of the login helpers: an
// interactive login approved for a user, a refused key login recorded with
// its answers, a second profile on the same machine, a switch back, and a
// restart that keeps the login but not the disco key.
func TestLoginProfilesAndRestart(t *testing.T) {
t.Parallel()
srv := servertest.NewServer(t)
owner := srv.CreateUser(t, "owner")
other := srv.CreateUser(t, "other")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
approve := func(user *types.User) func(string) error {
return func(authID string) error { return srv.ApproveAuth(ctx, authID, user.Name) }
}
client := servertest.NewClient(t, srv, "machine", servertest.WithInteractiveLogin(approve(owner)))
first := findNodeID(t, srv, client.Name)
node, ok := srv.State().GetNodeByID(first)
require.True(t, ok)
assert.Equal(t, owner.Name, node.User().Name(), "the approving user owns the node")
once := srv.CreateEphemeralPreAuthKey(t, types.UserID(owner.ID))
_, err := servertest.TryNewClient(t, srv, "spent", servertest.WithAuthKey(once))
require.NoError(t, err)
refused, err := servertest.TryNewClient(t, srv, "late", servertest.WithAuthKey(once))
require.Error(t, err, "a spent single-use key is refused")
attempts := refused.RegisterAttempts()
require.NotEmpty(t, attempts)
assert.Equal(t, err.Error(), attempts[len(attempts)-1].Err, "the refusal is the last attempt")
assert.Empty(t, srv.State().GetNodesByMachineKeyAllUsers(refused.MachineKey()), "a refused login leaves no node")
hi := &tailcfg.Hostinfo{BackendLogID: "servertest-machine", Hostname: "machine"}
ownerLogin := client.Persist()
require.NoError(t, client.Login(ctx, persist.Persist{}, "", controlclient.LoginInteractive, hi, approve(other)))
assert.Len(t, srv.State().GetNodesByMachineKeyAllUsers(client.MachineKey()), 2,
"another user's profile adds a node on the machine")
require.NoError(t, client.Login(ctx, ownerLogin, "", controlclient.LoginDefault, hi, nil))
client.WaitForCondition(t, "back on the first profile", 10*time.Second,
func(nm *netmap.NetworkMap) bool { return nm.SelfNode.ID() == first.NodeID() })
before, ok := srv.State().GetNodeByID(first)
require.True(t, ok)
require.NoError(t, client.Restart(ctx, hi))
require.EventuallyWithT(t, func(c *assert.CollectT) {
after, ok := srv.State().GetNodeByID(first)
if assert.True(c, ok) {
assert.Equal(c, before.NodeKey(), after.NodeKey(), "a restart keeps the node key")
assert.NotEqual(c, before.DiscoKey(), after.DiscoKey(), "a restart brings a new disco key")
}
}, 10*time.Second, 50*time.Millisecond)
}
func TestNodeExpiryRouteFailover(t *testing.T) {
t.Parallel()
+42
View File
@@ -5,7 +5,11 @@
package servertest
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/netip"
@@ -320,6 +324,44 @@ func (s *TestServer) CreateUser(tb testing.TB, name string) *types.User {
return u
}
// ApproveAuth registers the pending login authID to user through the v1
// API, as `headscale auth register` does.
func (s *TestServer) ApproveAuth(ctx context.Context, authID, user string) error {
apiKey, _, err := s.st.CreateAPIKey(nil)
if err != nil {
return fmt.Errorf("servertest: ApproveAuth: %w", err)
}
body, err := json.Marshal(map[string]string{"authId": authID, "user": user})
if err != nil {
return fmt.Errorf("servertest: ApproveAuth: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.URL+"/api/v1/auth/register", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("servertest: ApproveAuth: %w", err)
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Transport: &http.Transport{DialContext: s.memNet.Dial}}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("servertest: ApproveAuth: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
msg, _ := io.ReadAll(resp.Body)
return fmt.Errorf("servertest: ApproveAuth: %d: %s", resp.StatusCode, bytes.TrimSpace(msg)) //nolint:err113
}
return nil
}
// CreateAPIKey mints an API key string (the Bearer/Basic credential). When
// owner is non-nil the key is owned by that user, so the v2 API mints
// user-owned (untagged) auth keys on its behalf.