Merge branch 'main' into fix_update_settings_value_aware

Resolves a semantic conflict the merge introduces without a textual one:
main added Preferences.GetRemoteJobsAllowed to the Android and iOS SDKs,
reading through profilemanager.ReadConfig, while this branch renamed that
function to ReadOrGenerateConfig. Neither side is broken alone, so only the
merge CI builds for a pull request caught it:

  client/android/preferences.go:334:29: undefined: profilemanager.ReadConfig

Both new call sites now use ReadOrGenerateConfig, which is what the getters
around them already do.
This commit is contained in:
riccardom
2026-09-08 10:15:11 +02:00
202 changed files with 15466 additions and 7710 deletions
+52 -8
View File
@@ -23,6 +23,7 @@ import (
"github.com/netbirdio/netbird/client/internal/auth"
"github.com/netbirdio/netbird/client/internal/expose"
"github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/prometheus/client_golang/prometheus"
"github.com/netbirdio/netbird/client/internal/localmetrics"
@@ -155,9 +156,17 @@ type Server struct {
}
type oauthAuthFlow struct {
expiresAt time.Time
flow auth.OAuthFlow
info auth.AuthFlowInfo
expiresAt time.Time
flow auth.OAuthFlow
info auth.AuthFlowInfo
// cacheGeneration is the SSH JWT cache's generation as of the start of the
// request that created this flow. The flow outlives a profile switch, so
// reading the generation any later — when the IdP has answered, or when the
// token finally arrives — would read the new session's one and let the old
// session's token into the new session's cache.
cacheGeneration uint64
waitCancel context.CancelFunc
}
@@ -1271,6 +1280,8 @@ func (s *Server) SwitchProfile(callerCtx context.Context, msg *proto.SwitchProfi
s.config = config
s.localMetrics.Reconcile(config.LocalMetricsEnabled, config.LocalMetricsAddress)
s.jwtCache.clear()
if msg != nil && msg.ProfileName != nil {
s.publishProfileListChanged(*msg.ProfileName)
}
@@ -1441,6 +1452,7 @@ func (s *Server) cleanupAfterProfileLogout(id profilemanager.ID, username string
if err := s.cleanupConnection(); err != nil && !errors.Is(err, ErrServiceNotUp) {
log.Errorf("failed to cleanup connection: %v", err)
}
s.jwtCache.clear()
state := internal.CtxGetState(s.rootCtx)
state.Set(internal.StatusNeedsLogin)
}
@@ -1469,6 +1481,7 @@ func (s *Server) handleActiveProfileLogout(ctx context.Context) (*proto.LogoutRe
log.Errorf("failed to cleanup connection: %v", err)
return nil, err
}
s.jwtCache.clear()
state := internal.CtxGetState(s.rootCtx)
state.Set(internal.StatusNeedsLogin)
@@ -1830,6 +1843,20 @@ func (s *Server) getJWTCacheTTL() time.Duration {
return ttl
}
// cachedJWT returns the cached SSH JWT to the identity that obtained it, and a
// miss on a control channel that carries no caller identity.
func (s *Server) cachedJWT(ctx context.Context) (string, bool) {
caller, ok := ipcauth.CallerIdentity(ctx)
if !ok {
// Expected and handled on a control channel with no peer identity: the
// caller re-authenticates. daemonServerOptions warns about it once at
// startup, so this stays out of the per-request log.
log.Debug("not serving the cached SSH JWT: the caller's identity cannot be verified on this control channel")
return "", false
}
return s.jwtCache.get(caller)
}
// RequestJWTAuth initiates JWT authentication flow for SSH
func (s *Server) RequestJWTAuth(
ctx context.Context,
@@ -1839,8 +1866,14 @@ func (s *Server) RequestJWTAuth(
return nil, ctx.Err()
}
// The generation is read here, with the config and under the same lock, not
// where the flow is stored below: RequestAuthInfo talks to the IdP in
// between, and a switch or a logout during that call would otherwise be
// read as the generation this flow belongs to. SwitchProfile holds
// s.mutex across its own clear(), so the pair cannot be torn.
s.mutex.Lock()
config := s.config
cacheGeneration := s.jwtCache.currentGeneration()
s.mutex.Unlock()
if config == nil {
@@ -1849,7 +1882,7 @@ func (s *Server) RequestJWTAuth(
jwtCacheTTL := s.getJWTCacheTTL()
if jwtCacheTTL > 0 {
if cachedToken, found := s.jwtCache.get(); found {
if cachedToken, found := s.cachedJWT(ctx); found {
log.Debugf("JWT token found in cache, returning cached token for SSH authentication")
return &proto.RequestJWTAuthResponse{
@@ -1883,6 +1916,7 @@ func (s *Server) RequestJWTAuth(
s.oauthAuthFlow.flow = oAuthFlow
s.oauthAuthFlow.info = authInfo
s.oauthAuthFlow.expiresAt = time.Now().Add(time.Duration(authInfo.ExpiresIn) * time.Second)
s.oauthAuthFlow.cacheGeneration = cacheGeneration
s.mutex.Unlock()
return &proto.RequestJWTAuthResponse{
@@ -1907,6 +1941,10 @@ func (s *Server) WaitJWTToken(
s.mutex.Lock()
oAuthFlow := s.oauthAuthFlow.flow
authInfo := s.oauthAuthFlow.info
// Recorded when the flow was created, not read here: the flow survives a
// profile switch, and everything from RequestJWTAuth to the IdP answering
// has to count as the same session for the cache.
generation := s.oauthAuthFlow.cacheGeneration
s.mutex.Unlock()
if oAuthFlow == nil || authInfo.DeviceCode != req.DeviceCode {
@@ -1921,11 +1959,17 @@ func (s *Server) WaitJWTToken(
token := tokenInfo.GetTokenToUse()
jwtCacheTTL := s.getJWTCacheTTL()
if jwtCacheTTL > 0 {
s.jwtCache.store(token, jwtCacheTTL)
log.Debugf("JWT token cached for SSH authentication, TTL: %v", jwtCacheTTL)
} else {
switch caller, ok := ipcauth.CallerIdentity(ctx); {
case jwtCacheTTL <= 0:
log.Debug("JWT caching disabled, not storing token")
case !ok:
log.Debug("not caching the SSH JWT: the caller's identity cannot be verified on this control channel")
default:
if s.jwtCache.store(token, caller, jwtCacheTTL, generation) {
log.Debugf("JWT token cached for SSH authentication, TTL: %v", jwtCacheTTL)
} else {
log.Debug("not caching the SSH JWT: the session it was obtained under ended while the IdP was polled")
}
}
s.mutex.Lock()