Files
tailscale/control/controlclient/key_expiry_test.go
Avery Pennarun 6a822dcc36 control/controlclient: continue map poll during key expiry to receive extensions
When a client's node key expires and the user clicks "Login" (or runs
`tailscale up`), the Login() method was cancelling the map poll context.
This caused key extension notifications from the server to be lost,
leaving clients stuck in NeedsLogin state even after an admin extended
their key.

The fix has three parts:

1. Login(): Don't cancel mapCtx if we have valid credentials (loggedIn=true)
   or a valid node key. This allows the map poll to continue receiving
   server notifications while the auth flow proceeds in parallel.

2. mapRoutine(): Poll when we have a node key, even if !loggedIn. This
   handles the tsnet restart scenario where control returns an AuthURL
   (so loggedIn=false) but we still have a valid node key that can
   receive map updates.

3. sendStatus()/UpdateFullNetmap(): Forward netmaps when we have a node
   key, not just when loggedIn. This ensures the backend sees key expiry
   changes even when the auth flow hasn't completed.

"First successful flow wins": if a key extension arrives via map poll,
the client recovers automatically. If the auth flow completes first,
that works too. Either way, the client is no longer stuck.

This aligns with the SeamlessKeyRenewal philosophy: maintain connectivity
paths while authentication proceeds, allowing server-initiated recovery.

Fixes #19326

Change-Id: I26dbbc1fa7c1159ba075362e44d02814355d6b44
Signed-off-by: Avery Pennarun <apenwarr@tailscale.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-11 19:21:09 +01:00

153 lines
5.0 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package controlclient
import (
"context"
"testing"
"tailscale.com/types/key"
"tailscale.com/types/persist"
)
// TestLoginPreservesMapPollWhenLoggedIn tests the fix for the key extension bug.
//
// When a client has valid credentials (loggedIn=true) but needs to re-authenticate
// due to key expiry, calling Login() should NOT cancel the map poll. This allows
// the client to continue receiving key extension notifications from the server
// while the auth flow proceeds in parallel.
func TestLoginPreservesMapPollWhenLoggedIn(t *testing.T) {
// Create an Auto client that is already logged in
// This simulates a client with valid credentials but expired key
auto := &Auto{
logf: t.Logf,
loggedIn: true, // Already authenticated (key expired, but creds valid)
closed: false,
}
auto.mapCtx, auto.mapCancel = context.WithCancel(context.Background())
t.Cleanup(auto.mapCancel)
auto.authCtx, auto.authCancel = context.WithCancel(context.Background())
t.Cleanup(auto.authCancel)
originalMapCtx := auto.mapCtx
// Call Login() - this is what happens when user clicks "Login" after key expiry
auto.Login(LoginInteractive)
// The fix: when loggedIn=true, mapCtx should NOT be cancelled
// This allows the map poll to continue receiving key extension notifications
select {
case <-originalMapCtx.Done():
t.Error("Login() cancelled mapCtx even though loggedIn=true; key extension notifications would be lost")
default:
// Good - map context still active
}
// Verify loginGoal was set (auth flow can proceed in parallel)
auto.mu.Lock()
hasLoginGoal := auto.loginGoal != nil
auto.mu.Unlock()
if !hasLoginGoal {
t.Error("loginGoal should be set even though mapCtx wasn't cancelled")
}
}
// TestLoginPreservesMapPollWithNodeKey tests the tsnet restart scenario.
//
// When a tsnet server restarts with an expired key:
// 1. The server has a valid node key stored in persist
// 2. Control returns an AuthURL (for interactive login)
// 3. loggedIn is false (because TryLogin returned a URL, not success)
// 4. But we should NOT cancel the map poll, because the server might send
// a key extension notification via the existing node key
//
// This test verifies that Login() preserves the map poll when we have a
// valid node key, even if loggedIn=false.
func TestLoginPreservesMapPollWithNodeKey(t *testing.T) {
// Create persist data with a valid node key (simulating stored credentials)
nodeKey := key.NewNode()
p := &persist.Persist{
PrivateNodeKey: nodeKey,
}
// Create a Direct client with the persist data
direct := &Direct{
persist: p.View(),
}
// Create an Auto client that is NOT logged in but HAS a valid node key
// This simulates a tsnet server restart with expired key:
// - loggedIn=false because control returned an AuthURL
// - but we have a valid node key that can receive map updates
auto := &Auto{
logf: t.Logf,
loggedIn: false, // Control returned AuthURL, so not "logged in" yet
closed: false,
direct: direct,
}
auto.mapCtx, auto.mapCancel = context.WithCancel(context.Background())
t.Cleanup(auto.mapCancel)
auto.authCtx, auto.authCancel = context.WithCancel(context.Background())
t.Cleanup(auto.authCancel)
originalMapCtx := auto.mapCtx
// Call Login() - this is what tsnet's StartLoginInteractive does
auto.Login(LoginInteractive)
// The fix: even though loggedIn=false, we have a valid node key,
// so mapCtx should NOT be cancelled. This allows us to receive
// key extension notifications from the server.
select {
case <-originalMapCtx.Done():
t.Error("Login() cancelled mapCtx even though we have a valid node key; " +
"key extension notifications would be lost in tsnet restart scenario")
default:
// Good - map context still active, can receive key extensions
}
// Verify loginGoal was set (auth flow can proceed in parallel)
auto.mu.Lock()
hasLoginGoal := auto.loginGoal != nil
auto.mu.Unlock()
if !hasLoginGoal {
t.Error("loginGoal should be set for the auth flow to proceed")
}
}
// TestLoginCancelsMapPollWhenNoNodeKey verifies that when there's no node key
// at all (fresh install, never authenticated), Login() should cancel the map poll.
func TestLoginCancelsMapPollWhenNoNodeKey(t *testing.T) {
// Create a Direct client with empty persist (no node key)
direct := &Direct{
persist: new(persist.Persist).View(),
}
auto := &Auto{
logf: t.Logf,
loggedIn: false,
closed: false,
direct: direct,
}
auto.mapCtx, auto.mapCancel = context.WithCancel(context.Background())
t.Cleanup(auto.mapCancel)
auto.authCtx, auto.authCancel = context.WithCancel(context.Background())
t.Cleanup(auto.authCancel)
originalMapCtx := auto.mapCtx
// Call Login()
auto.Login(LoginInteractive)
// When loggedIn=false AND no node key, mapCtx SHOULD be cancelled
select {
case <-originalMapCtx.Done():
// Good - cancelled as expected for fresh login with no credentials
default:
t.Error("mapCtx should be cancelled when loggedIn=false and no node key")
}
}