diff --git a/hscontrol/servertest/client.go b/hscontrol/servertest/client.go index f178e8ea2..a385e48f7 100644 --- a/hscontrol/servertest/client.go +++ b/hscontrol/servertest/client.go @@ -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 { diff --git a/hscontrol/servertest/lifecycle_test.go b/hscontrol/servertest/lifecycle_test.go index 1ffa93abd..6f4d42227 100644 --- a/hscontrol/servertest/lifecycle_test.go +++ b/hscontrol/servertest/lifecycle_test.go @@ -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()