mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-08 14:39:09 +02:00
Merge branch 'main' into fix/pkce-flow-session-extend
# Conflicts: # client/ios/NetBirdSDK/login.go # client/server/server.go # shared/management/proto/management.pb.go
This commit is contained in:
+137
-92
@@ -4,12 +4,14 @@ package NetBirdSDK
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
@@ -37,6 +39,8 @@ const (
|
||||
AnonymizeLevelStrict = nbAnonymize.LevelStrictString
|
||||
)
|
||||
|
||||
var errClientAlreadyRunning = errors.New("client is already running")
|
||||
|
||||
// RouteListener export internal RouteListener for mobile
|
||||
type NetworkChangeListener interface {
|
||||
listener.NetworkChangeListener
|
||||
@@ -74,25 +78,36 @@ type Client struct {
|
||||
cacheDir string
|
||||
logFilePath string
|
||||
recorder *peer.Status
|
||||
ctxCancel context.CancelFunc
|
||||
ctxCancelLock *sync.Mutex
|
||||
deviceName string
|
||||
osName string
|
||||
osVersion string
|
||||
networkChangeListener listener.NetworkChangeListener
|
||||
onHostDnsFn func([]string)
|
||||
dnsManager dns.IosDnsManager
|
||||
loginComplete bool
|
||||
loginComplete atomic.Bool
|
||||
// netMgr outlives engine restarts: it mirrors the OS connectivity, not
|
||||
// the engine lifecycle. Run injects its state and sweeper into each new
|
||||
// ConnectClient.
|
||||
netMgr *netevents.Manager
|
||||
// preloadedConfig holds config loaded from JSON (used on tvOS where file writes are blocked)
|
||||
preloadedConfig *profilemanager.Config
|
||||
netMgr *netevents.Manager
|
||||
preloadedConfigJSON atomic.Pointer[string]
|
||||
|
||||
// mdmSource holds the per-Client MDM policy source and its change
|
||||
// detector as one unit. Set by SetMDMPolicyFetcher (called from the
|
||||
// Swift side at extension init). Each Run passes the loader to the
|
||||
// resolved Config so applyMDMPolicy picks up the active overlay. Nil
|
||||
// means "MDM enforcement off for this Client".
|
||||
mdmSource atomic.Pointer[mdmSource]
|
||||
|
||||
// stateMu guards the run lifecycle as one unit: the cancel installed by
|
||||
// the current run, the channel it closes on exit, and the state it
|
||||
// published. One run at a time: startRun refuses a second Run while the
|
||||
// previous one has not exited, and the platform serializes Stop before
|
||||
// Start, so no generation tracking is needed.
|
||||
stateMu sync.RWMutex
|
||||
connectClient *internal.ConnectClient
|
||||
config *profilemanager.Config
|
||||
runDone chan struct{}
|
||||
ctxCancel context.CancelFunc
|
||||
}
|
||||
|
||||
// NewClient instantiate a new Client
|
||||
@@ -107,66 +122,69 @@ func NewClient(cfgFile, stateFile, cacheDir, logFilePath, deviceName string, osV
|
||||
osName: osName,
|
||||
osVersion: osVersion,
|
||||
recorder: recorder,
|
||||
ctxCancelLock: &sync.Mutex{},
|
||||
networkChangeListener: networkChangeListener,
|
||||
dnsManager: dnsManager,
|
||||
netMgr: netevents.NewManager(recorder),
|
||||
}
|
||||
}
|
||||
|
||||
// SetConfigFromJSON loads config from a JSON string into memory.
|
||||
// This is used on tvOS where file writes to App Group containers are blocked.
|
||||
// When set, IsLoginRequired() and Run() will use this preloaded config instead of reading from file.
|
||||
// SetConfigFromJSON stores the JSON config that later loads resolve instead of the config file (tvOS).
|
||||
func (c *Client) SetConfigFromJSON(jsonStr string) error {
|
||||
cfg, err := profilemanager.ConfigFromJSON(jsonStr)
|
||||
if err != nil {
|
||||
if _, err := profilemanager.ConfigFromJSON(jsonStr); err != nil {
|
||||
log.Errorf("SetConfigFromJSON: failed to parse config JSON: %v", err)
|
||||
return err
|
||||
}
|
||||
c.preloadedConfig = cfg
|
||||
c.preloadedConfigJSON.Store(&jsonStr)
|
||||
log.Infof("SetConfigFromJSON: config loaded successfully from JSON")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) loadConfig(input profilemanager.ConfigInput) (*profilemanager.Config, error) {
|
||||
var cfg *profilemanager.Config
|
||||
var err error
|
||||
if preloaded := c.preloadedConfigJSON.Load(); preloaded != nil {
|
||||
cfg, err = profilemanager.ConfigFromJSON(*preloaded)
|
||||
} else {
|
||||
cfg, err = profilemanager.DirectUpdateOrCreateConfig(input)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.applyMDMOverlay(cfg)
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Run start the internal client. It is a blocker function
|
||||
func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error {
|
||||
exportEnvList(envList)
|
||||
log.Infof("Starting NetBird client")
|
||||
log.Debugf("Tunnel uses interface: %s", interfaceName)
|
||||
|
||||
var cfg *profilemanager.Config
|
||||
var err error
|
||||
|
||||
// Use preloaded config if available (tvOS where file writes are blocked)
|
||||
if c.preloadedConfig != nil {
|
||||
log.Infof("Run: using preloaded config from memory")
|
||||
cfg = c.preloadedConfig
|
||||
} else {
|
||||
log.Infof("Run: loading config from file")
|
||||
// Use DirectUpdateOrCreateConfig to avoid atomic file operations (temp file + rename)
|
||||
// which are blocked by the tvOS sandbox in App Group containers
|
||||
cfg, err = profilemanager.DirectUpdateOrCreateConfig(profilemanager.ConfigInput{
|
||||
ConfigPath: c.cfgFile,
|
||||
StateFilePath: c.stateFile,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := c.loadConfig(profilemanager.ConfigInput{
|
||||
ConfigPath: c.cfgFile,
|
||||
StateFilePath: c.stateFile,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.recorder.UpdateManagementAddress(cfg.ManagementURL.String())
|
||||
c.recorder.UpdateRosenpass(cfg.RosenpassEnabled, cfg.RosenpassPermissive)
|
||||
|
||||
var ctx context.Context
|
||||
//nolint
|
||||
ctxWithValues := context.WithValue(context.Background(), system.DeviceNameCtxKey, c.deviceName)
|
||||
//nolint
|
||||
ctxWithValues = context.WithValue(ctxWithValues, system.OsNameCtxKey, c.osName)
|
||||
//nolint
|
||||
ctxWithValues = context.WithValue(ctxWithValues, system.OsVersionCtxKey, c.osVersion)
|
||||
c.ctxCancelLock.Lock()
|
||||
ctx, c.ctxCancel = context.WithCancel(ctxWithValues)
|
||||
defer c.ctxCancel()
|
||||
c.ctxCancelLock.Unlock()
|
||||
runCtx, runCancel := context.WithCancel(ctxWithValues)
|
||||
defer runCancel()
|
||||
|
||||
done, err := c.startRun(runCancel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer c.finishRun(done)
|
||||
ctx := runCtx
|
||||
|
||||
// No login pre-flight here. The engine's own loginToManagement (connect.go) performs
|
||||
// the authoritative Login immediately before the first Sync, so a LoginSync() call at
|
||||
@@ -215,16 +233,40 @@ func (c *Client) NotifyNetworkChange() {
|
||||
c.netMgr.NotifyNetworkChange()
|
||||
}
|
||||
|
||||
// Stop the internal client and free the resources
|
||||
// Stop cancels the running client and waits for the run loop to exit, so a
|
||||
// caller that restarts immediately cannot race the outgoing teardown.
|
||||
func (c *Client) Stop() {
|
||||
c.ctxCancelLock.Lock()
|
||||
defer c.ctxCancelLock.Unlock()
|
||||
if c.ctxCancel == nil {
|
||||
done := c.cancelRun()
|
||||
if done == nil {
|
||||
return
|
||||
}
|
||||
|
||||
c.ctxCancel()
|
||||
c.setState(nil, nil)
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(stopRunWaitTimeout):
|
||||
log.Warnf("Stop: timed out waiting for the run loop to exit")
|
||||
}
|
||||
}
|
||||
|
||||
// StopWithoutWait cancels the running client without waiting for the run loop.
|
||||
// Use it where the caller is on a deadline the wait could overrun, such as
|
||||
// NEPacketTunnelProvider.stopTunnel, which iOS gives only a few seconds
|
||||
// before it kills the extension.
|
||||
func (c *Client) StopWithoutWait() {
|
||||
c.cancelRun()
|
||||
}
|
||||
|
||||
func (c *Client) cancelRun() chan struct{} {
|
||||
c.stateMu.RLock()
|
||||
done := c.runDone
|
||||
cancel := c.ctxCancel
|
||||
c.stateMu.RUnlock()
|
||||
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
|
||||
return done
|
||||
}
|
||||
|
||||
// DebugBundle generates a debug bundle, uploads it and returns the upload key.
|
||||
@@ -238,19 +280,13 @@ func (c *Client) DebugBundle(anonymize bool, anonymizeLevel string) (string, err
|
||||
|
||||
// If the engine hasn't been started, load config so we can reach management.
|
||||
if cfg == nil {
|
||||
if c.preloadedConfig != nil {
|
||||
cfg = c.preloadedConfig
|
||||
} else {
|
||||
var err error
|
||||
// Use DirectUpdateOrCreateConfig to avoid atomic file operations
|
||||
// (temp file + rename) blocked by the tvOS sandbox.
|
||||
cfg, err = profilemanager.DirectUpdateOrCreateConfig(profilemanager.ConfigInput{
|
||||
ConfigPath: c.cfgFile,
|
||||
StateFilePath: c.stateFile,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
var err error
|
||||
cfg, err = c.loadConfig(profilemanager.ConfigInput{
|
||||
ConfigPath: c.cfgFile,
|
||||
StateFilePath: c.stateFile,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,40 +412,18 @@ func (c *Client) IsLoginRequiredCached() bool {
|
||||
}
|
||||
|
||||
func (c *Client) IsLoginRequired() bool {
|
||||
var ctx context.Context
|
||||
//nolint
|
||||
ctxWithValues := context.WithValue(context.Background(), system.DeviceNameCtxKey, c.deviceName)
|
||||
//nolint
|
||||
ctxWithValues = context.WithValue(ctxWithValues, system.OsNameCtxKey, c.osName)
|
||||
//nolint
|
||||
ctxWithValues = context.WithValue(ctxWithValues, system.OsVersionCtxKey, c.osVersion)
|
||||
c.ctxCancelLock.Lock()
|
||||
defer c.ctxCancelLock.Unlock()
|
||||
ctx, c.ctxCancel = context.WithCancel(ctxWithValues)
|
||||
ctx, cancel := context.WithCancel(ctxWithValues)
|
||||
defer cancel()
|
||||
|
||||
var cfg *profilemanager.Config
|
||||
var err error
|
||||
|
||||
// Use preloaded config if available (tvOS where file writes are blocked)
|
||||
if c.preloadedConfig != nil {
|
||||
log.Infof("IsLoginRequired: using preloaded config from memory")
|
||||
cfg = c.preloadedConfig
|
||||
} else {
|
||||
log.Infof("IsLoginRequired: loading config from file")
|
||||
// Use DirectUpdateOrCreateConfig to avoid atomic file operations (temp file + rename)
|
||||
// which are blocked by the tvOS sandbox in App Group containers
|
||||
cfg, err = profilemanager.DirectUpdateOrCreateConfig(profilemanager.ConfigInput{
|
||||
ConfigPath: c.cfgFile,
|
||||
})
|
||||
if err != nil {
|
||||
log.Errorf("IsLoginRequired: failed to load config: %v", err)
|
||||
// If we can't load config, assume login is required
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if cfg == nil {
|
||||
log.Errorf("IsLoginRequired: config is nil")
|
||||
cfg, err := c.loadConfig(profilemanager.ConfigInput{ConfigPath: c.cfgFile})
|
||||
if err != nil {
|
||||
log.Errorf("IsLoginRequired: failed to load config: %v", err)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -433,17 +447,22 @@ func (c *Client) IsLoginRequired() bool {
|
||||
// loginForMobileAuthTimeout is the timeout for requesting auth info from the server
|
||||
const loginForMobileAuthTimeout = 30 * time.Second
|
||||
|
||||
const stopRunWaitTimeout = 20 * time.Second
|
||||
|
||||
func (c *Client) LoginForMobile() string {
|
||||
var ctx context.Context
|
||||
//nolint
|
||||
ctxWithValues := context.WithValue(context.Background(), system.DeviceNameCtxKey, c.deviceName)
|
||||
//nolint
|
||||
ctxWithValues = context.WithValue(ctxWithValues, system.OsNameCtxKey, c.osName)
|
||||
//nolint
|
||||
ctxWithValues = context.WithValue(ctxWithValues, system.OsVersionCtxKey, c.osVersion)
|
||||
c.ctxCancelLock.Lock()
|
||||
defer c.ctxCancelLock.Unlock()
|
||||
ctx, c.ctxCancel = context.WithCancel(ctxWithValues)
|
||||
ctx, cancel := context.WithCancel(ctxWithValues)
|
||||
loginDone := false
|
||||
defer func() {
|
||||
if !loginDone {
|
||||
cancel()
|
||||
}
|
||||
}()
|
||||
|
||||
// Use DirectUpdateOrCreateConfig to avoid atomic file operations (temp file + rename)
|
||||
// which are blocked by the tvOS sandbox in App Group containers
|
||||
@@ -454,6 +473,7 @@ func (c *Client) LoginForMobile() string {
|
||||
log.Errorf("LoginForMobile: failed to load config: %v", err)
|
||||
return fmt.Sprintf("failed to load config: %v", err)
|
||||
}
|
||||
c.applyMDMOverlay(cfg)
|
||||
|
||||
oAuthFlow, err := auth.NewOAuthFlow(ctx, cfg, false, false, "", false)
|
||||
if err != nil {
|
||||
@@ -470,7 +490,9 @@ func (c *Client) LoginForMobile() string {
|
||||
}
|
||||
|
||||
// This could cause a potential race condition with loading the extension which need to be handled on swift side
|
||||
loginDone = true
|
||||
go func() {
|
||||
defer cancel()
|
||||
tokenInfo, err := oAuthFlow.WaitToken(ctx, flowInfo)
|
||||
if err != nil {
|
||||
log.Errorf("LoginForMobile: WaitToken failed: %v", err)
|
||||
@@ -487,18 +509,18 @@ func (c *Client) LoginForMobile() string {
|
||||
log.Errorf("LoginForMobile: Login failed: %v", err)
|
||||
return
|
||||
}
|
||||
c.loginComplete = true
|
||||
c.loginComplete.Store(true)
|
||||
}()
|
||||
|
||||
return flowInfo.VerificationURIComplete
|
||||
}
|
||||
|
||||
func (c *Client) IsLoginComplete() bool {
|
||||
return c.loginComplete
|
||||
return c.loginComplete.Load()
|
||||
}
|
||||
|
||||
func (c *Client) ClearLoginComplete() {
|
||||
c.loginComplete = false
|
||||
c.loginComplete.Store(false)
|
||||
}
|
||||
|
||||
func (c *Client) GetRoutesSelectionDetails() (*RoutesSelectionDetails, error) {
|
||||
@@ -718,13 +740,36 @@ func (c *Client) DeselectRoute(id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// setState stores the running engine state so DebugBundle can reuse the live
|
||||
// config and ConnectClient. It is cleared on Stop.
|
||||
func (c *Client) setState(cfg *profilemanager.Config, cc *internal.ConnectClient) {
|
||||
func (c *Client) startRun(cancel context.CancelFunc) (chan struct{}, error) {
|
||||
c.stateMu.Lock()
|
||||
defer c.stateMu.Unlock()
|
||||
|
||||
if c.runDone != nil {
|
||||
return nil, errClientAlreadyRunning
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
c.runDone = done
|
||||
c.ctxCancel = cancel
|
||||
return done, nil
|
||||
}
|
||||
|
||||
func (c *Client) finishRun(done chan struct{}) {
|
||||
c.stateMu.Lock()
|
||||
c.connectClient = nil
|
||||
c.config = nil
|
||||
c.runDone = nil
|
||||
c.ctxCancel = nil
|
||||
c.stateMu.Unlock()
|
||||
|
||||
close(done)
|
||||
}
|
||||
|
||||
func (c *Client) setState(cfg *profilemanager.Config, cc *internal.ConnectClient) {
|
||||
c.stateMu.Lock()
|
||||
c.config = cfg
|
||||
c.connectClient = cc
|
||||
c.stateMu.Unlock()
|
||||
}
|
||||
|
||||
// stateSnapshot returns the current config and ConnectClient under the lock.
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/auth"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/mdm"
|
||||
"github.com/netbirdio/netbird/client/mobile"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
)
|
||||
|
||||
@@ -38,14 +40,22 @@ type Auth struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
config *profilemanager.Config
|
||||
base *profilemanager.Config
|
||||
policy *mdm.Policy
|
||||
cfgPath string
|
||||
}
|
||||
|
||||
// NewAuth instantiate Auth struct and validate the management URL
|
||||
func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
|
||||
inputCfg := profilemanager.ConfigInput{
|
||||
ConfigPath: cfgPath,
|
||||
ManagementURL: mgmURL,
|
||||
// NewAuth instantiate Auth struct and validate the management URL.
|
||||
// Auth is constructed under the active MDM policy: the policy is overlaid on
|
||||
// the resolved config so the login runs against the enforced values, while
|
||||
// the persisted config keeps the caller-supplied ones; a caller-supplied
|
||||
// management URL is ignored while MDM manages that key. A nil fetcher
|
||||
// disables MDM enforcement.
|
||||
func NewAuth(cfgPath string, mgmURL string, fetcher PolicyFetcher) (*Auth, error) {
|
||||
policy := loaderFor(fetcher).Load()
|
||||
inputCfg := profilemanager.ConfigInput{ConfigPath: cfgPath}
|
||||
if _, managed := policy.GetString(mdm.KeyManagementURL); !managed {
|
||||
inputCfg.ManagementURL = mgmURL
|
||||
}
|
||||
|
||||
// Load the existing config when a config file is already present so an
|
||||
@@ -66,6 +76,10 @@ func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a := &Auth{policy: policy, cfgPath: cfgPath}
|
||||
if err := a.setBaseConfig(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Use a cancellable context so Stop() can abort an in-progress interactive
|
||||
// login. The PKCE flow's WaitToken blocks (and keeps its loopback HTTP server
|
||||
@@ -75,14 +89,8 @@ func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
|
||||
// process (decoupled from the network extension), so without this the server
|
||||
// lingers after the user dismisses the browser and the next connect stalls
|
||||
// trying to bind the same port.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
return &Auth{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
config: cfg,
|
||||
cfgPath: cfgPath,
|
||||
}, nil
|
||||
a.ctx, a.cancel = context.WithCancel(context.Background())
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// NewAuthWithConfig instantiate Auth based on existing config
|
||||
@@ -105,9 +113,7 @@ func (a *Auth) Stop() {
|
||||
}
|
||||
}
|
||||
|
||||
// SaveConfigIfSSOSupported test the connectivity with the management server by retrieving the server device flow info.
|
||||
// If it returns a flow info than save the configuration and return true. If it gets a codes.NotFound, it means that SSO
|
||||
// is not supported and returns false without saving the configuration. For other errors return false.
|
||||
// SaveConfigIfSSOSupported reports whether the management server supports SSO; the config is already persisted by NewAuth.
|
||||
func (a *Auth) SaveConfigIfSSOSupported(listener SSOListener) {
|
||||
if listener == nil {
|
||||
log.Errorf("SaveConfigIfSSOSupported: listener is nil")
|
||||
@@ -135,17 +141,10 @@ func (a *Auth) saveConfigIfSSOSupported() (bool, error) {
|
||||
return false, fmt.Errorf("failed to check SSO support: %v", err)
|
||||
}
|
||||
|
||||
if !supportsSSO {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Use DirectWriteOutConfig to avoid atomic file operations (temp file + rename)
|
||||
// which are blocked by the tvOS sandbox in App Group containers
|
||||
err = profilemanager.DirectWriteOutConfig(a.cfgPath, a.config)
|
||||
return true, err
|
||||
return supportsSSO, nil
|
||||
}
|
||||
|
||||
// LoginWithSetupKeyAndSaveConfig test the connectivity with the management server with the setup key.
|
||||
// LoginWithSetupKeyAndSaveConfig registers the peer with the setup key; the config is already persisted by NewAuth.
|
||||
func (a *Auth) LoginWithSetupKeyAndSaveConfig(resultListener ErrListener, setupKey string, deviceName string) {
|
||||
if resultListener == nil {
|
||||
log.Errorf("LoginWithSetupKeyAndSaveConfig: resultListener is nil")
|
||||
@@ -174,10 +173,7 @@ func (a *Auth) loginWithSetupKeyAndSaveConfig(setupKey string, deviceName string
|
||||
if err != nil {
|
||||
return fmt.Errorf("login failed: %v", err)
|
||||
}
|
||||
|
||||
// Use DirectWriteOutConfig to avoid atomic file operations (temp file + rename)
|
||||
// which are blocked by the tvOS sandbox in App Group containers
|
||||
return profilemanager.DirectWriteOutConfig(a.cfgPath, a.config)
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoginSync performs a synchronous login check without UI interaction
|
||||
@@ -284,12 +280,14 @@ func (a *Auth) login(urlOpener URLOpener, forceDeviceAuth bool, deviceName strin
|
||||
}
|
||||
|
||||
jwtToken := ""
|
||||
email := ""
|
||||
if needsLogin {
|
||||
tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, forceDeviceAuth)
|
||||
if err != nil {
|
||||
return fmt.Errorf("interactive sso login failed: %v", err)
|
||||
}
|
||||
jwtToken = tokenInfo.GetTokenToUse()
|
||||
email = tokenInfo.Email
|
||||
}
|
||||
|
||||
err, isAuthError := authClient.Login(ctx, "", jwtToken)
|
||||
@@ -301,16 +299,11 @@ func (a *Auth) login(urlOpener URLOpener, forceDeviceAuth bool, deviceName strin
|
||||
return fmt.Errorf("login failed: %v", err)
|
||||
}
|
||||
|
||||
// Save the config before notifying success to ensure persistence completes
|
||||
// before the callback potentially triggers teardown on the Swift side.
|
||||
// Note: This differs from Android which doesn't save config after login.
|
||||
// On iOS/tvOS, we save here because:
|
||||
// 1. The config may have been modified during login (e.g., new tokens)
|
||||
// 2. On tvOS, the Network Extension context may be the only place with
|
||||
// write permissions to the App Group container
|
||||
if a.cfgPath != "" {
|
||||
if err := profilemanager.DirectWriteOutConfig(a.cfgPath, a.config); err != nil {
|
||||
log.Warnf("failed to save config after login: %v", err)
|
||||
// Stored after Login, not before: a rejected token must not leave a hint
|
||||
// pointing at an account that cannot be used.
|
||||
if email != "" && a.cfgPath != "" {
|
||||
if err := mobile.WriteProfileEmail(a.cfgPath, email); err != nil {
|
||||
log.Warnf("failed to store profile account email: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,10 +313,24 @@ func (a *Auth) login(urlOpener URLOpener, forceDeviceAuth bool, deviceName strin
|
||||
return nil
|
||||
}
|
||||
|
||||
// profileLoginHint returns the stored account email for the profile at cfgPath,
|
||||
// so a re-login targets the account the profile already belongs to instead of
|
||||
// whatever session the shared browser cookie jar happens to hold.
|
||||
//
|
||||
// An empty hint is deliberate, not a fallback: a fresh profile leaves the
|
||||
// choice to the IdP. Switching accounts is done by switching or removing
|
||||
// profiles, not by logging out — logout keeps the email.
|
||||
func profileLoginHint(cfgPath string) string {
|
||||
if cfgPath == "" {
|
||||
return ""
|
||||
}
|
||||
return mobile.ReadProfileEmail(cfgPath)
|
||||
}
|
||||
|
||||
const authInfoRequestTimeout = 30 * time.Second
|
||||
|
||||
func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener, forceDeviceAuth bool) (*auth.TokenInfo, error) {
|
||||
oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, forceDeviceAuth, false, "")
|
||||
oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, forceDeviceAuth, false, profileLoginHint(a.cfgPath))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get OAuth flow: %v", err)
|
||||
}
|
||||
@@ -350,23 +357,44 @@ func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener
|
||||
return &tokenInfo, nil
|
||||
}
|
||||
|
||||
// GetConfigJSON returns the current config as a JSON string.
|
||||
// This can be used by the caller to persist the config via alternative storage
|
||||
// mechanisms (e.g., UserDefaults on tvOS where file writes are blocked).
|
||||
// GetConfigJSON returns the config without the MDM overlay as JSON, for persisting it outside the config file (tvOS).
|
||||
func (a *Auth) GetConfigJSON() (string, error) {
|
||||
if a.config == nil {
|
||||
cfg := a.base
|
||||
if cfg == nil {
|
||||
cfg = a.config
|
||||
}
|
||||
if cfg == nil {
|
||||
return "", fmt.Errorf("no config available")
|
||||
}
|
||||
return profilemanager.ConfigToJSON(a.config)
|
||||
return profilemanager.ConfigToJSON(cfg)
|
||||
}
|
||||
|
||||
// SetConfigFromJSON loads config from a JSON string.
|
||||
// This can be used to restore config from alternative storage mechanisms.
|
||||
// SetConfigFromJSON replaces the config from JSON; the MDM overlay is applied on top for the login.
|
||||
func (a *Auth) SetConfigFromJSON(jsonStr string) error {
|
||||
cfg, err := profilemanager.ConfigFromJSON(jsonStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.config = cfg
|
||||
return a.setBaseConfig(cfg)
|
||||
}
|
||||
|
||||
func (a *Auth) setBaseConfig(base *profilemanager.Config) error {
|
||||
overlaid, err := copyConfig(base)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if a.policy != nil {
|
||||
overlaid.ApplyMDMPolicy(a.policy)
|
||||
}
|
||||
a.base = base
|
||||
a.config = overlaid
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyConfig(cfg *profilemanager.Config) (*profilemanager.Config, error) {
|
||||
raw, err := profilemanager.ConfigToJSON(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return profilemanager.ConfigFromJSON(raw)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
//go:build ios
|
||||
|
||||
package NetBirdSDK
|
||||
|
||||
import (
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/mdm"
|
||||
)
|
||||
|
||||
// PolicyFetcher is implemented by the native layer to return the current
|
||||
// managed configuration as a JSON-encoded object string; "" means no MDM
|
||||
// source is present.
|
||||
type PolicyFetcher interface {
|
||||
FetchJSON() string
|
||||
}
|
||||
|
||||
type mdmSource struct {
|
||||
loader *mdm.Loader
|
||||
detector *mdm.ChangeDetector
|
||||
}
|
||||
|
||||
// SetMDMPolicyFetcher registers the native-provided MDM policy fetcher on
|
||||
// this Client; passing nil disables MDM enforcement.
|
||||
func (c *Client) SetMDMPolicyFetcher(p PolicyFetcher) {
|
||||
loader := loaderFor(p)
|
||||
c.mdmSource.Store(&mdmSource{loader: loader, detector: mdm.NewChangeDetector(loader)})
|
||||
}
|
||||
|
||||
// HasMDMPolicyChanged re-reads the managed configuration and reports whether
|
||||
// it changed since the last observation; call it from the native OS-change
|
||||
// notification and restart the engine only on true.
|
||||
func (c *Client) HasMDMPolicyChanged() bool {
|
||||
src := c.mdmSource.Load()
|
||||
if src == nil {
|
||||
return false
|
||||
}
|
||||
return src.detector.Changed()
|
||||
}
|
||||
|
||||
// GetRestrictionsJSON returns the UI enforcement snapshot derived from the
|
||||
// active MDM policy, in the JSON shape shared with the desktop frontend.
|
||||
func (c *Client) GetRestrictionsJSON() (string, error) {
|
||||
return mdm.BuildRestrictions(c.mdmLoader().Load()).JSON()
|
||||
}
|
||||
|
||||
func (c *Client) applyMDMOverlay(cfg *profilemanager.Config) {
|
||||
loader := c.mdmLoader()
|
||||
if cfg == nil || loader == nil {
|
||||
return
|
||||
}
|
||||
cfg.ApplyMDMPolicy(loader.Load())
|
||||
}
|
||||
|
||||
func (c *Client) mdmLoader() *mdm.Loader {
|
||||
if src := c.mdmSource.Load(); src != nil {
|
||||
return src.loader
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loaderFor(p PolicyFetcher) *mdm.Loader {
|
||||
if p == nil {
|
||||
return mdm.NewJSONLoader(nil)
|
||||
}
|
||||
return mdm.NewJSONLoader(p.FetchJSON)
|
||||
}
|
||||
@@ -3,12 +3,16 @@
|
||||
package NetBirdSDK
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/mdm"
|
||||
)
|
||||
|
||||
// Preferences export a subset of the internal config for gomobile
|
||||
type Preferences struct {
|
||||
configInput profilemanager.ConfigInput
|
||||
mdmLoader atomic.Pointer[mdm.Loader]
|
||||
}
|
||||
|
||||
// NewPreferences create new Preferences instance
|
||||
@@ -17,11 +21,30 @@ func NewPreferences(configPath string, stateFilePath string) *Preferences {
|
||||
ConfigPath: configPath,
|
||||
StateFilePath: stateFilePath,
|
||||
}
|
||||
return &Preferences{ci}
|
||||
return &Preferences{configInput: ci}
|
||||
}
|
||||
|
||||
// SetMDMPolicyFetcher registers the native-provided MDM policy fetcher on
|
||||
// this Preferences instance; passing nil disables MDM enforcement.
|
||||
func (p *Preferences) SetMDMPolicyFetcher(f PolicyFetcher) {
|
||||
p.mdmLoader.Store(loaderFor(f))
|
||||
}
|
||||
|
||||
// GetRestrictionsJSON returns the UI enforcement snapshot derived from the
|
||||
// active MDM policy, in the JSON shape shared with the desktop frontend.
|
||||
func (p *Preferences) GetRestrictionsJSON() (string, error) {
|
||||
return mdm.BuildRestrictions(p.policy()).JSON()
|
||||
}
|
||||
|
||||
func (p *Preferences) policy() *mdm.Policy {
|
||||
return p.mdmLoader.Load().Load()
|
||||
}
|
||||
|
||||
// GetManagementURL read url from config file
|
||||
func (p *Preferences) GetManagementURL() (string, error) {
|
||||
if v, ok := p.policy().GetString(mdm.KeyManagementURL); ok {
|
||||
return mdm.CanonicalURL(v), nil
|
||||
}
|
||||
if p.configInput.ManagementURL != "" {
|
||||
return p.configInput.ManagementURL, nil
|
||||
}
|
||||
@@ -30,7 +53,7 @@ func (p *Preferences) GetManagementURL() (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return cfg.ManagementURL.String(), err
|
||||
return cfg.ManagementURL.String(), nil
|
||||
}
|
||||
|
||||
// SetManagementURL store the given url and wait for commit
|
||||
@@ -56,17 +79,21 @@ func (p *Preferences) SetAdminURL(url string) {
|
||||
p.configInput.AdminURL = url
|
||||
}
|
||||
|
||||
// GetPreSharedKey read preshared key from config file
|
||||
func (p *Preferences) GetPreSharedKey() (string, error) {
|
||||
// HasPreSharedKey reports whether a pre-shared key is staged, persisted, or
|
||||
// enforced by MDM; the key itself is never handed to the native layer.
|
||||
func (p *Preferences) HasPreSharedKey() (bool, error) {
|
||||
if _, ok := p.policy().GetString(mdm.KeyPreSharedKey); ok {
|
||||
return true, nil
|
||||
}
|
||||
if p.configInput.PreSharedKey != nil {
|
||||
return *p.configInput.PreSharedKey, nil
|
||||
return *p.configInput.PreSharedKey != "", nil
|
||||
}
|
||||
|
||||
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return false, err
|
||||
}
|
||||
return cfg.PreSharedKey, err
|
||||
return cfg.PreSharedKey != "", nil
|
||||
}
|
||||
|
||||
// SetPreSharedKey store the given key and wait for commit
|
||||
@@ -81,6 +108,9 @@ func (p *Preferences) SetRosenpassEnabled(enabled bool) {
|
||||
|
||||
// GetRosenpassEnabled read rosenpass enabled from config file
|
||||
func (p *Preferences) GetRosenpassEnabled() (bool, error) {
|
||||
if v, ok := p.policy().GetBool(mdm.KeyRosenpassEnabled); ok {
|
||||
return v, nil
|
||||
}
|
||||
if p.configInput.RosenpassEnabled != nil {
|
||||
return *p.configInput.RosenpassEnabled, nil
|
||||
}
|
||||
@@ -99,6 +129,9 @@ func (p *Preferences) SetRosenpassPermissive(permissive bool) {
|
||||
|
||||
// GetRosenpassPermissive read rosenpass permissive from config file
|
||||
func (p *Preferences) GetRosenpassPermissive() (bool, error) {
|
||||
if v, ok := p.policy().GetBool(mdm.KeyRosenpassPermissive); ok {
|
||||
return v, nil
|
||||
}
|
||||
if p.configInput.RosenpassPermissive != nil {
|
||||
return *p.configInput.RosenpassPermissive, nil
|
||||
}
|
||||
@@ -128,8 +161,34 @@ func (p *Preferences) SetDisableIPv6(disable bool) {
|
||||
p.configInput.DisableIPv6 = &disable
|
||||
}
|
||||
|
||||
// GetRemoteJobsAllowed reads the remote jobs opt-in from config file
|
||||
func (p *Preferences) GetRemoteJobsAllowed() (bool, error) {
|
||||
policy := p.policy()
|
||||
if !policy.HasKey(mdm.KeyRemoteJobsAllowed) && p.configInput.RemoteJobsAllowed != nil {
|
||||
return *p.configInput.RemoteJobsAllowed, nil
|
||||
}
|
||||
|
||||
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
cfg.ApplyMDMPolicy(policy)
|
||||
if cfg.RemoteJobsAllowed == nil {
|
||||
return false, nil
|
||||
}
|
||||
return *cfg.RemoteJobsAllowed, nil
|
||||
}
|
||||
|
||||
// SetRemoteJobsAllowed stores the given value and waits for commit
|
||||
func (p *Preferences) SetRemoteJobsAllowed(allowed bool) {
|
||||
p.configInput.RemoteJobsAllowed = &allowed
|
||||
}
|
||||
|
||||
// Commit write out the changes into config file
|
||||
func (p *Preferences) Commit() error {
|
||||
if err := profilemanager.CheckMDMConflicts(p.configInput, p.policy()); err != nil {
|
||||
return err
|
||||
}
|
||||
// Use DirectUpdateOrCreateConfig to avoid atomic file operations (temp file + rename)
|
||||
// which are blocked by the tvOS sandbox in App Group containers
|
||||
_, err := profilemanager.DirectUpdateOrCreateConfig(p.configInput)
|
||||
|
||||
@@ -31,14 +31,13 @@ func TestPreferences_DefaultValues(t *testing.T) {
|
||||
t.Errorf("invalid default management url: %s", defaultVar)
|
||||
}
|
||||
|
||||
var preSharedKey string
|
||||
preSharedKey, err = p.GetPreSharedKey()
|
||||
hasPSK, err := p.HasPreSharedKey()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read default preshared key: %s", err)
|
||||
t.Fatalf("failed to read default preshared key presence: %s", err)
|
||||
}
|
||||
|
||||
if preSharedKey != "" {
|
||||
t.Errorf("invalid preshared key: %s", preSharedKey)
|
||||
if hasPSK {
|
||||
t.Errorf("unexpected preshared key presence on fresh config")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,13 +68,13 @@ func TestPreferences_ReadUncommitedValues(t *testing.T) {
|
||||
}
|
||||
|
||||
p.SetPreSharedKey(exampleString)
|
||||
resp, err = p.GetPreSharedKey()
|
||||
hasPSK, err := p.HasPreSharedKey()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read preshared key: %s", err)
|
||||
t.Fatalf("failed to read preshared key presence: %s", err)
|
||||
}
|
||||
|
||||
if resp != exampleString {
|
||||
t.Errorf("unexpected preshared key: %s", resp)
|
||||
if !hasPSK {
|
||||
t.Errorf("expected preshared key presence after staging one")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,12 +113,12 @@ func TestPreferences_Commit(t *testing.T) {
|
||||
t.Errorf("unexpected management url: %s", resp)
|
||||
}
|
||||
|
||||
resp, err = p.GetPreSharedKey()
|
||||
hasPSK, err := p.HasPreSharedKey()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read preshared key: %s", err)
|
||||
t.Fatalf("failed to read preshared key presence: %s", err)
|
||||
}
|
||||
|
||||
if resp != examplePresharedKey {
|
||||
t.Errorf("unexpected preshared key: %s", resp)
|
||||
if !hasPSK {
|
||||
t.Errorf("expected preshared key presence after commit")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,12 @@ func NewProfileManager(configDir string) *ProfileManager {
|
||||
return &ProfileManager{impl: mobile.NewProfileManager(configDir, iosUsername)}
|
||||
}
|
||||
|
||||
// SetMDMPolicyFetcher registers the native-provided MDM policy fetcher on
|
||||
// this ProfileManager; passing nil disables MDM enforcement.
|
||||
func (pm *ProfileManager) SetMDMPolicyFetcher(f PolicyFetcher) {
|
||||
pm.impl.SetMDMLoader(loaderFor(f))
|
||||
}
|
||||
|
||||
// ListProfiles returns all available profiles, including the default profile,
|
||||
// with their active status set.
|
||||
func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) {
|
||||
|
||||
Reference in New Issue
Block a user