From db2c9b6f49ac88343f546c14476c47e2a3fe7268 Mon Sep 17 00:00:00 2001 From: riccardom Date: Mon, 15 Jun 2026 12:04:26 +0200 Subject: [PATCH 01/30] MDM Android mobile wiring --- client/android/mdm.go | 79 +++++++++++++++++++++++++++++++++++++ client/mdm/policy_mobile.go | 44 +++++++++++++++++---- 2 files changed, 115 insertions(+), 8 deletions(-) create mode 100644 client/android/mdm.go diff --git a/client/android/mdm.go b/client/android/mdm.go new file mode 100644 index 00000000000..5f76de60260 --- /dev/null +++ b/client/android/mdm.go @@ -0,0 +1,79 @@ +//go:build android + +package android + +import ( + "encoding/json" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/mdm" +) + +// PolicyFetcher is the mobile-side bridge for the MDM managed-config +// snapshot. The native layer (Kotlin) implements this and registers +// the instance via SetMobilePolicyFetcher at app start. Every +// invocation must read the current RestrictionsManager state and +// return the result as a JSON-encoded map[string]any string. +// +// JSON is used because gomobile does not support map[string]any +// crossing the JNI boundary — the adapter on the Go side parses the +// string back into the map[string]any expected by mdm.LoadPolicy. +// +// Return value contract: +// - "" (empty) : interpreted as "no MDM source / no managed keys" +// - "{}" : managed config explicitly empty +// - "{...}" : JSON object with key/value pairs +// - malformed JSON : logged and treated as empty +type PolicyFetcher interface { + FetchJSON() string +} + +// jsonFetcherAdapter wraps a gomobile-exposed PolicyFetcher into the +// internal mdm.PolicyFetcher interface, taking care of JSON decoding +// on every Fetch. +type jsonFetcherAdapter struct { + inner PolicyFetcher +} + +func (a *jsonFetcherAdapter) Fetch() map[string]any { + raw := a.inner.FetchJSON() + if raw == "" { + return nil + } + var out map[string]any + if err := json.Unmarshal([]byte(raw), &out); err != nil { + log.Warnf("MDM mobile fetcher: invalid JSON payload from native: %v", err) + return nil + } + return out +} + +// SetMobilePolicyFetcher registers the native-provided MDM policy +// fetcher. Call exactly once from the gomobile-init code (Kotlin +// Application.onCreate) before the daemon starts. Passing nil +// effectively disables MDM enforcement on this build. +func SetMobilePolicyFetcher(p PolicyFetcher) { + if p == nil { + mdm.SetMobilePolicyFetcher(nil) + return + } + mdm.SetMobilePolicyFetcher(&jsonFetcherAdapter{inner: p}) +} + +// OnMDMPolicyChanged is the mobile entry point the native layer calls +// when the OS broadcasts a managed-config change +// (ACTION_APPLICATION_RESTRICTIONS_CHANGED on Android, +// UserDefaults.didChangeNotification on iOS). The hook cancels the +// current engine context so the current Run() returns; the native +// loop is expected to re-invoke Run() afterwards, which re-reads +// the config (including the fresh MDM overlay via the registered +// PolicyFetcher). +// +// No payload is passed — the OS notification only signals "something +// changed"; the actual values are read on-demand by the next +// loadPlatformPolicy call inside Config.apply. +func (c *Client) OnMDMPolicyChanged() { + log.Info("MDM policy change signaled by native; stopping engine to restart with fresh config") + c.Stop() +} diff --git a/client/mdm/policy_mobile.go b/client/mdm/policy_mobile.go index ec25d4bb133..40ec0ceab03 100644 --- a/client/mdm/policy_mobile.go +++ b/client/mdm/policy_mobile.go @@ -2,13 +2,41 @@ package mdm -// loadPlatformPolicy is unused on mobile: the native layer (Swift on iOS, -// Kotlin/Java on Android) reads the OS managed-config store and pushes the -// resulting dictionary in-process via a gomobile entry point that lands in -// Phase 5 / Phase 6. The stub keeps the package compilable for mobile -// builds and returns (nil, nil) — the platform-absent sentinel that -// LoadPolicy in policy.go treats as "no MDM source present". +// PolicyFetcher is the bridge between Go and the mobile native layer +// (Kotlin/Java on Android, Swift on iOS). The native layer registers +// an implementation at gomobile init via SetMobilePolicyFetcher; +// thereafter every call to loadPlatformPolicy delegates to the +// registered fetcher, which reads the OS-native managed-config store +// (RestrictionsManager on Android, com.apple.configuration.managed +// UserDefaults on iOS) and returns the current snapshot. +// +// Set-once at init, never mutated at runtime → no synchronisation +// required for the read path. The native layer must register before +// any Go code starts polling or processing MDM events. +type PolicyFetcher interface { + Fetch() map[string]any +} + +var fetcher PolicyFetcher + +// SetMobilePolicyFetcher registers the native-provided fetcher. Call +// exactly once from the gomobile init code (Kotlin Application.onCreate +// / Swift AppDelegate) before the daemon starts. Passing nil disables +// MDM enforcement on this build (loadPlatformPolicy returns +// (nil, nil) — the platform-absent sentinel that LoadPolicy treats as +// "no MDM source present"). +func SetMobilePolicyFetcher(p PolicyFetcher) { + fetcher = p +} + +// loadPlatformPolicy delegates to the native-provided fetcher. Returns +// (nil, nil) — the platform-absent sentinel — when no fetcher has been +// registered yet, so the package behaves identically to a desktop +// device without an MDM source. func loadPlatformPolicy() (map[string]any, error) { - //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy. - return nil, nil + if fetcher == nil { + //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy. + return nil, nil + } + return fetcher.Fetch(), nil } From bec26d5a14e7ac6f1d97ff533bf5275577bb30db Mon Sep 17 00:00:00 2001 From: riccardom Date: Mon, 15 Jun 2026 13:01:04 +0200 Subject: [PATCH 02/30] Removes dead code --- client/android/mdm.go | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/client/android/mdm.go b/client/android/mdm.go index 5f76de60260..2a72dfe7816 100644 --- a/client/android/mdm.go +++ b/client/android/mdm.go @@ -60,20 +60,3 @@ func SetMobilePolicyFetcher(p PolicyFetcher) { } mdm.SetMobilePolicyFetcher(&jsonFetcherAdapter{inner: p}) } - -// OnMDMPolicyChanged is the mobile entry point the native layer calls -// when the OS broadcasts a managed-config change -// (ACTION_APPLICATION_RESTRICTIONS_CHANGED on Android, -// UserDefaults.didChangeNotification on iOS). The hook cancels the -// current engine context so the current Run() returns; the native -// loop is expected to re-invoke Run() afterwards, which re-reads -// the config (including the fresh MDM overlay via the registered -// PolicyFetcher). -// -// No payload is passed — the OS notification only signals "something -// changed"; the actual values are read on-demand by the next -// loadPlatformPolicy call inside Config.apply. -func (c *Client) OnMDMPolicyChanged() { - log.Info("MDM policy change signaled by native; stopping engine to restart with fresh config") - c.Stop() -} From 874195440cf0f011355ee88dce4cdbe2649a4e87 Mon Sep 17 00:00:00 2001 From: riccardom Date: Mon, 15 Jun 2026 17:29:10 +0200 Subject: [PATCH 03/30] Removes static vars --- client/android/client.go | 11 ++ client/android/mdm.go | 40 ++++-- client/internal/profilemanager/config.go | 35 ++++-- .../profilemanager/config_mdm_test.go | 117 ++++++++++-------- client/mdm/policy.go | 42 ++++++- client/mdm/policy_darwin.go | 14 ++- client/mdm/policy_mobile.go | 43 ++----- client/mdm/policy_other.go | 20 +-- client/mdm/policy_test.go | 10 +- client/mdm/policy_windows.go | 14 ++- client/mdm/ticker.go | 32 ++--- client/mdm/ticker_test.go | 67 +++++----- client/server/mdm.go | 4 - client/server/server.go | 27 +++- client/server/setconfig_mdm_test.go | 69 +++++++---- 15 files changed, 337 insertions(+), 208 deletions(-) diff --git a/client/android/client.go b/client/android/client.go index 99ccdf3937e..6345e28185f 100644 --- a/client/android/client.go +++ b/client/android/client.go @@ -23,6 +23,7 @@ import ( "github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/internal/routemanager" "github.com/netbirdio/netbird/client/internal/stdnet" + "github.com/netbirdio/netbird/client/mdm" "github.com/netbirdio/netbird/client/net" "github.com/netbirdio/netbird/client/system" "github.com/netbirdio/netbird/formatter" @@ -75,6 +76,13 @@ type Client struct { connectClient *internal.ConnectClient config *profilemanager.Config cacheDir string + + // mdmLoader holds the per-Client MDM policy source. Set by + // SetMDMPolicyFetcher (called from the Kotlin side). Each Run + // passes this loader to the resolved Config so applyMDMPolicy + // picks up the active overlay. Nil means "MDM enforcement off + // for this Client". + mdmLoader *mdm.Loader } func (c *Client) setState(cfg *profilemanager.Config, cacheDir string, cc *internal.ConnectClient) { @@ -129,6 +137,7 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid if err != nil { return err } + c.applyMDMOverlay(cfg) c.recorder.UpdateManagementAddress(cfg.ManagementURL.String()) c.recorder.UpdateRosenpass(cfg.RosenpassEnabled, cfg.RosenpassPermissive) @@ -173,6 +182,7 @@ func (c *Client) RunWithoutLogin(platformFiles PlatformFiles, dns *DNSList, dnsR if err != nil { return err } + c.applyMDMOverlay(cfg) c.recorder.UpdateManagementAddress(cfg.ManagementURL.String()) c.recorder.UpdateRosenpass(cfg.RosenpassEnabled, cfg.RosenpassPermissive) @@ -230,6 +240,7 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (strin if err != nil { return "", fmt.Errorf("load config: %w", err) } + c.applyMDMOverlay(cfg) cacheDir = platformFiles.CacheDir() } diff --git a/client/android/mdm.go b/client/android/mdm.go index 2a72dfe7816..30f081bb4f4 100644 --- a/client/android/mdm.go +++ b/client/android/mdm.go @@ -7,18 +7,19 @@ import ( log "github.com/sirupsen/logrus" + "github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/mdm" ) // PolicyFetcher is the mobile-side bridge for the MDM managed-config // snapshot. The native layer (Kotlin) implements this and registers -// the instance via SetMobilePolicyFetcher at app start. Every -// invocation must read the current RestrictionsManager state and -// return the result as a JSON-encoded map[string]any string. +// the instance per Client via Client.SetMDMPolicyFetcher. Every +// invocation of fetchJSON must read the current RestrictionsManager +// state and return the result as a JSON-encoded map[string]any string. // // JSON is used because gomobile does not support map[string]any // crossing the JNI boundary — the adapter on the Go side parses the -// string back into the map[string]any expected by mdm.LoadPolicy. +// string back into the map[string]any expected by mdm.Loader. // // Return value contract: // - "" (empty) : interpreted as "no MDM source / no managed keys" @@ -49,14 +50,31 @@ func (a *jsonFetcherAdapter) Fetch() map[string]any { return out } -// SetMobilePolicyFetcher registers the native-provided MDM policy -// fetcher. Call exactly once from the gomobile-init code (Kotlin -// Application.onCreate) before the daemon starts. Passing nil -// effectively disables MDM enforcement on this build. -func SetMobilePolicyFetcher(p PolicyFetcher) { +// SetMDMPolicyFetcher registers the native-provided MDM policy fetcher +// on this Client. Call once from the gomobile-init code (Kotlin +// Application.onCreate or Service onCreate) before invoking Run / +// RunWithoutLogin. Passing nil disables MDM enforcement on this +// Client. +// +// The fetcher is held as a *mdm.Loader instance on the Client (no +// package-level state) — multiple Clients in the same process get +// independent Loaders, and tests can inject fakes per Client. +func (c *Client) SetMDMPolicyFetcher(p PolicyFetcher) { if p == nil { - mdm.SetMobilePolicyFetcher(nil) + c.mdmLoader = mdm.NewLoader(nil) + return + } + c.mdmLoader = mdm.NewLoader(&jsonFetcherAdapter{inner: p}) +} + +// applyMDMOverlay applies the Client-held MDM Loader's current policy +// on top of the just-read Config. Called immediately after every +// UpdateOrCreateConfig — profilemanager's apply() initialises the +// policy to empty and leaves overlay responsibility to the lifecycle +// owner. No-op when no fetcher was registered. +func (c *Client) applyMDMOverlay(cfg *profilemanager.Config) { + if cfg == nil || c.mdmLoader == nil { return } - mdm.SetMobilePolicyFetcher(&jsonFetcherAdapter{inner: p}) + cfg.ApplyMDMPolicy(c.mdmLoader.Load()) } diff --git a/client/internal/profilemanager/config.go b/client/internal/profilemanager/config.go index b0c7fd470cc..10aa2233240 100644 --- a/client/internal/profilemanager/config.go +++ b/client/internal/profilemanager/config.go @@ -58,10 +58,6 @@ var DefaultInterfaceBlacklist = []string{ "Tailscale", "tailscale", "docker", "veth", "br-", "lo", } -// loadMDMPolicy is the package-level indirection used by apply() to read the -// active MDM policy. Tests override this to inject a fake policy. -var loadMDMPolicy = mdm.LoadPolicy - // ConfigInput carries configuration changes to the client type ConfigInput struct { ManagementURL string @@ -180,14 +176,27 @@ type Config struct { MTU uint16 - // policy is the MDM policy that produced the currently-set values for - // any MDM-enforced fields. Set by applyMDMPolicy at the tail of apply() - // and reset on every apply() invocation. Never persisted to disk. - // Callers query enforcement state via Policy() and the mdm.Policy API - // (HasKey, ManagedKeys, IsEmpty). + // policy is the MDM policy that produced the currently-set values + // for any MDM-enforced fields. Set by ApplyMDMPolicy on every + // invocation. Never persisted to disk. Callers query enforcement + // state via Policy() and the mdm.Policy API (HasKey, ManagedKeys, + // IsEmpty). policy *mdm.Policy `json:"-"` } +// ApplyMDMPolicy overlays the supplied MDM Policy on top of the +// currently resolved Config values. Idempotent — pass an empty Policy +// to clear any prior overlay. The lifecycle owner (Server.getConfig +// on desktop, the Client.Run path on mobile) calls this with +// loader.Load() once the per-process Loader is known; the Config +// itself holds no reference to the Loader. +func (config *Config) ApplyMDMPolicy(policy *mdm.Policy) { + if config == nil { + return + } + config.applyMDMPolicy(policy) +} + // Policy returns the MDM policy applied to this Config. Returns a non-nil // empty Policy when MDM enforcement is inactive; callers can always invoke // HasKey / ManagedKeys / IsEmpty without a nil check. @@ -634,9 +643,11 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - // MDM is the last override layer: any key present in the policy - // supersedes defaults, on-disk config, env vars and CLI input. - config.applyMDMPolicy(loadMDMPolicy()) + // Initialise the MDM overlay to "no enforcement" so Config.Policy() + // never returns a stale or nil policy on a freshly applied Config. + // Lifecycle owners that want to enforce a real MDM policy invoke + // Config.ApplyMDMPolicy(loader.Load()) after this returns. + config.applyMDMPolicy(mdm.NewPolicy(nil)) return updated, nil } diff --git a/client/internal/profilemanager/config_mdm_test.go b/client/internal/profilemanager/config_mdm_test.go index 6a201235e46..127104c31cd 100644 --- a/client/internal/profilemanager/config_mdm_test.go +++ b/client/internal/profilemanager/config_mdm_test.go @@ -10,24 +10,58 @@ import ( "github.com/netbirdio/netbird/client/mdm" ) -// withMDMPolicy temporarily overrides the package-level loadMDMPolicy hook so -// apply() observes the supplied Policy. The original loader is restored at -// test cleanup. -func withMDMPolicy(t *testing.T, policy *mdm.Policy) { +// fakeFetcher implements mdm.PolicyFetcher returning a pre-set policy +// map. Test helper used to construct a Loader without touching the OS +// or any package-level state. +type fakeFetcher struct{ values map[string]any } + +func (f *fakeFetcher) Fetch() map[string]any { return f.values } + +// loaderFor builds an mdm.Loader whose loadPlatform returns the +// supplied Policy's underlying values. +func loaderFor(policy *mdm.Policy) *mdm.Loader { + if policy == nil || policy.IsEmpty() { + return mdm.NewLoader(&fakeFetcher{values: nil}) + } + values := make(map[string]any) + for _, k := range policy.ManagedKeys() { + if v, ok := policy.GetString(k); ok { + values[k] = v + continue + } + if v, ok := policy.GetBool(k); ok { + values[k] = v + continue + } + if v, ok := policy.GetInt(k); ok { + values[k] = v + continue + } + if v, ok := policy.GetStringSlice(k); ok { + values[k] = v + } + } + return mdm.NewLoader(&fakeFetcher{values: values}) +} + +// configWithMDM is the test convenience that builds a Config via +// UpdateOrCreateConfig and overlays the supplied MDM policy on top — +// mirrors the production pattern (Server.getConfig / Client.applyMDMOverlay) +// where the Loader lives outside Config and the apply step is driven +// by the lifecycle owner. +func configWithMDM(t *testing.T, input ConfigInput, policy *mdm.Policy) *Config { t.Helper() - prev := loadMDMPolicy - loadMDMPolicy = func() *mdm.Policy { return policy } - t.Cleanup(func() { loadMDMPolicy = prev }) + cfg, err := UpdateOrCreateConfig(input) + require.NoError(t, err) + require.NotNil(t, cfg) + cfg.ApplyMDMPolicy(loaderFor(policy).Load()) + return cfg } func TestApply_MDMEmpty_NoEnforcement(t *testing.T) { - withMDMPolicy(t, mdm.NewPolicy(nil)) - - cfg, err := UpdateOrCreateConfig(ConfigInput{ + cfg := configWithMDM(t, ConfigInput{ ConfigPath: filepath.Join(t.TempDir(), "config.json"), - }) - require.NoError(t, err) - require.NotNil(t, cfg) + }, mdm.NewPolicy(nil)) assert.True(t, cfg.Policy().IsEmpty(), "no MDM source ⇒ empty Policy") assert.False(t, cfg.Policy().HasKey(mdm.KeyManagementURL)) @@ -39,18 +73,15 @@ func TestApply_MDMEmpty_NoEnforcement(t *testing.T) { func TestApply_MDMOnly_OverridesDefaults(t *testing.T) { const mdmURL = "https://corp.mdm.example.com:443" - withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + + cfg := configWithMDM(t, ConfigInput{ + ConfigPath: filepath.Join(t.TempDir(), "config.json"), + }, mdm.NewPolicy(map[string]any{ mdm.KeyManagementURL: mdmURL, mdm.KeyDisableClientRoutes: true, mdm.KeyBlockInbound: true, })) - cfg, err := UpdateOrCreateConfig(ConfigInput{ - ConfigPath: filepath.Join(t.TempDir(), "config.json"), - }) - require.NoError(t, err) - require.NotNil(t, cfg) - assert.Equal(t, mdmURL, cfg.ManagementURL.String()) assert.True(t, cfg.DisableClientRoutes) assert.True(t, cfg.BlockInbound) @@ -65,16 +96,12 @@ func TestApply_MDMBeatsCLIInput(t *testing.T) { const mdmURL = "https://mdm.example.com:443" const cliURL = "https://cli.example.com:443" - withMDMPolicy(t, mdm.NewPolicy(map[string]any{ - mdm.KeyManagementURL: mdmURL, - })) - - cfg, err := UpdateOrCreateConfig(ConfigInput{ + cfg := configWithMDM(t, ConfigInput{ ConfigPath: filepath.Join(t.TempDir(), "config.json"), ManagementURL: cliURL, - }) - require.NoError(t, err) - require.NotNil(t, cfg) + }, mdm.NewPolicy(map[string]any{ + mdm.KeyManagementURL: mdmURL, + })) // MDM wins over CLI-supplied management URL. assert.Equal(t, mdmURL, cfg.ManagementURL.String()) @@ -82,16 +109,12 @@ func TestApply_MDMBeatsCLIInput(t *testing.T) { } func TestApply_MDMInvalidURL_KeepsPreviousValue(t *testing.T) { - withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + cfg := configWithMDM(t, ConfigInput{ + ConfigPath: filepath.Join(t.TempDir(), "config.json"), + }, mdm.NewPolicy(map[string]any{ mdm.KeyManagementURL: "not-a-url", })) - cfg, err := UpdateOrCreateConfig(ConfigInput{ - ConfigPath: filepath.Join(t.TempDir(), "config.json"), - }) - require.NoError(t, err) - require.NotNil(t, cfg) - // Invalid MDM URL is logged and skipped: default URL stays in place // to keep the client functional. assert.Equal(t, DefaultManagementURL, cfg.ManagementURL.String()) @@ -106,24 +129,20 @@ func TestApply_MDMBoolKeysOverrideOnDiskValue(t *testing.T) { tmp := filepath.Join(t.TempDir(), "config.json") // Seed without MDM. - withMDMPolicy(t, mdm.NewPolicy(nil)) - _, err := UpdateOrCreateConfig(ConfigInput{ + configWithMDM(t, ConfigInput{ ConfigPath: tmp, DisableClientRoutes: boolPtr(false), RosenpassEnabled: boolPtr(false), - }) - require.NoError(t, err) + }, mdm.NewPolicy(nil)) // Now enable MDM enforcement for these keys. - withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + cfg := configWithMDM(t, ConfigInput{ + ConfigPath: tmp, + }, mdm.NewPolicy(map[string]any{ mdm.KeyDisableClientRoutes: true, mdm.KeyRosenpassEnabled: true, })) - cfg, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: tmp}) - require.NoError(t, err) - require.NotNil(t, cfg) - assert.True(t, cfg.DisableClientRoutes, "MDM override should flip on-disk false to true") assert.True(t, cfg.RosenpassEnabled) assert.True(t, cfg.Policy().HasKey(mdm.KeyDisableClientRoutes)) @@ -133,16 +152,12 @@ func TestApply_MDMBoolKeysOverrideOnDiskValue(t *testing.T) { func TestApply_MDMPreSharedKeyRedactionSentinelRejected(t *testing.T) { const maskSentinel = "**********" - withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + cfg := configWithMDM(t, ConfigInput{ + ConfigPath: filepath.Join(t.TempDir(), "config.json"), + }, mdm.NewPolicy(map[string]any{ mdm.KeyPreSharedKey: maskSentinel, })) - cfg, err := UpdateOrCreateConfig(ConfigInput{ - ConfigPath: filepath.Join(t.TempDir(), "config.json"), - }) - require.NoError(t, err) - require.NotNil(t, cfg) - // Mask sentinel must not be persisted as the actual PSK. assert.NotEqual(t, maskSentinel, cfg.PreSharedKey) // Key still marked managed so user writes are still rejected. diff --git a/client/mdm/policy.go b/client/mdm/policy.go index 109fb322ea3..7f00fc1544f 100644 --- a/client/mdm/policy.go +++ b/client/mdm/policy.go @@ -84,16 +84,48 @@ func NewPolicy(values map[string]any) *Policy { return &Policy{values: values} } -// LoadPolicy reads the platform-native MDM configuration. Returns an -// empty (but non-nil) Policy when no source is present, the source is -// empty, or the platform is unsupported. +// PolicyFetcher is implemented by mobile platforms (Android / iOS) that +// push the OS-managed configuration into the Go runtime instead of +// having Go read an on-disk source directly. Desktop platforms ignore +// this interface — Loader.loadPlatform on windows/darwin reads the +// registry / plist on its own. A Loader constructed with a non-nil +// fetcher delegates to it on mobile; passing nil disables MDM +// enforcement (loadPlatform returns nil values). +type PolicyFetcher interface { + Fetch() map[string]any +} + +// Loader is the DI-friendly entry point for reading the active MDM +// policy. Construct one at the daemon's lifecycle owner (Server on +// desktop, gomobile-exposed bridge on mobile) and pass it to anything +// that needs to read MDM state (the reload ticker, profilemanager's +// Config). Each callsite has the Loader handed in instead of looking +// up package-level state. +type Loader struct { + fetcher PolicyFetcher +} + +// NewLoader constructs a Loader. The fetcher is consulted only on +// mobile builds (ios || android); on desktop it is unused but accepted +// to keep a single constructor signature across platforms — pass nil +// on desktop. +func NewLoader(f PolicyFetcher) *Loader { + return &Loader{fetcher: f} +} + +// Load reads the platform-native MDM configuration and returns a +// Policy. Returns an empty (but non-nil) Policy when no source is +// present, the source is empty, or the platform is unsupported. // // Diagnostic logging differentiates the three states: // - source absent / unsupported platform: trace log only // - source present, zero keys: info "MDM enrolled (no managed keys)" // - source present, N keys: info "MDM enrolled with N managed keys: [...]" -func LoadPolicy() *Policy { - values, err := loadPlatformPolicy() +func (l *Loader) Load() *Policy { + if l == nil { + return &Policy{values: map[string]any{}} + } + values, err := l.loadPlatform() if err != nil { log.Tracef("MDM policy load: %v", err) return &Policy{values: map[string]any{}} diff --git a/client/mdm/policy_darwin.go b/client/mdm/policy_darwin.go index 57aa1168c95..d7c0fc8f115 100644 --- a/client/mdm/policy_darwin.go +++ b/client/mdm/policy_darwin.go @@ -25,8 +25,10 @@ import ( // writable plist, as a defense against tampered installs. const policyPlistPath = "/Library/Managed Preferences/io.netbird.client.plist" -// loadPlatformPolicy reads the MDM-managed configuration from the macOS -// managed-preferences plist at policyPlistPath. Returns: +// loadPlatform reads the MDM-managed configuration from the macOS +// managed-preferences plist at policyPlistPath. The Loader's fetcher +// field is unused on this platform — the plist is the authoritative +// source. Returns: // - (nil, nil) when the plist is absent (device not MDM-enrolled for // NetBird, or admin has not yet pushed a payload) // - (map, nil) with N entries when N managed values are present @@ -39,7 +41,13 @@ const policyPlistPath = "/Library/Managed Preferences/io.netbird.client.plist" // skipped so a stray entry in the payload does not block startup. // Native plist value types map naturally onto the Policy accessor // expectations (GetString / GetBool / GetInt / GetStringSlice). -func loadPlatformPolicy() (map[string]any, error) { +func (l *Loader) loadPlatform() (map[string]any, error) { + // Honour the injected fetcher when present so tests (and any + // future non-macOS MDM channel) can short-circuit the plist read + // with a scripted policy. + if l != nil && l.fetcher != nil { + return l.fetcher.Fetch(), nil + } f, err := os.Open(policyPlistPath) if err != nil { if errors.Is(err, fs.ErrNotExist) { diff --git a/client/mdm/policy_mobile.go b/client/mdm/policy_mobile.go index 40ec0ceab03..2e25a2bb5f5 100644 --- a/client/mdm/policy_mobile.go +++ b/client/mdm/policy_mobile.go @@ -2,41 +2,14 @@ package mdm -// PolicyFetcher is the bridge between Go and the mobile native layer -// (Kotlin/Java on Android, Swift on iOS). The native layer registers -// an implementation at gomobile init via SetMobilePolicyFetcher; -// thereafter every call to loadPlatformPolicy delegates to the -// registered fetcher, which reads the OS-native managed-config store -// (RestrictionsManager on Android, com.apple.configuration.managed -// UserDefaults on iOS) and returns the current snapshot. -// -// Set-once at init, never mutated at runtime → no synchronisation -// required for the read path. The native layer must register before -// any Go code starts polling or processing MDM events. -type PolicyFetcher interface { - Fetch() map[string]any -} - -var fetcher PolicyFetcher - -// SetMobilePolicyFetcher registers the native-provided fetcher. Call -// exactly once from the gomobile init code (Kotlin Application.onCreate -// / Swift AppDelegate) before the daemon starts. Passing nil disables -// MDM enforcement on this build (loadPlatformPolicy returns -// (nil, nil) — the platform-absent sentinel that LoadPolicy treats as -// "no MDM source present"). -func SetMobilePolicyFetcher(p PolicyFetcher) { - fetcher = p -} - -// loadPlatformPolicy delegates to the native-provided fetcher. Returns -// (nil, nil) — the platform-absent sentinel — when no fetcher has been -// registered yet, so the package behaves identically to a desktop -// device without an MDM source. -func loadPlatformPolicy() (map[string]any, error) { - if fetcher == nil { - //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy. +// loadPlatform reads the OS-managed configuration via the native +// PolicyFetcher injected at Loader construction. Returns +// (nil, nil) — the platform-absent sentinel that Loader.Load treats as +// "no MDM source present" — when no fetcher was provided. +func (l *Loader) loadPlatform() (map[string]any, error) { + if l == nil || l.fetcher == nil { + //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see Loader.Load. return nil, nil } - return fetcher.Fetch(), nil + return l.fetcher.Fetch(), nil } diff --git a/client/mdm/policy_other.go b/client/mdm/policy_other.go index f4263afa212..5d0b17cfd01 100644 --- a/client/mdm/policy_other.go +++ b/client/mdm/policy_other.go @@ -2,13 +2,17 @@ package mdm -// loadPlatformPolicy returns no policy on platforms without an MDM channel -// (Linux, FreeBSD). MDM enforcement is off and the client behaves as if -// the feature did not exist. Returns (nil, nil) — the platform-absent -// sentinel the caller (LoadPolicy in policy.go) treats as "no MDM -// source present"; an error here would just translate to the same -// outcome with an extra log line. -func loadPlatformPolicy() (map[string]any, error) { - //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy. +// loadPlatform reads the MDM policy on platforms without a native MDM +// channel (Linux, FreeBSD). When no fetcher was injected the policy is +// (nil, nil) — the platform-absent sentinel that Loader.Load treats as +// "MDM enforcement disabled". A non-nil fetcher takes precedence: it +// is the test-seam used by unit tests to inject a scripted policy +// without touching the OS, and the same hook supports any future +// non-mobile OS that grows an out-of-band MDM channel. +func (l *Loader) loadPlatform() (map[string]any, error) { + if l != nil && l.fetcher != nil { + return l.fetcher.Fetch(), nil + } + //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see Loader.Load. return nil, nil } diff --git a/client/mdm/policy_test.go b/client/mdm/policy_test.go index 47a6ed2c9ae..e8d05cd56a8 100644 --- a/client/mdm/policy_test.go +++ b/client/mdm/policy_test.go @@ -150,10 +150,12 @@ func TestPolicy_GetStringSlice(t *testing.T) { }) } -func TestLoadPolicy_PlatformStubReturnsEmpty(t *testing.T) { - // loadPlatformPolicy is a stub on every OS for Phase 1. LoadPolicy must - // degrade gracefully and never return nil. - p := LoadPolicy() +func TestLoader_NilFetcherReturnsEmpty(t *testing.T) { + // Loader.Load with no fetcher (desktop construction) must degrade + // gracefully and never return nil; on linux loadPlatform is a stub + // returning (nil, nil), and Load is expected to translate that + // into a non-nil empty Policy. + p := NewLoader(nil).Load() require.NotNil(t, p) assert.True(t, p.IsEmpty()) assert.Empty(t, p.ManagedKeys()) diff --git a/client/mdm/policy_windows.go b/client/mdm/policy_windows.go index 0c2629f98c6..d13085602a2 100644 --- a/client/mdm/policy_windows.go +++ b/client/mdm/policy_windows.go @@ -61,8 +61,10 @@ func readRegistryValue(k registry.Key, name, canonical string, out map[string]an } } -// loadPlatformPolicy reads the MDM-managed configuration from the -// Windows registry under HKLM\Software\Policies\NetBird. Returns: +// loadPlatform reads the MDM-managed configuration from the Windows +// registry under HKLM\Software\Policies\NetBird. The Loader's fetcher +// field is unused on this platform — the registry is the +// authoritative source. Returns: // - (nil, nil) when the key is absent (device not MDM-enrolled for NetBird) // - (map, nil) with N entries when N managed values are set (N may be 0) // - (nil, err) on open / enumerate registry errors @@ -70,7 +72,13 @@ func readRegistryValue(k registry.Key, name, canonical string, out map[string]an // Per-value type coercion + skip-on-error is delegated to // readRegistryValue. Unknown value names are logged and skipped so a // malformed deployment does not block startup. -func loadPlatformPolicy() (map[string]any, error) { +func (l *Loader) loadPlatform() (map[string]any, error) { + // Honour the injected fetcher when present so tests (and any + // future non-Windows MDM channel) can short-circuit the registry + // read with a scripted policy. + if l != nil && l.fetcher != nil { + return l.fetcher.Fetch(), nil + } k, err := registry.OpenKey(registry.LOCAL_MACHINE, policyRegistryPath, registry.QUERY_VALUE) if err != nil { if errors.Is(err, registry.ErrNotExist) { diff --git a/client/mdm/ticker.go b/client/mdm/ticker.go index abd6ae23386..8dc41943c5c 100644 --- a/client/mdm/ticker.go +++ b/client/mdm/ticker.go @@ -15,33 +15,33 @@ import ( // instead, hence anticipating the ticker mechanism entirely. const DefaultReloadInterval = 1 * time.Minute -// policyLoader is the indirection through which the ticker reads the -// OS-native policy, both for the initial observation and on every tick. -// Production points it at LoadPolicy; tests in this package override it to -// feed a scripted sequence of policies without touching the real OS store. -var policyLoader = LoadPolicy - -// Ticker periodically re-reads the OS-native MDM policy via LoadPolicy and -// invokes the onChange callback (supplied to Run) whenever the observed -// Policy diverges from the last observation (added / removed / changed -// keys). Launch with Run from a goroutine; cancel the supplied context -// to stop. +// Ticker periodically re-reads the OS-native MDM policy via the +// injected Loader and invokes the onChange callback (supplied to Run) +// whenever the observed Policy diverges from the last observation +// (added / removed / changed keys). Launch with Run from a goroutine; +// cancel the supplied context to stop. type Ticker struct { interval time.Duration + loader *Loader prev *Policy } // NewTicker constructs a Ticker that will re-read the OS-native policy -// every reloadInterval once Run is called. -// The initial snapshot is populated by calling policyLoader at +// every reloadInterval once Run is called. The Loader is injected so +// the ticker doesn't depend on any package-level state — production +// passes the daemon-owned Loader, tests pass a fake Loader (built with +// a fake PolicyFetcher). +// +// The initial snapshot is populated by calling loader.Load() at // construction time so the first tick only fires // onChange when the policy actually changed since boot — without // this baseline the first tick would report every currently-managed // key as "added" and trigger a spurious engine restart. -func NewTicker(reloadInterval time.Duration) *Ticker { +func NewTicker(reloadInterval time.Duration, loader *Loader) *Ticker { return &Ticker{ interval: reloadInterval, - prev: policyLoader(), + loader: loader, + prev: loader.Load(), } } @@ -58,7 +58,7 @@ func (t *Ticker) Run(ctx context.Context, onChange func(prev, curr *Policy) erro log.Info("MDM policy reload ticker stopped") return case <-tk.C: - curr := policyLoader() + curr := t.loader.Load() if policiesEqual(t.prev, curr) { continue } diff --git a/client/mdm/ticker_test.go b/client/mdm/ticker_test.go index 17f3cfc2f00..29e48e728f1 100644 --- a/client/mdm/ticker_test.go +++ b/client/mdm/ticker_test.go @@ -13,28 +13,40 @@ import ( // testReloadInterval for speeding up the ticker cadence under `go test` const testReloadInterval = 1 * time.Second -// withPolicyLoader overrides the package-level policyLoader for the duration -// of the test so the ticker observes a scripted policy instead of the real -// OS-native store. The original loader is restored on cleanup. -func withPolicyLoader(t *testing.T, fn func() *Policy) { - t.Helper() - prev := policyLoader - policyLoader = fn - t.Cleanup(func() { policyLoader = prev }) +// fakePolicyFetcher implements PolicyFetcher returning a scripted +// policy map. Goroutine-safe so the test can mutate the script while +// the ticker is observing it. +type fakePolicyFetcher struct { + mu sync.Mutex + values map[string]any +} + +func (f *fakePolicyFetcher) Fetch() map[string]any { + f.mu.Lock() + defer f.mu.Unlock() + if f.values == nil { + return nil + } + out := make(map[string]any, len(f.values)) + for k, v := range f.values { + out[k] = v + } + return out +} + +func (f *fakePolicyFetcher) set(values map[string]any) { + f.mu.Lock() + defer f.mu.Unlock() + f.values = values } func TestTicker_FiresOnChangeWithDelta(t *testing.T) { - var mu sync.Mutex - current := NewPolicy(nil) // initial observation: empty (no enforcement) - withPolicyLoader(t, func() *Policy { - mu.Lock() - defer mu.Unlock() - return current - }) + fetcher := &fakePolicyFetcher{} // initial observation: empty (no enforcement) + loader := NewLoader(fetcher) type change struct{ prev, curr *Policy } changes := make(chan change, 1) - tk := NewTicker(testReloadInterval) + tk := NewTicker(testReloadInterval, loader) require.Equal(t, testReloadInterval, tk.interval) ctx, cancel := context.WithCancel(context.Background()) @@ -49,15 +61,13 @@ func TestTicker_FiresOnChangeWithDelta(t *testing.T) { }) close(done) }() - // Stop Run and wait for it to exit before returning, so the policyLoader - // restore in t.Cleanup can't race the ticker goroutine still reading it. + // Stop Run and wait for it to exit before returning, so the test + // goroutine doesn't race the still-running ticker. defer func() { cancel(); <-done }() - // Flip the OS-observed policy from empty to one managed key. The next - // tick must detect the diff and invoke onChange. - mu.Lock() - current = NewPolicy(map[string]any{KeyManagementURL: "https://mdm.example.com:443"}) - mu.Unlock() + // Flip the OS-observed policy from empty to one managed key. The + // next tick must detect the diff and invoke onChange. + fetcher.set(map[string]any{KeyManagementURL: "https://mdm.example.com:443"}) select { case c := <-changes: @@ -69,12 +79,11 @@ func TestTicker_FiresOnChangeWithDelta(t *testing.T) { } func TestTicker_NoCallbackWhenPolicyUnchanged(t *testing.T) { - withPolicyLoader(t, func() *Policy { - return NewPolicy(map[string]any{KeyBlockInbound: true}) - }) + fetcher := &fakePolicyFetcher{values: map[string]any{KeyBlockInbound: true}} + loader := NewLoader(fetcher) fired := make(chan struct{}, 1) - tk := NewTicker(testReloadInterval) + tk := NewTicker(testReloadInterval, loader) ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) @@ -90,8 +99,8 @@ func TestTicker_NoCallbackWhenPolicyUnchanged(t *testing.T) { }() defer func() { cancel(); <-done }() - // Over ~2 ticks at the 1s test cadence the policy never changes, so the - // diff guard must suppress the callback entirely. + // Over ~2 ticks at the 1s test cadence the policy never changes, + // so the diff guard must suppress the callback entirely. select { case <-fired: t.Fatal("onChange fired despite an unchanged policy") diff --git a/client/server/mdm.go b/client/server/mdm.go index 0da0ec5d190..15393112476 100644 --- a/client/server/mdm.go +++ b/client/server/mdm.go @@ -20,10 +20,6 @@ import ( // a no-op echo, never as a conflict with the policy. const preSharedKeyRedactedSentinel = "**********" -// loadMDMPolicy is the indirection used by server handlers to read the -// active MDM policy. Tests override this to inject a fake policy. -var loadMDMPolicy = mdm.LoadPolicy - // conflictCheck is a value-aware comparison between a single field in // the incoming request and the corresponding MDM-enforced value. It // runs only when the field was actually set in the request (presence diff --git a/client/server/server.go b/client/server/server.go index 32daf7718c5..a38b3856860 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -110,6 +110,15 @@ type Server struct { // stopped by the rootCtx cancellation. mdmTicker *mdm.Ticker + // mdmLoader is the daemon-owned source of the active MDM policy. + // Constructed once during Server.Start (with a nil PolicyFetcher on + // desktop — the build-tagged Loader.loadPlatform reads the OS + // registry / plist directly) and injected into every consumer: + // mdmTicker for its periodic reload, the SetConfig / Login MDM + // gates for conflict detection, and every Config produced via + // getConfig() so its apply() picks up the same overlay. + mdmLoader *mdm.Loader + updateManager *updater.Manager jwtCache *jwtCache @@ -173,8 +182,14 @@ func (s *Server) Start() error { // Runs re-resolves Config (re-running profilemanager.Config.apply which // applies the freshly-read MDM policy as the last layer) and brings // the engine back with the new values. + if s.mdmLoader == nil { + // Desktop builds pass a nil PolicyFetcher: the Loader's + // build-tagged loadPlatform reads the OS source directly + // (registry on Windows, plist on macOS, no-op elsewhere). + s.mdmLoader = mdm.NewLoader(nil) + } if s.mdmTicker == nil { - s.mdmTicker = mdm.NewTicker(mdm.DefaultReloadInterval) + s.mdmTicker = mdm.NewTicker(mdm.DefaultReloadInterval, s.mdmLoader) go s.mdmTicker.Run(s.rootCtx, s.onMDMPolicyChange) } @@ -370,7 +385,7 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques // by the active MDM policy. The error carries an MDMManagedFields- // Violation detail listing the offending key names. Non-conflicting // fields in the same request are not applied either. - policy := loadMDMPolicy() + policy := s.mdmLoader.Load() if err := rejectMDMManagedFieldConflicts(mdmManagedFieldConflicts(msg, policy)); err != nil { return nil, err } @@ -496,7 +511,7 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro if s.checkUpdateSettingsDisabled() { return nil, gstatus.Errorf(codes.Unavailable, errUpdateSettingsDisabled) } - policy := loadMDMPolicy() + policy := s.mdmLoader.Load() if err := rejectMDMManagedFieldConflicts(loginRequestMDMConflicts(msg, policy)); err != nil { return nil, err } @@ -1088,6 +1103,12 @@ func (s *Server) getConfig(activeProf *profilemanager.ActiveProfileState) (*prof return nil, false, fmt.Errorf("failed to get config: %w", err) } + // Apply the daemon-owned MDM policy on top of the just-resolved + // Config. profilemanager's apply() initialises the policy to + // empty — the Loader lives outside Config, so this overlay step + // is driven externally here. + config.ApplyMDMPolicy(s.mdmLoader.Load()) + return config, configExisted, nil } diff --git a/client/server/setconfig_mdm_test.go b/client/server/setconfig_mdm_test.go index 53232c70da5..8707c388552 100644 --- a/client/server/setconfig_mdm_test.go +++ b/client/server/setconfig_mdm_test.go @@ -16,14 +16,40 @@ import ( "github.com/netbirdio/netbird/client/proto" ) -// withMDMPolicy temporarily overrides the server-package loadMDMPolicy hook -// so SetConfig observes the supplied Policy. Restores the original loader -// at test cleanup. -func withMDMPolicy(t *testing.T, policy *mdm.Policy) { +// fakeMDMFetcher implements mdm.PolicyFetcher returning a pre-set +// policy map. Tests build one per Server instance to inject a +// scripted MDM overlay via a Loader rather than via package-level state. +type fakeMDMFetcher struct{ values map[string]any } + +func (f *fakeMDMFetcher) Fetch() map[string]any { return f.values } + +// withMDMPolicy installs an mdm.Loader on the given Server whose +// loadPlatform returns the supplied Policy's underlying values. Use +// after setupServerWithProfile to inject the scripted policy the +// SetConfig / Login MDM gates will observe. +func withMDMPolicy(t *testing.T, s *Server, policy *mdm.Policy) { t.Helper() - prev := loadMDMPolicy - loadMDMPolicy = func() *mdm.Policy { return policy } - t.Cleanup(func() { loadMDMPolicy = prev }) + values := map[string]any{} + if policy != nil { + for _, k := range policy.ManagedKeys() { + if v, ok := policy.GetString(k); ok { + values[k] = v + continue + } + if v, ok := policy.GetBool(k); ok { + values[k] = v + continue + } + if v, ok := policy.GetInt(k); ok { + values[k] = v + continue + } + if v, ok := policy.GetStringSlice(k); ok { + values[k] = v + } + } + } + s.mdmLoader = mdm.NewLoader(&fakeMDMFetcher{values: values}) } // setupServerWithProfile mirrors the boilerplate of TestSetConfig_AllFieldsSaved: @@ -89,12 +115,11 @@ func extractViolation(t *testing.T, err error) *proto.MDMManagedFieldsViolation } func TestSetConfig_MDMReject_SingleField(t *testing.T) { - withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + s, ctx, profName, username, _ := setupServerWithProfile(t) + withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{ mdm.KeyManagementURL: "https://mdm.example.com:443", })) - s, ctx, profName, username, _ := setupServerWithProfile(t) - _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ ProfileName: profName, Username: username, @@ -106,13 +131,12 @@ func TestSetConfig_MDMReject_SingleField(t *testing.T) { } func TestSetConfig_MDMReject_MultipleFields(t *testing.T) { - withMDMPolicy(t, mdm.NewPolicy(map[string]any{ - mdm.KeyManagementURL: "https://mdm.example.com:443", - mdm.KeyBlockInbound: true, - mdm.KeyRosenpassEnabled: true, - })) - s, ctx, profName, username, _ := setupServerWithProfile(t) + withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{ + mdm.KeyManagementURL: "https://mdm.example.com:443", + mdm.KeyBlockInbound: true, + mdm.KeyRosenpassEnabled: true, + })) blockInbound := false rosenpassEnabled := false @@ -137,12 +161,11 @@ func TestSetConfig_MDMReject_AllOrNothing(t *testing.T) { // enforced field AND a non-enforced field (RosenpassEnabled). // The whole request must be rejected — non-conflicting fields are not // applied either. - withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + s, ctx, profName, username, cfgPath := setupServerWithProfile(t) + withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{ mdm.KeyManagementURL: "https://mdm.example.com:443", })) - s, ctx, profName, username, cfgPath := setupServerWithProfile(t) - rosenpassEnabled := true _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ ProfileName: profName, @@ -164,12 +187,11 @@ func TestSetConfig_MDMReject_AllOrNothing(t *testing.T) { func TestSetConfig_MDMAllow_NonManagedFields(t *testing.T) { // MDM enforces ManagementURL but the user only writes RosenpassEnabled. // Request must succeed. - withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + s, ctx, profName, username, _ := setupServerWithProfile(t) + withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{ mdm.KeyManagementURL: "https://mdm.example.com:443", })) - s, ctx, profName, username, _ := setupServerWithProfile(t) - rosenpassEnabled := true resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{ ProfileName: profName, @@ -183,9 +205,8 @@ func TestSetConfig_MDMAllow_NonManagedFields(t *testing.T) { func TestSetConfig_MDMEmpty_NoEnforcement(t *testing.T) { // No MDM policy active: any field can be written. - withMDMPolicy(t, mdm.NewPolicy(nil)) - s, ctx, profName, username, _ := setupServerWithProfile(t) + withMDMPolicy(t, s, mdm.NewPolicy(nil)) resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{ ProfileName: profName, From 034089385468346d1a3765b99d97c9ea314388ca Mon Sep 17 00:00:00 2001 From: riccardom Date: Mon, 15 Jun 2026 18:36:38 +0200 Subject: [PATCH 04/30] Now we need to apply MDM in the GetConfig --- client/server/server.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/client/server/server.go b/client/server/server.go index a38b3856860..005e7697c8c 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -1163,6 +1163,9 @@ func (s *Server) logoutFromProfile(ctx context.Context, profileName, username st if err != nil { return fmt.Errorf("profile '%s' not found", profileName) } + // Honour any MDM-enforced ManagementURL when issuing the logout + // RPC: the user-stored value may have been overridden by policy. + config.ApplyMDMPolicy(s.mdmLoader.Load()) return s.sendLogoutRequestWithConfig(ctx, config) } @@ -1595,6 +1598,11 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p log.Errorf("failed to get active profile config: %v", err) return nil, fmt.Errorf("failed to get active profile config: %w", err) } + // Overlay the active MDM policy so the response's MDMManagedFields + // list reflects what the GUI / CLI must render as read-only. + // profilemanager.GetConfig itself returns a Config without the + // overlay (Loader lives outside profilemanager). + cfg.ApplyMDMPolicy(s.mdmLoader.Load()) managementURL := cfg.ManagementURL adminURL := cfg.AdminURL From b2c5732847ae5c22ab8f526204d9f121df72869e Mon Sep 17 00:00:00 2001 From: riccardom Date: Mon, 15 Jun 2026 18:36:38 +0200 Subject: [PATCH 05/30] You now need to explicitly call these around --- client/cmd/login.go | 6 ++++++ client/cmd/up.go | 5 +++++ client/embed/embed.go | 5 +++++ 3 files changed, 16 insertions(+) diff --git a/client/cmd/login.go b/client/cmd/login.go index bd37e30f1e2..795a9196801 100644 --- a/client/cmd/login.go +++ b/client/cmd/login.go @@ -17,6 +17,7 @@ import ( "github.com/netbirdio/netbird/client/internal" "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/proto" "github.com/netbirdio/netbird/client/system" "github.com/netbirdio/netbird/util" @@ -248,6 +249,11 @@ func doForegroundLogin(ctx context.Context, cmd *cobra.Command, setupKey string, if err != nil { return fmt.Errorf("read config file %s: %v", configFilePath, err) } + // CLI standalone login: profilemanager no longer auto-applies MDM, + // so layer in the OS-native policy here. Desktop builds construct + // a Loader with no fetcher — the build-tagged loadPlatform reads + // the registry/plist directly. + config.ApplyMDMPolicy(mdm.NewLoader(nil).Load()) err = foregroundLogin(ctx, cmd, config, setupKey, activeProf.Name) if err != nil { diff --git a/client/cmd/up.go b/client/cmd/up.go index cabd0aacff1..0d3bd932d66 100644 --- a/client/cmd/up.go +++ b/client/cmd/up.go @@ -21,6 +21,7 @@ import ( "github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mdm" "github.com/netbirdio/netbird/client/proto" "github.com/netbirdio/netbird/client/system" "github.com/netbirdio/netbird/shared/management/domain" @@ -187,6 +188,10 @@ func runInForegroundMode(ctx context.Context, cmd *cobra.Command, activeProf *pr if err != nil { return fmt.Errorf("get config file: %v", err) } + // CLI foreground path runs without the daemon Server: layer in the + // active MDM policy explicitly so a forced ManagementURL / PSK / + // other managed key actually takes effect on this run. + config.ApplyMDMPolicy(mdm.NewLoader(nil).Load()) _, _ = profilemanager.UpdateOldManagementURL(ctx, config, configFilePath) diff --git a/client/embed/embed.go b/client/embed/embed.go index 0e8991be2a7..342d336eabe 100644 --- a/client/embed/embed.go +++ b/client/embed/embed.go @@ -21,6 +21,7 @@ import ( "github.com/netbirdio/netbird/client/internal/auth" "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mdm" sshcommon "github.com/netbirdio/netbird/client/ssh" "github.com/netbirdio/netbird/client/system" "github.com/netbirdio/netbird/shared/management/domain" @@ -215,6 +216,10 @@ func New(opts Options) (*Client, error) { if err != nil { return nil, fmt.Errorf("create config: %w", err) } + // Embedded path runs without the daemon Server: apply the active + // MDM policy explicitly so a forced ManagementURL / PSK / other + // managed key takes effect on this embedded engine instance. + config.ApplyMDMPolicy(mdm.NewLoader(nil).Load()) if opts.PrivateKey != "" { config.PrivateKey = opts.PrivateKey From 7715c382ee37aa7f541a1b3eaa4d41d18dc95959 Mon Sep 17 00:00:00 2001 From: riccardom Date: Tue, 16 Jun 2026 12:21:25 +0200 Subject: [PATCH 06/30] Adds iOS wiring --- client/ios/NetBirdSDK/client.go | 11 +++++ client/ios/NetBirdSDK/mdm.go | 82 +++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 client/ios/NetBirdSDK/mdm.go diff --git a/client/ios/NetBirdSDK/client.go b/client/ios/NetBirdSDK/client.go index bafbb003106..1acebff3eeb 100644 --- a/client/ios/NetBirdSDK/client.go +++ b/client/ios/NetBirdSDK/client.go @@ -21,6 +21,7 @@ import ( "github.com/netbirdio/netbird/client/internal/listener" "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mdm" "github.com/netbirdio/netbird/client/system" "github.com/netbirdio/netbird/formatter" "github.com/netbirdio/netbird/route" @@ -78,6 +79,13 @@ type Client struct { connectClient *internal.ConnectClient // preloadedConfig holds config loaded from JSON (used on tvOS where file writes are blocked) preloadedConfig *profilemanager.Config + + // mdmLoader holds the per-Client MDM policy source. Set by + // SetMDMPolicyFetcher (called from the Swift side at extension + // init). Each Run passes this loader to the resolved Config so + // applyMDMPolicy picks up the active overlay. Nil means "MDM + // enforcement off for this Client". + mdmLoader *mdm.Loader } // NewClient instantiate a new Client @@ -133,6 +141,7 @@ func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error { if err != nil { return err } + c.applyMDMOverlay(cfg) } c.recorder.UpdateManagementAddress(cfg.ManagementURL.String()) c.recorder.UpdateRosenpass(cfg.RosenpassEnabled, cfg.RosenpassPermissive) @@ -258,6 +267,7 @@ func (c *Client) IsLoginRequired() bool { // If we can't load config, assume login is required return true } + c.applyMDMOverlay(cfg) } if cfg == nil { @@ -306,6 +316,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 { diff --git a/client/ios/NetBirdSDK/mdm.go b/client/ios/NetBirdSDK/mdm.go new file mode 100644 index 00000000000..6f9696b7ae4 --- /dev/null +++ b/client/ios/NetBirdSDK/mdm.go @@ -0,0 +1,82 @@ +//go:build ios + +package NetBirdSDK + +import ( + "encoding/json" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mdm" +) + +// PolicyFetcher is the mobile-side bridge for the MDM managed-config +// snapshot. The native layer (Swift) implements this and registers +// the instance per Client via Client.SetMDMPolicyFetcher. Every +// invocation of fetchJSON must read the current +// UserDefaults.standard.dictionary(forKey: "com.apple.configuration.managed") +// and return the result as a JSON-encoded map[string]any string. +// +// JSON is used because gomobile does not support map[string]any +// crossing the Objective-C boundary — the adapter on the Go side +// parses the string back into the map[string]any expected by +// mdm.Loader. +// +// Return value contract: +// - "" (empty) : interpreted as "no MDM source / no managed keys" +// - "{}" : managed config explicitly empty +// - "{...}" : JSON object with key/value pairs +// - malformed JSON : logged and treated as empty +type PolicyFetcher interface { + FetchJSON() string +} + +// jsonFetcherAdapter wraps a gomobile-exposed PolicyFetcher into the +// internal mdm.PolicyFetcher interface, taking care of JSON decoding +// on every Fetch. +type jsonFetcherAdapter struct { + inner PolicyFetcher +} + +func (a *jsonFetcherAdapter) Fetch() map[string]any { + raw := a.inner.FetchJSON() + if raw == "" { + return nil + } + var out map[string]any + if err := json.Unmarshal([]byte(raw), &out); err != nil { + log.Warnf("MDM mobile fetcher: invalid JSON payload from native: %v", err) + return nil + } + return out +} + +// SetMDMPolicyFetcher registers the native-provided MDM policy fetcher +// on this Client. Call once from the gomobile-init code (Swift +// AppDelegate / PacketTunnelProvider.startTunnel) before invoking Run. +// Passing nil disables MDM enforcement on this Client. +// +// The fetcher is held as a *mdm.Loader instance on the Client (no +// package-level state) — multiple Clients in the same process get +// independent Loaders, and tests can inject fakes per Client. +func (c *Client) SetMDMPolicyFetcher(p PolicyFetcher) { + if p == nil { + c.mdmLoader = mdm.NewLoader(nil) + return + } + c.mdmLoader = mdm.NewLoader(&jsonFetcherAdapter{inner: p}) +} + +// applyMDMOverlay applies the Client-held MDM Loader's current policy +// on top of the just-read Config. Called immediately after every +// UpdateOrCreateConfig / DirectUpdateOrCreateConfig — profilemanager's +// apply() initialises the policy to empty and leaves overlay +// responsibility to the lifecycle owner. No-op when no fetcher was +// registered. +func (c *Client) applyMDMOverlay(cfg *profilemanager.Config) { + if cfg == nil || c.mdmLoader == nil { + return + } + cfg.ApplyMDMPolicy(c.mdmLoader.Load()) +} From e92aa7dfb06858dc6e455d77fe0fc34d697f3e91 Mon Sep 17 00:00:00 2001 From: riccardom Date: Tue, 28 Jul 2026 11:04:04 +0200 Subject: [PATCH 07/30] 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 --- client/cmd/login.go | 3 --- client/ios/NetBirdSDK/client.go | 4 +--- client/server/setconfig_mdm_test.go | 14 ++------------ 3 files changed, 3 insertions(+), 18 deletions(-) diff --git a/client/cmd/login.go b/client/cmd/login.go index 90667026d4e..fb12323258b 100644 --- a/client/cmd/login.go +++ b/client/cmd/login.go @@ -17,11 +17,8 @@ import ( "github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal/auth" "github.com/netbirdio/netbird/client/internal/profilemanager" -<<<<<<< ours "github.com/netbirdio/netbird/client/mdm" -======= nbnet "github.com/netbirdio/netbird/client/net" ->>>>>>> theirs "github.com/netbirdio/netbird/client/proto" "github.com/netbirdio/netbird/client/server" "github.com/netbirdio/netbird/client/system" diff --git a/client/ios/NetBirdSDK/client.go b/client/ios/NetBirdSDK/client.go index e46a6058fe1..622e9d7faa6 100644 --- a/client/ios/NetBirdSDK/client.go +++ b/client/ios/NetBirdSDK/client.go @@ -84,18 +84,16 @@ type Client struct { // preloadedConfig holds config loaded from JSON (used on tvOS where file writes are blocked) preloadedConfig *profilemanager.Config -<<<<<<< ours // mdmLoader holds the per-Client MDM policy source. Set by // SetMDMPolicyFetcher (called from the Swift side at extension // init). Each Run passes this loader to the resolved Config so // applyMDMPolicy picks up the active overlay. Nil means "MDM // enforcement off for this Client". mdmLoader *mdm.Loader -======= + stateMu sync.RWMutex connectClient *internal.ConnectClient config *profilemanager.Config ->>>>>>> theirs } // NewClient instantiate a new Client diff --git a/client/server/setconfig_mdm_test.go b/client/server/setconfig_mdm_test.go index b30e5414225..fa29655b704 100644 --- a/client/server/setconfig_mdm_test.go +++ b/client/server/setconfig_mdm_test.go @@ -131,15 +131,6 @@ func TestSetConfig_MDMReject_SingleField(t *testing.T) { } func TestSetConfig_MDMReject_MultipleFields(t *testing.T) { -<<<<<<< ours -======= - withMDMPolicy(t, mdm.NewPolicy(map[string]any{ - mdm.KeyManagementURL: "https://mdm.example.com:443", - mdm.KeyBlockInbound: true, - mdm.KeyRosenpassEnabled: true, - })) - ->>>>>>> theirs s, ctx, profName, username, _ := setupServerWithProfile(t) withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{ mdm.KeyManagementURL: "https://mdm.example.com:443", @@ -229,12 +220,11 @@ func TestSetConfig_MDMAllow_ManagementURLPortNormalized(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + s, ctx, profName, username, _ := setupServerWithProfile(t) + withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{ mdm.KeyManagementURL: tc.mdmURL, })) - s, ctx, profName, username, _ := setupServerWithProfile(t) - rosenpassEnabled := true resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{ ProfileName: profName, From f91f9fc05c04c5dac66b0250777a116272990d60 Mon Sep 17 00:00:00 2001 From: riccardom Date: Tue, 28 Jul 2026 11:44:15 +0200 Subject: [PATCH 08/30] Convey MDM overlay config to Debug Bundle output Aligns to other clients OSes behavior --- client/ios/NetBirdSDK/client.go | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ios/NetBirdSDK/client.go b/client/ios/NetBirdSDK/client.go index 622e9d7faa6..769e68b8727 100644 --- a/client/ios/NetBirdSDK/client.go +++ b/client/ios/NetBirdSDK/client.go @@ -224,6 +224,7 @@ func (c *Client) DebugBundle(anonymize bool) (string, error) { return "", fmt.Errorf("load config: %w", err) } } + c.applyMDMOverlay(cfg) } deps := debug.GeneratorDependencies{ From 982626caeb57fd256e3b94e1d8098631a9361f05 Mon Sep 17 00:00:00 2001 From: riccardom Date: Fri, 7 Aug 2026 11:59:56 +0200 Subject: [PATCH 09/30] Solved conflict in client.go --- client/android/client.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/client/android/client.go b/client/android/client.go index ffe53f05f5e..3eab7af70bb 100644 --- a/client/android/client.go +++ b/client/android/client.go @@ -83,7 +83,6 @@ type Client struct { connectClient *internal.ConnectClient config *profilemanager.Config cacheDir string -<<<<<<< HEAD // mdmLoader holds the per-Client MDM policy source. Set by // SetMDMPolicyFetcher (called from the Kotlin side). Each Run @@ -91,7 +90,7 @@ type Client struct { // picks up the active overlay. Nil means "MDM enforcement off // for this Client". mdmLoader *mdm.Loader -======= + // Identifies the running profile for the SSO login hint; see profile_state.go. cfgPath string @@ -112,7 +111,6 @@ type Client struct { extendMu sync.Mutex extendCancel context.CancelFunc ->>>>>>> origin/main } func (c *Client) setState(cfg *profilemanager.Config, cacheDir string, cfgPath string, cc *internal.ConnectClient) { From faa19622ae3af0d069f6eedd4bd9011ba9c10f13 Mon Sep 17 00:00:00 2001 From: riccardom Date: Fri, 7 Aug 2026 12:56:16 +0200 Subject: [PATCH 10/30] Fixup helper withMDMPolicy -> configWithMDM --- client/internal/profilemanager/config_mdm_test.go | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/client/internal/profilemanager/config_mdm_test.go b/client/internal/profilemanager/config_mdm_test.go index 0a79cd20a3d..717f0d3dbcc 100644 --- a/client/internal/profilemanager/config_mdm_test.go +++ b/client/internal/profilemanager/config_mdm_test.go @@ -164,16 +164,12 @@ func TestApply_MDMLazyConnection(t *testing.T) { } for _, c := range cases { t.Run(c.name, func(t *testing.T) { - withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + cfg := configWithMDM(t, ConfigInput{ + ConfigPath: filepath.Join(t.TempDir(), "config.json"), + }, mdm.NewPolicy(map[string]any{ mdm.KeyLazyConnection: c.raw, })) - cfg, err := UpdateOrCreateConfig(ConfigInput{ - ConfigPath: filepath.Join(t.TempDir(), "config.json"), - }) - require.NoError(t, err) - require.NotNil(t, cfg) - assert.Equal(t, c.want, cfg.LazyConnection) assert.True(t, cfg.Policy().HasKey(mdm.KeyLazyConnection)) }) From e4d4b44568acefe4d00ebf3f04cab64c9daada6b Mon Sep 17 00:00:00 2001 From: riccardom Date: Fri, 7 Aug 2026 16:02:30 +0200 Subject: [PATCH 11/30] Fixup after merge --- client/ui/autostart_default.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ui/autostart_default.go b/client/ui/autostart_default.go index 16292257903..0c67667dda2 100644 --- a/client/ui/autostart_default.go +++ b/client/ui/autostart_default.go @@ -72,7 +72,7 @@ func netbirdFootprintExists() bool { // retrying autostart entry writes on every launch. A user's later disable in // Settings is never overridden: the marker guarantees at-most-once, ever. func applyAutostartDefault(ctx context.Context, autostart *services.Autostart, prefs *preferences.Store, prefsFileExisted bool) { - mdmDisabled := autostartDisabledByMDM(mdm.LoadPolicy()) + mdmDisabled := autostartDisabledByMDM(mdm.NewLoader(nil).Load()) if mdmDisabled { if enabled, err := autostart.IsEnabled(ctx); err != nil { From c41d4391852901a3d8061db63a0390c5130957ca Mon Sep 17 00:00:00 2001 From: riccardom Date: Tue, 25 Aug 2026 16:58:25 +0200 Subject: [PATCH 12/30] Resolve merge conflicts --- client/cmd/up.go | 4 ---- client/embed/embed.go | 4 ---- client/ios/NetBirdSDK/client.go | 3 --- 3 files changed, 11 deletions(-) diff --git a/client/cmd/up.go b/client/cmd/up.go index 77224a35897..8b49ea504f2 100644 --- a/client/cmd/up.go +++ b/client/cmd/up.go @@ -21,11 +21,7 @@ import ( "github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/internal/profilemanager" -<<<<<<< HEAD "github.com/netbirdio/netbird/client/mdm" - "github.com/netbirdio/netbird/client/proto" -======= ->>>>>>> main nbnet "github.com/netbirdio/netbird/client/net" "github.com/netbirdio/netbird/client/proto" "github.com/netbirdio/netbird/client/server" diff --git a/client/embed/embed.go b/client/embed/embed.go index 3d884a22892..4bc3527c6aa 100644 --- a/client/embed/embed.go +++ b/client/embed/embed.go @@ -21,12 +21,8 @@ import ( "github.com/netbirdio/netbird/client/internal/auth" "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/internal/profilemanager" -<<<<<<< HEAD "github.com/netbirdio/netbird/client/mdm" - sshcommon "github.com/netbirdio/netbird/client/ssh" -======= nbssh "github.com/netbirdio/netbird/client/ssh" ->>>>>>> main "github.com/netbirdio/netbird/client/system" "github.com/netbirdio/netbird/shared/management/domain" mgmProto "github.com/netbirdio/netbird/shared/management/proto" diff --git a/client/ios/NetBirdSDK/client.go b/client/ios/NetBirdSDK/client.go index 8c58dea0423..775be0f5988 100644 --- a/client/ios/NetBirdSDK/client.go +++ b/client/ios/NetBirdSDK/client.go @@ -22,12 +22,9 @@ import ( "github.com/netbirdio/netbird/client/internal/listener" "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/internal/profilemanager" -<<<<<<< HEAD "github.com/netbirdio/netbird/client/mdm" -======= "github.com/netbirdio/netbird/client/netstate" "github.com/netbirdio/netbird/client/netsweep" ->>>>>>> main "github.com/netbirdio/netbird/client/system" "github.com/netbirdio/netbird/formatter" "github.com/netbirdio/netbird/route" From c281b15cfad2eef08a89082b011a3cb8481001fd Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Wed, 26 Aug 2026 09:42:13 +0200 Subject: [PATCH 13/30] [client] Move MDM enforcement logic into a shared Go layer (#7319) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/android/client.go | 3 +- client/android/client_mdm.go | 38 +++++ client/android/login.go | 13 +- client/android/login_test.go | 6 +- client/android/mdm.go | 73 +------- client/android/preferences.go | 58 ++++++- client/android/preferences_test.go | 25 ++- client/android/profile_manager.go | 31 ++++ client/internal/profilemanager/config_mdm.go | 49 ++++++ client/ios/NetBirdSDK/client.go | 3 +- client/ios/NetBirdSDK/login.go | 14 +- client/ios/NetBirdSDK/mdm.go | 82 +++------ client/ios/NetBirdSDK/preferences.go | 46 ++++- client/ios/NetBirdSDK/preferences_test.go | 25 ++- client/mdm/changedetector.go | 47 ++++++ client/mdm/conflicts.go | 108 ++++++++++++ client/mdm/jsonloader.go | 34 ++++ client/mdm/restrictions.go | 89 ++++++++++ client/server/mdm.go | 169 +++---------------- client/ui/services/settings.go | 33 +--- 20 files changed, 606 insertions(+), 340 deletions(-) create mode 100644 client/android/client_mdm.go create mode 100644 client/internal/profilemanager/config_mdm.go create mode 100644 client/mdm/changedetector.go create mode 100644 client/mdm/conflicts.go create mode 100644 client/mdm/jsonloader.go create mode 100644 client/mdm/restrictions.go diff --git a/client/android/client.go b/client/android/client.go index 234201191c2..38c8a7e610a 100644 --- a/client/android/client.go +++ b/client/android/client.go @@ -101,7 +101,8 @@ type Client struct { // passes this loader to the resolved Config so applyMDMPolicy // picks up the active overlay. Nil means "MDM enforcement off // for this Client". - mdmLoader *mdm.Loader + mdmLoader *mdm.Loader + mdmDetector *mdm.ChangeDetector // Identifies the running profile for the SSO login hint; see profile_state.go. cfgPath string diff --git a/client/android/client_mdm.go b/client/android/client_mdm.go new file mode 100644 index 00000000000..c9a8b965c49 --- /dev/null +++ b/client/android/client_mdm.go @@ -0,0 +1,38 @@ +//go:build android + +package android + +import ( + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mdm" +) + +// SetMDMPolicyFetcher registers the native-provided MDM policy fetcher on +// this Client; passing nil disables MDM enforcement. +func (c *Client) SetMDMPolicyFetcher(p PolicyFetcher) { + c.mdmLoader = loaderFor(p) + c.mdmDetector = mdm.NewChangeDetector(c.mdmLoader) +} + +// 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 { + if c.mdmDetector == nil { + return false + } + return c.mdmDetector.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) { + if cfg == nil || c.mdmLoader == nil { + return + } + cfg.ApplyMDMPolicy(c.mdmLoader.Load()) +} diff --git a/client/android/login.go b/client/android/login.go index 24c911eb5e2..b00fe4899eb 100644 --- a/client/android/login.go +++ b/client/android/login.go @@ -8,6 +8,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/system" ) @@ -45,7 +46,16 @@ type Auth struct { // an earlier call is orphaned on the server. It also breaks a client that enrols and then runs from // the persisted config, because the identity it registered is not the one it runs with — the // management stream rejects it with "no peer auth method provided". -func NewAuth(cfgPath string, mgmURL string) (*Auth, error) { +// +// Auth is constructed under the active MDM policy: a managed management URL +// replaces the caller-supplied one before the config is persisted, and the +// policy is overlaid on the resolved config so the login runs against the +// enforced values. A nil fetcher disables MDM enforcement. +func NewAuth(cfgPath string, mgmURL string, fetcher PolicyFetcher) (*Auth, error) { + policy := loaderFor(fetcher).Load() + if v, ok := policy.GetString(mdm.KeyManagementURL); ok { + mgmURL = v + } inputCfg := profilemanager.ConfigInput{ ConfigPath: cfgPath, ManagementURL: mgmURL, @@ -55,6 +65,7 @@ func NewAuth(cfgPath string, mgmURL string) (*Auth, error) { if err != nil { return nil, err } + cfg.ApplyMDMPolicy(policy) return &Auth{ ctx: context.Background(), diff --git a/client/android/login_test.go b/client/android/login_test.go index b04790f6be4..130a846fc2a 100644 --- a/client/android/login_test.go +++ b/client/android/login_test.go @@ -16,7 +16,7 @@ import ( func TestNewAuth_ReusesPersistedIdentity(t *testing.T) { cfgPath := filepath.Join(t.TempDir(), "config.json") - first, err := NewAuth(cfgPath, "https://api.example.com:443") + first, err := NewAuth(cfgPath, "https://api.example.com:443", nil) if err != nil { t.Fatalf("first NewAuth: %v", err) } @@ -24,7 +24,7 @@ func TestNewAuth_ReusesPersistedIdentity(t *testing.T) { t.Fatal("first NewAuth produced no private key") } - second, err := NewAuth(cfgPath, "https://api.example.com:443") + second, err := NewAuth(cfgPath, "https://api.example.com:443", nil) if err != nil { t.Fatalf("second NewAuth: %v", err) } @@ -38,7 +38,7 @@ func TestNewAuth_ReusesPersistedIdentity(t *testing.T) { func TestNewAuth_CreatesConfigWhenAbsent(t *testing.T) { cfgPath := filepath.Join(t.TempDir(), "config.json") - auth, err := NewAuth(cfgPath, "https://api.example.com:443") + auth, err := NewAuth(cfgPath, "https://api.example.com:443", nil) if err != nil { t.Fatalf("NewAuth: %v", err) } diff --git a/client/android/mdm.go b/client/android/mdm.go index 30f081bb4f4..617d8f7cbb2 100644 --- a/client/android/mdm.go +++ b/client/android/mdm.go @@ -1,80 +1,19 @@ -//go:build android - package android import ( - "encoding/json" - - log "github.com/sirupsen/logrus" - - "github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/mdm" ) -// PolicyFetcher is the mobile-side bridge for the MDM managed-config -// snapshot. The native layer (Kotlin) implements this and registers -// the instance per Client via Client.SetMDMPolicyFetcher. Every -// invocation of fetchJSON must read the current RestrictionsManager -// state and return the result as a JSON-encoded map[string]any string. -// -// JSON is used because gomobile does not support map[string]any -// crossing the JNI boundary — the adapter on the Go side parses the -// string back into the map[string]any expected by mdm.Loader. -// -// Return value contract: -// - "" (empty) : interpreted as "no MDM source / no managed keys" -// - "{}" : managed config explicitly empty -// - "{...}" : JSON object with key/value pairs -// - malformed JSON : logged and treated as empty +// 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 } -// jsonFetcherAdapter wraps a gomobile-exposed PolicyFetcher into the -// internal mdm.PolicyFetcher interface, taking care of JSON decoding -// on every Fetch. -type jsonFetcherAdapter struct { - inner PolicyFetcher -} - -func (a *jsonFetcherAdapter) Fetch() map[string]any { - raw := a.inner.FetchJSON() - if raw == "" { - return nil - } - var out map[string]any - if err := json.Unmarshal([]byte(raw), &out); err != nil { - log.Warnf("MDM mobile fetcher: invalid JSON payload from native: %v", err) - return nil - } - return out -} - -// SetMDMPolicyFetcher registers the native-provided MDM policy fetcher -// on this Client. Call once from the gomobile-init code (Kotlin -// Application.onCreate or Service onCreate) before invoking Run / -// RunWithoutLogin. Passing nil disables MDM enforcement on this -// Client. -// -// The fetcher is held as a *mdm.Loader instance on the Client (no -// package-level state) — multiple Clients in the same process get -// independent Loaders, and tests can inject fakes per Client. -func (c *Client) SetMDMPolicyFetcher(p PolicyFetcher) { +func loaderFor(p PolicyFetcher) *mdm.Loader { if p == nil { - c.mdmLoader = mdm.NewLoader(nil) - return - } - c.mdmLoader = mdm.NewLoader(&jsonFetcherAdapter{inner: p}) -} - -// applyMDMOverlay applies the Client-held MDM Loader's current policy -// on top of the just-read Config. Called immediately after every -// UpdateOrCreateConfig — profilemanager's apply() initialises the -// policy to empty and leaves overlay responsibility to the lifecycle -// owner. No-op when no fetcher was registered. -func (c *Client) applyMDMOverlay(cfg *profilemanager.Config) { - if cfg == nil || c.mdmLoader == nil { - return + return mdm.NewJSONLoader(nil) } - cfg.ApplyMDMPolicy(c.mdmLoader.Load()) + return mdm.NewJSONLoader(p.FetchJSON) } diff --git a/client/android/preferences.go b/client/android/preferences.go index 06647729329..2b2b196f366 100644 --- a/client/android/preferences.go +++ b/client/android/preferences.go @@ -2,11 +2,13 @@ package android import ( "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mdm" ) // Preferences exports a subset of the internal config for gomobile type Preferences struct { configInput profilemanager.ConfigInput + mdmLoader *mdm.Loader } // NewPreferences creates a new Preferences instance @@ -14,11 +16,30 @@ func NewPreferences(configPath string) *Preferences { ci := profilemanager.ConfigInput{ ConfigPath: configPath, } - 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 = 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() } // GetManagementURL reads 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 } @@ -53,17 +74,21 @@ func (p *Preferences) SetAdminURL(url string) { p.configInput.AdminURL = url } -// GetPreSharedKey reads pre-shared 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 stores the given key and waits for commit @@ -78,6 +103,9 @@ func (p *Preferences) SetRosenpassEnabled(enabled bool) { // GetRosenpassEnabled reads Rosenpass enabled status 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 } @@ -96,6 +124,9 @@ func (p *Preferences) SetRosenpassPermissive(permissive bool) { // GetRosenpassPermissive reads Rosenpass permissive setting 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 } @@ -109,6 +140,9 @@ func (p *Preferences) GetRosenpassPermissive() (bool, error) { // GetDisableClientRoutes reads disable client routes setting from config file func (p *Preferences) GetDisableClientRoutes() (bool, error) { + if v, ok := p.policy().GetBool(mdm.KeyDisableClientRoutes); ok { + return v, nil + } if p.configInput.DisableClientRoutes != nil { return *p.configInput.DisableClientRoutes, nil } @@ -127,6 +161,9 @@ func (p *Preferences) SetDisableClientRoutes(disable bool) { // GetDisableServerRoutes reads disable server routes setting from config file func (p *Preferences) GetDisableServerRoutes() (bool, error) { + if v, ok := p.policy().GetBool(mdm.KeyDisableServerRoutes); ok { + return v, nil + } if p.configInput.DisableServerRoutes != nil { return *p.configInput.DisableServerRoutes, nil } @@ -181,6 +218,9 @@ func (p *Preferences) SetDisableFirewall(disable bool) { // GetServerSSHAllowed reads server SSH allowed setting from config file func (p *Preferences) GetServerSSHAllowed() (bool, error) { + if v, ok := p.policy().GetBool(mdm.KeyAllowServerSSH); ok { + return v, nil + } if p.configInput.ServerSSHAllowed != nil { return *p.configInput.ServerSSHAllowed, nil } @@ -291,6 +331,9 @@ func (p *Preferences) SetEnableSSHRemotePortForwarding(enabled bool) { // GetBlockInbound reads block inbound setting from config file func (p *Preferences) GetBlockInbound() (bool, error) { + if v, ok := p.policy().GetBool(mdm.KeyBlockInbound); ok { + return v, nil + } if p.configInput.BlockInbound != nil { return *p.configInput.BlockInbound, nil } @@ -327,6 +370,9 @@ func (p *Preferences) SetDisableIPv6(disable bool) { // Commit writes out the changes to the config file func (p *Preferences) Commit() error { + if err := profilemanager.CheckMDMConflicts(p.configInput, p.policy()); err != nil { + return err + } _, err := profilemanager.UpdateOrCreateConfig(p.configInput) return err } diff --git a/client/android/preferences_test.go b/client/android/preferences_test.go index 2bbccef86c3..d9f5b191804 100644 --- a/client/android/preferences_test.go +++ b/client/android/preferences_test.go @@ -28,14 +28,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") } } @@ -65,13 +64,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") } } @@ -109,12 +108,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") } } diff --git a/client/android/profile_manager.go b/client/android/profile_manager.go index 20d585d6a99..4e7ff9adcce 100644 --- a/client/android/profile_manager.go +++ b/client/android/profile_manager.go @@ -3,6 +3,7 @@ package android import ( + "errors" "fmt" "os" "path/filepath" @@ -10,6 +11,7 @@ import ( log "github.com/sirupsen/logrus" "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mdm" ) const ( @@ -17,6 +19,9 @@ const ( androidUsername = "android" ) +// ErrProfilesDisabled marks a profile mutation rejected by MDM policy. +var ErrProfilesDisabled = errors.New("profile management is disabled by MDM policy") + // Profile represents a profile for gomobile type Profile struct { ID string @@ -64,6 +69,7 @@ func (p *ProfileArray) Get(i int) *Profile { type ProfileManager struct { configDir string serviceMgr *profilemanager.ServiceManager + mdmLoader *mdm.Loader } // NewProfileManager creates a new profile manager for Android @@ -144,6 +150,9 @@ func (pm *ProfileManager) profileEmail(id string) string { // SwitchProfile switches to a different profile func (pm *ProfileManager) SwitchProfile(id string) error { + if err := pm.checkProfilesAllowed(); err != nil { + return err + } // Use ServiceManager to stay consistent with ListProfiles // ServiceManager uses active_profile.json err := pm.serviceMgr.SetActiveProfileState(&profilemanager.ActiveProfileState{ @@ -160,6 +169,9 @@ func (pm *ProfileManager) SwitchProfile(id string) error { // AddProfile creates a new profile func (pm *ProfileManager) AddProfile(profileName string) error { + if err := pm.checkProfilesAllowed(); err != nil { + return err + } // Use ServiceManager (creates profile in profiles/ directory) profile, err := pm.serviceMgr.AddProfile(profileName, androidUsername) if err != nil { @@ -213,6 +225,9 @@ func (pm *ProfileManager) LogoutProfile(id string) error { // is rewritten. This works for the default profile too, whose config lives in // netbird.cfg rather than under profiles/. func (pm *ProfileManager) RenameProfile(id string, newName string) error { + if err := pm.checkProfilesAllowed(); err != nil { + return err + } if err := pm.serviceMgr.RenameProfile(profilemanager.ID(id), androidUsername, newName); err != nil { return fmt.Errorf("failed to rename profile: %w", err) } @@ -223,6 +238,9 @@ func (pm *ProfileManager) RenameProfile(id string, newName string) error { // RemoveProfile deletes a profile func (pm *ProfileManager) RemoveProfile(id string) error { + if err := pm.checkProfilesAllowed(); err != nil { + return err + } configPath, err := pm.getProfileConfigPath(id) if err != nil { return err @@ -245,6 +263,19 @@ func (pm *ProfileManager) RemoveProfile(id string) error { return nil } +// SetMDMPolicyFetcher registers the native-provided MDM policy fetcher on +// this ProfileManager; passing nil disables MDM enforcement. +func (pm *ProfileManager) SetMDMPolicyFetcher(f PolicyFetcher) { + pm.mdmLoader = loaderFor(f) +} + +func (pm *ProfileManager) checkProfilesAllowed() error { + if v, ok := pm.mdmLoader.Load().GetBool(mdm.KeyDisableProfiles); ok && v { + return ErrProfilesDisabled + } + return nil +} + // getProfileConfigPath returns the config file path for a profile // This is needed for Android-specific path handling (netbird.cfg for default profile) func (pm *ProfileManager) getProfileConfigPath(id string) (string, error) { diff --git a/client/internal/profilemanager/config_mdm.go b/client/internal/profilemanager/config_mdm.go new file mode 100644 index 00000000000..f3b215c85f0 --- /dev/null +++ b/client/internal/profilemanager/config_mdm.go @@ -0,0 +1,49 @@ +package profilemanager + +import ( + "errors" + "fmt" + + "github.com/netbirdio/netbird/client/mdm" +) + +// ErrMDMManagedFields marks a config change rejected because it diverges from +// MDM-enforced values. +var ErrMDMManagedFields = errors.New("fields managed by MDM cannot be modified") + +// MDMConflicts returns the names of MDM-managed keys whose requested value in +// the ConfigInput differs from the policy-enforced value; a field set to the +// enforced value is a no-op echo, not a conflict. +func MDMConflicts(input ConfigInput, policy *mdm.Policy) []string { + pskGot := "" + if input.PreSharedKey != nil && !isPreSharedKeyHidden(input.PreSharedKey) { + pskGot = *input.PreSharedKey + } + var port *int64 + if input.WireguardPort != nil { + v := int64(*input.WireguardPort) + port = &v + } + return mdm.ResolveConflicts(policy, []mdm.ConflictCheck{ + mdm.ConflictURL(mdm.KeyManagementURL, input.ManagementURL), + mdm.ConflictString(mdm.KeyPreSharedKey, pskGot), + mdm.ConflictBool(mdm.KeyRosenpassEnabled, input.RosenpassEnabled), + mdm.ConflictBool(mdm.KeyRosenpassPermissive, input.RosenpassPermissive), + mdm.ConflictBool(mdm.KeyDisableAutoConnect, input.DisableAutoConnect), + mdm.ConflictBool(mdm.KeyAllowServerSSH, input.ServerSSHAllowed), + mdm.ConflictBool(mdm.KeyDisableClientRoutes, input.DisableClientRoutes), + mdm.ConflictBool(mdm.KeyDisableServerRoutes, input.DisableServerRoutes), + mdm.ConflictBool(mdm.KeyBlockInbound, input.BlockInbound), + mdm.ConflictInt64(mdm.KeyWireguardPort, port), + }) +} + +// CheckMDMConflicts returns an ErrMDMManagedFields-wrapped error naming the +// conflicting keys, or nil when the input does not fight the policy. +func CheckMDMConflicts(input ConfigInput, policy *mdm.Policy) error { + conflicts := MDMConflicts(input, policy) + if len(conflicts) == 0 { + return nil + } + return fmt.Errorf("%w: %v", ErrMDMManagedFields, conflicts) +} diff --git a/client/ios/NetBirdSDK/client.go b/client/ios/NetBirdSDK/client.go index 775be0f5988..2ba3bcd78a9 100644 --- a/client/ios/NetBirdSDK/client.go +++ b/client/ios/NetBirdSDK/client.go @@ -99,7 +99,8 @@ type Client struct { // init). Each Run passes this loader to the resolved Config so // applyMDMPolicy picks up the active overlay. Nil means "MDM // enforcement off for this Client". - mdmLoader *mdm.Loader + mdmLoader *mdm.Loader + mdmDetector *mdm.ChangeDetector stateMu sync.RWMutex connectClient *internal.ConnectClient diff --git a/client/ios/NetBirdSDK/login.go b/client/ios/NetBirdSDK/login.go index 42a575359d3..3d9a29f0088 100644 --- a/client/ios/NetBirdSDK/login.go +++ b/client/ios/NetBirdSDK/login.go @@ -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/system" ) @@ -41,8 +42,16 @@ type Auth struct { cfgPath string } -// NewAuth instantiate Auth struct and validate the management URL -func NewAuth(cfgPath string, mgmURL string) (*Auth, error) { +// NewAuth instantiate Auth struct and validate the management URL. +// Auth is constructed under the active MDM policy: a managed management URL +// replaces the caller-supplied one before the config is persisted, and the +// policy is overlaid on the resolved config so the login runs against the +// enforced values. A nil fetcher disables MDM enforcement. +func NewAuth(cfgPath string, mgmURL string, fetcher PolicyFetcher) (*Auth, error) { + policy := loaderFor(fetcher).Load() + if v, ok := policy.GetString(mdm.KeyManagementURL); ok { + mgmURL = v + } inputCfg := profilemanager.ConfigInput{ ConfigPath: cfgPath, ManagementURL: mgmURL, @@ -66,6 +75,7 @@ func NewAuth(cfgPath string, mgmURL string) (*Auth, error) { if err != nil { return nil, err } + cfg.ApplyMDMPolicy(policy) // 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 diff --git a/client/ios/NetBirdSDK/mdm.go b/client/ios/NetBirdSDK/mdm.go index 6f9696b7ae4..21ba209cd1f 100644 --- a/client/ios/NetBirdSDK/mdm.go +++ b/client/ios/NetBirdSDK/mdm.go @@ -3,80 +3,50 @@ package NetBirdSDK import ( - "encoding/json" - - log "github.com/sirupsen/logrus" - "github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/mdm" ) -// PolicyFetcher is the mobile-side bridge for the MDM managed-config -// snapshot. The native layer (Swift) implements this and registers -// the instance per Client via Client.SetMDMPolicyFetcher. Every -// invocation of fetchJSON must read the current -// UserDefaults.standard.dictionary(forKey: "com.apple.configuration.managed") -// and return the result as a JSON-encoded map[string]any string. -// -// JSON is used because gomobile does not support map[string]any -// crossing the Objective-C boundary — the adapter on the Go side -// parses the string back into the map[string]any expected by -// mdm.Loader. -// -// Return value contract: -// - "" (empty) : interpreted as "no MDM source / no managed keys" -// - "{}" : managed config explicitly empty -// - "{...}" : JSON object with key/value pairs -// - malformed JSON : logged and treated as empty +// 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 } -// jsonFetcherAdapter wraps a gomobile-exposed PolicyFetcher into the -// internal mdm.PolicyFetcher interface, taking care of JSON decoding -// on every Fetch. -type jsonFetcherAdapter struct { - inner PolicyFetcher +// SetMDMPolicyFetcher registers the native-provided MDM policy fetcher on +// this Client; passing nil disables MDM enforcement. +func (c *Client) SetMDMPolicyFetcher(p PolicyFetcher) { + c.mdmLoader = loaderFor(p) + c.mdmDetector = mdm.NewChangeDetector(c.mdmLoader) } -func (a *jsonFetcherAdapter) Fetch() map[string]any { - raw := a.inner.FetchJSON() - if raw == "" { - return nil +// 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 { + if c.mdmDetector == nil { + return false } - var out map[string]any - if err := json.Unmarshal([]byte(raw), &out); err != nil { - log.Warnf("MDM mobile fetcher: invalid JSON payload from native: %v", err) - return nil - } - return out + return c.mdmDetector.Changed() } -// SetMDMPolicyFetcher registers the native-provided MDM policy fetcher -// on this Client. Call once from the gomobile-init code (Swift -// AppDelegate / PacketTunnelProvider.startTunnel) before invoking Run. -// Passing nil disables MDM enforcement on this Client. -// -// The fetcher is held as a *mdm.Loader instance on the Client (no -// package-level state) — multiple Clients in the same process get -// independent Loaders, and tests can inject fakes per Client. -func (c *Client) SetMDMPolicyFetcher(p PolicyFetcher) { - if p == nil { - c.mdmLoader = mdm.NewLoader(nil) - return - } - c.mdmLoader = mdm.NewLoader(&jsonFetcherAdapter{inner: p}) +// 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() } -// applyMDMOverlay applies the Client-held MDM Loader's current policy -// on top of the just-read Config. Called immediately after every -// UpdateOrCreateConfig / DirectUpdateOrCreateConfig — profilemanager's -// apply() initialises the policy to empty and leaves overlay -// responsibility to the lifecycle owner. No-op when no fetcher was -// registered. func (c *Client) applyMDMOverlay(cfg *profilemanager.Config) { if cfg == nil || c.mdmLoader == nil { return } cfg.ApplyMDMPolicy(c.mdmLoader.Load()) } + +func loaderFor(p PolicyFetcher) *mdm.Loader { + if p == nil { + return mdm.NewJSONLoader(nil) + } + return mdm.NewJSONLoader(p.FetchJSON) +} diff --git a/client/ios/NetBirdSDK/preferences.go b/client/ios/NetBirdSDK/preferences.go index ed49ccddb96..19ffc13c6ec 100644 --- a/client/ios/NetBirdSDK/preferences.go +++ b/client/ios/NetBirdSDK/preferences.go @@ -4,11 +4,13 @@ package NetBirdSDK import ( "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 *mdm.Loader } // NewPreferences create new Preferences instance @@ -17,11 +19,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 = 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() } // 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 } @@ -56,17 +77,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 +106,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 +127,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,6 +161,9 @@ func (p *Preferences) SetDisableIPv6(disable 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) diff --git a/client/ios/NetBirdSDK/preferences_test.go b/client/ios/NetBirdSDK/preferences_test.go index 5f75e7c9a2b..2382e123cfb 100644 --- a/client/ios/NetBirdSDK/preferences_test.go +++ b/client/ios/NetBirdSDK/preferences_test.go @@ -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") } } diff --git a/client/mdm/changedetector.go b/client/mdm/changedetector.go new file mode 100644 index 00000000000..ebdb740970e --- /dev/null +++ b/client/mdm/changedetector.go @@ -0,0 +1,47 @@ +package mdm + +import ( + "sync" + + log "github.com/sirupsen/logrus" +) + +// ChangeDetector tracks the last observed policy of a Loader so an +// OS-notification-driven caller can ask whether the managed configuration +// actually changed before restarting anything. +type ChangeDetector struct { + mu sync.Mutex + loader *Loader + prev *Policy +} + +// NewChangeDetector constructs a ChangeDetector seeded with the loader's +// current policy, so only a later change reports as changed. +func NewChangeDetector(loader *Loader) *ChangeDetector { + return &ChangeDetector{ + loader: loader, + prev: loader.Load(), + } +} + +// Changed re-reads the policy, logs the per-key diff, and reports whether it +// diverged from the last observation; the new snapshot becomes the baseline. +func (d *ChangeDetector) Changed() bool { + d.mu.Lock() + defer d.mu.Unlock() + curr := d.loader.Load() + if policiesEqual(d.prev, curr) { + return false + } + added, removed, changed := diffPolicies(d.prev, curr) + log.Infof("MDM policy changed: added=%v removed=%v changed=%v", added, removed, changed) + d.prev = curr + return true +} + +// Current returns the last observed policy snapshot. +func (d *ChangeDetector) Current() *Policy { + d.mu.Lock() + defer d.mu.Unlock() + return d.prev +} diff --git a/client/mdm/conflicts.go b/client/mdm/conflicts.go new file mode 100644 index 00000000000..c96709bd1b6 --- /dev/null +++ b/client/mdm/conflicts.go @@ -0,0 +1,108 @@ +package mdm + +import "net/url" + +// PreSharedKeyRedactedSentinel is the redaction mask returned in place of a +// real pre-shared key; an incoming value equal to it is a round-trip echo, +// never an override. +const PreSharedKeyRedactedSentinel = "**********" + +// ConflictCheck is a value-aware comparison between a single requested field +// and the corresponding MDM-enforced value. +type ConflictCheck struct { + Key string + Check func(*Policy) bool +} + +// ConflictBool builds a ConflictCheck for a boolean MDM key. +func ConflictBool(key string, p *bool) ConflictCheck { + return ConflictCheck{ + Key: key, + Check: func(pol *Policy) bool { + if p == nil { + return true + } + want, ok := pol.GetBool(key) + return ok && want == *p + }, + } +} + +// ConflictString builds a ConflictCheck for a string MDM key. +func ConflictString(key, got string) ConflictCheck { + return ConflictCheck{ + Key: key, + Check: func(pol *Policy) bool { + if got == "" { + return true + } + want, ok := pol.GetString(key) + return ok && want == got + }, + } +} + +// ConflictURL builds a ConflictCheck for a URL-typed MDM key; both sides are +// normalized via CanonicalURL before comparison. +func ConflictURL(key, got string) ConflictCheck { + return ConflictCheck{ + Key: key, + Check: func(pol *Policy) bool { + if got == "" { + return true + } + want, ok := pol.GetString(key) + return ok && CanonicalURL(want) == CanonicalURL(got) + }, + } +} + +// ConflictInt64 builds a ConflictCheck for an integer MDM key. +func ConflictInt64(key string, p *int64) ConflictCheck { + return ConflictCheck{ + Key: key, + Check: func(pol *Policy) bool { + if p == nil { + return true + } + want, ok := pol.GetInt(key) + return ok && want == *p + }, + } +} + +// ResolveConflicts returns the names of keys whose requested value diverges +// from the policy-enforced value; keys the policy does not manage are skipped. +func ResolveConflicts(policy *Policy, checks []ConflictCheck) []string { + if policy.IsEmpty() { + return nil + } + var conflicts []string + for _, c := range checks { + if !policy.HasKey(c.Key) { + continue + } + if !c.Check(policy) { + conflicts = append(conflicts, c.Key) + } + } + return conflicts +} + +// CanonicalURL normalizes a service URL by appending the scheme default port +// when none is present; unparseable input is returned unchanged. +func CanonicalURL(s string) string { + u, err := url.ParseRequestURI(s) + if err != nil { + return s + } + if u.Port() == "" { + switch u.Scheme { + case "https": + u.Host += ":443" + case "http": + u.Host += ":80" + } + } + return u.String() +} diff --git a/client/mdm/jsonloader.go b/client/mdm/jsonloader.go new file mode 100644 index 00000000000..7139b0e4ffa --- /dev/null +++ b/client/mdm/jsonloader.go @@ -0,0 +1,34 @@ +package mdm + +import ( + "encoding/json" + + log "github.com/sirupsen/logrus" +) + +type jsonPolicyFetcher struct { + fetch func() string +} + +// NewJSONLoader constructs a Loader whose policy source is a JSON-encoded +// object string, as produced by the mobile native layers; a nil fetch +// disables MDM enforcement. +func NewJSONLoader(fetch func() string) *Loader { + if fetch == nil { + return NewLoader(nil) + } + return NewLoader(&jsonPolicyFetcher{fetch: fetch}) +} + +func (f *jsonPolicyFetcher) Fetch() map[string]any { + raw := f.fetch() + if raw == "" { + return nil + } + var out map[string]any + if err := json.Unmarshal([]byte(raw), &out); err != nil { + log.Warnf("MDM mobile fetcher: invalid JSON payload from native: %v", err) + return nil + } + return out +} diff --git a/client/mdm/restrictions.go b/client/mdm/restrictions.go new file mode 100644 index 00000000000..e46eb00ddb4 --- /dev/null +++ b/client/mdm/restrictions.go @@ -0,0 +1,89 @@ +package mdm + +import "encoding/json" + +// Fields carries the per-key MDM enforcement state for a UI: value-typed +// fields hold the enforced value, boolean fields report that the key is +// managed. +type Fields struct { + ManagementURL string `json:"managementURL"` + PreSharedKey bool `json:"preSharedKey"` + WireguardPort bool `json:"wireguardPort"` + RosenpassEnabled bool `json:"rosenpassEnabled"` + RosenpassPermissive bool `json:"rosenpassPermissive"` + DisableClientRoutes bool `json:"disableClientRoutes"` + DisableServerRoutes bool `json:"disableServerRoutes"` + AllowServerSSH *bool `json:"allowServerSSH"` + DisableAutoConnect bool `json:"disableAutoConnect"` + DisableAutostart bool `json:"disableAutostart"` + BlockInbound bool `json:"blockInbound"` + DisableMetricsCollection bool `json:"disableMetricsCollection"` + SplitTunnelMode bool `json:"splitTunnelMode"` + SplitTunnelApps bool `json:"splitTunnelApps"` + DisableAdvancedView bool `json:"disableAdvancedView"` +} + +// Features carries the feature gates a UI must honor. +type Features struct { + DisableProfiles bool `json:"disableProfiles"` + DisableNetworks bool `json:"disableNetworks"` + DisableUpdateSettings bool `json:"disableUpdateSettings"` +} + +// Restrictions is the UI-facing enforcement snapshot; the JSON shape is +// shared by the desktop frontend and the mobile bridges. +type Restrictions struct { + MDM Fields `json:"mdm"` + Features Features `json:"features"` +} + +// BuildRestrictions derives the UI enforcement snapshot from the active +// policy. +func BuildRestrictions(policy *Policy) Restrictions { + var r Restrictions + if policy.IsEmpty() { + return r + } + + if v, ok := policy.GetString(KeyManagementURL); ok { + r.MDM.ManagementURL = CanonicalURL(v) + } + r.MDM.PreSharedKey = policy.HasKey(KeyPreSharedKey) + r.MDM.WireguardPort = policy.HasKey(KeyWireguardPort) + r.MDM.RosenpassEnabled = policy.HasKey(KeyRosenpassEnabled) + r.MDM.RosenpassPermissive = policy.HasKey(KeyRosenpassPermissive) + r.MDM.DisableClientRoutes = policy.HasKey(KeyDisableClientRoutes) + r.MDM.DisableServerRoutes = policy.HasKey(KeyDisableServerRoutes) + r.MDM.DisableAutoConnect = policy.HasKey(KeyDisableAutoConnect) + r.MDM.DisableAutostart = policy.HasKey(KeyDisableAutostart) + r.MDM.BlockInbound = policy.HasKey(KeyBlockInbound) + r.MDM.DisableMetricsCollection = policy.HasKey(KeyDisableMetricsCollection) + r.MDM.SplitTunnelMode = policy.HasKey(KeySplitTunnelMode) + r.MDM.SplitTunnelApps = policy.HasKey(KeySplitTunnelApps) + if v, ok := policy.GetBool(KeyAllowServerSSH); ok { + r.MDM.AllowServerSSH = &v + } + if v, ok := policy.GetBool(KeyDisableAdvancedView); ok { + r.MDM.DisableAdvancedView = v + } + + if v, ok := policy.GetBool(KeyDisableProfiles); ok { + r.Features.DisableProfiles = v + } + if v, ok := policy.GetBool(KeyDisableNetworks); ok { + r.Features.DisableNetworks = v + } + if v, ok := policy.GetBool(KeyDisableUpdateSettings); ok { + r.Features.DisableUpdateSettings = v + } + return r +} + +// JSON renders the snapshot in the shared UI JSON shape. +func (r Restrictions) JSON() (string, error) { + b, err := json.Marshal(r) + if err != nil { + return "", err + } + return string(b), nil +} diff --git a/client/server/mdm.go b/client/server/mdm.go index d5170348408..e9aca847e4d 100644 --- a/client/server/mdm.go +++ b/client/server/mdm.go @@ -3,7 +3,6 @@ package server import ( "context" "fmt" - "net/url" "time" log "github.com/sirupsen/logrus" @@ -14,24 +13,6 @@ import ( "github.com/netbirdio/netbird/client/proto" ) -// preSharedKeyRedactedSentinel is the value GetConfig returns in place -// of an actual PSK, so a UI that round-trips the field back to the -// daemon (via SetConfig / Login) can be distinguished from a deliberate -// override. Any incoming PSK that equals this sentinel is treated as -// a no-op echo, never as a conflict with the policy. -const preSharedKeyRedactedSentinel = "**********" - -// conflictCheck is a value-aware comparison between a single field in -// the incoming request and the corresponding MDM-enforced value. It -// runs only when the field was actually set in the request (presence -// already filtered upstream); ok=true reports the policy value, ok=false -// means the policy is silent on the key — both are treated as conflicts -// to be safe (an MDM key declared as managed must hold a value). -type conflictCheck struct { - key string - check func(*mdm.Policy) (match bool) -} - // onMDMPolicyChange is invoked by the MDM reload ticker every time the // OS-native managed-config store reports a diff vs the last observation. // @@ -164,108 +145,6 @@ func (s *Server) restartEngineForMDMLocked() error { return nil } -// conflictBool builds a conflictCheck for a boolean MDM key. If p is nil -// the field is treated as matching (no override requested); otherwise the -// check returns true only when the policy contains the key and its -// boolean value equals *p. -func conflictBool(key string, p *bool) conflictCheck { - return conflictCheck{ - key: key, - check: func(pol *mdm.Policy) bool { - if p == nil { - return true // absent → match by definition - } - want, ok := pol.GetBool(key) - return ok && want == *p - }, - } -} - -func canonicalURL(s string) string { - u, err := url.ParseRequestURI(s) - if err != nil { - return s - } - if u.Port() == "" { - switch u.Scheme { - case "https": - u.Host += ":443" - case "http": - u.Host += ":80" - } - } - return u.String() -} - -// conflictURL is conflictString for URL-typed keys: both sides are -// normalized via canonicalURL before comparison. -func conflictURL(key, got string) conflictCheck { - return conflictCheck{ - key: key, - check: func(pol *mdm.Policy) bool { - if got == "" { - return true - } - want, ok := pol.GetString(key) - return ok && canonicalURL(want) == canonicalURL(got) - }, - } -} - -// conflictString builds a conflictCheck for a string MDM key. An empty -// `got` is treated as "field not set" (no override requested); otherwise -// the check returns true only when the policy contains the key and its -// value equals got. -func conflictString(key, got string) conflictCheck { - return conflictCheck{ - key: key, - check: func(pol *mdm.Policy) bool { - if got == "" { - return true - } - want, ok := pol.GetString(key) - return ok && want == got - }, - } -} - -// conflictInt64 builds a conflictCheck for an integer MDM key. If p is -// nil the field is treated as matching; otherwise the check returns -// true only when the policy contains the key and its int value equals *p. -func conflictInt64(key string, p *int64) conflictCheck { - return conflictCheck{ - key: key, - check: func(pol *mdm.Policy) bool { - if p == nil { - return true - } - want, ok := pol.GetInt(key) - return ok && want == *p - }, - } -} - -// resolveConflicts walks the per-field checks against the active MDM -// policy and returns the names of keys whose requested value diverges -// from the policy-enforced value. Keys not present in the policy are -// skipped silently (the gate fires only for keys the admin has -// actually pushed). Returns nil for an empty policy. -func resolveConflicts(policy *mdm.Policy, checks []conflictCheck) []string { - if policy.IsEmpty() { - return nil - } - var conflicts []string - for _, c := range checks { - if !policy.HasKey(c.key) { - continue - } - if !c.check(policy) { - conflicts = append(conflicts, c.key) - } - } - return conflicts -} - // mdmManagedFieldConflicts returns the names of MDM-managed keys whose // requested value in the SetConfigRequest differs from the MDM-enforced // value. A field set to the same value the policy already enforces is @@ -282,21 +161,21 @@ func mdmManagedFieldConflicts(msg *proto.SetConfigRequest, policy *mdm.Policy) [ // PSK round-trip echo: collapse the sentinel to empty so the // shared check treats it as "field not set". pskGot := "" - if msg.OptionalPreSharedKey != nil && *msg.OptionalPreSharedKey != preSharedKeyRedactedSentinel { + if msg.OptionalPreSharedKey != nil && *msg.OptionalPreSharedKey != mdm.PreSharedKeyRedactedSentinel { pskGot = *msg.OptionalPreSharedKey } - return resolveConflicts(policy, []conflictCheck{ - conflictURL(mdm.KeyManagementURL, msg.ManagementUrl), - conflictString(mdm.KeyPreSharedKey, pskGot), - conflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled), - conflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive), - conflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect), - conflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed), - conflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes), - conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes), - conflictBool(mdm.KeyBlockInbound, msg.BlockInbound), - conflictInt64(mdm.KeyWireguardPort, msg.WireguardPort), + return mdm.ResolveConflicts(policy, []mdm.ConflictCheck{ + mdm.ConflictURL(mdm.KeyManagementURL, msg.ManagementUrl), + mdm.ConflictString(mdm.KeyPreSharedKey, pskGot), + mdm.ConflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled), + mdm.ConflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive), + mdm.ConflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect), + mdm.ConflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed), + mdm.ConflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes), + mdm.ConflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes), + mdm.ConflictBool(mdm.KeyBlockInbound, msg.BlockInbound), + mdm.ConflictInt64(mdm.KeyWireguardPort, msg.WireguardPort), }) } @@ -403,21 +282,21 @@ func loginRequestMDMConflicts(msg *proto.LoginRequest, policy *mdm.Policy) []str } else if msg.PreSharedKey != "" { //nolint:staticcheck // SA1019: legacy proto field still accepted by Login pskGot = msg.PreSharedKey //nolint:staticcheck // SA1019 } - if pskGot == preSharedKeyRedactedSentinel { + if pskGot == mdm.PreSharedKeyRedactedSentinel { pskGot = "" } - return resolveConflicts(policy, []conflictCheck{ - conflictURL(mdm.KeyManagementURL, msg.ManagementUrl), - conflictString(mdm.KeyPreSharedKey, pskGot), - conflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled), - conflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive), - conflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect), - conflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed), - conflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes), - conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes), - conflictBool(mdm.KeyBlockInbound, msg.BlockInbound), - conflictInt64(mdm.KeyWireguardPort, msg.WireguardPort), + return mdm.ResolveConflicts(policy, []mdm.ConflictCheck{ + mdm.ConflictURL(mdm.KeyManagementURL, msg.ManagementUrl), + mdm.ConflictString(mdm.KeyPreSharedKey, pskGot), + mdm.ConflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled), + mdm.ConflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive), + mdm.ConflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect), + mdm.ConflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed), + mdm.ConflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes), + mdm.ConflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes), + mdm.ConflictBool(mdm.KeyBlockInbound, msg.BlockInbound), + mdm.ConflictInt64(mdm.KeyWireguardPort, msg.WireguardPort), }) } diff --git a/client/ui/services/settings.go b/client/ui/services/settings.go index 74e6f913caf..804ef3075e4 100644 --- a/client/ui/services/settings.go +++ b/client/ui/services/settings.go @@ -11,37 +11,18 @@ import ( "github.com/netbirdio/netbird/client/internal/daemonaddr" "github.com/netbirdio/netbird/client/internal/ipcauth" + "github.com/netbirdio/netbird/client/mdm" "github.com/netbirdio/netbird/client/proto" ) -type MDMFields struct { - ManagementURL string `json:"managementURL"` - PreSharedKey bool `json:"preSharedKey"` - WireguardPort bool `json:"wireguardPort"` - RosenpassEnabled bool `json:"rosenpassEnabled"` - RosenpassPermissive bool `json:"rosenpassPermissive"` - DisableClientRoutes bool `json:"disableClientRoutes"` - DisableServerRoutes bool `json:"disableServerRoutes"` - AllowServerSSH *bool `json:"allowServerSSH"` - DisableAutoConnect bool `json:"disableAutoConnect"` - DisableAutostart bool `json:"disableAutostart"` - BlockInbound bool `json:"blockInbound"` - DisableMetricsCollection bool `json:"disableMetricsCollection"` - SplitTunnelMode bool `json:"splitTunnelMode"` - SplitTunnelApps bool `json:"splitTunnelApps"` - DisableAdvancedView bool `json:"disableAdvancedView"` -} +// MDMFields is the shared per-key MDM enforcement snapshot; see mdm.Fields. +type MDMFields = mdm.Fields -type Features struct { - DisableProfiles bool `json:"disableProfiles"` - DisableNetworks bool `json:"disableNetworks"` - DisableUpdateSettings bool `json:"disableUpdateSettings"` -} +// Features is the shared feature-gate snapshot; see mdm.Features. +type Features = mdm.Features -type Restrictions struct { - MDM MDMFields `json:"mdm"` - Features Features `json:"features"` -} +// Restrictions is the shared UI enforcement snapshot; see mdm.Restrictions. +type Restrictions = mdm.Restrictions // Privilege tells the frontend whether this process may perform the changes the // daemon restricts to root/administrator, and carries the command for each so a From 5d15ac00bc7f4be1f62474eb09d7a98ad1b98d16 Mon Sep 17 00:00:00 2001 From: riccardom Date: Fri, 4 Sep 2026 14:39:27 +0200 Subject: [PATCH 14/30] [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. --- .../profilemanager/config_mdm_test.go | 15 +- client/ios/NetBirdSDK/client.go | 3 - client/ios/NetBirdSDK/login.go | 3 - client/mdm/conflicts.go | 16 ++ client/server/mdm.go | 163 +----------------- client/server/setconfig_mdm_test.go | 10 +- 6 files changed, 32 insertions(+), 178 deletions(-) diff --git a/client/internal/profilemanager/config_mdm_test.go b/client/internal/profilemanager/config_mdm_test.go index 15951d55deb..44fbfd7a2c9 100644 --- a/client/internal/profilemanager/config_mdm_test.go +++ b/client/internal/profilemanager/config_mdm_test.go @@ -153,22 +153,19 @@ func TestApply_MDMLocalMetrics(t *testing.T) { tmp := filepath.Join(t.TempDir(), "config.json") // Seed without MDM. - withMDMPolicy(t, mdm.NewPolicy(nil)) - _, err := UpdateOrCreateConfig(ConfigInput{ + configWithMDM(t, ConfigInput{ ConfigPath: tmp, LocalMetricsEnabled: boolPtr(false), - }) - require.NoError(t, err) + }, mdm.NewPolicy(nil)) - withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + // Now enable MDM enforcement for these keys. + cfg := configWithMDM(t, ConfigInput{ + ConfigPath: tmp, + }, mdm.NewPolicy(map[string]any{ mdm.KeyEnableLocalMetrics: true, mdm.KeyLocalMetricsAddress: "127.0.0.1:9292", })) - cfg, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: tmp}) - require.NoError(t, err) - require.NotNil(t, cfg) - assert.True(t, cfg.LocalMetricsEnabled, "MDM override should flip on-disk false to true") assert.Equal(t, "127.0.0.1:9292", cfg.LocalMetricsAddress) assert.True(t, cfg.Policy().HasKey(mdm.KeyEnableLocalMetrics)) diff --git a/client/ios/NetBirdSDK/client.go b/client/ios/NetBirdSDK/client.go index 0456a9989cd..bab286287cd 100644 --- a/client/ios/NetBirdSDK/client.go +++ b/client/ios/NetBirdSDK/client.go @@ -93,7 +93,6 @@ type Client struct { // preloadedConfig holds config loaded from JSON (used on tvOS where file writes are blocked) preloadedConfig *profilemanager.Config -<<<<<<< HEAD // mdmLoader holds the per-Client MDM policy source. Set by // SetMDMPolicyFetcher (called from the Swift side at extension // init). Each Run passes this loader to the resolved Config so @@ -102,13 +101,11 @@ type Client struct { mdmLoader *mdm.Loader mdmDetector *mdm.ChangeDetector -======= // 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. ->>>>>>> main stateMu sync.RWMutex connectClient *internal.ConnectClient config *profilemanager.Config diff --git a/client/ios/NetBirdSDK/login.go b/client/ios/NetBirdSDK/login.go index a3fd49f9821..2d356120bc3 100644 --- a/client/ios/NetBirdSDK/login.go +++ b/client/ios/NetBirdSDK/login.go @@ -11,11 +11,8 @@ import ( "github.com/netbirdio/netbird/client/internal/auth" "github.com/netbirdio/netbird/client/internal/profilemanager" -<<<<<<< HEAD "github.com/netbirdio/netbird/client/mdm" -======= "github.com/netbirdio/netbird/client/mobile" ->>>>>>> main "github.com/netbirdio/netbird/client/system" ) diff --git a/client/mdm/conflicts.go b/client/mdm/conflicts.go index c96709bd1b6..ac559bcf571 100644 --- a/client/mdm/conflicts.go +++ b/client/mdm/conflicts.go @@ -42,6 +42,22 @@ func ConflictString(key, got string) ConflictCheck { } } +// ConflictStringPtr is ConflictString for optional proto fields, where an +// explicit empty value is still a request to change the setting. A nil p means +// "field not set" (no override requested). +func ConflictStringPtr(key string, p *string) ConflictCheck { + return ConflictCheck{ + Key: key, + Check: func(pol *Policy) bool { + if p == nil { + return true + } + want, ok := pol.GetString(key) + return ok && want == *p + }, + } +} + // ConflictURL builds a ConflictCheck for a URL-typed MDM key; both sides are // normalized via CanonicalURL before comparison. func ConflictURL(key, got string) ConflictCheck { diff --git a/client/server/mdm.go b/client/server/mdm.go index 881bc573347..638628c8118 100644 --- a/client/server/mdm.go +++ b/client/server/mdm.go @@ -145,129 +145,6 @@ func (s *Server) restartEngineForMDMLocked() error { return nil } -<<<<<<< HEAD -======= -// conflictBool builds a conflictCheck for a boolean MDM key. If p is nil -// the field is treated as matching (no override requested); otherwise the -// check returns true only when the policy contains the key and its -// boolean value equals *p. -func conflictBool(key string, p *bool) conflictCheck { - return conflictCheck{ - key: key, - check: func(pol *mdm.Policy) bool { - if p == nil { - return true // absent → match by definition - } - want, ok := pol.GetBool(key) - return ok && want == *p - }, - } -} - -func canonicalURL(s string) string { - u, err := url.ParseRequestURI(s) - if err != nil { - return s - } - if u.Port() == "" { - switch u.Scheme { - case "https": - u.Host += ":443" - case "http": - u.Host += ":80" - } - } - return u.String() -} - -// conflictURL is conflictString for URL-typed keys: both sides are -// normalized via canonicalURL before comparison. -func conflictURL(key, got string) conflictCheck { - return conflictCheck{ - key: key, - check: func(pol *mdm.Policy) bool { - if got == "" { - return true - } - want, ok := pol.GetString(key) - return ok && canonicalURL(want) == canonicalURL(got) - }, - } -} - -// conflictString builds a conflictCheck for a string MDM key. An empty -// `got` is treated as "field not set" (no override requested); otherwise -// the check returns true only when the policy contains the key and its -// value equals got. -func conflictString(key, got string) conflictCheck { - return conflictCheck{ - key: key, - check: func(pol *mdm.Policy) bool { - if got == "" { - return true - } - want, ok := pol.GetString(key) - return ok && want == got - }, - } -} - -// conflictStringPtr is conflictString for optional proto fields, where an -// explicit empty value is still a request to change the setting. If p is -// nil the field is treated as matching (no override requested); otherwise -// the check returns true only when the policy contains the key and its -// value equals *p. -func conflictStringPtr(key string, p *string) conflictCheck { - return conflictCheck{ - key: key, - check: func(pol *mdm.Policy) bool { - if p == nil { - return true - } - want, ok := pol.GetString(key) - return ok && want == *p - }, - } -} - -// conflictInt64 builds a conflictCheck for an integer MDM key. If p is -// nil the field is treated as matching; otherwise the check returns -// true only when the policy contains the key and its int value equals *p. -func conflictInt64(key string, p *int64) conflictCheck { - return conflictCheck{ - key: key, - check: func(pol *mdm.Policy) bool { - if p == nil { - return true - } - want, ok := pol.GetInt(key) - return ok && want == *p - }, - } -} - -// resolveConflicts walks the per-field checks against the active MDM -// policy and returns the names of keys whose requested value diverges -// from the policy-enforced value. Keys not present in the policy are -// skipped silently (the gate fires only for keys the admin has -// actually pushed). Returns nil for an empty policy. -func resolveConflicts(policy *mdm.Policy, checks []conflictCheck) []string { - if policy.IsEmpty() { - return nil - } - var conflicts []string - for _, c := range checks { - if !policy.HasKey(c.key) { - continue - } - if !c.check(policy) { - conflicts = append(conflicts, c.key) - } - } - return conflicts -} - ->>>>>>> main // mdmManagedFieldConflicts returns the names of MDM-managed keys whose // requested value in the SetConfigRequest differs from the MDM-enforced // value. A field set to the same value the policy already enforces is @@ -288,7 +165,6 @@ func mdmManagedFieldConflicts(msg *proto.SetConfigRequest, policy *mdm.Policy) [ pskGot = *msg.OptionalPreSharedKey } -<<<<<<< HEAD return mdm.ResolveConflicts(policy, []mdm.ConflictCheck{ mdm.ConflictURL(mdm.KeyManagementURL, msg.ManagementUrl), mdm.ConflictString(mdm.KeyPreSharedKey, pskGot), @@ -296,26 +172,13 @@ func mdmManagedFieldConflicts(msg *proto.SetConfigRequest, policy *mdm.Policy) [ mdm.ConflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive), mdm.ConflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect), mdm.ConflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed), + mdm.ConflictBool(mdm.KeyRemoteJobsAllowed, msg.RemoteJobsAllowed), mdm.ConflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes), mdm.ConflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes), mdm.ConflictBool(mdm.KeyBlockInbound, msg.BlockInbound), mdm.ConflictInt64(mdm.KeyWireguardPort, msg.WireguardPort), -======= - return resolveConflicts(policy, []conflictCheck{ - conflictURL(mdm.KeyManagementURL, msg.ManagementUrl), - conflictString(mdm.KeyPreSharedKey, pskGot), - conflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled), - conflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive), - conflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect), - conflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed), - conflictBool(mdm.KeyRemoteJobsAllowed, msg.RemoteJobsAllowed), - conflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes), - conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes), - conflictBool(mdm.KeyBlockInbound, msg.BlockInbound), - conflictInt64(mdm.KeyWireguardPort, msg.WireguardPort), - conflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics), - conflictStringPtr(mdm.KeyLocalMetricsAddress, msg.LocalMetricsAddress), ->>>>>>> main + mdm.ConflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics), + mdm.ConflictStringPtr(mdm.KeyLocalMetricsAddress, msg.LocalMetricsAddress), }) } @@ -432,7 +295,6 @@ func loginRequestMDMConflicts(msg *proto.LoginRequest, policy *mdm.Policy) []str pskGot = "" } -<<<<<<< HEAD return mdm.ResolveConflicts(policy, []mdm.ConflictCheck{ mdm.ConflictURL(mdm.KeyManagementURL, msg.ManagementUrl), mdm.ConflictString(mdm.KeyPreSharedKey, pskGot), @@ -440,26 +302,13 @@ func loginRequestMDMConflicts(msg *proto.LoginRequest, policy *mdm.Policy) []str mdm.ConflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive), mdm.ConflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect), mdm.ConflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed), + mdm.ConflictBool(mdm.KeyRemoteJobsAllowed, msg.RemoteJobsAllowed), mdm.ConflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes), mdm.ConflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes), mdm.ConflictBool(mdm.KeyBlockInbound, msg.BlockInbound), mdm.ConflictInt64(mdm.KeyWireguardPort, msg.WireguardPort), -======= - return resolveConflicts(policy, []conflictCheck{ - conflictURL(mdm.KeyManagementURL, msg.ManagementUrl), - conflictString(mdm.KeyPreSharedKey, pskGot), - conflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled), - conflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive), - conflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect), - conflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed), - conflictBool(mdm.KeyRemoteJobsAllowed, msg.RemoteJobsAllowed), - conflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes), - conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes), - conflictBool(mdm.KeyBlockInbound, msg.BlockInbound), - conflictInt64(mdm.KeyWireguardPort, msg.WireguardPort), - conflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics), - conflictStringPtr(mdm.KeyLocalMetricsAddress, msg.LocalMetricsAddress), ->>>>>>> main + mdm.ConflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics), + mdm.ConflictStringPtr(mdm.KeyLocalMetricsAddress, msg.LocalMetricsAddress), }) } diff --git a/client/server/setconfig_mdm_test.go b/client/server/setconfig_mdm_test.go index 5b69177b8ad..5a1dc7f28f2 100644 --- a/client/server/setconfig_mdm_test.go +++ b/client/server/setconfig_mdm_test.go @@ -161,13 +161,12 @@ func TestSetConfig_MDMReject_MultipleFields(t *testing.T) { } func TestSetConfig_MDMReject_LocalMetrics(t *testing.T) { - withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + s, ctx, profName, username, _ := setupServerWithProfile(t) + withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{ mdm.KeyEnableLocalMetrics: true, mdm.KeyLocalMetricsAddress: "127.0.0.1:9191", })) - s, ctx, profName, username, _ := setupServerWithProfile(t) - enabled := false addr := "0.0.0.0:9999" _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ @@ -188,12 +187,11 @@ func TestSetConfig_MDMReject_LocalMetrics(t *testing.T) { // (the manager falls back to the default), so presence must be honored // rather than collapsed to "field not set". func TestSetConfig_MDMReject_LocalMetricsEmptyAddress(t *testing.T) { - withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + s, ctx, profName, username, _ := setupServerWithProfile(t) + withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{ mdm.KeyLocalMetricsAddress: "127.0.0.1:9999", })) - s, ctx, profName, username, _ := setupServerWithProfile(t) - addr := "" _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ ProfileName: profName, From d96d22a3243c14df8ad3f55b5752f0112cf92ed6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Fri, 4 Sep 2026 20:48:35 +0200 Subject: [PATCH 15/30] [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/internal/profilemanager/config_mdm.go | 8 +-- .../profilemanager/config_mdm_test.go | 27 ++++++++ client/mdm/conflicts.go | 20 +----- client/server/mdm.go | 28 +++----- client/server/setconfig_mdm_test.go | 64 +++++++++++++++++++ 5 files changed, 108 insertions(+), 39 deletions(-) diff --git a/client/internal/profilemanager/config_mdm.go b/client/internal/profilemanager/config_mdm.go index f3b215c85f0..5556b8e770a 100644 --- a/client/internal/profilemanager/config_mdm.go +++ b/client/internal/profilemanager/config_mdm.go @@ -15,9 +15,9 @@ var ErrMDMManagedFields = errors.New("fields managed by MDM cannot be modified") // the ConfigInput differs from the policy-enforced value; a field set to the // enforced value is a no-op echo, not a conflict. func MDMConflicts(input ConfigInput, policy *mdm.Policy) []string { - pskGot := "" - if input.PreSharedKey != nil && !isPreSharedKeyHidden(input.PreSharedKey) { - pskGot = *input.PreSharedKey + pskGot := input.PreSharedKey + if isPreSharedKeyHidden(pskGot) { + pskGot = nil } var port *int64 if input.WireguardPort != nil { @@ -26,7 +26,7 @@ func MDMConflicts(input ConfigInput, policy *mdm.Policy) []string { } return mdm.ResolveConflicts(policy, []mdm.ConflictCheck{ mdm.ConflictURL(mdm.KeyManagementURL, input.ManagementURL), - mdm.ConflictString(mdm.KeyPreSharedKey, pskGot), + mdm.ConflictStringPtr(mdm.KeyPreSharedKey, pskGot), mdm.ConflictBool(mdm.KeyRosenpassEnabled, input.RosenpassEnabled), mdm.ConflictBool(mdm.KeyRosenpassPermissive, input.RosenpassPermissive), mdm.ConflictBool(mdm.KeyDisableAutoConnect, input.DisableAutoConnect), diff --git a/client/internal/profilemanager/config_mdm_test.go b/client/internal/profilemanager/config_mdm_test.go index 44fbfd7a2c9..82bc13f569a 100644 --- a/client/internal/profilemanager/config_mdm_test.go +++ b/client/internal/profilemanager/config_mdm_test.go @@ -214,4 +214,31 @@ func TestApply_MDMPreSharedKeyRedactionSentinelRejected(t *testing.T) { assert.True(t, cfg.Policy().HasKey(mdm.KeyPreSharedKey)) } +func TestMDMConflicts_PreSharedKey(t *testing.T) { + policy := mdm.NewPolicy(map[string]any{ + mdm.KeyPreSharedKey: "mdm-enforced-psk", + }) + empty := "" + sentinel := "**********" + same := "mdm-enforced-psk" + other := "user-psk" + + tests := []struct { + name string + psk *string + want []string + }{ + {name: "unset", psk: nil, want: nil}, + {name: "explicit empty", psk: &empty, want: []string{mdm.KeyPreSharedKey}}, + {name: "sentinel echo", psk: &sentinel, want: nil}, + {name: "same value", psk: &same, want: nil}, + {name: "divergent", psk: &other, want: []string{mdm.KeyPreSharedKey}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, MDMConflicts(ConfigInput{PreSharedKey: tc.psk}, policy)) + }) + } +} + func boolPtr(b bool) *bool { return &b } diff --git a/client/mdm/conflicts.go b/client/mdm/conflicts.go index ac559bcf571..18f2bdc89d0 100644 --- a/client/mdm/conflicts.go +++ b/client/mdm/conflicts.go @@ -28,23 +28,9 @@ func ConflictBool(key string, p *bool) ConflictCheck { } } -// ConflictString builds a ConflictCheck for a string MDM key. -func ConflictString(key, got string) ConflictCheck { - return ConflictCheck{ - Key: key, - Check: func(pol *Policy) bool { - if got == "" { - return true - } - want, ok := pol.GetString(key) - return ok && want == got - }, - } -} - -// ConflictStringPtr is ConflictString for optional proto fields, where an -// explicit empty value is still a request to change the setting. A nil p means -// "field not set" (no override requested). +// ConflictStringPtr builds a ConflictCheck for an optional string MDM key, +// where an explicit empty value is still a request to change the setting. A +// nil p means "field not set" (no override requested). func ConflictStringPtr(key string, p *string) ConflictCheck { return ConflictCheck{ Key: key, diff --git a/client/server/mdm.go b/client/server/mdm.go index 638628c8118..7a47b2a57aa 100644 --- a/client/server/mdm.go +++ b/client/server/mdm.go @@ -158,16 +158,14 @@ func mdmManagedFieldConflicts(msg *proto.SetConfigRequest, policy *mdm.Policy) [ return nil } - // PSK round-trip echo: collapse the sentinel to empty so the - // shared check treats it as "field not set". - pskGot := "" - if msg.OptionalPreSharedKey != nil && *msg.OptionalPreSharedKey != mdm.PreSharedKeyRedactedSentinel { - pskGot = *msg.OptionalPreSharedKey + pskGot := msg.OptionalPreSharedKey + if pskGot != nil && *pskGot == mdm.PreSharedKeyRedactedSentinel { + pskGot = nil } return mdm.ResolveConflicts(policy, []mdm.ConflictCheck{ mdm.ConflictURL(mdm.KeyManagementURL, msg.ManagementUrl), - mdm.ConflictString(mdm.KeyPreSharedKey, pskGot), + mdm.ConflictStringPtr(mdm.KeyPreSharedKey, pskGot), mdm.ConflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled), mdm.ConflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive), mdm.ConflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect), @@ -281,23 +279,17 @@ func loginRequestMDMConflicts(msg *proto.LoginRequest, policy *mdm.Policy) []str return nil } - // Collapse the two PSK fields + the redaction sentinel down to a - // single "got" string the shared check can compare against the - // policy: OptionalPreSharedKey wins if set; PreSharedKey (deprecated) - // is the fallback; sentinel echo is treated as "field not set". - pskGot := "" - if msg.OptionalPreSharedKey != nil { - pskGot = *msg.OptionalPreSharedKey - } else if msg.PreSharedKey != "" { //nolint:staticcheck // SA1019: legacy proto field still accepted by Login - pskGot = msg.PreSharedKey //nolint:staticcheck // SA1019 + pskGot := msg.OptionalPreSharedKey + if pskGot == nil && msg.PreSharedKey != "" { //nolint:staticcheck // SA1019: legacy proto field still accepted by Login + pskGot = &msg.PreSharedKey //nolint:staticcheck // SA1019 } - if pskGot == mdm.PreSharedKeyRedactedSentinel { - pskGot = "" + if pskGot != nil && *pskGot == mdm.PreSharedKeyRedactedSentinel { + pskGot = nil } return mdm.ResolveConflicts(policy, []mdm.ConflictCheck{ mdm.ConflictURL(mdm.KeyManagementURL, msg.ManagementUrl), - mdm.ConflictString(mdm.KeyPreSharedKey, pskGot), + mdm.ConflictStringPtr(mdm.KeyPreSharedKey, pskGot), mdm.ConflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled), mdm.ConflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive), mdm.ConflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect), diff --git a/client/server/setconfig_mdm_test.go b/client/server/setconfig_mdm_test.go index 5a1dc7f28f2..91e2ce3e664 100644 --- a/client/server/setconfig_mdm_test.go +++ b/client/server/setconfig_mdm_test.go @@ -203,6 +203,70 @@ func TestSetConfig_MDMReject_LocalMetricsEmptyAddress(t *testing.T) { assert.ElementsMatch(t, []string{mdm.KeyLocalMetricsAddress}, v.GetFields()) } +func TestSetConfig_MDMReject_EmptyPreSharedKey(t *testing.T) { + s, ctx, profName, username, _ := setupServerWithProfile(t) + withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{ + mdm.KeyPreSharedKey: "mdm-enforced-psk", + })) + + psk := "" + _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + OptionalPreSharedKey: &psk, + }) + + v := extractViolation(t, err) + assert.ElementsMatch(t, []string{mdm.KeyPreSharedKey}, v.GetFields()) +} + +func TestSetConfig_MDMAllow_PreSharedKeySentinelEcho(t *testing.T) { + s, ctx, profName, username, _ := setupServerWithProfile(t) + withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{ + mdm.KeyPreSharedKey: "mdm-enforced-psk", + })) + + psk := mdm.PreSharedKeyRedactedSentinel + resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + OptionalPreSharedKey: &psk, + }) + + require.NoError(t, err) + require.NotNil(t, resp) +} + +func TestLoginRequestMDMConflicts_PreSharedKey(t *testing.T) { + policy := mdm.NewPolicy(map[string]any{ + mdm.KeyPreSharedKey: "mdm-enforced-psk", + }) + empty := "" + sentinel := mdm.PreSharedKeyRedactedSentinel + same := "mdm-enforced-psk" + other := "user-psk" + + tests := []struct { + name string + msg *proto.LoginRequest + want []string + }{ + {name: "unset", msg: &proto.LoginRequest{}, want: nil}, + {name: "optional empty", msg: &proto.LoginRequest{OptionalPreSharedKey: &empty}, want: []string{mdm.KeyPreSharedKey}}, + {name: "optional sentinel echo", msg: &proto.LoginRequest{OptionalPreSharedKey: &sentinel}, want: nil}, + {name: "optional same value", msg: &proto.LoginRequest{OptionalPreSharedKey: &same}, want: nil}, + {name: "optional divergent", msg: &proto.LoginRequest{OptionalPreSharedKey: &other}, want: []string{mdm.KeyPreSharedKey}}, + {name: "legacy empty is unset", msg: &proto.LoginRequest{PreSharedKey: ""}, want: nil}, + {name: "legacy sentinel echo", msg: &proto.LoginRequest{PreSharedKey: sentinel}, want: nil}, + {name: "legacy divergent", msg: &proto.LoginRequest{PreSharedKey: other}, want: []string{mdm.KeyPreSharedKey}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, loginRequestMDMConflicts(tc.msg, policy)) + }) + } +} + func TestSetConfig_MDMReject_AllOrNothing(t *testing.T) { // MDM enforces ManagementURL only; user request touches both the // enforced field AND a non-enforced field (RosenpassEnabled). From 47238e0cf1a99e7d9da2d1e7b7186ece8445a8c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Fri, 4 Sep 2026 20:52:08 +0200 Subject: [PATCH 16/30] [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/ios/NetBirdSDK/client.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ios/NetBirdSDK/client.go b/client/ios/NetBirdSDK/client.go index bab286287cd..0d04203391a 100644 --- a/client/ios/NetBirdSDK/client.go +++ b/client/ios/NetBirdSDK/client.go @@ -169,8 +169,8 @@ func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error { if err != nil { return err } - c.applyMDMOverlay(cfg) } + c.applyMDMOverlay(cfg) c.recorder.UpdateManagementAddress(cfg.ManagementURL.String()) c.recorder.UpdateRosenpass(cfg.RosenpassEnabled, cfg.RosenpassPermissive) From 42f0a63ca9458779ff91b0362a2326babf81a143 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Fri, 4 Sep 2026 20:56:28 +0200 Subject: [PATCH 17/30] [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/mobile/profile_manager.go | 11 +++ client/mobile/profile_manager_mdm_test.go | 83 +++++++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 client/mobile/profile_manager_mdm_test.go diff --git a/client/mobile/profile_manager.go b/client/mobile/profile_manager.go index 60c7cc25066..9ac7f1f845e 100644 --- a/client/mobile/profile_manager.go +++ b/client/mobile/profile_manager.go @@ -180,6 +180,9 @@ func (pm *ProfileManager) RenameProfile(id string, newName string) error { // private key and SSH key from the config, forcing a re-login. The management // URL and other settings are preserved. func (pm *ProfileManager) LogoutProfile(id string) error { + if err := pm.checkProfileLogoutAllowed(id); err != nil { + return err + } configPath, err := pm.getProfileConfigPath(id) if err != nil { return err @@ -300,6 +303,14 @@ func (pm *ProfileManager) checkProfilesAllowed() error { return nil } +func (pm *ProfileManager) checkProfileLogoutAllowed(id string) error { + active, err := pm.serviceMgr.GetActiveProfileState() + if err == nil && active.ID.String() == id { + return nil + } + return pm.checkProfilesAllowed() +} + func (pm *ProfileManager) profileEmail(id string) string { configPath, err := pm.getProfileConfigPath(id) if err != nil { diff --git a/client/mobile/profile_manager_mdm_test.go b/client/mobile/profile_manager_mdm_test.go new file mode 100644 index 00000000000..305becac3e5 --- /dev/null +++ b/client/mobile/profile_manager_mdm_test.go @@ -0,0 +1,83 @@ +package mobile + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mdm" +) + +type fakeFetcher struct{ values map[string]any } + +func (f *fakeFetcher) Fetch() map[string]any { return f.values } + +func newTestProfileManager(t *testing.T) *ProfileManager { + t.Helper() + origDir := profilemanager.DefaultConfigPathDir + origPath := profilemanager.DefaultConfigPath + origActive := profilemanager.ActiveProfileStatePath + t.Cleanup(func() { + profilemanager.DefaultConfigPathDir = origDir + profilemanager.DefaultConfigPath = origPath + profilemanager.ActiveProfileStatePath = origActive + }) + + configDir := t.TempDir() + pm := NewProfileManager(configDir, "mobile") + _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{ + ConfigPath: filepath.Join(configDir, defaultConfigFilename), + }) + require.NoError(t, err) + return pm +} + +func privateKeyOf(t *testing.T, pm *ProfileManager, id string) string { + t.Helper() + path, err := pm.getProfileConfigPath(id) + require.NoError(t, err) + raw, err := os.ReadFile(path) + require.NoError(t, err) + var cfg struct{ PrivateKey string } + require.NoError(t, json.Unmarshal(raw, &cfg)) + return cfg.PrivateKey +} + +func TestLogoutProfile_DisableProfiles(t *testing.T) { + pm := newTestProfileManager(t) + other, err := pm.AddProfile("work") + require.NoError(t, err) + require.NoError(t, pm.SwitchProfile(profilemanager.DefaultProfileName)) + require.NotEmpty(t, privateKeyOf(t, pm, profilemanager.DefaultProfileName)) + require.NotEmpty(t, privateKeyOf(t, pm, other.ID)) + + pm.SetMDMLoader(mdm.NewLoader(&fakeFetcher{values: map[string]any{ + mdm.KeyDisableProfiles: true, + }})) + + err = pm.LogoutProfile(other.ID) + assert.ErrorIs(t, err, ErrProfilesDisabled) + assert.NotEmpty(t, privateKeyOf(t, pm, other.ID)) + + require.NoError(t, pm.LogoutProfile(profilemanager.DefaultProfileName)) + assert.Empty(t, privateKeyOf(t, pm, profilemanager.DefaultProfileName)) +} + +func TestLogoutProfile_ProfilesAllowed(t *testing.T) { + pm := newTestProfileManager(t) + other, err := pm.AddProfile("work") + require.NoError(t, err) + require.NoError(t, pm.SwitchProfile(profilemanager.DefaultProfileName)) + + pm.SetMDMLoader(mdm.NewLoader(&fakeFetcher{values: map[string]any{ + mdm.KeyDisableProfiles: false, + }})) + + require.NoError(t, pm.LogoutProfile(other.ID)) + assert.Empty(t, privateKeyOf(t, pm, other.ID)) +} From 3234ce7bcc12fe22b0adcc195df433bc35269195 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Fri, 4 Sep 2026 21:07:02 +0200 Subject: [PATCH 18/30] [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/android/login.go | 12 ++++-------- client/android/preferences.go | 9 ++++----- client/ios/NetBirdSDK/login.go | 12 ++++-------- client/ios/NetBirdSDK/preferences.go | 9 ++++----- 4 files changed, 16 insertions(+), 26 deletions(-) diff --git a/client/android/login.go b/client/android/login.go index ddbe2e02cba..74877e2f24f 100644 --- a/client/android/login.go +++ b/client/android/login.go @@ -8,7 +8,6 @@ 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" ) @@ -48,15 +47,12 @@ type Auth struct { // the persisted config, because the identity it registered is not the one it runs with — the // management stream rejects it with "no peer auth method provided". // -// Auth is constructed under the active MDM policy: a managed management URL -// replaces the caller-supplied one before the config is persisted, and the -// policy is overlaid on the resolved config so the login runs against the -// enforced values. A nil fetcher disables MDM enforcement. +// 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 nil fetcher disables +// MDM enforcement. func NewAuth(cfgPath string, mgmURL string, fetcher PolicyFetcher) (*Auth, error) { policy := loaderFor(fetcher).Load() - if v, ok := policy.GetString(mdm.KeyManagementURL); ok { - mgmURL = v - } inputCfg := profilemanager.ConfigInput{ ConfigPath: cfgPath, ManagementURL: mgmURL, diff --git a/client/android/preferences.go b/client/android/preferences.go index 39e633efbf4..5e2040c72c4 100644 --- a/client/android/preferences.go +++ b/client/android/preferences.go @@ -37,10 +37,8 @@ func (p *Preferences) policy() *mdm.Policy { // GetManagementURL reads 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 != "" { + policy := p.policy() + if !policy.HasKey(mdm.KeyManagementURL) && p.configInput.ManagementURL != "" { return p.configInput.ManagementURL, nil } @@ -48,7 +46,8 @@ func (p *Preferences) GetManagementURL() (string, error) { if err != nil { return "", err } - return cfg.ManagementURL.String(), err + cfg.ApplyMDMPolicy(policy) + return cfg.ManagementURL.String(), nil } // SetManagementURL stores the given URL and waits for commit diff --git a/client/ios/NetBirdSDK/login.go b/client/ios/NetBirdSDK/login.go index 2d356120bc3..89adae9adfb 100644 --- a/client/ios/NetBirdSDK/login.go +++ b/client/ios/NetBirdSDK/login.go @@ -11,7 +11,6 @@ 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" ) @@ -44,15 +43,12 @@ type Auth struct { } // NewAuth instantiate Auth struct and validate the management URL. -// Auth is constructed under the active MDM policy: a managed management URL -// replaces the caller-supplied one before the config is persisted, and the -// policy is overlaid on the resolved config so the login runs against the -// enforced values. A nil fetcher disables MDM enforcement. +// 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 nil fetcher disables +// MDM enforcement. func NewAuth(cfgPath string, mgmURL string, fetcher PolicyFetcher) (*Auth, error) { policy := loaderFor(fetcher).Load() - if v, ok := policy.GetString(mdm.KeyManagementURL); ok { - mgmURL = v - } inputCfg := profilemanager.ConfigInput{ ConfigPath: cfgPath, ManagementURL: mgmURL, diff --git a/client/ios/NetBirdSDK/preferences.go b/client/ios/NetBirdSDK/preferences.go index d5011912ad4..d390339298b 100644 --- a/client/ios/NetBirdSDK/preferences.go +++ b/client/ios/NetBirdSDK/preferences.go @@ -40,10 +40,8 @@ func (p *Preferences) policy() *mdm.Policy { // 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 != "" { + policy := p.policy() + if !policy.HasKey(mdm.KeyManagementURL) && p.configInput.ManagementURL != "" { return p.configInput.ManagementURL, nil } @@ -51,7 +49,8 @@ func (p *Preferences) GetManagementURL() (string, error) { if err != nil { return "", err } - return cfg.ManagementURL.String(), err + cfg.ApplyMDMPolicy(policy) + return cfg.ManagementURL.String(), nil } // SetManagementURL store the given url and wait for commit From 047715a516810f658207103d7c97353dadc21003 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Fri, 4 Sep 2026 21:18:00 +0200 Subject: [PATCH 19/30] [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/mdm/changedetector.go | 7 ------- client/mdm/policy.go | 2 +- client/mdm/policy_darwin.go | 2 +- client/mdm/policy_windows.go | 2 +- client/mobile/profile_manager.go | 4 ++-- 5 files changed, 5 insertions(+), 12 deletions(-) diff --git a/client/mdm/changedetector.go b/client/mdm/changedetector.go index ebdb740970e..452b80de114 100644 --- a/client/mdm/changedetector.go +++ b/client/mdm/changedetector.go @@ -38,10 +38,3 @@ func (d *ChangeDetector) Changed() bool { d.prev = curr return true } - -// Current returns the last observed policy snapshot. -func (d *ChangeDetector) Current() *Policy { - d.mu.Lock() - defer d.mu.Unlock() - return d.prev -} diff --git a/client/mdm/policy.go b/client/mdm/policy.go index 6c211ba61cc..4b4830babaf 100644 --- a/client/mdm/policy.go +++ b/client/mdm/policy.go @@ -302,7 +302,7 @@ func (p *Policy) GetStringSlice(key string) ([]string, bool) { } // sortedKeys returns the keys of m as a deterministic, lexicographically -// sorted slice. Used internally by Policy.ManagedKeys and LoadPolicy's +// sorted slice. Used internally by Policy.ManagedKeys and Loader.Load's // diagnostic log line so callers see a stable key order across runs // regardless of Go's randomised map iteration. func sortedKeys(m map[string]any) []string { diff --git a/client/mdm/policy_darwin.go b/client/mdm/policy_darwin.go index d7c0fc8f115..608506bdda3 100644 --- a/client/mdm/policy_darwin.go +++ b/client/mdm/policy_darwin.go @@ -53,7 +53,7 @@ func (l *Loader) loadPlatform() (map[string]any, error) { if errors.Is(err, fs.ErrNotExist) { // Not enrolled for NetBird. Caller treats nil as // "no MDM source present". - //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy. + //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see Loader.Load. return nil, nil } return nil, fmt.Errorf("open %s: %w", policyPlistPath, err) diff --git a/client/mdm/policy_windows.go b/client/mdm/policy_windows.go index d13085602a2..5a6085c0ff6 100644 --- a/client/mdm/policy_windows.go +++ b/client/mdm/policy_windows.go @@ -83,7 +83,7 @@ func (l *Loader) loadPlatform() (map[string]any, error) { if err != nil { if errors.Is(err, registry.ErrNotExist) { // Not enrolled. Caller treats nil as "no MDM source present". - //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy. + //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see Loader.Load. return nil, nil } return nil, fmt.Errorf("open %s: %w", policyRegistryPath, err) diff --git a/client/mobile/profile_manager.go b/client/mobile/profile_manager.go index 9ac7f1f845e..348b7253bad 100644 --- a/client/mobile/profile_manager.go +++ b/client/mobile/profile_manager.go @@ -288,8 +288,6 @@ func (pm *ProfileManager) GetActiveStateFilePath() (string, error) { return pm.GetStateFilePath(activeProfile.ID) } -// profileEmail returns the account email recorded for a profile. Display-only, -// so an unresolvable path degrades to "" rather than an error. // SetMDMLoader registers the MDM policy source consulted before profile // mutations; a nil loader disables enforcement. func (pm *ProfileManager) SetMDMLoader(loader *mdm.Loader) { @@ -311,6 +309,8 @@ func (pm *ProfileManager) checkProfileLogoutAllowed(id string) error { return pm.checkProfilesAllowed() } +// profileEmail returns the account email recorded for a profile. Display-only, +// so an unresolvable path degrades to "" rather than an error. func (pm *ProfileManager) profileEmail(id string) string { configPath, err := pm.getProfileConfigPath(id) if err != nil { From c1e2e20ac0d0181d9e9fb45f501f6efc01d21fb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Fri, 4 Sep 2026 21:25:08 +0200 Subject: [PATCH 20/30] [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/internal/profilemanager/config_mdm.go | 3 ++ .../profilemanager/config_mdm_test.go | 38 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/client/internal/profilemanager/config_mdm.go b/client/internal/profilemanager/config_mdm.go index 5556b8e770a..25b9f18f7f8 100644 --- a/client/internal/profilemanager/config_mdm.go +++ b/client/internal/profilemanager/config_mdm.go @@ -31,10 +31,13 @@ func MDMConflicts(input ConfigInput, policy *mdm.Policy) []string { mdm.ConflictBool(mdm.KeyRosenpassPermissive, input.RosenpassPermissive), mdm.ConflictBool(mdm.KeyDisableAutoConnect, input.DisableAutoConnect), mdm.ConflictBool(mdm.KeyAllowServerSSH, input.ServerSSHAllowed), + mdm.ConflictBool(mdm.KeyRemoteJobsAllowed, input.RemoteJobsAllowed), mdm.ConflictBool(mdm.KeyDisableClientRoutes, input.DisableClientRoutes), mdm.ConflictBool(mdm.KeyDisableServerRoutes, input.DisableServerRoutes), mdm.ConflictBool(mdm.KeyBlockInbound, input.BlockInbound), mdm.ConflictInt64(mdm.KeyWireguardPort, port), + mdm.ConflictBool(mdm.KeyEnableLocalMetrics, input.LocalMetricsEnabled), + mdm.ConflictStringPtr(mdm.KeyLocalMetricsAddress, input.LocalMetricsAddress), }) } diff --git a/client/internal/profilemanager/config_mdm_test.go b/client/internal/profilemanager/config_mdm_test.go index 82bc13f569a..6c862b8d54c 100644 --- a/client/internal/profilemanager/config_mdm_test.go +++ b/client/internal/profilemanager/config_mdm_test.go @@ -241,4 +241,42 @@ func TestMDMConflicts_PreSharedKey(t *testing.T) { } } +func TestMDMConflicts_RemoteJobsAndLocalMetrics(t *testing.T) { + policy := mdm.NewPolicy(map[string]any{ + mdm.KeyRemoteJobsAllowed: false, + mdm.KeyEnableLocalMetrics: true, + mdm.KeyLocalMetricsAddress: "127.0.0.1:9999", + }) + sameAddr := "127.0.0.1:9999" + otherAddr := "0.0.0.0:9999" + emptyAddr := "" + + tests := []struct { + name string + input ConfigInput + want []string + }{ + {name: "unset", input: ConfigInput{}, want: nil}, + {name: "echo", input: ConfigInput{ + RemoteJobsAllowed: boolPtr(false), + LocalMetricsEnabled: boolPtr(true), + LocalMetricsAddress: &sameAddr, + }, want: nil}, + {name: "remote jobs divergent", input: ConfigInput{RemoteJobsAllowed: boolPtr(true)}, want: []string{mdm.KeyRemoteJobsAllowed}}, + {name: "metrics disabled", input: ConfigInput{LocalMetricsEnabled: boolPtr(false)}, want: []string{mdm.KeyEnableLocalMetrics}}, + {name: "metrics address divergent", input: ConfigInput{LocalMetricsAddress: &otherAddr}, want: []string{mdm.KeyLocalMetricsAddress}}, + {name: "metrics address explicit empty", input: ConfigInput{LocalMetricsAddress: &emptyAddr}, want: []string{mdm.KeyLocalMetricsAddress}}, + {name: "all divergent", input: ConfigInput{ + RemoteJobsAllowed: boolPtr(true), + LocalMetricsEnabled: boolPtr(false), + LocalMetricsAddress: &otherAddr, + }, want: []string{mdm.KeyRemoteJobsAllowed, mdm.KeyEnableLocalMetrics, mdm.KeyLocalMetricsAddress}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, MDMConflicts(tc.input, policy)) + }) + } +} + func boolPtr(b bool) *bool { return &b } From a52c7baa05f036d6eaba5822e0c2070f72def786 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Fri, 4 Sep 2026 21:35:39 +0200 Subject: [PATCH 21/30] [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/server/setconfig_mdm_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/server/setconfig_mdm_test.go b/client/server/setconfig_mdm_test.go index 91e2ce3e664..52867892ccf 100644 --- a/client/server/setconfig_mdm_test.go +++ b/client/server/setconfig_mdm_test.go @@ -256,9 +256,9 @@ func TestLoginRequestMDMConflicts_PreSharedKey(t *testing.T) { {name: "optional sentinel echo", msg: &proto.LoginRequest{OptionalPreSharedKey: &sentinel}, want: nil}, {name: "optional same value", msg: &proto.LoginRequest{OptionalPreSharedKey: &same}, want: nil}, {name: "optional divergent", msg: &proto.LoginRequest{OptionalPreSharedKey: &other}, want: []string{mdm.KeyPreSharedKey}}, - {name: "legacy empty is unset", msg: &proto.LoginRequest{PreSharedKey: ""}, want: nil}, - {name: "legacy sentinel echo", msg: &proto.LoginRequest{PreSharedKey: sentinel}, want: nil}, - {name: "legacy divergent", msg: &proto.LoginRequest{PreSharedKey: other}, want: []string{mdm.KeyPreSharedKey}}, + {name: "legacy empty is unset", msg: &proto.LoginRequest{PreSharedKey: ""}, want: nil}, //nolint:staticcheck // SA1019: legacy proto field still accepted by Login + {name: "legacy sentinel echo", msg: &proto.LoginRequest{PreSharedKey: sentinel}, want: nil}, //nolint:staticcheck // SA1019: legacy proto field still accepted by Login + {name: "legacy divergent", msg: &proto.LoginRequest{PreSharedKey: other}, want: []string{mdm.KeyPreSharedKey}}, //nolint:staticcheck // SA1019: legacy proto field still accepted by Login } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { From 86d6f7d4c72dd499a95be63a09c31b5bfe993f22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Fri, 4 Sep 2026 21:35:39 +0200 Subject: [PATCH 22/30] [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/android/client.go | 15 +++++++-------- client/android/client_mdm.go | 28 +++++++++++++++++++++------- client/android/preferences.go | 8 +++++--- client/ios/NetBirdSDK/client.go | 14 ++++++-------- client/ios/NetBirdSDK/mdm.go | 28 +++++++++++++++++++++------- client/ios/NetBirdSDK/preferences.go | 8 +++++--- 6 files changed, 65 insertions(+), 36 deletions(-) diff --git a/client/android/client.go b/client/android/client.go index 6aeaf9dd136..e47a1c13d5a 100644 --- a/client/android/client.go +++ b/client/android/client.go @@ -9,6 +9,7 @@ import ( "slices" "strings" "sync" + "sync/atomic" "time" "golang.org/x/exp/maps" @@ -25,7 +26,6 @@ import ( "github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/internal/routemanager" "github.com/netbirdio/netbird/client/internal/stdnet" - "github.com/netbirdio/netbird/client/mdm" "github.com/netbirdio/netbird/client/net" "github.com/netbirdio/netbird/client/netevents" "github.com/netbirdio/netbird/client/system" @@ -92,13 +92,12 @@ type Client struct { config *profilemanager.Config cacheDir string - // mdmLoader holds the per-Client MDM policy source. Set by - // SetMDMPolicyFetcher (called from the Kotlin side). Each Run - // passes this loader to the resolved Config so applyMDMPolicy - // picks up the active overlay. Nil means "MDM enforcement off - // for this Client". - mdmLoader *mdm.Loader - mdmDetector *mdm.ChangeDetector + // mdmSource holds the per-Client MDM policy source and its change + // detector as one unit. Set by SetMDMPolicyFetcher (called from the + // Kotlin side). 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] // Identifies the running profile for the SSO login hint; see profile_state.go. cfgPath string diff --git a/client/android/client_mdm.go b/client/android/client_mdm.go index c9a8b965c49..d043b85d35d 100644 --- a/client/android/client_mdm.go +++ b/client/android/client_mdm.go @@ -7,32 +7,46 @@ import ( "github.com/netbirdio/netbird/client/mdm" ) +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) { - c.mdmLoader = loaderFor(p) - c.mdmDetector = mdm.NewChangeDetector(c.mdmLoader) + 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 { - if c.mdmDetector == nil { + src := c.mdmSource.Load() + if src == nil { return false } - return c.mdmDetector.Changed() + 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() + return mdm.BuildRestrictions(c.mdmLoader().Load()).JSON() } func (c *Client) applyMDMOverlay(cfg *profilemanager.Config) { - if cfg == nil || c.mdmLoader == nil { + loader := c.mdmLoader() + if cfg == nil || loader == nil { return } - cfg.ApplyMDMPolicy(c.mdmLoader.Load()) + cfg.ApplyMDMPolicy(loader.Load()) +} + +func (c *Client) mdmLoader() *mdm.Loader { + if src := c.mdmSource.Load(); src != nil { + return src.loader + } + return nil } diff --git a/client/android/preferences.go b/client/android/preferences.go index 5e2040c72c4..c41f7107ecb 100644 --- a/client/android/preferences.go +++ b/client/android/preferences.go @@ -1,6 +1,8 @@ package android import ( + "sync/atomic" + "github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/mdm" ) @@ -8,7 +10,7 @@ import ( // Preferences exports a subset of the internal config for gomobile type Preferences struct { configInput profilemanager.ConfigInput - mdmLoader *mdm.Loader + mdmLoader atomic.Pointer[mdm.Loader] } // NewPreferences creates a new Preferences instance @@ -22,7 +24,7 @@ func NewPreferences(configPath string) *Preferences { // 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 = loaderFor(f) + p.mdmLoader.Store(loaderFor(f)) } // GetRestrictionsJSON returns the UI enforcement snapshot derived from the @@ -32,7 +34,7 @@ func (p *Preferences) GetRestrictionsJSON() (string, error) { } func (p *Preferences) policy() *mdm.Policy { - return p.mdmLoader.Load() + return p.mdmLoader.Load().Load() } // GetManagementURL reads URL from config file diff --git a/client/ios/NetBirdSDK/client.go b/client/ios/NetBirdSDK/client.go index 0d04203391a..04017d3ad27 100644 --- a/client/ios/NetBirdSDK/client.go +++ b/client/ios/NetBirdSDK/client.go @@ -24,7 +24,6 @@ import ( "github.com/netbirdio/netbird/client/internal/listener" "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/internal/profilemanager" - "github.com/netbirdio/netbird/client/mdm" "github.com/netbirdio/netbird/client/netevents" "github.com/netbirdio/netbird/client/system" "github.com/netbirdio/netbird/formatter" @@ -93,13 +92,12 @@ type Client struct { // preloadedConfig holds config loaded from JSON (used on tvOS where file writes are blocked) preloadedConfig *profilemanager.Config - // mdmLoader holds the per-Client MDM policy source. Set by - // SetMDMPolicyFetcher (called from the Swift side at extension - // init). Each Run passes this loader to the resolved Config so - // applyMDMPolicy picks up the active overlay. Nil means "MDM - // enforcement off for this Client". - mdmLoader *mdm.Loader - mdmDetector *mdm.ChangeDetector + // 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 diff --git a/client/ios/NetBirdSDK/mdm.go b/client/ios/NetBirdSDK/mdm.go index 21ba209cd1f..93a31916c3e 100644 --- a/client/ios/NetBirdSDK/mdm.go +++ b/client/ios/NetBirdSDK/mdm.go @@ -14,34 +14,48 @@ 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) { - c.mdmLoader = loaderFor(p) - c.mdmDetector = mdm.NewChangeDetector(c.mdmLoader) + 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 { - if c.mdmDetector == nil { + src := c.mdmSource.Load() + if src == nil { return false } - return c.mdmDetector.Changed() + 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() + return mdm.BuildRestrictions(c.mdmLoader().Load()).JSON() } func (c *Client) applyMDMOverlay(cfg *profilemanager.Config) { - if cfg == nil || c.mdmLoader == nil { + loader := c.mdmLoader() + if cfg == nil || loader == nil { return } - cfg.ApplyMDMPolicy(c.mdmLoader.Load()) + 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 { diff --git a/client/ios/NetBirdSDK/preferences.go b/client/ios/NetBirdSDK/preferences.go index d390339298b..a72f11be9ab 100644 --- a/client/ios/NetBirdSDK/preferences.go +++ b/client/ios/NetBirdSDK/preferences.go @@ -3,6 +3,8 @@ package NetBirdSDK import ( + "sync/atomic" + "github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/mdm" ) @@ -10,7 +12,7 @@ import ( // Preferences export a subset of the internal config for gomobile type Preferences struct { configInput profilemanager.ConfigInput - mdmLoader *mdm.Loader + mdmLoader atomic.Pointer[mdm.Loader] } // NewPreferences create new Preferences instance @@ -25,7 +27,7 @@ func NewPreferences(configPath string, stateFilePath string) *Preferences { // 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 = loaderFor(f) + p.mdmLoader.Store(loaderFor(f)) } // GetRestrictionsJSON returns the UI enforcement snapshot derived from the @@ -35,7 +37,7 @@ func (p *Preferences) GetRestrictionsJSON() (string, error) { } func (p *Preferences) policy() *mdm.Policy { - return p.mdmLoader.Load() + return p.mdmLoader.Load().Load() } // GetManagementURL read url from config file From a2b1c992c51c844ce25b78c50be4347cec055fe7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Fri, 4 Sep 2026 21:44:27 +0200 Subject: [PATCH 23/30] [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/android/preferences.go | 6 ++++-- client/ios/NetBirdSDK/preferences.go | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/client/android/preferences.go b/client/android/preferences.go index c41f7107ecb..ac986b3ae55 100644 --- a/client/android/preferences.go +++ b/client/android/preferences.go @@ -371,7 +371,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 } @@ -379,10 +380,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 diff --git a/client/ios/NetBirdSDK/preferences.go b/client/ios/NetBirdSDK/preferences.go index a72f11be9ab..b7a9e275435 100644 --- a/client/ios/NetBirdSDK/preferences.go +++ b/client/ios/NetBirdSDK/preferences.go @@ -162,7 +162,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 } @@ -170,10 +171,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 From 1b0dd668e92a111d9ca6b1af2278b707ec50fda9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Mon, 7 Sep 2026 12:17:33 +0200 Subject: [PATCH 24/30] [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/android/login.go | 16 ++++------------ client/ios/NetBirdSDK/login.go | 33 ++++----------------------------- 2 files changed, 8 insertions(+), 41 deletions(-) diff --git a/client/android/login.go b/client/android/login.go index 74877e2f24f..85f1046a83e 100644 --- a/client/android/login.go +++ b/client/android/login.go @@ -82,9 +82,7 @@ func NewAuthWithConfig(ctx context.Context, config *profilemanager.Config, cfgPa } } -// 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) { go func() { sso, err := a.saveConfigIfSSOSupported() @@ -108,15 +106,10 @@ func (a *Auth) saveConfigIfSSOSupported() (bool, error) { return false, fmt.Errorf("failed to check SSO support: %v", err) } - if !supportsSSO { - return false, nil - } - - err = profilemanager.WriteOutConfig(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) { go func() { err := a.loginWithSetupKeyAndSaveConfig(setupKey, deviceName) @@ -141,8 +134,7 @@ func (a *Auth) loginWithSetupKeyAndSaveConfig(setupKey string, deviceName string if err != nil { return fmt.Errorf("login failed: %v", err) } - - return profilemanager.WriteOutConfig(a.cfgPath, a.config) + return nil } // Login try register the client on the server diff --git a/client/ios/NetBirdSDK/login.go b/client/ios/NetBirdSDK/login.go index 89adae9adfb..bc9c28dce04 100644 --- a/client/ios/NetBirdSDK/login.go +++ b/client/ios/NetBirdSDK/login.go @@ -112,9 +112,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") @@ -142,17 +140,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") @@ -181,10 +172,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 @@ -318,19 +306,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() From ca548eaee161feba91ad6a9c82ea73f1d4e3a0bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Mon, 7 Sep 2026 12:21:24 +0200 Subject: [PATCH 25/30] [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/internal/profilemanager/config.go | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/client/internal/profilemanager/config.go b/client/internal/profilemanager/config.go index e5ef73e2c5c..412f81b5cd4 100644 --- a/client/internal/profilemanager/config.go +++ b/client/internal/profilemanager/config.go @@ -206,12 +206,11 @@ type Config struct { policy *mdm.Policy `json:"-"` } -// ApplyMDMPolicy overlays the supplied MDM Policy on top of the -// currently resolved Config values. Idempotent — pass an empty Policy -// to clear any prior overlay. The lifecycle owner (Server.getConfig -// on desktop, the Client.Run path on mobile) calls this with -// loader.Load() once the per-process Loader is known; the Config -// itself holds no reference to the Loader. +// ApplyMDMPolicy overlays the supplied MDM Policy on top of the current +// Config values and records it as Policy(). The overlay is not reversible: +// an empty Policy only clears the enforcement metadata, so resolve the base +// Config again (from disk or JSON) before applying a changed policy, the way +// the lifecycle owners do on every load. func (config *Config) ApplyMDMPolicy(policy *mdm.Policy) { if config == nil { return From cb5efcd6f029300498b7d93d8d1b42eb36f12c26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Mon, 7 Sep 2026 12:29:12 +0200 Subject: [PATCH 26/30] [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/ios/NetBirdSDK/client.go | 102 +++++++++++--------------------- client/ios/NetBirdSDK/login.go | 55 +++++++++++------ 2 files changed, 74 insertions(+), 83 deletions(-) diff --git a/client/ios/NetBirdSDK/client.go b/client/ios/NetBirdSDK/client.go index 04017d3ad27..96c747ae4fe 100644 --- a/client/ios/NetBirdSDK/client.go +++ b/client/ios/NetBirdSDK/client.go @@ -88,9 +88,8 @@ 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 @@ -129,46 +128,45 @@ 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.applyMDMOverlay(cfg) c.recorder.UpdateManagementAddress(cfg.ManagementURL.String()) c.recorder.UpdateRosenpass(cfg.RosenpassEnabled, cfg.RosenpassPermissive) @@ -282,21 +280,14 @@ 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) } - c.applyMDMOverlay(cfg) } deps := debug.GeneratorDependencies{ @@ -430,30 +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 - } - c.applyMDMOverlay(cfg) - } - - 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 } diff --git a/client/ios/NetBirdSDK/login.go b/client/ios/NetBirdSDK/login.go index bc9c28dce04..5d705b3da2c 100644 --- a/client/ios/NetBirdSDK/login.go +++ b/client/ios/NetBirdSDK/login.go @@ -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,6 +40,8 @@ type Auth struct { ctx context.Context cancel context.CancelFunc config *profilemanager.Config + base *profilemanager.Config + policy *mdm.Policy cfgPath string } @@ -72,7 +75,10 @@ func NewAuth(cfgPath string, mgmURL string, fetcher PolicyFetcher) (*Auth, error if err != nil { return nil, err } - cfg.ApplyMDMPolicy(policy) + 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 @@ -82,14 +88,8 @@ func NewAuth(cfgPath string, mgmURL string, fetcher PolicyFetcher) (*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 @@ -356,23 +356,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) +} From c0197009284bbea2a8fb6f6fc199cd666efd3d00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Mon, 7 Sep 2026 12:40:43 +0200 Subject: [PATCH 27/30] [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/android/login.go | 12 +++++++----- client/android/preferences.go | 7 ++++--- client/ios/NetBirdSDK/login.go | 11 ++++++----- client/ios/NetBirdSDK/preferences.go | 7 ++++--- 4 files changed, 21 insertions(+), 16 deletions(-) diff --git a/client/android/login.go b/client/android/login.go index 85f1046a83e..155c6eadd74 100644 --- a/client/android/login.go +++ b/client/android/login.go @@ -8,6 +8,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" ) @@ -49,13 +50,14 @@ type Auth struct { // // 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 nil fetcher disables -// MDM enforcement. +// 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, - ManagementURL: mgmURL, + inputCfg := profilemanager.ConfigInput{ConfigPath: cfgPath} + if _, managed := policy.GetString(mdm.KeyManagementURL); !managed { + inputCfg.ManagementURL = mgmURL } cfg, err := profilemanager.UpdateOrCreateConfig(inputCfg) diff --git a/client/android/preferences.go b/client/android/preferences.go index ac986b3ae55..5ce31026c79 100644 --- a/client/android/preferences.go +++ b/client/android/preferences.go @@ -39,8 +39,10 @@ func (p *Preferences) policy() *mdm.Policy { // GetManagementURL reads URL from config file func (p *Preferences) GetManagementURL() (string, error) { - policy := p.policy() - if !policy.HasKey(mdm.KeyManagementURL) && p.configInput.ManagementURL != "" { + if v, ok := p.policy().GetString(mdm.KeyManagementURL); ok { + return mdm.CanonicalURL(v), nil + } + if p.configInput.ManagementURL != "" { return p.configInput.ManagementURL, nil } @@ -48,7 +50,6 @@ func (p *Preferences) GetManagementURL() (string, error) { if err != nil { return "", err } - cfg.ApplyMDMPolicy(policy) return cfg.ManagementURL.String(), nil } diff --git a/client/ios/NetBirdSDK/login.go b/client/ios/NetBirdSDK/login.go index 5d705b3da2c..0dfff620e0e 100644 --- a/client/ios/NetBirdSDK/login.go +++ b/client/ios/NetBirdSDK/login.go @@ -48,13 +48,14 @@ type Auth struct { // 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 nil fetcher disables -// MDM enforcement. +// 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, - ManagementURL: mgmURL, + 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 diff --git a/client/ios/NetBirdSDK/preferences.go b/client/ios/NetBirdSDK/preferences.go index b7a9e275435..5297920a3ca 100644 --- a/client/ios/NetBirdSDK/preferences.go +++ b/client/ios/NetBirdSDK/preferences.go @@ -42,8 +42,10 @@ func (p *Preferences) policy() *mdm.Policy { // GetManagementURL read url from config file func (p *Preferences) GetManagementURL() (string, error) { - policy := p.policy() - if !policy.HasKey(mdm.KeyManagementURL) && p.configInput.ManagementURL != "" { + if v, ok := p.policy().GetString(mdm.KeyManagementURL); ok { + return mdm.CanonicalURL(v), nil + } + if p.configInput.ManagementURL != "" { return p.configInput.ManagementURL, nil } @@ -51,7 +53,6 @@ func (p *Preferences) GetManagementURL() (string, error) { if err != nil { return "", err } - cfg.ApplyMDMPolicy(policy) return cfg.ManagementURL.String(), nil } From 204360e14a0fec5b6cfabe6121c54c2c360251ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Mon, 7 Sep 2026 12:43:02 +0200 Subject: [PATCH 28/30] [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/mdm/policy.go | 20 +++++++++----------- client/mdm/policy_darwin.go | 5 ++--- client/mdm/policy_windows.go | 5 ++--- client/mdm/restrictions.go | 8 ++++---- client/ui/services/settings.go | 5 +---- 5 files changed, 18 insertions(+), 25 deletions(-) diff --git a/client/mdm/policy.go b/client/mdm/policy.go index 4b4830babaf..c57c5303e0e 100644 --- a/client/mdm/policy.go +++ b/client/mdm/policy.go @@ -119,13 +119,12 @@ func NewPolicy(values map[string]any) *Policy { return &Policy{values: values} } -// PolicyFetcher is implemented by mobile platforms (Android / iOS) that -// push the OS-managed configuration into the Go runtime instead of -// having Go read an on-disk source directly. Desktop platforms ignore -// this interface — Loader.loadPlatform on windows/darwin reads the -// registry / plist on its own. A Loader constructed with a non-nil -// fetcher delegates to it on mobile; passing nil disables MDM -// enforcement (loadPlatform returns nil values). +// PolicyFetcher supplies the managed configuration to a Loader. Mobile +// platforms (Android / iOS) implement it to push the OS-managed values +// into the Go runtime. On every platform a non-nil fetcher takes +// precedence over the native source, which is the test seam for the +// registry / plist loaders; a nil fetcher leaves the native source in +// charge, or disables MDM enforcement where there is none. type PolicyFetcher interface { Fetch() map[string]any } @@ -140,10 +139,9 @@ type Loader struct { fetcher PolicyFetcher } -// NewLoader constructs a Loader. The fetcher is consulted only on -// mobile builds (ios || android); on desktop it is unused but accepted -// to keep a single constructor signature across platforms — pass nil -// on desktop. +// NewLoader constructs a Loader. A non-nil fetcher takes precedence over +// the platform-native source; production desktop callers pass nil so the +// registry / plist stays authoritative. func NewLoader(f PolicyFetcher) *Loader { return &Loader{fetcher: f} } diff --git a/client/mdm/policy_darwin.go b/client/mdm/policy_darwin.go index 608506bdda3..4159f5b7e21 100644 --- a/client/mdm/policy_darwin.go +++ b/client/mdm/policy_darwin.go @@ -26,9 +26,8 @@ import ( const policyPlistPath = "/Library/Managed Preferences/io.netbird.client.plist" // loadPlatform reads the MDM-managed configuration from the macOS -// managed-preferences plist at policyPlistPath. The Loader's fetcher -// field is unused on this platform — the plist is the authoritative -// source. Returns: +// managed-preferences plist at policyPlistPath, unless a fetcher was +// injected, in which case its values are returned instead. Returns: // - (nil, nil) when the plist is absent (device not MDM-enrolled for // NetBird, or admin has not yet pushed a payload) // - (map, nil) with N entries when N managed values are present diff --git a/client/mdm/policy_windows.go b/client/mdm/policy_windows.go index 5a6085c0ff6..9363db4365f 100644 --- a/client/mdm/policy_windows.go +++ b/client/mdm/policy_windows.go @@ -62,9 +62,8 @@ func readRegistryValue(k registry.Key, name, canonical string, out map[string]an } // loadPlatform reads the MDM-managed configuration from the Windows -// registry under HKLM\Software\Policies\NetBird. The Loader's fetcher -// field is unused on this platform — the registry is the -// authoritative source. Returns: +// registry under HKLM\Software\Policies\NetBird, unless a fetcher was +// injected, in which case its values are returned instead. Returns: // - (nil, nil) when the key is absent (device not MDM-enrolled for NetBird) // - (map, nil) with N entries when N managed values are set (N may be 0) // - (nil, err) on open / enumerate registry errors diff --git a/client/mdm/restrictions.go b/client/mdm/restrictions.go index e46eb00ddb4..c8e4433954a 100644 --- a/client/mdm/restrictions.go +++ b/client/mdm/restrictions.go @@ -3,8 +3,8 @@ package mdm import "encoding/json" // Fields carries the per-key MDM enforcement state for a UI: value-typed -// fields hold the enforced value, boolean fields report that the key is -// managed. +// fields hold the enforced value (nil pointer = not managed), boolean +// fields report that the key is managed. type Fields struct { ManagementURL string `json:"managementURL"` PreSharedKey bool `json:"preSharedKey"` @@ -20,7 +20,7 @@ type Fields struct { DisableMetricsCollection bool `json:"disableMetricsCollection"` SplitTunnelMode bool `json:"splitTunnelMode"` SplitTunnelApps bool `json:"splitTunnelApps"` - DisableAdvancedView bool `json:"disableAdvancedView"` + DisableAdvancedView *bool `json:"disableAdvancedView"` } // Features carries the feature gates a UI must honor. @@ -64,7 +64,7 @@ func BuildRestrictions(policy *Policy) Restrictions { r.MDM.AllowServerSSH = &v } if v, ok := policy.GetBool(KeyDisableAdvancedView); ok { - r.MDM.DisableAdvancedView = v + r.MDM.DisableAdvancedView = &v } if v, ok := policy.GetBool(KeyDisableProfiles); ok { diff --git a/client/ui/services/settings.go b/client/ui/services/settings.go index 73322f63ad1..7c20184bdfd 100644 --- a/client/ui/services/settings.go +++ b/client/ui/services/settings.go @@ -364,7 +364,7 @@ func (s *Settings) GetRestrictions(ctx context.Context) (Restrictions, error) { }, } applyMDMRestrictions(&r.MDM, cfgResp) - r.MDM.DisableAdvancedView = featResp.GetDisableAdvancedView() + r.MDM.DisableAdvancedView = featResp.DisableAdvancedView return r, nil } @@ -392,9 +392,6 @@ func applyMDMRestrictions(mdm *MDMFields, cfgResp *proto.GetConfigResponse) { if v.Field(i).Kind() != reflect.Bool { continue } - if t.Field(i).Name == "DisableAdvancedView" { - continue - } if _, ok := set[t.Field(i).Tag.Get("json")]; ok { v.Field(i).SetBool(true) } From 93645126fa5b1739b5fbb33660415c9341eac3f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Mon, 7 Sep 2026 12:44:30 +0200 Subject: [PATCH 29/30] [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/internal/profilemanager/config_mdm_test.go | 8 ++++---- client/mdm/changedetector.go | 10 ++-------- client/mdm/conflicts.go | 5 +++-- client/mdm/policy_test.go | 4 ++++ client/mdm/ticker.go | 14 ++++++++++---- 5 files changed, 23 insertions(+), 18 deletions(-) diff --git a/client/internal/profilemanager/config_mdm_test.go b/client/internal/profilemanager/config_mdm_test.go index 6c862b8d54c..716b7a553c7 100644 --- a/client/internal/profilemanager/config_mdm_test.go +++ b/client/internal/profilemanager/config_mdm_test.go @@ -29,11 +29,11 @@ func loaderFor(policy *mdm.Policy) *mdm.Loader { values[k] = v continue } - if v, ok := policy.GetBool(k); ok { + if v, ok := policy.GetInt(k); ok { values[k] = v continue } - if v, ok := policy.GetInt(k); ok { + if v, ok := policy.GetBool(k); ok { values[k] = v continue } @@ -200,7 +200,7 @@ func TestApply_MDMLazyConnection(t *testing.T) { } func TestApply_MDMPreSharedKeyRedactionSentinelRejected(t *testing.T) { - const maskSentinel = "**********" + const maskSentinel = mdm.PreSharedKeyRedactedSentinel cfg := configWithMDM(t, ConfigInput{ ConfigPath: filepath.Join(t.TempDir(), "config.json"), @@ -219,7 +219,7 @@ func TestMDMConflicts_PreSharedKey(t *testing.T) { mdm.KeyPreSharedKey: "mdm-enforced-psk", }) empty := "" - sentinel := "**********" + sentinel := mdm.PreSharedKeyRedactedSentinel same := "mdm-enforced-psk" other := "user-psk" diff --git a/client/mdm/changedetector.go b/client/mdm/changedetector.go index 452b80de114..5c21ae35582 100644 --- a/client/mdm/changedetector.go +++ b/client/mdm/changedetector.go @@ -1,10 +1,6 @@ package mdm -import ( - "sync" - - log "github.com/sirupsen/logrus" -) +import "sync" // ChangeDetector tracks the last observed policy of a Loader so an // OS-notification-driven caller can ask whether the managed configuration @@ -30,11 +26,9 @@ func (d *ChangeDetector) Changed() bool { d.mu.Lock() defer d.mu.Unlock() curr := d.loader.Load() - if policiesEqual(d.prev, curr) { + if !policyChanged(d.prev, curr) { return false } - added, removed, changed := diffPolicies(d.prev, curr) - log.Infof("MDM policy changed: added=%v removed=%v changed=%v", added, removed, changed) d.prev = curr return true } diff --git a/client/mdm/conflicts.go b/client/mdm/conflicts.go index 18f2bdc89d0..a04cfb05c2c 100644 --- a/client/mdm/conflicts.go +++ b/client/mdm/conflicts.go @@ -74,7 +74,8 @@ func ConflictInt64(key string, p *int64) ConflictCheck { } // ResolveConflicts returns the names of keys whose requested value diverges -// from the policy-enforced value; keys the policy does not manage are skipped. +// from the policy-enforced value; keys the policy does not manage are skipped, +// a managed key without a Check counts as a conflict. func ResolveConflicts(policy *Policy, checks []ConflictCheck) []string { if policy.IsEmpty() { return nil @@ -84,7 +85,7 @@ func ResolveConflicts(policy *Policy, checks []ConflictCheck) []string { if !policy.HasKey(c.Key) { continue } - if !c.Check(policy) { + if c.Check == nil || !c.Check(policy) { conflicts = append(conflicts, c.Key) } } diff --git a/client/mdm/policy_test.go b/client/mdm/policy_test.go index 7d69747e93c..177fcd55005 100644 --- a/client/mdm/policy_test.go +++ b/client/mdm/policy_test.go @@ -1,6 +1,7 @@ package mdm import ( + "runtime" "testing" "github.com/stretchr/testify/assert" @@ -160,6 +161,9 @@ func TestLoader_NilFetcherReturnsEmpty(t *testing.T) { // gracefully and never return nil; on linux loadPlatform is a stub // returning (nil, nil), and Load is expected to translate that // into a non-nil empty Policy. + if runtime.GOOS == "windows" || runtime.GOOS == "darwin" { + t.Skip("a nil fetcher reads the OS-managed policy on this platform") + } p := NewLoader(nil).Load() require.NotNil(t, p) assert.True(t, p.IsEmpty()) diff --git a/client/mdm/ticker.go b/client/mdm/ticker.go index 8dc41943c5c..be8fdcce7cc 100644 --- a/client/mdm/ticker.go +++ b/client/mdm/ticker.go @@ -59,12 +59,9 @@ func (t *Ticker) Run(ctx context.Context, onChange func(prev, curr *Policy) erro return case <-tk.C: curr := t.loader.Load() - if policiesEqual(t.prev, curr) { + if !policyChanged(t.prev, curr) { continue } - added, removed, changed := diffPolicies(t.prev, curr) - log.Infof("MDM policy changed: added=%v removed=%v changed=%v", - added, removed, changed) prev := t.prev if err := onChange(prev, curr); err != nil { log.Errorf("MDM policy change handler failed (retrying in 1 minute): %v", err) @@ -127,3 +124,12 @@ func mapOf(p *Policy) map[string]any { } return out } + +func policyChanged(prev, curr *Policy) bool { + if policiesEqual(prev, curr) { + return false + } + added, removed, changed := diffPolicies(prev, curr) + log.Infof("MDM policy changed: added=%v removed=%v changed=%v", added, removed, changed) + return true +} From b65f49329a4ba2babf0793d20f4e061f06617d2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Tue, 8 Sep 2026 11:12:34 +0200 Subject: [PATCH 30/30] [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. --- client/server/setconfig_mdm_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/server/setconfig_mdm_test.go b/client/server/setconfig_mdm_test.go index 52867892ccf..a392af6d308 100644 --- a/client/server/setconfig_mdm_test.go +++ b/client/server/setconfig_mdm_test.go @@ -36,11 +36,11 @@ func withMDMPolicy(t *testing.T, s *Server, policy *mdm.Policy) { values[k] = v continue } - if v, ok := policy.GetBool(k); ok { + if v, ok := policy.GetInt(k); ok { values[k] = v continue } - if v, ok := policy.GetInt(k); ok { + if v, ok := policy.GetBool(k); ok { values[k] = v continue }