mirror of
https://github.com/tailscale/tailscale.git
synced 2026-05-05 04:06:35 +02:00
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>
This commit is contained in:
parent
03c3551ee5
commit
66918a65f6
@ -444,16 +444,15 @@ func (mrs mapRoutineState) UpdateFullNetmap(nm *netmap.NetworkMap) {
|
||||
c.mu.Lock()
|
||||
c.inMapPoll = true
|
||||
c.expiry = nm.SelfKeyExpiry()
|
||||
stillAuthed := c.loggedIn
|
||||
c.logf("[v1] mapRoutine: netmap received: loggedIn=%v inMapPoll=true", stillAuthed)
|
||||
c.logf("[v1] mapRoutine: netmap received: loggedIn=%v inMapPoll=true", c.loggedIn)
|
||||
|
||||
// Reset the backoff timer if we got a netmap.
|
||||
mrs.bo.Reset()
|
||||
c.mu.Unlock()
|
||||
|
||||
if stillAuthed {
|
||||
c.sendStatus("mapRoutine-got-netmap", nil, "", nm)
|
||||
}
|
||||
// Always send status - sendStatus will check if we should forward the netmap
|
||||
// based on loggedIn, hasNodeKey, and inMapPoll.
|
||||
c.sendStatus("mapRoutine-got-netmap", nil, "", nm)
|
||||
}
|
||||
|
||||
func (mrs mapRoutineState) UpdateNetmapDelta(muts []netmap.NodeMutation) bool {
|
||||
@ -516,10 +515,16 @@ func (c *Auto) mapRoutine() {
|
||||
|
||||
c.mu.Lock()
|
||||
loggedIn := c.loggedIn
|
||||
c.logf("[v1] mapRoutine: loggedIn=%v", loggedIn)
|
||||
ctx := c.mapCtx
|
||||
c.mu.Unlock()
|
||||
|
||||
// Check if we have a valid node key that could receive updates.
|
||||
// Even if !loggedIn (e.g., key expired, waiting for interactive auth),
|
||||
// we should still poll if we have credentials, because the server
|
||||
// might send us a key extension notification.
|
||||
_, hasNodeKey := c.direct.GetPersist().PublicNodeKeyOK()
|
||||
c.logf("[v1] mapRoutine: loggedIn=%v hasNodeKey=%v", loggedIn, hasNodeKey)
|
||||
|
||||
report := func(err error, msg string) {
|
||||
c.logf("[v1] %s: %v", msg, err)
|
||||
err = fmt.Errorf("%s: %w", msg, err)
|
||||
@ -530,8 +535,8 @@ func (c *Auto) mapRoutine() {
|
||||
}
|
||||
}
|
||||
|
||||
if !loggedIn {
|
||||
// Wait for something interesting to happen
|
||||
if !loggedIn && !hasNodeKey {
|
||||
// No credentials at all, wait for auth to complete.
|
||||
c.mu.Lock()
|
||||
c.inMapPoll = false
|
||||
c.mu.Unlock()
|
||||
@ -622,14 +627,17 @@ func (c *Auto) sendStatus(who string, err error, url string, nm *netmap.NetworkM
|
||||
loginGoal := c.loginGoal
|
||||
c.mu.Unlock()
|
||||
|
||||
c.logf("[v1] sendStatus: %s: loggedIn=%v inMapPoll=%v", who, loggedIn, inMapPoll)
|
||||
// Check if we have a valid node key - if so, we should forward the netmap
|
||||
// even if !loggedIn, to allow the backend to see key expiry changes.
|
||||
_, hasNodeKey := c.direct.GetPersist().PublicNodeKeyOK()
|
||||
c.logf("[v1] sendStatus: %s: loggedIn=%v inMapPoll=%v hasNodeKey=%v", who, loggedIn, inMapPoll, hasNodeKey)
|
||||
|
||||
var p persist.PersistView
|
||||
if nm != nil && loggedIn && inMapPoll {
|
||||
if nm != nil && (loggedIn || hasNodeKey) && inMapPoll {
|
||||
p = c.direct.GetPersist()
|
||||
} else {
|
||||
// don't send netmap status, as it's misleading when we're
|
||||
// not logged in.
|
||||
// not logged in and have no credentials.
|
||||
nm = nil
|
||||
}
|
||||
newSt := &Status{
|
||||
@ -744,7 +752,29 @@ func (c *Auto) Login(flags LoginFlags) {
|
||||
c.loginGoal = &LoginGoal{
|
||||
flags: flags,
|
||||
}
|
||||
c.cancelMapCtxLocked()
|
||||
// If we have valid credentials (loggedIn=true) or a valid node key,
|
||||
// don't cancel the map poll. This allows the client to continue receiving
|
||||
// key extension notifications from the server while the auth flow proceeds
|
||||
// in parallel.
|
||||
//
|
||||
// This is important for the "Extend key" feature: if the admin extends a
|
||||
// key while the user has clicked "Login", we want the map poll to receive
|
||||
// that notification and recover without requiring the user to complete the
|
||||
// auth flow.
|
||||
//
|
||||
// The hasNodeKey check handles the case where a tsnet server restarts with
|
||||
// an expired key: loggedIn is false (server returned AuthURL), but we have
|
||||
// a valid node key that can still receive map updates including key extensions.
|
||||
//
|
||||
// "First successful flow wins": if a key extension arrives via map poll,
|
||||
// the client recovers. If the auth flow completes first, that works too.
|
||||
var hasNodeKey bool
|
||||
if c.direct != nil {
|
||||
_, hasNodeKey = c.direct.GetPersist().PublicNodeKeyOK()
|
||||
}
|
||||
if !c.loggedIn && !hasNodeKey {
|
||||
c.cancelMapCtxLocked()
|
||||
}
|
||||
c.cancelAuthCtxLocked()
|
||||
}
|
||||
|
||||
|
||||
146
control/controlclient/key_expiry_test.go
Normal file
146
control/controlclient/key_expiry_test.go
Normal file
@ -0,0 +1,146 @@
|
||||
// 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())
|
||||
auto.authCtx, auto.authCancel = context.WithCancel(context.Background())
|
||||
|
||||
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())
|
||||
auto.authCtx, auto.authCancel = context.WithCancel(context.Background())
|
||||
|
||||
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())
|
||||
auto.authCtx, auto.authCancel = context.WithCancel(context.Background())
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
465
ipn/ipnlocal/key_extension_test.go
Normal file
465
ipn/ipnlocal/key_extension_test.go
Normal file
@ -0,0 +1,465 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package ipnlocal
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
qt "github.com/frankban/quicktest"
|
||||
"tailscale.com/control/controlclient"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/store/mem"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tsd"
|
||||
"tailscale.com/tstest"
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/types/logid"
|
||||
"tailscale.com/types/netmap"
|
||||
"tailscale.com/wgengine"
|
||||
)
|
||||
|
||||
// TestKeyExtensionWakesUpExpiredClient verifies that when a client is in NeedsLogin
|
||||
// state due to key expiry, receiving a netmap with an extended (future) KeyExpiry
|
||||
// correctly transitions the client back to a working state.
|
||||
//
|
||||
// This tests the key recovery path: client has expired key -> admin extends key
|
||||
// -> server sends updated netmap -> client should recover.
|
||||
func TestKeyExtensionWakesUpExpiredClient(t *testing.T) {
|
||||
envknob.Setenv("TAILSCALE_USE_WIP_CODE", "1")
|
||||
defer envknob.Setenv("TAILSCALE_USE_WIP_CODE", "")
|
||||
|
||||
c := qt.New(t)
|
||||
logf := tstest.WhileTestRunningLogger(t)
|
||||
|
||||
// Setup test infrastructure
|
||||
sys := tsd.NewSystem()
|
||||
store := new(mem.Store)
|
||||
sys.Set(store)
|
||||
e, err := wgengine.NewFakeUserspaceEngine(logf, sys.Set, sys.HealthTracker.Get(), sys.UserMetricsRegistry(), sys.Bus.Get())
|
||||
c.Assert(err, qt.IsNil)
|
||||
t.Cleanup(e.Close)
|
||||
sys.Set(e)
|
||||
|
||||
b, err := NewLocalBackend(logf, logid.PublicID{}, sys, 0)
|
||||
c.Assert(err, qt.IsNil)
|
||||
t.Cleanup(b.Shutdown)
|
||||
|
||||
var cc *mockControl
|
||||
b.SetControlClientGetterForTesting(func(opts controlclient.Options) (controlclient.Client, error) {
|
||||
cc = newClient(t, opts)
|
||||
return cc, nil
|
||||
})
|
||||
|
||||
// Start the backend
|
||||
c.Assert(b.Start(ipn.Options{}), qt.IsNil)
|
||||
|
||||
// Simulate successful login and authenticated state
|
||||
cc.populateKeys()
|
||||
nodeKey := key.NewNode().Public()
|
||||
now := time.Now()
|
||||
|
||||
// First, get to a Running state with a valid key
|
||||
futureExpiry := now.Add(1 * time.Hour)
|
||||
cc.send(sendOpt{
|
||||
loginFinished: true,
|
||||
nm: &netmap.NetworkMap{
|
||||
SelfNode: (&tailcfg.Node{
|
||||
ID: 1,
|
||||
Key: nodeKey,
|
||||
MachineAuthorized: true,
|
||||
KeyExpiry: futureExpiry,
|
||||
}).View(),
|
||||
},
|
||||
})
|
||||
|
||||
// Enable WantRunning - required for keyExpired to trigger NeedsLogin state
|
||||
b.EditPrefs(&ipn.MaskedPrefs{
|
||||
WantRunningSet: true,
|
||||
Prefs: ipn.Prefs{WantRunning: true},
|
||||
})
|
||||
|
||||
// Verify we're in a good state initially
|
||||
b.mu.Lock()
|
||||
c.Assert(b.keyExpired, qt.IsFalse, qt.Commentf("key should not be expired initially"))
|
||||
b.mu.Unlock()
|
||||
|
||||
// Now simulate key expiry by sending a netmap with past KeyExpiry
|
||||
pastExpiry := now.Add(-1 * time.Hour)
|
||||
cc.send(sendOpt{
|
||||
nm: &netmap.NetworkMap{
|
||||
SelfNode: (&tailcfg.Node{
|
||||
ID: 1,
|
||||
Key: nodeKey,
|
||||
MachineAuthorized: true,
|
||||
KeyExpiry: pastExpiry,
|
||||
}).View(),
|
||||
},
|
||||
})
|
||||
|
||||
// Verify the client detects key expiry
|
||||
b.mu.Lock()
|
||||
c.Assert(b.keyExpired, qt.IsTrue, qt.Commentf("key should be detected as expired"))
|
||||
b.mu.Unlock()
|
||||
|
||||
// Verify state is NeedsLogin (requires WantRunning=true)
|
||||
state := b.State()
|
||||
c.Assert(state, qt.Equals, ipn.NeedsLogin, qt.Commentf("state should be NeedsLogin when key is expired and WantRunning=true"))
|
||||
|
||||
// Set blocked to true to simulate the engine being blocked (as would happen
|
||||
// when entering NeedsLogin due to key expiry in real flow)
|
||||
b.mu.Lock()
|
||||
b.blocked = true
|
||||
b.mu.Unlock()
|
||||
|
||||
// Now simulate admin extending the key - server sends new netmap with extended expiry
|
||||
extendedExpiry := now.Add(30 * time.Minute)
|
||||
cc.send(sendOpt{
|
||||
nm: &netmap.NetworkMap{
|
||||
SelfNode: (&tailcfg.Node{
|
||||
ID: 1,
|
||||
Key: nodeKey,
|
||||
MachineAuthorized: true,
|
||||
KeyExpiry: extendedExpiry,
|
||||
}).View(),
|
||||
},
|
||||
})
|
||||
|
||||
// Verify the client recovers:
|
||||
// 1. keyExpired should be false
|
||||
b.mu.Lock()
|
||||
c.Assert(b.keyExpired, qt.IsFalse, qt.Commentf("key should no longer be expired after extension"))
|
||||
|
||||
// 2. blocked should be false (unblocked when key extended)
|
||||
c.Assert(b.blocked, qt.IsFalse, qt.Commentf("engine should be unblocked after key extension"))
|
||||
b.mu.Unlock()
|
||||
|
||||
// 3. state should transition away from NeedsLogin
|
||||
// Note: exact state depends on other factors (MachineAuthorized, etc.)
|
||||
// but it should NOT be NeedsLogin anymore
|
||||
state = b.State()
|
||||
if state == ipn.NeedsLogin {
|
||||
// Check if it's still NeedsLogin for a reason OTHER than key expiry
|
||||
b.mu.Lock()
|
||||
keyExp := b.keyExpired
|
||||
b.mu.Unlock()
|
||||
if !keyExp {
|
||||
// Key is not expired, so NeedsLogin must be for another reason
|
||||
// (which is acceptable in this test's context)
|
||||
t.Logf("state is NeedsLogin but keyExpired=false, which is acceptable")
|
||||
} else {
|
||||
t.Errorf("state is still NeedsLogin with keyExpired=true after key extension")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestKeyExpiredStateMachine verifies that when a key expires, the state machine
|
||||
// correctly transitions to NeedsLogin and sets keyExpired=true.
|
||||
func TestKeyExpiredStateMachine(t *testing.T) {
|
||||
envknob.Setenv("TAILSCALE_USE_WIP_CODE", "1")
|
||||
defer envknob.Setenv("TAILSCALE_USE_WIP_CODE", "")
|
||||
|
||||
c := qt.New(t)
|
||||
logf := tstest.WhileTestRunningLogger(t)
|
||||
|
||||
sys := tsd.NewSystem()
|
||||
store := new(mem.Store)
|
||||
sys.Set(store)
|
||||
e, err := wgengine.NewFakeUserspaceEngine(logf, sys.Set, sys.HealthTracker.Get(), sys.UserMetricsRegistry(), sys.Bus.Get())
|
||||
c.Assert(err, qt.IsNil)
|
||||
t.Cleanup(e.Close)
|
||||
sys.Set(e)
|
||||
|
||||
b, err := NewLocalBackend(logf, logid.PublicID{}, sys, 0)
|
||||
c.Assert(err, qt.IsNil)
|
||||
t.Cleanup(b.Shutdown)
|
||||
|
||||
var cc *mockControl
|
||||
b.SetControlClientGetterForTesting(func(opts controlclient.Options) (controlclient.Client, error) {
|
||||
cc = newClient(t, opts)
|
||||
return cc, nil
|
||||
})
|
||||
|
||||
c.Assert(b.Start(ipn.Options{}), qt.IsNil)
|
||||
|
||||
cc.populateKeys()
|
||||
nodeKey := key.NewNode().Public()
|
||||
now := time.Now()
|
||||
|
||||
// Get to Running state with valid key
|
||||
futureExpiry := now.Add(1 * time.Hour)
|
||||
cc.send(sendOpt{
|
||||
loginFinished: true,
|
||||
nm: &netmap.NetworkMap{
|
||||
SelfNode: (&tailcfg.Node{
|
||||
ID: 1,
|
||||
Key: nodeKey,
|
||||
MachineAuthorized: true,
|
||||
KeyExpiry: futureExpiry,
|
||||
}).View(),
|
||||
},
|
||||
})
|
||||
|
||||
// Enable WantRunning
|
||||
b.EditPrefs(&ipn.MaskedPrefs{
|
||||
WantRunningSet: true,
|
||||
Prefs: ipn.Prefs{WantRunning: true},
|
||||
})
|
||||
|
||||
// Verify initial state
|
||||
b.mu.Lock()
|
||||
c.Assert(b.keyExpired, qt.IsFalse)
|
||||
b.mu.Unlock()
|
||||
|
||||
// Now expire the key
|
||||
pastExpiry := now.Add(-1 * time.Hour)
|
||||
cc.send(sendOpt{
|
||||
nm: &netmap.NetworkMap{
|
||||
SelfNode: (&tailcfg.Node{
|
||||
ID: 1,
|
||||
Key: nodeKey,
|
||||
MachineAuthorized: true,
|
||||
KeyExpiry: pastExpiry,
|
||||
}).View(),
|
||||
},
|
||||
})
|
||||
|
||||
// Verify keyExpired is set
|
||||
b.mu.Lock()
|
||||
c.Assert(b.keyExpired, qt.IsTrue, qt.Commentf("keyExpired should be true after receiving expired KeyExpiry"))
|
||||
b.mu.Unlock()
|
||||
|
||||
// Verify state is NeedsLogin
|
||||
c.Assert(b.State(), qt.Equals, ipn.NeedsLogin)
|
||||
}
|
||||
|
||||
// TestKeyExpiryExtendedUnblocksEngine verifies that when a key is extended,
|
||||
// the engine is unblocked even if it was blocked due to key expiry.
|
||||
func TestKeyExpiryExtendedUnblocksEngine(t *testing.T) {
|
||||
envknob.Setenv("TAILSCALE_USE_WIP_CODE", "1")
|
||||
defer envknob.Setenv("TAILSCALE_USE_WIP_CODE", "")
|
||||
|
||||
c := qt.New(t)
|
||||
logf := tstest.WhileTestRunningLogger(t)
|
||||
|
||||
sys := tsd.NewSystem()
|
||||
store := new(mem.Store)
|
||||
sys.Set(store)
|
||||
e, err := wgengine.NewFakeUserspaceEngine(logf, sys.Set, sys.HealthTracker.Get(), sys.UserMetricsRegistry(), sys.Bus.Get())
|
||||
c.Assert(err, qt.IsNil)
|
||||
t.Cleanup(e.Close)
|
||||
sys.Set(e)
|
||||
|
||||
b, err := NewLocalBackend(logf, logid.PublicID{}, sys, 0)
|
||||
c.Assert(err, qt.IsNil)
|
||||
t.Cleanup(b.Shutdown)
|
||||
|
||||
var cc *mockControl
|
||||
b.SetControlClientGetterForTesting(func(opts controlclient.Options) (controlclient.Client, error) {
|
||||
cc = newClient(t, opts)
|
||||
return cc, nil
|
||||
})
|
||||
|
||||
c.Assert(b.Start(ipn.Options{}), qt.IsNil)
|
||||
|
||||
cc.populateKeys()
|
||||
nodeKey := key.NewNode().Public()
|
||||
now := time.Now()
|
||||
|
||||
// Get to authenticated state
|
||||
cc.send(sendOpt{
|
||||
loginFinished: true,
|
||||
nm: &netmap.NetworkMap{
|
||||
SelfNode: (&tailcfg.Node{
|
||||
ID: 1,
|
||||
Key: nodeKey,
|
||||
MachineAuthorized: true,
|
||||
KeyExpiry: now.Add(1 * time.Hour),
|
||||
}).View(),
|
||||
},
|
||||
})
|
||||
|
||||
// Simulate key expiry
|
||||
pastExpiry := now.Add(-1 * time.Hour)
|
||||
cc.send(sendOpt{
|
||||
nm: &netmap.NetworkMap{
|
||||
SelfNode: (&tailcfg.Node{
|
||||
ID: 1,
|
||||
Key: nodeKey,
|
||||
MachineAuthorized: true,
|
||||
KeyExpiry: pastExpiry,
|
||||
}).View(),
|
||||
},
|
||||
})
|
||||
|
||||
// Manually set blocked=true to simulate blocked state
|
||||
b.mu.Lock()
|
||||
b.blocked = true
|
||||
wasBlocked := b.blocked
|
||||
b.mu.Unlock()
|
||||
c.Assert(wasBlocked, qt.IsTrue)
|
||||
|
||||
// Extend the key
|
||||
extendedExpiry := now.Add(30 * time.Minute)
|
||||
cc.send(sendOpt{
|
||||
nm: &netmap.NetworkMap{
|
||||
SelfNode: (&tailcfg.Node{
|
||||
ID: 1,
|
||||
Key: nodeKey,
|
||||
MachineAuthorized: true,
|
||||
KeyExpiry: extendedExpiry,
|
||||
}).View(),
|
||||
},
|
||||
})
|
||||
|
||||
// Verify engine is unblocked
|
||||
b.mu.Lock()
|
||||
c.Assert(b.blocked, qt.IsFalse, qt.Commentf("engine should be unblocked after key extension"))
|
||||
c.Assert(b.keyExpired, qt.IsFalse, qt.Commentf("keyExpired should be false after extension"))
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
// TestKeyExpiryZeroMeansNoExpiry verifies that a zero KeyExpiry (used for
|
||||
// tagged nodes or nodes with expiry disabled) is not treated as expired.
|
||||
func TestKeyExpiryZeroMeansNoExpiry(t *testing.T) {
|
||||
envknob.Setenv("TAILSCALE_USE_WIP_CODE", "1")
|
||||
defer envknob.Setenv("TAILSCALE_USE_WIP_CODE", "")
|
||||
|
||||
c := qt.New(t)
|
||||
logf := tstest.WhileTestRunningLogger(t)
|
||||
|
||||
sys := tsd.NewSystem()
|
||||
store := new(mem.Store)
|
||||
sys.Set(store)
|
||||
e, err := wgengine.NewFakeUserspaceEngine(logf, sys.Set, sys.HealthTracker.Get(), sys.UserMetricsRegistry(), sys.Bus.Get())
|
||||
c.Assert(err, qt.IsNil)
|
||||
t.Cleanup(e.Close)
|
||||
sys.Set(e)
|
||||
|
||||
b, err := NewLocalBackend(logf, logid.PublicID{}, sys, 0)
|
||||
c.Assert(err, qt.IsNil)
|
||||
t.Cleanup(b.Shutdown)
|
||||
|
||||
var cc *mockControl
|
||||
b.SetControlClientGetterForTesting(func(opts controlclient.Options) (controlclient.Client, error) {
|
||||
cc = newClient(t, opts)
|
||||
return cc, nil
|
||||
})
|
||||
|
||||
c.Assert(b.Start(ipn.Options{}), qt.IsNil)
|
||||
|
||||
cc.populateKeys()
|
||||
nodeKey := key.NewNode().Public()
|
||||
|
||||
// Send netmap with zero KeyExpiry (like a tagged node)
|
||||
cc.send(sendOpt{
|
||||
loginFinished: true,
|
||||
nm: &netmap.NetworkMap{
|
||||
SelfNode: (&tailcfg.Node{
|
||||
ID: 1,
|
||||
Key: nodeKey,
|
||||
MachineAuthorized: true,
|
||||
KeyExpiry: time.Time{}, // zero = no expiry
|
||||
}).View(),
|
||||
},
|
||||
})
|
||||
|
||||
// Verify key is NOT considered expired
|
||||
b.mu.Lock()
|
||||
c.Assert(b.keyExpired, qt.IsFalse, qt.Commentf("zero KeyExpiry should not be treated as expired"))
|
||||
b.mu.Unlock()
|
||||
|
||||
// State should not be NeedsLogin due to expiry
|
||||
state := b.State()
|
||||
c.Assert(state, qt.Not(qt.Equals), ipn.NeedsLogin, qt.Commentf("should not be in NeedsLogin with zero KeyExpiry"))
|
||||
}
|
||||
|
||||
// TestKeyExpiryWithNetMapUpdate verifies that key expiry detection works
|
||||
// correctly across multiple netmap updates with varying expiry times.
|
||||
func TestKeyExpiryWithNetMapUpdate(t *testing.T) {
|
||||
envknob.Setenv("TAILSCALE_USE_WIP_CODE", "1")
|
||||
defer envknob.Setenv("TAILSCALE_USE_WIP_CODE", "")
|
||||
|
||||
c := qt.New(t)
|
||||
logf := tstest.WhileTestRunningLogger(t)
|
||||
|
||||
sys := tsd.NewSystem()
|
||||
store := new(mem.Store)
|
||||
sys.Set(store)
|
||||
e, err := wgengine.NewFakeUserspaceEngine(logf, sys.Set, sys.HealthTracker.Get(), sys.UserMetricsRegistry(), sys.Bus.Get())
|
||||
c.Assert(err, qt.IsNil)
|
||||
t.Cleanup(e.Close)
|
||||
sys.Set(e)
|
||||
|
||||
b, err := NewLocalBackend(logf, logid.PublicID{}, sys, 0)
|
||||
c.Assert(err, qt.IsNil)
|
||||
t.Cleanup(b.Shutdown)
|
||||
|
||||
var cc *mockControl
|
||||
b.SetControlClientGetterForTesting(func(opts controlclient.Options) (controlclient.Client, error) {
|
||||
cc = newClient(t, opts)
|
||||
return cc, nil
|
||||
})
|
||||
|
||||
c.Assert(b.Start(ipn.Options{}), qt.IsNil)
|
||||
|
||||
cc.populateKeys()
|
||||
nodeKey := key.NewNode().Public()
|
||||
now := time.Now()
|
||||
|
||||
// Initial login with future expiry
|
||||
futureExpiry := now.Add(24 * time.Hour)
|
||||
cc.send(sendOpt{
|
||||
loginFinished: true,
|
||||
nm: &netmap.NetworkMap{
|
||||
SelfNode: (&tailcfg.Node{
|
||||
ID: 1,
|
||||
Key: nodeKey,
|
||||
MachineAuthorized: true,
|
||||
KeyExpiry: futureExpiry,
|
||||
}).View(),
|
||||
},
|
||||
})
|
||||
|
||||
b.mu.Lock()
|
||||
c.Assert(b.keyExpired, qt.IsFalse)
|
||||
b.mu.Unlock()
|
||||
|
||||
// Simulate multiple netmap updates, tracking keyExpired state
|
||||
testCases := []struct {
|
||||
name string
|
||||
expiry time.Time
|
||||
wantExpired bool
|
||||
}{
|
||||
{"still valid", now.Add(12 * time.Hour), false},
|
||||
{"expires soon", now.Add(5 * time.Minute), false},
|
||||
{"just expired", now.Add(-1 * time.Second), true},
|
||||
{"expired long ago", now.Add(-24 * time.Hour), true},
|
||||
{"extended again", now.Add(1 * time.Hour), false},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cc.send(sendOpt{
|
||||
nm: &netmap.NetworkMap{
|
||||
SelfNode: (&tailcfg.Node{
|
||||
ID: 1,
|
||||
Key: nodeKey,
|
||||
MachineAuthorized: true,
|
||||
KeyExpiry: tc.expiry,
|
||||
}).View(),
|
||||
},
|
||||
})
|
||||
|
||||
b.mu.Lock()
|
||||
gotExpired := b.keyExpired
|
||||
b.mu.Unlock()
|
||||
|
||||
c.Assert(gotExpired, qt.Equals, tc.wantExpired,
|
||||
qt.Commentf("%s: expiry=%v, keyExpired=%v, want=%v",
|
||||
tc.name, tc.expiry, gotExpired, tc.wantExpired))
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user