servertest: add relogin and auth key helpers

Relogin builds a new Direct on the same machine key and persisted
node key, as tailscaled does on up --auth-key; force rotates the key.
This commit is contained in:
Kristoffer Dalby committed 2026-10-29 14:00:02 +01:00
1 parent 286d6ea05d
commit 1716eece74
2 files changed
+154 -30

No files matched your search

+115 -30
View File
@@ -15,6 +15,7 @@ import (
"tailscale.com/net/tsdial"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"tailscale.com/types/logger"
"tailscale.com/types/netmap"
"tailscale.com/types/persist"
"tailscale.com/types/views"
@@ -35,6 +36,12 @@ type TestClient struct {
authKey string
user *types.User
// machineKey and discoKey survive [TestClient.Relogin], as they survive
// a tailscaled login.
machineKey key.MachinePrivate
discoKey key.DiscoPublic
logf logger.Logf
// Connection lifecycle.
pollCtx context.Context //nolint:containedctx // test-only; context stored for cancel control
pollCancel context.CancelFunc
@@ -68,6 +75,7 @@ type clientConfig struct {
hostname string
tags []string
user *types.User
authKey string
deltaUpdates bool
noHistory bool
}
@@ -102,6 +110,11 @@ func WithTags(tags ...string) ClientOption {
return func(c *clientConfig) { c.tags = tags }
}
// WithAuthKey registers with key instead of a key minted for the client.
func WithAuthKey(key string) ClientOption {
return func(c *clientConfig) { c.authKey = key }
}
// WithUser sets the user for the client. If not set, the harness
// creates a default user.
func WithUser(user *types.User) ClientOption {
@@ -133,6 +146,8 @@ func NewClient(tb testing.TB, server *TestServer, name string, opts ...ClientOpt
var authKey string
switch {
case cc.authKey != "":
authKey = cc.authKey
case cc.ephemeral:
authKey = server.CreateEphemeralPreAuthKey(tb, uid)
case len(cc.tags) > 0:
@@ -151,47 +166,38 @@ func NewClient(tb testing.TB, server *TestServer, name string, opts ...ClientOpt
// so that no real TCP sockets are used.
dialer.SetSystemDialerForTest(server.MemNet().Dial)
machineKey := key.NewMachine()
direct, err := controlclient.NewDirect(controlclient.Options{
Persist: persist.Persist{},
GetMachinePrivateKey: func() (key.MachinePrivate, error) { return machineKey, nil },
ServerURL: server.URL,
AuthKey: authKey,
Hostinfo: &tailcfg.Hostinfo{
BackendLogID: "servertest-" + name,
Hostname: cc.hostname,
},
DiscoPublicKey: key.NewDisco().Public(),
Logf: tb.Logf,
HealthTracker: tracker,
Dialer: dialer,
Bus: bus,
})
if err != nil {
tb.Fatalf("servertest: NewDirect(%s): %v", name, err)
}
updateBuffer := 64
if cc.noHistory {
updateBuffer = 1
}
tc := &TestClient{
Name: name,
server: server,
direct: direct,
authKey: authKey,
user: user,
updates: make(chan *netmap.NetworkMap, updateBuffer),
bus: bus,
dialer: dialer,
tracker: tracker,
Name: name,
server: server,
authKey: authKey,
user: user,
machineKey: key.NewMachine(),
discoKey: key.NewDisco().Public(),
logf: tb.Logf,
updates: make(chan *netmap.NetworkMap, updateBuffer),
bus: bus,
dialer: dialer,
tracker: tracker,
deltaUpdates: cc.deltaUpdates,
noHistory: cc.noHistory,
}
direct, err := tc.newDirect(persist.Persist{}, authKey, &tailcfg.Hostinfo{
BackendLogID: "servertest-" + name,
Hostname: cc.hostname,
})
if err != nil {
tb.Fatalf("servertest: NewDirect(%s): %v", name, err)
}
tc.direct = direct
tb.Cleanup(func() {
tc.cleanup()
})
@@ -205,6 +211,26 @@ func NewClient(tb testing.TB, server *TestServer, name string, opts ...ClientOpt
return tc
}
// newDirect builds a [controlclient.Direct] for the client's machine.
func (c *TestClient) newDirect(
p persist.Persist,
authKey string,
hi *tailcfg.Hostinfo,
) (*controlclient.Direct, error) {
return controlclient.NewDirect(controlclient.Options{
Persist: p,
GetMachinePrivateKey: func() (key.MachinePrivate, error) { return c.machineKey, nil },
ServerURL: c.server.URL,
AuthKey: authKey,
Hostinfo: hi,
DiscoPublicKey: c.discoKey,
Logf: c.logf,
HealthTracker: c.tracker,
Dialer: c.dialer,
Bus: c.bus,
})
}
// register performs the initial [controlclient.Direct.TryLogin] to register the client.
func (c *TestClient) register(tb testing.TB) {
tb.Helper()
@@ -439,6 +465,59 @@ func (c *TestClient) ReloginAndPoll(ctx context.Context) error {
return nil
}
// 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.
//
// Safe to call from non-test goroutines.
func (c *TestClient) Relogin(ctx context.Context, authKey string, force bool, hi *tailcfg.Hostinfo) 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())
}
}
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)
}
c.direct.Close()
c.direct = direct
c.authKey = authKey
flags := controlclient.LoginDefault
if force {
flags = controlclient.LoginInteractive
}
url, err := direct.TryLogin(ctx, flags)
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
}
c.resetNetmapState()
c.startPollLoop()
return nil
}
// 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
@@ -654,6 +733,12 @@ func (c *TestClient) WaitForCondition(tb testing.TB, desc string, timeout time.D
}
}
// MachineKey is the client's machine key, which identifies its nodes across
// logins.
func (c *TestClient) MachineKey() key.MachinePublic {
return c.machineKey.Public()
}
// Direct returns the underlying [controlclient.Direct] for
// advanced operations like [controlclient.Direct.SetHostinfo] or SendUpdate.
func (c *TestClient) Direct() *controlclient.Direct {
+39
View File
@@ -361,6 +361,45 @@ func TestReloginPeerVisibleHostinfoReachesPeers(t *testing.T) {
}, 5*time.Second, 10*time.Millisecond, "observer must see the relogin's peer-visible Hostinfo")
}
// TestReloginKeepsNodeKeyUnlessForced pins the shape of
// [servertest.TestClient.Relogin]: a login with a given key presents the
// current node key, and only a forced one rotates it. The node stays the
// same either way.
func TestReloginKeepsNodeKeyUnlessForced(t *testing.T) {
t.Parallel()
srv := servertest.NewServer(t)
user := srv.CreateUser(t, "relogin")
key := srv.CreatePreAuthKey(t, types.UserID(user.ID))
client := servertest.NewClient(t, srv, "relogin",
servertest.WithUser(user), servertest.WithAuthKey(key))
id := findNodeID(t, srv, client.Name)
nodeKey := func() string {
node, ok := srv.State().GetNodeByID(id)
require.True(t, ok)
return node.NodeKey().String()
}
first := nodeKey()
hi := &tailcfg.Hostinfo{BackendLogID: "servertest-relogin", Hostname: "relogin"}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
require.NoError(t, client.Relogin(ctx, key, false, hi))
assert.Equal(t, first, nodeKey(), "a login without force keeps the node key")
require.NoError(t, client.Relogin(ctx, key, true, hi))
assert.NotEqual(t, first, nodeKey(), "a forced login rotates the node key")
assert.Len(t, srv.State().GetNodesByMachineKeyAllUsers(client.MachineKey()), 1,
"relogin keeps one node per machine")
client.WaitForCondition(t, "netmap after relogin", 10*time.Second,
func(nm *netmap.NetworkMap) bool { return nm.SelfNode.ID() == id.NodeID() })
}
func TestNodeExpiryRouteFailover(t *testing.T) {
t.Parallel()