mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-23 13:59:09 +02:00
[client] Extract SSH JWT cache into shared jwtcache package
Move the daemon's memguard-backed JWT token cache from client/server into client/ssh/jwtcache so it can be reused outside the daemon process. The TTL derivation from Config.SSHJWTCacheTTL (nil=default, 0=disabled) moves into jwtcache.ResolveTTL. Wire the cache into the iOS SDK's SSH client: iOS has no daemon to delegate caching to, so without it every reconnect forced the user through the browser OAuth device-code flow. The cache lives on the long-lived Client (the app creates a new SSHClient per session) and uses the same config-driven TTL semantics as the daemon.
This commit is contained in:
@@ -21,6 +21,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/internal/listener"
|
"github.com/netbirdio/netbird/client/internal/listener"
|
||||||
"github.com/netbirdio/netbird/client/internal/peer"
|
"github.com/netbirdio/netbird/client/internal/peer"
|
||||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||||
|
"github.com/netbirdio/netbird/client/ssh/jwtcache"
|
||||||
"github.com/netbirdio/netbird/client/system"
|
"github.com/netbirdio/netbird/client/system"
|
||||||
"github.com/netbirdio/netbird/formatter"
|
"github.com/netbirdio/netbird/formatter"
|
||||||
"github.com/netbirdio/netbird/route"
|
"github.com/netbirdio/netbird/route"
|
||||||
@@ -87,6 +88,11 @@ type Client struct {
|
|||||||
// config holds the active configuration once Run has loaded it. Consumed by
|
// config holds the active configuration once Run has loaded it. Consumed by
|
||||||
// the in-app SSH client for the NetBird SSH key and the OAuth flow.
|
// the in-app SSH client for the NetBird SSH key and the OAuth flow.
|
||||||
config *profilemanager.Config
|
config *profilemanager.Config
|
||||||
|
|
||||||
|
// sshJWTCache keeps the SSH JWT token between reconnects so the user is not
|
||||||
|
// forced through the browser OAuth flow on every session. Lives on Client
|
||||||
|
// (not SSHClient) because the app creates a new SSHClient per session.
|
||||||
|
sshJWTCache *jwtcache.Cache
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewClient instantiate a new Client
|
// NewClient instantiate a new Client
|
||||||
@@ -103,6 +109,7 @@ func NewClient(cfgFile, stateFile, cacheDir, logFilePath, deviceName string, osV
|
|||||||
ctxCancelLock: &sync.Mutex{},
|
ctxCancelLock: &sync.Mutex{},
|
||||||
networkChangeListener: networkChangeListener,
|
networkChangeListener: networkChangeListener,
|
||||||
dnsManager: dnsManager,
|
dnsManager: dnsManager,
|
||||||
|
sshJWTCache: jwtcache.New(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||||
nbssh "github.com/netbirdio/netbird/client/ssh"
|
nbssh "github.com/netbirdio/netbird/client/ssh"
|
||||||
"github.com/netbirdio/netbird/client/ssh/detection"
|
"github.com/netbirdio/netbird/client/ssh/detection"
|
||||||
|
"github.com/netbirdio/netbird/client/ssh/jwtcache"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -293,6 +294,14 @@ func (s *SSHClient) buildAuth(cfg *profilemanager.Config, engine *internal.Engin
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *SSHClient) requestJWTToken(cfg *profilemanager.Config) (string, error) {
|
func (s *SSHClient) requestJWTToken(cfg *profilemanager.Config) (string, error) {
|
||||||
|
// Reuse a cached token so the user is not forced through the browser OAuth
|
||||||
|
// flow on every reconnect. TTL comes from cfg.SSHJWTCacheTTL, same as the
|
||||||
|
// daemon's cache; unset/0 disables caching.
|
||||||
|
if token, ok := s.nb.sshJWTCache.Get(); ok {
|
||||||
|
log.Debug("SSH: reusing cached JWT token")
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
urlOpener := s.urlOpener
|
urlOpener := s.urlOpener
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
@@ -324,6 +333,10 @@ func (s *SSHClient) requestJWTToken(cfg *profilemanager.Config) (string, error)
|
|||||||
if token == "" {
|
if token == "" {
|
||||||
return "", errors.New("empty token returned by IdP")
|
return "", errors.New("empty token returned by IdP")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ttl := jwtcache.ResolveTTL(cfg.SSHJWTCacheTTL); ttl > 0 {
|
||||||
|
s.nb.sshJWTCache.Store(token, ttl)
|
||||||
|
}
|
||||||
return token, nil
|
return token, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/awnumar/memguard"
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
)
|
|
||||||
|
|
||||||
type jwtCache struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
enclave *memguard.Enclave
|
|
||||||
expiresAt time.Time
|
|
||||||
timer *time.Timer
|
|
||||||
maxTokenSize int
|
|
||||||
}
|
|
||||||
|
|
||||||
func newJWTCache() *jwtCache {
|
|
||||||
return &jwtCache{
|
|
||||||
maxTokenSize: 8192,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *jwtCache) store(token string, maxAge time.Duration) {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
|
|
||||||
c.cleanup()
|
|
||||||
|
|
||||||
if c.timer != nil {
|
|
||||||
c.timer.Stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
tokenBytes := []byte(token)
|
|
||||||
c.enclave = memguard.NewEnclave(tokenBytes)
|
|
||||||
|
|
||||||
c.expiresAt = time.Now().Add(maxAge)
|
|
||||||
|
|
||||||
var timer *time.Timer
|
|
||||||
timer = time.AfterFunc(maxAge, func() {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
if c.timer != timer {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.cleanup()
|
|
||||||
c.timer = nil
|
|
||||||
log.Debugf("JWT token cache expired after %v, securely wiped from memory", maxAge)
|
|
||||||
})
|
|
||||||
c.timer = timer
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *jwtCache) get() (string, bool) {
|
|
||||||
c.mu.RLock()
|
|
||||||
defer c.mu.RUnlock()
|
|
||||||
|
|
||||||
if c.enclave == nil || time.Now().After(c.expiresAt) {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
buffer, err := c.enclave.Open()
|
|
||||||
if err != nil {
|
|
||||||
log.Debugf("Failed to open JWT token enclave: %v", err)
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
defer buffer.Destroy()
|
|
||||||
|
|
||||||
token := string(buffer.Bytes())
|
|
||||||
return token, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// cleanup destroys the secure enclave, must be called with lock held
|
|
||||||
func (c *jwtCache) cleanup() {
|
|
||||||
if c.enclave != nil {
|
|
||||||
c.enclave = nil
|
|
||||||
}
|
|
||||||
c.expiresAt = time.Time{}
|
|
||||||
}
|
|
||||||
+8
-18
@@ -26,6 +26,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||||
sleephandler "github.com/netbirdio/netbird/client/internal/sleep/handler"
|
sleephandler "github.com/netbirdio/netbird/client/internal/sleep/handler"
|
||||||
"github.com/netbirdio/netbird/client/mdm"
|
"github.com/netbirdio/netbird/client/mdm"
|
||||||
|
"github.com/netbirdio/netbird/client/ssh/jwtcache"
|
||||||
"github.com/netbirdio/netbird/client/system"
|
"github.com/netbirdio/netbird/client/system"
|
||||||
mgm "github.com/netbirdio/netbird/shared/management/client"
|
mgm "github.com/netbirdio/netbird/shared/management/client"
|
||||||
"github.com/netbirdio/netbird/shared/management/domain"
|
"github.com/netbirdio/netbird/shared/management/domain"
|
||||||
@@ -50,9 +51,6 @@ const (
|
|||||||
defaultMaxRetryTime = 14 * 24 * time.Hour
|
defaultMaxRetryTime = 14 * 24 * time.Hour
|
||||||
defaultRetryMultiplier = 1.7
|
defaultRetryMultiplier = 1.7
|
||||||
|
|
||||||
// JWT token cache TTL for the client daemon (disabled by default)
|
|
||||||
defaultJWTCacheTTL = 0
|
|
||||||
|
|
||||||
errRestoreResidualState = "failed to restore residual state: %v"
|
errRestoreResidualState = "failed to restore residual state: %v"
|
||||||
errProfilesDisabled = "profiles are disabled, you cannot use this feature without profiles enabled"
|
errProfilesDisabled = "profiles are disabled, you cannot use this feature without profiles enabled"
|
||||||
errUpdateSettingsDisabled = "update settings are disabled, you cannot use this feature without update settings enabled"
|
errUpdateSettingsDisabled = "update settings are disabled, you cannot use this feature without update settings enabled"
|
||||||
@@ -134,7 +132,7 @@ type Server struct {
|
|||||||
|
|
||||||
updateManager *updater.Manager
|
updateManager *updater.Manager
|
||||||
|
|
||||||
jwtCache *jwtCache
|
jwtCache *jwtcache.Cache
|
||||||
}
|
}
|
||||||
|
|
||||||
type oauthAuthFlow struct {
|
type oauthAuthFlow struct {
|
||||||
@@ -156,7 +154,7 @@ func New(ctx context.Context, logFile string, configFile string, profilesDisable
|
|||||||
updateSettingsDisabled: updateSettingsDisabled,
|
updateSettingsDisabled: updateSettingsDisabled,
|
||||||
captureEnabled: captureEnabled,
|
captureEnabled: captureEnabled,
|
||||||
networksDisabled: networksDisabled,
|
networksDisabled: networksDisabled,
|
||||||
jwtCache: newJWTCache(),
|
jwtCache: jwtcache.New(),
|
||||||
extendAuthSessionFlow: auth.NewPendingFlow(),
|
extendAuthSessionFlow: auth.NewPendingFlow(),
|
||||||
probeThrottle: newProbeThrottle(probeThreshold),
|
probeThrottle: newProbeThrottle(probeThreshold),
|
||||||
}
|
}
|
||||||
@@ -1624,19 +1622,11 @@ func (s *Server) getJWTCacheTTL() time.Duration {
|
|||||||
config := s.config
|
config := s.config
|
||||||
s.mutex.Unlock()
|
s.mutex.Unlock()
|
||||||
|
|
||||||
if config == nil || config.SSHJWTCacheTTL == nil {
|
if config == nil {
|
||||||
return defaultJWTCacheTTL
|
return jwtcache.DefaultTTL
|
||||||
}
|
}
|
||||||
|
|
||||||
seconds := *config.SSHJWTCacheTTL
|
return jwtcache.ResolveTTL(config.SSHJWTCacheTTL)
|
||||||
if seconds == 0 {
|
|
||||||
log.Debug("SSH JWT cache disabled (configured to 0)")
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
ttl := time.Duration(seconds) * time.Second
|
|
||||||
log.Debugf("SSH JWT cache TTL set to %v from config", ttl)
|
|
||||||
return ttl
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RequestJWTAuth initiates JWT authentication flow for SSH
|
// RequestJWTAuth initiates JWT authentication flow for SSH
|
||||||
@@ -1658,7 +1648,7 @@ func (s *Server) RequestJWTAuth(
|
|||||||
|
|
||||||
jwtCacheTTL := s.getJWTCacheTTL()
|
jwtCacheTTL := s.getJWTCacheTTL()
|
||||||
if jwtCacheTTL > 0 {
|
if jwtCacheTTL > 0 {
|
||||||
if cachedToken, found := s.jwtCache.get(); found {
|
if cachedToken, found := s.jwtCache.Get(); found {
|
||||||
log.Debugf("JWT token found in cache, returning cached token for SSH authentication")
|
log.Debugf("JWT token found in cache, returning cached token for SSH authentication")
|
||||||
|
|
||||||
return &proto.RequestJWTAuthResponse{
|
return &proto.RequestJWTAuthResponse{
|
||||||
@@ -1731,7 +1721,7 @@ func (s *Server) WaitJWTToken(
|
|||||||
|
|
||||||
jwtCacheTTL := s.getJWTCacheTTL()
|
jwtCacheTTL := s.getJWTCacheTTL()
|
||||||
if jwtCacheTTL > 0 {
|
if jwtCacheTTL > 0 {
|
||||||
s.jwtCache.store(token, jwtCacheTTL)
|
s.jwtCache.Store(token, jwtCacheTTL)
|
||||||
log.Debugf("JWT token cached for SSH authentication, TTL: %v", jwtCacheTTL)
|
log.Debugf("JWT token cached for SSH authentication, TTL: %v", jwtCacheTTL)
|
||||||
} else {
|
} else {
|
||||||
log.Debug("JWT caching disabled, not storing token")
|
log.Debug("JWT caching disabled, not storing token")
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// Package jwtcache provides an in-memory, TTL-bound cache for SSH JWT tokens.
|
||||||
|
// The token is kept in a secure memguard enclave and wiped from memory when it
|
||||||
|
// expires. It is shared by the daemon gRPC server and the mobile SDKs, which
|
||||||
|
// have no daemon process to delegate caching to.
|
||||||
|
package jwtcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/awnumar/memguard"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultTTL is used when no TTL is configured: caching disabled.
|
||||||
|
const DefaultTTL = 0
|
||||||
|
|
||||||
|
// Cache stores a single JWT token in a secure enclave until it expires.
|
||||||
|
type Cache struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
enclave *memguard.Enclave
|
||||||
|
expiresAt time.Time
|
||||||
|
timer *time.Timer
|
||||||
|
maxTokenSize int
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates an empty Cache.
|
||||||
|
func New() *Cache {
|
||||||
|
return &Cache{
|
||||||
|
maxTokenSize: 8192,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store caches the token for maxAge. A previously stored token is wiped.
|
||||||
|
func (c *Cache) Store(token string, maxAge time.Duration) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
c.cleanup()
|
||||||
|
|
||||||
|
if c.timer != nil {
|
||||||
|
c.timer.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenBytes := []byte(token)
|
||||||
|
c.enclave = memguard.NewEnclave(tokenBytes)
|
||||||
|
|
||||||
|
c.expiresAt = time.Now().Add(maxAge)
|
||||||
|
|
||||||
|
var timer *time.Timer
|
||||||
|
timer = time.AfterFunc(maxAge, func() {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.timer != timer {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.cleanup()
|
||||||
|
c.timer = nil
|
||||||
|
log.Debugf("JWT token cache expired after %v, securely wiped from memory", maxAge)
|
||||||
|
})
|
||||||
|
c.timer = timer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the cached token, or false if none is stored or it has expired.
|
||||||
|
func (c *Cache) Get() (string, bool) {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
|
||||||
|
if c.enclave == nil || time.Now().After(c.expiresAt) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer, err := c.enclave.Open()
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("Failed to open JWT token enclave: %v", err)
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
defer buffer.Destroy()
|
||||||
|
|
||||||
|
token := string(buffer.Bytes())
|
||||||
|
return token, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanup destroys the secure enclave, must be called with lock held
|
||||||
|
func (c *Cache) cleanup() {
|
||||||
|
if c.enclave != nil {
|
||||||
|
c.enclave = nil
|
||||||
|
}
|
||||||
|
c.expiresAt = time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveTTL converts the configured TTL (seconds, from
|
||||||
|
// profilemanager.Config.SSHJWTCacheTTL) into a duration. Returns DefaultTTL
|
||||||
|
// when unset; 0 means caching is disabled.
|
||||||
|
func ResolveTTL(configuredSeconds *int) time.Duration {
|
||||||
|
if configuredSeconds == nil {
|
||||||
|
return DefaultTTL
|
||||||
|
}
|
||||||
|
|
||||||
|
seconds := *configuredSeconds
|
||||||
|
if seconds == 0 {
|
||||||
|
log.Debug("SSH JWT cache disabled (configured to 0)")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
ttl := time.Duration(seconds) * time.Second
|
||||||
|
log.Debugf("SSH JWT cache TTL set to %v from config", ttl)
|
||||||
|
return ttl
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user