WIP: rebase fork onto upstream/main (v1.103.0) #15

Closed
codinget wants to merge 670 commits from webnet into save/webnet-2026-07-29
3 changed files with 643 additions and 12 deletions
Showing only changes of commit 6a822dcc36 - Show all commits
+42 -12
View File
@@ -454,16 +454,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 {
@@ -596,10 +595,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)
@@ -610,8 +615,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()
@@ -702,14 +707,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{
@@ -824,7 +832,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()
}
+152
View File
@@ -0,0 +1,152 @@
// 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")
}
}
+449
View File
@@ -0,0 +1,449 @@
// 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/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) {
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) {
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) {
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) {
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) {
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))
})
}
}