mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-24 07:39:07 +02:00
[client] mobile MDM bridge — iOS + Android setMDMPolicyFetcher entrypoint (#6435)
* MDM Android mobile wiring * Removes dead code * Removes static vars * Now we need to apply MDM in the GetConfig * You now need to explicitly call these around * Adds iOS wiring * Resolve merge conflicts from main - login.go: keep both new imports (mdm + nbnet + server) - ios/NetBirdSDK/client.go: additive struct-field merge (mdmLoader + stateMu/connectClient/config) - setconfig_mdm_test.go: adopt new withMDMPolicy(t, s, policy) signature; fix stray old-signature call in TestSetConfig_MDMAllow_ManagementURLPortNormalized * Convey MDM overlay config to Debug Bundle output Aligns to other clients OSes behavior * Solved conflict in client.go * Fixup helper withMDMPolicy -> configWithMDM * Fixup after merge * Resolve merge conflicts * [client] Move MDM enforcement logic into a shared Go layer (#7319) The mobile bridges only carried the policy fetcher, leaving every enforcement decision to the native apps: the desktop derived its UI restrictions in the Wails service layer, the daemon kept the conflict machinery in the server package, and both mobile bridges duplicated the JSON fetch adapter. Anything the native side had to reimplement was a place for iOS and Android to drift apart. Enforcement now lives in client/mdm and is consumed identically by all three platforms: - conflicts.go holds the value-aware conflict checks lifted out of the daemon, so the same normalization (canonical URLs, PSK sentinel echo) applies wherever a config change is validated. - restrictions.go derives the UI enforcement snapshot from a policy and renders it in the JSON shape the desktop frontend already consumes. The service-layer types become aliases, keeping one source of truth. - jsonloader.go replaces the adapter that was copy-pasted into both bridges. - changedetector.go moves change detection off the native side: the caller forwards the OS notification and asks whether the managed configuration actually changed, instead of diffing dictionaries itself. The mobile bridges gain the enforcement the daemon already had. The Preferences getters resolve managed keys from the policy, so a naive UI shows the enforced value; Commit rejects a staged change that diverges from a managed key; NewAuth resolves the managed management URL before persisting the config and overlays the policy on it, so a login can no longer run against a URL the policy forbids. Android's profile mutations fail closed when disableProfiles is set. NewAuth takes the fetcher as a required argument rather than keeping a policy-blind overload: the apps consume this code as a submodule, so a compile error at the bump is the point. The mobile PSK getter is replaced by a presence check — the key has no reason to cross the bridge, and not returning it means the native side needs no redaction sentinel of its own. * [client] Resolve the main merge conflicts in the MDM integration The merge commit was recorded with the conflict markers still in the tree. Resolve them so the branch builds again: - client/ios/NetBirdSDK: keep both the mdm and mobile imports, and keep the mdmLoader/mdmDetector fields next to main's stateMu documentation. - client/server/mdm.go: drop the conflict helpers main added locally, they already live in the client/mdm package on this branch, and keep the new checks main introduced (allowRemoteJobs, enableLocalMetrics, localMetricsAddress) as calls into the package-level helpers. - client/mdm/conflicts.go: add ConflictStringPtr, the presence-aware string check main needs for the optional localMetricsAddress field. - Port the two tests main added over the per-Server loader helper and the configWithMDM helper, both of which replaced the package-level policy injection this branch removed. * [client] Reject explicit empty PSK when MDM enforces a pre-shared key The SetConfig, Login and mobile Commit conflict checks collapsed the PSK to a plain string, so an explicit empty value was indistinguishable from an unset field and slipped past the MDM gate, clearing the persisted key. Carry the optional field as a pointer through ConflictStringPtr, treating only the redaction sentinel as a no-op echo. ConflictString had no other callers and is removed. * [client] Apply MDM overlay on the preloaded iOS config in Run Run only overlaid the MDM policy when the config was loaded from file, so the tvOS path fed by SetConfigFromJSON started with unmanaged settings. Apply the overlay after the config source is selected, as the other resolution sites already do. * [client] Gate non-active profile logout behind the MDM profiles switch The mobile ProfileManager let LogoutProfile clear credentials of any profile even when disableProfiles was enforced. Follow the daemon's validateProfileLogout semantics: logging out of the active profile is a plain logout and stays allowed, logging out of any other profile is profile management and is rejected under the policy. * [client] Resolve the managed management URL through the MDM overlay on mobile NewAuth on Android and iOS replaced the caller URL with the raw policy value before persisting, so a malformed managed URL failed config validation and blocked the login instead of being skipped with a warning like the overlay does. Preferences.GetManagementURL likewise echoed the raw policy string to the native UI even when the overlay had rejected it. Follow the daemon: persist the caller URL, overlay the policy on the resolved config, and report the overlaid ManagementURL as the effective value. * [client] Clean up MDM review leftovers Drop the unused ChangeDetector.Current, point the stale LoadPolicy comment references at Loader.Load, and move the profileEmail godoc back above its function. * [client] Check remote jobs and local metrics keys in the mobile MDM conflict gate MDMConflicts skipped allowRemoteJobs, enableLocalMetrics and localMetricsAddress even though the overlay applies all three and the daemon gate already checks them, so a mobile Commit could persist values diverging from the enforced policy. Align the list with the daemon. * [client] Silence the deprecated PreSharedKey lint in the login conflict test The legacy LoginRequest.PreSharedKey field is deliberately exercised by the test, matching the nolint already carried by the production path. * [client] Publish the mobile MDM loader and detector atomically SetMDMPolicyFetcher wrote the loader and change detector as two plain fields that Run, the OS-change callback and the restrictions getter read from other threads without synchronization. Hold both behind a single atomic pointer so a registration is published as one unit and readers always observe a matching loader and detector pair; Preferences gets the same treatment for its loader. Exported signatures are unchanged. * [client] Report the MDM-overlaid remote jobs value from mobile Preferences GetRemoteJobsAllowed returned the staged or persisted value even when the policy manages allowRemoteJobs, so the native settings UI could show a value the Commit gate would reject. Resolve it through the overlay like GetManagementURL does. * [client] Stop persisting the MDM-overlaid config after mobile logins NewAuth already writes the config through UpdateOrCreateConfig before the MDM policy is overlaid, and the login itself never mutates the Config. The post-login WriteOutConfig calls therefore only rewrote the same file with the enforced ManagementURL and PreSharedKey in it, so a removed or changed policy kept acting through the persisted values. * [client] Document that the MDM overlay on Config is not reversible ApplyMDMPolicy promised that an empty Policy clears a prior overlay, but applyMDMPolicy only resets the enforcement metadata and the runtime-only upload URL; the enforced ManagementURL, PreSharedKey and flags stay. Every lifecycle owner resolves the base Config again before applying, so state that contract instead of the reversibility that was never implemented. * [client] Re-resolve the tvOS preloaded config before every MDM overlay The iOS Client kept the config parsed from SetConfigFromJSON and applied the MDM overlay onto that same instance on every Run, IsLoginRequired and DebugBundle, so a key removed from the policy stayed enforced. Store the JSON instead and parse it per load through one loadConfig path. Auth serialized the overlaid config from GetConfigJSON, which tvOS then persisted to UserDefaults and fed back as the preload. Keep the resolved config as the base, run the login on a JSON round-trip copy with the overlay, and return the base from GetConfigJSON. * [client] Serve the MDM-managed management URL without touching the config file on mobile Preferences.GetManagementURL resolved a managed URL by reading and overlaying the persisted config, so a corrupt file or the tvOS sandbox turned an enforced URL into a read error. Return the canonical managed value directly, the same string BuildRestrictions already hands to the UI, and only fall back to the staged or persisted value when MDM does not manage the key. NewAuth validated the caller-supplied management URL before the overlay ran, so a malformed or echoed value blocked or persisted under an MDM policy that already dictates the URL. Ignore the caller value while the key is managed; the login runs against the overlay either way. * [client] Align the MDM loader docs with the fetcher precedence and make disableAdvancedView a tristate NewLoader, PolicyFetcher and the darwin/windows loadPlatform docs claimed the fetcher is unused on desktop, while every loader returns its values when one is injected. That precedence is the seam the server tests rely on across platforms, so the docs now describe it; production desktop callers still pass nil and keep the registry / plist authoritative. Fields.DisableAdvancedView collapsed "managed and false" into the same JSON as "not managed", unlike AllowServerSSH and the daemon's optional proto field. Carry it as a *bool so the UIs can tell the two apart; the desktop reflect loop skips pointer fields already, and the mobile decoders treat null as not managed. * [client] Clean up MDM review nits - ResolveConflicts treats a managed key whose ConflictCheck has no Check as a conflict instead of dereferencing nil. - Ticker.Run and ChangeDetector.Changed share policyChanged so the diff semantics and the log line cannot drift apart. - TestLoader_NilFetcherReturnsEmpty skips on windows/darwin, where a nil fetcher reads the real registry / plist. - The profilemanager test loader checks GetInt before GetBool so integer keys survive the round trip, and the PSK tests use the exported redaction sentinel. * [client] Fix int policy values coercing to bool in the MDM test helper withMDMPolicy rebuilt the policy map by trying GetString, then GetBool, then GetInt. Policy.GetBool accepts native ints (non-zero means true), so an int-valued key such as wireguardPort round-tripped through the helper as the bool true and GetInt was never reached. Try GetInt before GetBool, as the profilemanager helper already does; GetInt does not coerce bools, so booleans still fall through to GetBool. No test sets an int key today, so this was latent: the first test to exercise the wireguardPort conflict gate would have seen ConflictInt64 report a conflict for every value, including a matching one. --------- Co-authored-by: Zoltan Papp <zoltan.pmail@gmail.com>
This commit is contained in:
co-authored by
Zoltan Papp
parent
15c0a2903d
commit
e14006ddc1
@@ -88,9 +88,15 @@ type Client struct {
|
||||
// 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
|
||||
@@ -122,44 +128,44 @@ func NewClient(cfgFile, stateFile, cacheDir, logFilePath, deviceName string, osV
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -274,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,29 +421,9 @@ func (c *Client) IsLoginRequired() bool {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -493,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, "")
|
||||
if err != nil {
|
||||
|
||||
@@ -11,6 +11,7 @@ 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"
|
||||
)
|
||||
@@ -39,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
|
||||
@@ -67,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
|
||||
@@ -76,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
|
||||
@@ -106,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")
|
||||
@@ -136,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")
|
||||
@@ -175,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
|
||||
@@ -312,19 +307,6 @@ func (a *Auth) login(urlOpener URLOpener, forceDeviceAuth bool, deviceName strin
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// Notify caller of successful login synchronously before returning
|
||||
urlOpener.OnLoginSuccess()
|
||||
|
||||
@@ -375,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
|
||||
}
|
||||
@@ -130,7 +163,8 @@ func (p *Preferences) SetDisableIPv6(disable bool) {
|
||||
|
||||
// GetRemoteJobsAllowed reads the remote jobs opt-in from config file
|
||||
func (p *Preferences) GetRemoteJobsAllowed() (bool, error) {
|
||||
if p.configInput.RemoteJobsAllowed != nil {
|
||||
policy := p.policy()
|
||||
if !policy.HasKey(mdm.KeyRemoteJobsAllowed) && p.configInput.RemoteJobsAllowed != nil {
|
||||
return *p.configInput.RemoteJobsAllowed, nil
|
||||
}
|
||||
|
||||
@@ -138,10 +172,11 @@ func (p *Preferences) GetRemoteJobsAllowed() (bool, error) {
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
cfg.ApplyMDMPolicy(policy)
|
||||
if cfg.RemoteJobsAllowed == nil {
|
||||
return false, nil
|
||||
}
|
||||
return *cfg.RemoteJobsAllowed, err
|
||||
return *cfg.RemoteJobsAllowed, nil
|
||||
}
|
||||
|
||||
// SetRemoteJobsAllowed stores the given value and waits for commit
|
||||
@@ -151,6 +186,9 @@ func (p *Preferences) SetRemoteJobsAllowed(allowed bool) {
|
||||
|
||||
// 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