Files
netbird/client/server/server_connect_test.go
Riccardo Manfrin 7b22d55bf6 [client] Bind the cached SSH JWT to the local caller that obtained it (#7378)
* [client] Bind the cached SSH JWT to the local caller that obtained it

Record the identity that obtained the token and return it only to that
same identity, comparing the account alone: the group set and the
elevation flag describe what a token may do rather than who it belongs
to, and the same user may call once elevated and once not.

A control channel that carries no caller identity gets a miss on read
and stores nothing on write, matching how the other ipcauth consumers
fail closed.

Clear the entry when the session it speaks for ends: logout, down and
profile switch.

* [client] Cover the profile-switch path of the SSH JWT cache

The cache being correct buys nothing if a handler around it forgets to
clear it, and SwitchProfile had no test at all.

Point the profile globals at a temp dir holding a single default profile,
which is the one ActiveProfileState.FilePath resolves without consulting
the current OS user, and call SwitchProfile with no request so neither
the switch itself nor the profile-list event is involved.

* [client] Report the SSH JWT cache in the no-identity startup warning

daemonServerOptions already warns once, at startup, about what a control
channel with no caller identity gives up. Name the SSH JWT cache there
too, on both the TCP and the no-peer-identity-primitive paths.

The per-request logs in cachedJWT and WaitJWTToken drop to Debug: the
condition is expected and handled on such a channel, the caller simply
re-authenticates, and repeating it on every SSH authentication buried the
one message that is actionable.

* [client] Stop the local-metrics manager leaking out of the profile test

localmetrics.NewManager runs a goroutine until its context is done, and
the test handed it context.Background(), so the manager outlived the test
and stayed in the test binary for every case that followed.

* [client] Keep the cached SSH JWT across a down/up cycle

Clearing the cache in cleanupConnection also caught Down, which ends the
connection and not the session: the peer stays enrolled, `up` reconnects
without going back to the IdP, and the token still belongs to the same
NetBird identity. With a long cache TTL that cost the owner a fresh
device-code flow for nothing, since the owner binding is what keeps the
token away from other local accounts.

Clear it on the two paths where the session really ends and the next one
may belong to a different NetBird user: profile logout when the profile
is the active one, and active-profile logout. SwitchProfile already
cleared it on its own.

* [client] Resolve the merge conflict in the profile-logout cleanup

main extracted the inline profile-logout cleanup into
cleanupAfterProfileLogout, which this branch had edited in place to clear
the SSH JWT cache. Take main's helper and move the clear inside it.

The helper returns early when the profile that was deregistered is not
the active one, so the cache is still only cleared when the session that
owns the token actually ends.

* [client] Do not cache an SSH JWT obtained under a session that ended

WaitJWTToken polls the IdP with s.mutex released, and that wait can run
for as long as the user takes in the browser. A logout or a profile
switch in the meantime clears the cache, but the poll then completed and
stored its token anyway, so the entry the next session read belonged to
the previous one.

Give the cache a generation that clear advances. WaitJWTToken takes the generation
before the wait and hands it back to store, which keeps the token only
while the generation still matches.

The two mutexes are distinct, so this was never a data race and the race
detector could not have found it: the window is between two separately
locked sections.

* [client] Make the profile-switch test switch a profile

SwitchProfile with a nil request skips switchProfileIfNeeded, so the test
only covered the no-op path and would have passed with profile-transition
invalidation broken. Create a second profile and name it in the request,
then assert the active profile actually moved before checking the cache.

Also correct the comment on the Down test: the logout handlers do call
cleanupConnection. What changed is that clearing the cache is no longer
one of the things cleanupConnection does.

* [client] Take the SSH JWT cache generation when the flow is created

WaitJWTToken read the generation after validating the device code, but
the flow it belongs to is created earlier, in RequestJWTAuth, and
SwitchProfile does not reset s.oauthAuthFlow. A profile switch between
the two therefore advanced the generation before it was ever read: the
guard compared the new session against itself and let the token through,
which is the case it exists to stop.

Record the generation on the flow when RequestJWTAuth creates it, and
read it from there. The whole span from the request to the IdP answering
now counts as one session for the cache.

* [client] Correct two test comments the clear-on-Down change invalidated

Moving the clear out of cleanupConnection left two comments describing
the old behaviour: newTestServer said cleanupConnection clears the cache,
and the comment above TestJWTCache_ClearDropsTheEntry listed Down among
the callers of clear. Neither is true any more.

* [client] Read the SSH JWT cache generation before the IdP round trip

RequestJWTAuth read the generation where it stored the flow, which is
after RequestAuthInfo has talked to the IdP. A logout or a profile switch
during that call advanced the generation first, so the flow recorded the
new session's value and the later store was accepted: the window moved
rather than closed.

Read it with the config, under the same s.mutex section. SwitchProfile
holds that mutex across its own clear(), so the config and the generation
cannot be torn apart by a switch.
2026-09-02 14:04:09 +02:00

196 lines
6.0 KiB
Go

package server
import (
"context"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/client/proto"
)
func newTestServer() *Server {
return &Server{
rootCtx: context.Background(),
statusRecorder: peer.NewRecorder(""),
// New always populates the SSH JWT cache and the logout and
// profile-switch paths call into it unconditionally, so a Server
// assembled field by field has to populate it too.
jwtCache: newJWTCache(),
}
}
func newDummyConnectClient(ctx context.Context) *internal.ConnectClient {
return internal.NewConnectClient(ctx, nil, nil)
}
// TestConnectSetsClientWithMutex validates that connect() sets s.connectClient
// under mutex protection so concurrent readers see a consistent value.
func TestConnectSetsClientWithMutex(t *testing.T) {
s := newTestServer()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Manually simulate what connect() does (without calling Run which panics without full setup)
client := newDummyConnectClient(ctx)
s.mutex.Lock()
s.connectClient = client
s.mutex.Unlock()
// Verify the assignment is visible under mutex
s.mutex.Lock()
assert.Equal(t, client, s.connectClient, "connectClient should be set")
s.mutex.Unlock()
}
// TestConcurrentConnectClientAccess validates that concurrent reads of
// s.connectClient under mutex don't race with a write.
func TestConcurrentConnectClientAccess(t *testing.T) {
s := newTestServer()
ctx := context.Background()
client := newDummyConnectClient(ctx)
var wg sync.WaitGroup
nilCount := 0
setCount := 0
var mu sync.Mutex
// Start readers
for i := 0; i < 50; i++ {
wg.Add(1)
go func() {
defer wg.Done()
s.mutex.Lock()
c := s.connectClient
s.mutex.Unlock()
mu.Lock()
defer mu.Unlock()
if c == nil {
nilCount++
} else {
setCount++
}
}()
}
// Simulate connect() writing under mutex
time.Sleep(5 * time.Millisecond)
s.mutex.Lock()
s.connectClient = client
s.mutex.Unlock()
wg.Wait()
assert.Equal(t, 50, nilCount+setCount, "all goroutines should complete without panic")
}
// TestCleanupConnection_ClearsConnectClient validates that cleanupConnection
// properly nils out connectClient.
func TestCleanupConnection_ClearsConnectClient(t *testing.T) {
s := newTestServer()
_, cancel := context.WithCancel(context.Background())
s.actCancel = cancel
s.connectClient = newDummyConnectClient(context.Background())
s.clientRunning = true
err := s.cleanupConnection()
require.NoError(t, err)
assert.Nil(t, s.connectClient, "connectClient should be nil after cleanup")
assert.False(t, s.clientRunning, "clientRunning should be cleared after cleanup (intent = down)")
}
// TestCleanState_NilConnectClient validates that CleanState doesn't panic
// when connectClient is nil.
func TestCleanState_NilConnectClient(t *testing.T) {
s := newTestServer()
s.connectClient = nil
s.profileManager = nil // will cause error if it tries to proceed past the nil check
// Should not panic — the nil check should prevent calling Status() on nil
assert.NotPanics(t, func() {
_, _ = s.CleanState(context.Background(), &proto.CleanStateRequest{All: true})
})
}
// TestDeleteState_NilConnectClient validates that DeleteState doesn't panic
// when connectClient is nil.
func TestDeleteState_NilConnectClient(t *testing.T) {
s := newTestServer()
s.connectClient = nil
s.profileManager = nil
assert.NotPanics(t, func() {
_, _ = s.DeleteState(context.Background(), &proto.DeleteStateRequest{All: true})
})
}
// TestDownThenUp_StaleRunningChan documents the known state issue where
// clientRunningChan from a previous connection is already closed, causing
// waitForUp() to return immediately on reconnect.
func TestDownThenUp_StaleRunningChan(t *testing.T) {
s := newTestServer()
// Simulate state after a successful connection
s.clientRunning = true
s.clientRunningChan = make(chan struct{})
close(s.clientRunningChan) // closed when engine started
s.clientGiveUpChan = make(chan struct{})
s.connectClient = newDummyConnectClient(context.Background())
_, cancel := context.WithCancel(context.Background())
s.actCancel = cancel
// Simulate Down(): cleanupConnection sets connectClient = nil and
// flips clientRunning to false (intent = down). The connectionGoroutineRunning state
// remains independent of intent — derived from clientGiveUpChan.
s.mutex.Lock()
err := s.cleanupConnection()
s.mutex.Unlock()
require.NoError(t, err)
// After cleanup: connectClient is nil, clientRunning is false (intent
// cleared by cleanupConnection), connectionGoroutineRunning may still be true
// (goroutine teardown is independent of the intent flag).
s.mutex.Lock()
assert.Nil(t, s.connectClient, "connectClient should be nil after cleanup")
assert.False(t, s.clientRunning, "clientRunning should be cleared by cleanupConnection (intent = down)")
s.mutex.Unlock()
// waitForUp() returns immediately due to stale closed clientRunningChan
ctx, ctxCancel := context.WithTimeout(context.Background(), 2*time.Second)
defer ctxCancel()
waitDone := make(chan error, 1)
go func() {
_, err := s.waitForUp(ctx)
waitDone <- err
}()
select {
case err := <-waitDone:
assert.NoError(t, err, "waitForUp returns success on stale channel")
// But connectClient is still nil — this is the stale state issue
s.mutex.Lock()
assert.Nil(t, s.connectClient, "connectClient is nil despite waitForUp success")
s.mutex.Unlock()
case <-time.After(1 * time.Second):
t.Fatal("waitForUp should have returned immediately due to stale closed channel")
}
}
// TestConnectClient_EngineNilOnFreshClient validates that a newly created
// ConnectClient has nil Engine (before Run is called).
func TestConnectClient_EngineNilOnFreshClient(t *testing.T) {
client := newDummyConnectClient(context.Background())
assert.Nil(t, client.Engine(), "engine should be nil on fresh ConnectClient")
}