From 5d8c87af41b7ac036c237989d421d8d4eab0a93d Mon Sep 17 00:00:00 2001 From: David Brieck Date: Fri, 24 Apr 2026 13:38:39 +0000 Subject: [PATCH 1/4] feat: add MDM managed app configuration support for Android and iOS Add ManagedConfig type to the Go SDK that allows native Android and iOS apps to pass MDM-provided configuration values (management URL, setup key, admin URL, pre-shared key, rosenpass settings, auto-connect) into the NetBird client configuration. - Add ManagedConfig struct with gomobile-compatible setter API - Add Apply() to write MDM values to client config via profilemanager - Add LoginWithSetupKeySync() for silent setup key registration - Add key constant getters for native code to reference MDM keys - Android variant uses UpdateOrCreateConfig() - iOS variant uses DirectUpdateOrCreateConfig() for tvOS compatibility - Include unit tests for all ManagedConfig functionality Fixes netbirdio/netbird#1918 --- client/android/login.go | 6 + client/android/managed_config.go | 174 +++++++++++++++++++++++ client/android/managed_config_test.go | 181 ++++++++++++++++++++++++ client/ios/NetBirdSDK/login.go | 6 + client/ios/NetBirdSDK/managed_config.go | 178 +++++++++++++++++++++++ 5 files changed, 545 insertions(+) create mode 100644 client/android/managed_config.go create mode 100644 client/android/managed_config_test.go create mode 100644 client/ios/NetBirdSDK/managed_config.go diff --git a/client/android/login.go b/client/android/login.go index a9422cdbfde..32304aea892 100644 --- a/client/android/login.go +++ b/client/android/login.go @@ -124,6 +124,12 @@ func (a *Auth) loginWithSetupKeyAndSaveConfig(setupKey string, deviceName string return profilemanager.WriteOutConfig(a.cfgPath, a.config) } +// LoginWithSetupKeySync performs a synchronous setup key login and saves the config. +// This is used by the MDM managed configuration flow where the native app controls threading. +func (a *Auth) LoginWithSetupKeySync(setupKey string, deviceName string) error { + return a.loginWithSetupKeyAndSaveConfig(setupKey, deviceName) +} + // Login try register the client on the server func (a *Auth) Login(resultListener ErrListener, urlOpener URLOpener, isAndroidTV bool) { go func() { diff --git a/client/android/managed_config.go b/client/android/managed_config.go new file mode 100644 index 00000000000..97a1acd1f22 --- /dev/null +++ b/client/android/managed_config.go @@ -0,0 +1,174 @@ +package android + +import ( + "fmt" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/profilemanager" +) + +// Managed configuration key names for Android Enterprise managed configurations. +// These match the keys defined in app_restrictions.xml on the Android native app side. +const ( + managedConfigKeyManagementURL = "managementUrl" + managedConfigKeySetupKey = "setupKey" + managedConfigKeyAdminURL = "adminUrl" + managedConfigKeyPreSharedKey = "preSharedKey" + managedConfigKeyRosenpassEnabled = "rosenpassEnabled" + managedConfigKeyRosenpassPerm = "rosenpassPermissive" + managedConfigKeyDisableAutoConn = "disableAutoConnect" +) + +// Key name getters for the Android Java client to reference the same key constants. + +// GetManagedConfigKeyManagementURL returns the key name for management URL +func GetManagedConfigKeyManagementURL() string { return managedConfigKeyManagementURL } + +// GetManagedConfigKeySetupKey returns the key name for setup key +func GetManagedConfigKeySetupKey() string { return managedConfigKeySetupKey } + +// GetManagedConfigKeyAdminURL returns the key name for admin URL +func GetManagedConfigKeyAdminURL() string { return managedConfigKeyAdminURL } + +// GetManagedConfigKeyPreSharedKey returns the key name for pre-shared key +func GetManagedConfigKeyPreSharedKey() string { return managedConfigKeyPreSharedKey } + +// GetManagedConfigKeyRosenpassEnabled returns the key name for Rosenpass enabled +func GetManagedConfigKeyRosenpassEnabled() string { return managedConfigKeyRosenpassEnabled } + +// GetManagedConfigKeyRosenpassPermissive returns the key name for Rosenpass permissive +func GetManagedConfigKeyRosenpassPermissive() string { return managedConfigKeyRosenpassPerm } + +// GetManagedConfigKeyDisableAutoConnect returns the key name for disable auto-connect +func GetManagedConfigKeyDisableAutoConnect() string { return managedConfigKeyDisableAutoConn } + +// ManagedConfig holds configuration values pushed by an MDM/EMM via Android Enterprise +// managed configurations (app restrictions). Values set here override user preferences +// on every app launch. +// +// The native Android app reads from RestrictionsManager and populates this struct +// via the setter methods, then calls Apply() to write the values to the config file. +type ManagedConfig struct { + managementURL string + setupKey string + adminURL string + preSharedKey *string + rosenpassEnabled *bool + rosenpassPerm *bool + disableAutoConn *bool +} + +// NewManagedConfig creates a new empty ManagedConfig +func NewManagedConfig() *ManagedConfig { + return &ManagedConfig{} +} + +// SetManagementURL sets the management server URL from MDM config +func (m *ManagedConfig) SetManagementURL(url string) { + m.managementURL = url +} + +// SetSetupKey sets the setup key for silent device registration from MDM config +func (m *ManagedConfig) SetSetupKey(key string) { + m.setupKey = key +} + +// SetAdminURL sets the admin dashboard URL from MDM config +func (m *ManagedConfig) SetAdminURL(url string) { + m.adminURL = url +} + +// SetPreSharedKey sets the WireGuard pre-shared key from MDM config +func (m *ManagedConfig) SetPreSharedKey(key string) { + m.preSharedKey = &key +} + +// SetRosenpassEnabled sets whether Rosenpass post-quantum encryption is enabled +func (m *ManagedConfig) SetRosenpassEnabled(enabled bool) { + m.rosenpassEnabled = &enabled +} + +// SetRosenpassPermissive sets whether Rosenpass permissive mode is enabled +func (m *ManagedConfig) SetRosenpassPermissive(permissive bool) { + m.rosenpassPerm = &permissive +} + +// SetDisableAutoConnect sets whether auto-connect on launch is disabled +func (m *ManagedConfig) SetDisableAutoConnect(disable bool) { + m.disableAutoConn = &disable +} + +// HasSetupKey returns true if a setup key was provided by MDM +func (m *ManagedConfig) HasSetupKey() bool { + return m.setupKey != "" +} + +// GetSetupKey returns the MDM-provided setup key +func (m *ManagedConfig) GetSetupKey() string { + return m.setupKey +} + +// HasConfig returns true if any configuration value was set by MDM +func (m *ManagedConfig) HasConfig() bool { + return m.managementURL != "" || + m.setupKey != "" || + m.adminURL != "" || + m.preSharedKey != nil || + m.rosenpassEnabled != nil || + m.rosenpassPerm != nil || + m.disableAutoConn != nil +} + +// Apply writes the MDM-managed configuration values to the config file at configPath. +// Values provided by MDM override any existing user-set values. +// The setup key is NOT written to the config file — it is used separately for registration. +func (m *ManagedConfig) Apply(configPath string) error { + if !m.HasConfig() { + return nil + } + + log.Info("Applying MDM managed configuration") + + input := profilemanager.ConfigInput{ + ConfigPath: configPath, + } + + if m.managementURL != "" { + input.ManagementURL = m.managementURL + log.Infof("MDM: setting management URL") + } + + if m.adminURL != "" { + input.AdminURL = m.adminURL + log.Infof("MDM: setting admin URL") + } + + if m.preSharedKey != nil { + input.PreSharedKey = m.preSharedKey + log.Infof("MDM: setting pre-shared key") + } + + if m.rosenpassEnabled != nil { + input.RosenpassEnabled = m.rosenpassEnabled + log.Infof("MDM: setting Rosenpass enabled=%v", *m.rosenpassEnabled) + } + + if m.rosenpassPerm != nil { + input.RosenpassPermissive = m.rosenpassPerm + log.Infof("MDM: setting Rosenpass permissive=%v", *m.rosenpassPerm) + } + + if m.disableAutoConn != nil { + input.DisableAutoConnect = m.disableAutoConn + log.Infof("MDM: setting disable auto-connect=%v", *m.disableAutoConn) + } + + _, err := profilemanager.UpdateOrCreateConfig(input) + if err != nil { + return fmt.Errorf("failed to apply MDM config: %w", err) + } + + log.Info("MDM managed configuration applied successfully") + return nil +} diff --git a/client/android/managed_config_test.go b/client/android/managed_config_test.go new file mode 100644 index 00000000000..1aade0a010e --- /dev/null +++ b/client/android/managed_config_test.go @@ -0,0 +1,181 @@ +package android + +import ( + "path/filepath" + "testing" + + "github.com/netbirdio/netbird/client/internal/profilemanager" +) + +func TestManagedConfig_NewIsEmpty(t *testing.T) { + m := NewManagedConfig() + if m.HasConfig() { + t.Error("new ManagedConfig should not have config") + } + if m.HasSetupKey() { + t.Error("new ManagedConfig should not have setup key") + } + if m.GetSetupKey() != "" { + t.Error("new ManagedConfig setup key should be empty") + } +} + +func TestManagedConfig_SettersMarkHasConfig(t *testing.T) { + tests := []struct { + name string + setter func(*ManagedConfig) + }{ + {"managementURL", func(m *ManagedConfig) { m.SetManagementURL("https://example.com") }}, + {"setupKey", func(m *ManagedConfig) { m.SetSetupKey("test-key") }}, + {"adminURL", func(m *ManagedConfig) { m.SetAdminURL("https://admin.example.com") }}, + {"preSharedKey", func(m *ManagedConfig) { m.SetPreSharedKey("psk123") }}, + {"rosenpassEnabled", func(m *ManagedConfig) { m.SetRosenpassEnabled(true) }}, + {"rosenpassPermissive", func(m *ManagedConfig) { m.SetRosenpassPermissive(true) }}, + {"disableAutoConnect", func(m *ManagedConfig) { m.SetDisableAutoConnect(true) }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := NewManagedConfig() + tt.setter(m) + if !m.HasConfig() { + t.Errorf("HasConfig() should be true after setting %s", tt.name) + } + }) + } +} + +func TestManagedConfig_SetupKey(t *testing.T) { + m := NewManagedConfig() + m.SetSetupKey("my-setup-key") + if !m.HasSetupKey() { + t.Error("HasSetupKey() should be true") + } + if m.GetSetupKey() != "my-setup-key" { + t.Errorf("GetSetupKey() = %q, want %q", m.GetSetupKey(), "my-setup-key") + } +} + +func TestManagedConfig_ApplyEmpty(t *testing.T) { + m := NewManagedConfig() + cfgFile := filepath.Join(t.TempDir(), "netbird.json") + err := m.Apply(cfgFile) + if err != nil { + t.Fatalf("Apply on empty config should not error: %v", err) + } +} + +func TestManagedConfig_ApplyManagementURL(t *testing.T) { + cfgFile := filepath.Join(t.TempDir(), "netbird.json") + + m := NewManagedConfig() + m.SetManagementURL("https://custom.mgmt.example.com:443") + err := m.Apply(cfgFile) + if err != nil { + t.Fatalf("Apply failed: %v", err) + } + + cfg, err := profilemanager.ReadConfig(cfgFile) + if err != nil { + t.Fatalf("ReadConfig failed: %v", err) + } + if cfg.ManagementURL.String() != "https://custom.mgmt.example.com:443" { + t.Errorf("ManagementURL = %q, want %q", cfg.ManagementURL.String(), "https://custom.mgmt.example.com:443") + } +} + +func TestManagedConfig_ApplyOverridesExisting(t *testing.T) { + cfgFile := filepath.Join(t.TempDir(), "netbird.json") + + // Create initial config with default URL + p := NewPreferences(cfgFile) + p.SetManagementURL("https://original.example.com:443") + if err := p.Commit(); err != nil { + t.Fatalf("initial Commit failed: %v", err) + } + + // Apply MDM config that overrides the URL + m := NewManagedConfig() + m.SetManagementURL("https://mdm-managed.example.com:443") + m.SetRosenpassEnabled(true) + err := m.Apply(cfgFile) + if err != nil { + t.Fatalf("Apply failed: %v", err) + } + + cfg, err := profilemanager.ReadConfig(cfgFile) + if err != nil { + t.Fatalf("ReadConfig failed: %v", err) + } + if cfg.ManagementURL.String() != "https://mdm-managed.example.com:443" { + t.Errorf("ManagementURL = %q, want %q", cfg.ManagementURL.String(), "https://mdm-managed.example.com:443") + } + if !cfg.RosenpassEnabled { + t.Error("RosenpassEnabled should be true after MDM apply") + } +} + +func TestManagedConfig_ApplyPreSharedKey(t *testing.T) { + cfgFile := filepath.Join(t.TempDir(), "netbird.json") + + m := NewManagedConfig() + m.SetPreSharedKey("mdm-psk-value") + err := m.Apply(cfgFile) + if err != nil { + t.Fatalf("Apply failed: %v", err) + } + + cfg, err := profilemanager.ReadConfig(cfgFile) + if err != nil { + t.Fatalf("ReadConfig failed: %v", err) + } + if cfg.PreSharedKey != "mdm-psk-value" { + t.Errorf("PreSharedKey = %q, want %q", cfg.PreSharedKey, "mdm-psk-value") + } +} + +func TestManagedConfig_SetupKeyNotWrittenToConfig(t *testing.T) { + cfgFile := filepath.Join(t.TempDir(), "netbird.json") + + m := NewManagedConfig() + m.SetSetupKey("secret-setup-key") + m.SetManagementURL("https://example.com:443") + err := m.Apply(cfgFile) + if err != nil { + t.Fatalf("Apply failed: %v", err) + } + + // The setup key should NOT be in the config file — it's only used for registration + cfg, err := profilemanager.ReadConfig(cfgFile) + if err != nil { + t.Fatalf("ReadConfig failed: %v", err) + } + // Config has no SetupKey field, so if we got here without error, the key was correctly not written + if cfg.ManagementURL.String() != "https://example.com:443" { + t.Errorf("ManagementURL = %q, want %q", cfg.ManagementURL.String(), "https://example.com:443") + } +} + +func TestManagedConfig_KeyConstants(t *testing.T) { + if GetManagedConfigKeyManagementURL() != "managementUrl" { + t.Errorf("unexpected key: %s", GetManagedConfigKeyManagementURL()) + } + if GetManagedConfigKeySetupKey() != "setupKey" { + t.Errorf("unexpected key: %s", GetManagedConfigKeySetupKey()) + } + if GetManagedConfigKeyAdminURL() != "adminUrl" { + t.Errorf("unexpected key: %s", GetManagedConfigKeyAdminURL()) + } + if GetManagedConfigKeyPreSharedKey() != "preSharedKey" { + t.Errorf("unexpected key: %s", GetManagedConfigKeyPreSharedKey()) + } + if GetManagedConfigKeyRosenpassEnabled() != "rosenpassEnabled" { + t.Errorf("unexpected key: %s", GetManagedConfigKeyRosenpassEnabled()) + } + if GetManagedConfigKeyRosenpassPermissive() != "rosenpassPermissive" { + t.Errorf("unexpected key: %s", GetManagedConfigKeyRosenpassPermissive()) + } + if GetManagedConfigKeyDisableAutoConnect() != "disableAutoConnect" { + t.Errorf("unexpected key: %s", GetManagedConfigKeyDisableAutoConnect()) + } +} diff --git a/client/ios/NetBirdSDK/login.go b/client/ios/NetBirdSDK/login.go index 9d447ef3f18..e84b33b45c9 100644 --- a/client/ios/NetBirdSDK/login.go +++ b/client/ios/NetBirdSDK/login.go @@ -141,6 +141,12 @@ func (a *Auth) loginWithSetupKeyAndSaveConfig(setupKey string, deviceName string return profilemanager.DirectWriteOutConfig(a.cfgPath, a.config) } +// LoginWithSetupKeySync performs a synchronous setup key login and saves the config. +// This is used by the MDM managed configuration flow where the native app controls threading. +func (a *Auth) LoginWithSetupKeySync(setupKey string, deviceName string) error { + return a.loginWithSetupKeyAndSaveConfig(setupKey, deviceName) +} + // LoginSync performs a synchronous login check without UI interaction // Used for background VPN connection where user should already be authenticated func (a *Auth) LoginSync() error { diff --git a/client/ios/NetBirdSDK/managed_config.go b/client/ios/NetBirdSDK/managed_config.go new file mode 100644 index 00000000000..e29a814732c --- /dev/null +++ b/client/ios/NetBirdSDK/managed_config.go @@ -0,0 +1,178 @@ +//go:build ios + +package NetBirdSDK + +import ( + "fmt" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/profilemanager" +) + +// Managed configuration key names for Apple Managed App Configuration (AppConfig). +// These match the keys expected in the com.apple.configuration.managed UserDefaults domain. +const ( + managedConfigKeyManagementURL = "managementUrl" + managedConfigKeySetupKey = "setupKey" + managedConfigKeyAdminURL = "adminUrl" + managedConfigKeyPreSharedKey = "preSharedKey" + managedConfigKeyRosenpassEnabled = "rosenpassEnabled" + managedConfigKeyRosenpassPerm = "rosenpassPermissive" + managedConfigKeyDisableAutoConn = "disableAutoConnect" +) + +// Key name getters for the Swift client to reference the same key constants. + +// GetManagedConfigKeyManagementURL returns the key name for management URL +func GetManagedConfigKeyManagementURL() string { return managedConfigKeyManagementURL } + +// GetManagedConfigKeySetupKey returns the key name for setup key +func GetManagedConfigKeySetupKey() string { return managedConfigKeySetupKey } + +// GetManagedConfigKeyAdminURL returns the key name for admin URL +func GetManagedConfigKeyAdminURL() string { return managedConfigKeyAdminURL } + +// GetManagedConfigKeyPreSharedKey returns the key name for pre-shared key +func GetManagedConfigKeyPreSharedKey() string { return managedConfigKeyPreSharedKey } + +// GetManagedConfigKeyRosenpassEnabled returns the key name for Rosenpass enabled +func GetManagedConfigKeyRosenpassEnabled() string { return managedConfigKeyRosenpassEnabled } + +// GetManagedConfigKeyRosenpassPermissive returns the key name for Rosenpass permissive +func GetManagedConfigKeyRosenpassPermissive() string { return managedConfigKeyRosenpassPerm } + +// GetManagedConfigKeyDisableAutoConnect returns the key name for disable auto-connect +func GetManagedConfigKeyDisableAutoConnect() string { return managedConfigKeyDisableAutoConn } + +// ManagedConfig holds configuration values pushed by an MDM via Apple Managed App Configuration +// (AppConfig). Values set here override user preferences on every app launch. +// +// The native iOS/tvOS app reads from UserDefaults(suiteName: "com.apple.configuration.managed") +// and populates this struct via the setter methods, then calls Apply() to write the values +// to the config file. +type ManagedConfig struct { + managementURL string + setupKey string + adminURL string + preSharedKey *string + rosenpassEnabled *bool + rosenpassPerm *bool + disableAutoConn *bool +} + +// NewManagedConfig creates a new empty ManagedConfig +func NewManagedConfig() *ManagedConfig { + return &ManagedConfig{} +} + +// SetManagementURL sets the management server URL from MDM config +func (m *ManagedConfig) SetManagementURL(url string) { + m.managementURL = url +} + +// SetSetupKey sets the setup key for silent device registration from MDM config +func (m *ManagedConfig) SetSetupKey(key string) { + m.setupKey = key +} + +// SetAdminURL sets the admin dashboard URL from MDM config +func (m *ManagedConfig) SetAdminURL(url string) { + m.adminURL = url +} + +// SetPreSharedKey sets the WireGuard pre-shared key from MDM config +func (m *ManagedConfig) SetPreSharedKey(key string) { + m.preSharedKey = &key +} + +// SetRosenpassEnabled sets whether Rosenpass post-quantum encryption is enabled +func (m *ManagedConfig) SetRosenpassEnabled(enabled bool) { + m.rosenpassEnabled = &enabled +} + +// SetRosenpassPermissive sets whether Rosenpass permissive mode is enabled +func (m *ManagedConfig) SetRosenpassPermissive(permissive bool) { + m.rosenpassPerm = &permissive +} + +// SetDisableAutoConnect sets whether auto-connect on launch is disabled +func (m *ManagedConfig) SetDisableAutoConnect(disable bool) { + m.disableAutoConn = &disable +} + +// HasSetupKey returns true if a setup key was provided by MDM +func (m *ManagedConfig) HasSetupKey() bool { + return m.setupKey != "" +} + +// GetSetupKey returns the MDM-provided setup key +func (m *ManagedConfig) GetSetupKey() string { + return m.setupKey +} + +// HasConfig returns true if any configuration value was set by MDM +func (m *ManagedConfig) HasConfig() bool { + return m.managementURL != "" || + m.setupKey != "" || + m.adminURL != "" || + m.preSharedKey != nil || + m.rosenpassEnabled != nil || + m.rosenpassPerm != nil || + m.disableAutoConn != nil +} + +// Apply writes the MDM-managed configuration values to the config file at configPath. +// Values provided by MDM override any existing user-set values. +// The setup key is NOT written to the config file — it is used separately for registration. +// Uses DirectUpdateOrCreateConfig for tvOS sandbox compatibility. +func (m *ManagedConfig) Apply(configPath string) error { + if !m.HasConfig() { + return nil + } + + log.Info("Applying MDM managed configuration") + + input := profilemanager.ConfigInput{ + ConfigPath: configPath, + } + + if m.managementURL != "" { + input.ManagementURL = m.managementURL + log.Infof("MDM: setting management URL") + } + + if m.adminURL != "" { + input.AdminURL = m.adminURL + log.Infof("MDM: setting admin URL") + } + + if m.preSharedKey != nil { + input.PreSharedKey = m.preSharedKey + log.Infof("MDM: setting pre-shared key") + } + + if m.rosenpassEnabled != nil { + input.RosenpassEnabled = m.rosenpassEnabled + log.Infof("MDM: setting Rosenpass enabled=%v", *m.rosenpassEnabled) + } + + if m.rosenpassPerm != nil { + input.RosenpassPermissive = m.rosenpassPerm + log.Infof("MDM: setting Rosenpass permissive=%v", *m.rosenpassPerm) + } + + if m.disableAutoConn != nil { + input.DisableAutoConnect = m.disableAutoConn + log.Infof("MDM: setting disable auto-connect=%v", *m.disableAutoConn) + } + + // Use DirectUpdateOrCreateConfig for tvOS sandbox compatibility + _, err := profilemanager.DirectUpdateOrCreateConfig(input) + if err != nil { + return fmt.Errorf("failed to apply MDM config: %w", err) + } + + log.Info("MDM managed configuration applied successfully") + return nil +} From f216e7acee9fae8d64b200b1b8755784ebca71ab Mon Sep 17 00:00:00 2001 From: David Brieck Date: Fri, 24 Apr 2026 14:51:44 +0000 Subject: [PATCH 2/4] fix: address CodeRabbit review feedback on MDM managed config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SetPreSharedKey: treat empty string as absent (no override) - Apply: skip config file write when only setupKey is set (not persisted) - Add hasPersistentConfig() to separate setup-key-only from persistent fields - Fix log.Infof → log.Info for calls without format verbs - Test: verify setup key absence via raw file bytes (os.ReadFile) - Add tests for empty PSK and setup-key-only Apply scenarios --- client/android/managed_config.go | 26 +++++++++++++--- client/android/managed_config_test.go | 41 +++++++++++++++++++++++-- client/ios/NetBirdSDK/managed_config.go | 26 +++++++++++++--- 3 files changed, 81 insertions(+), 12 deletions(-) diff --git a/client/android/managed_config.go b/client/android/managed_config.go index 97a1acd1f22..f853526d5e5 100644 --- a/client/android/managed_config.go +++ b/client/android/managed_config.go @@ -79,8 +79,12 @@ func (m *ManagedConfig) SetAdminURL(url string) { m.adminURL = url } -// SetPreSharedKey sets the WireGuard pre-shared key from MDM config +// SetPreSharedKey sets the WireGuard pre-shared key from MDM config. +// An empty string is treated as absent (no override). func (m *ManagedConfig) SetPreSharedKey(key string) { + if key == "" { + return + } m.preSharedKey = &key } @@ -120,11 +124,23 @@ func (m *ManagedConfig) HasConfig() bool { m.disableAutoConn != nil } +// hasPersistentConfig returns true if any config value that gets written to +// the config file was set. The setup key is excluded because it is only used +// for registration and is never persisted. +func (m *ManagedConfig) hasPersistentConfig() bool { + return m.managementURL != "" || + m.adminURL != "" || + m.preSharedKey != nil || + m.rosenpassEnabled != nil || + m.rosenpassPerm != nil || + m.disableAutoConn != nil +} + // Apply writes the MDM-managed configuration values to the config file at configPath. // Values provided by MDM override any existing user-set values. // The setup key is NOT written to the config file — it is used separately for registration. func (m *ManagedConfig) Apply(configPath string) error { - if !m.HasConfig() { + if !m.hasPersistentConfig() { return nil } @@ -136,17 +152,17 @@ func (m *ManagedConfig) Apply(configPath string) error { if m.managementURL != "" { input.ManagementURL = m.managementURL - log.Infof("MDM: setting management URL") + log.Info("MDM: setting management URL") } if m.adminURL != "" { input.AdminURL = m.adminURL - log.Infof("MDM: setting admin URL") + log.Info("MDM: setting admin URL") } if m.preSharedKey != nil { input.PreSharedKey = m.preSharedKey - log.Infof("MDM: setting pre-shared key") + log.Info("MDM: setting pre-shared key") } if m.rosenpassEnabled != nil { diff --git a/client/android/managed_config_test.go b/client/android/managed_config_test.go index 1aade0a010e..ea6048bfcc6 100644 --- a/client/android/managed_config_test.go +++ b/client/android/managed_config_test.go @@ -1,6 +1,8 @@ package android import ( + "bytes" + "os" "path/filepath" "testing" @@ -145,15 +147,50 @@ func TestManagedConfig_SetupKeyNotWrittenToConfig(t *testing.T) { t.Fatalf("Apply failed: %v", err) } - // The setup key should NOT be in the config file — it's only used for registration + // Verify via structured read that the config was written correctly cfg, err := profilemanager.ReadConfig(cfgFile) if err != nil { t.Fatalf("ReadConfig failed: %v", err) } - // Config has no SetupKey field, so if we got here without error, the key was correctly not written if cfg.ManagementURL.String() != "https://example.com:443" { t.Errorf("ManagementURL = %q, want %q", cfg.ManagementURL.String(), "https://example.com:443") } + + // Verify via raw file bytes that the setup key string is NOT in the config file + raw, err := os.ReadFile(cfgFile) + if err != nil { + t.Fatalf("ReadFile failed: %v", err) + } + if bytes.Contains(raw, []byte("secret-setup-key")) { + t.Error("setup key should not appear in the config file") + } +} + +func TestManagedConfig_EmptyPreSharedKeyIgnored(t *testing.T) { + m := NewManagedConfig() + m.SetPreSharedKey("") + if m.preSharedKey != nil { + t.Error("empty pre-shared key should not be set") + } + if m.HasConfig() { + t.Error("HasConfig() should be false when only empty PSK was set") + } +} + +func TestManagedConfig_SetupKeyOnlyDoesNotCreateConfig(t *testing.T) { + cfgFile := filepath.Join(t.TempDir(), "netbird.json") + + m := NewManagedConfig() + m.SetSetupKey("some-key") + err := m.Apply(cfgFile) + if err != nil { + t.Fatalf("Apply failed: %v", err) + } + + // Config file should NOT have been created since setup key is not persisted + if _, err := os.Stat(cfgFile); err == nil { + t.Error("config file should not be created when only setup key is set") + } } func TestManagedConfig_KeyConstants(t *testing.T) { diff --git a/client/ios/NetBirdSDK/managed_config.go b/client/ios/NetBirdSDK/managed_config.go index e29a814732c..296f6bb1010 100644 --- a/client/ios/NetBirdSDK/managed_config.go +++ b/client/ios/NetBirdSDK/managed_config.go @@ -81,8 +81,12 @@ func (m *ManagedConfig) SetAdminURL(url string) { m.adminURL = url } -// SetPreSharedKey sets the WireGuard pre-shared key from MDM config +// SetPreSharedKey sets the WireGuard pre-shared key from MDM config. +// An empty string is treated as absent (no override). func (m *ManagedConfig) SetPreSharedKey(key string) { + if key == "" { + return + } m.preSharedKey = &key } @@ -122,12 +126,24 @@ func (m *ManagedConfig) HasConfig() bool { m.disableAutoConn != nil } +// hasPersistentConfig returns true if any config value that gets written to +// the config file was set. The setup key is excluded because it is only used +// for registration and is never persisted. +func (m *ManagedConfig) hasPersistentConfig() bool { + return m.managementURL != "" || + m.adminURL != "" || + m.preSharedKey != nil || + m.rosenpassEnabled != nil || + m.rosenpassPerm != nil || + m.disableAutoConn != nil +} + // Apply writes the MDM-managed configuration values to the config file at configPath. // Values provided by MDM override any existing user-set values. // The setup key is NOT written to the config file — it is used separately for registration. // Uses DirectUpdateOrCreateConfig for tvOS sandbox compatibility. func (m *ManagedConfig) Apply(configPath string) error { - if !m.HasConfig() { + if !m.hasPersistentConfig() { return nil } @@ -139,17 +155,17 @@ func (m *ManagedConfig) Apply(configPath string) error { if m.managementURL != "" { input.ManagementURL = m.managementURL - log.Infof("MDM: setting management URL") + log.Info("MDM: setting management URL") } if m.adminURL != "" { input.AdminURL = m.adminURL - log.Infof("MDM: setting admin URL") + log.Info("MDM: setting admin URL") } if m.preSharedKey != nil { input.PreSharedKey = m.preSharedKey - log.Infof("MDM: setting pre-shared key") + log.Info("MDM: setting pre-shared key") } if m.rosenpassEnabled != nil { From b517419b1490765d1cb2dcf100ddb88511d887b0 Mon Sep 17 00:00:00 2001 From: David Brieck Date: Fri, 24 Apr 2026 15:04:30 +0000 Subject: [PATCH 3/4] fix: add GetManagementURL getter and pass mgmt URL to NewAuth Add GetManagementURL() to ManagedConfig so native apps can pass the MDM-provided management URL to NewAuth for setup key registration, instead of passing an empty string which defaults to api.netbird.io. --- client/android/managed_config.go | 5 +++++ client/ios/NetBirdSDK/managed_config.go | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/client/android/managed_config.go b/client/android/managed_config.go index f853526d5e5..322c0c790a5 100644 --- a/client/android/managed_config.go +++ b/client/android/managed_config.go @@ -113,6 +113,11 @@ func (m *ManagedConfig) GetSetupKey() string { return m.setupKey } +// GetManagementURL returns the MDM-provided management URL +func (m *ManagedConfig) GetManagementURL() string { + return m.managementURL +} + // HasConfig returns true if any configuration value was set by MDM func (m *ManagedConfig) HasConfig() bool { return m.managementURL != "" || diff --git a/client/ios/NetBirdSDK/managed_config.go b/client/ios/NetBirdSDK/managed_config.go index 296f6bb1010..8976a9a9035 100644 --- a/client/ios/NetBirdSDK/managed_config.go +++ b/client/ios/NetBirdSDK/managed_config.go @@ -115,6 +115,11 @@ func (m *ManagedConfig) GetSetupKey() string { return m.setupKey } +// GetManagementURL returns the MDM-provided management URL +func (m *ManagedConfig) GetManagementURL() string { + return m.managementURL +} + // HasConfig returns true if any configuration value was set by MDM func (m *ManagedConfig) HasConfig() bool { return m.managementURL != "" || From 5b92c098ff7e9bd2698fba8f2db4d1d8439f4399 Mon Sep 17 00:00:00 2001 From: David Brieck Date: Tue, 12 May 2026 11:32:56 -0400 Subject: [PATCH 4/4] test: add iOS managed config tests to match Android parity Mirrors the coverage in client/android/managed_config_test.go: - HasConfig / HasSetupKey / GetSetupKey / GetManagementURL behaviour - Each setter marks HasConfig as true - Apply writes management URL, PSK, Rosenpass flags to disk - Apply overrides an existing user-set value - Setup key is never written to the config file (verified via raw bytes) - Setup-key-only config does not create a config file - Empty PSK is ignored - All key constant getters return expected strings Uses //go:build ios to match the package build constraint, consistent with the existing preferences_test.go in the same package. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- client/ios/NetBirdSDK/managed_config_test.go | 223 +++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 client/ios/NetBirdSDK/managed_config_test.go diff --git a/client/ios/NetBirdSDK/managed_config_test.go b/client/ios/NetBirdSDK/managed_config_test.go new file mode 100644 index 00000000000..4709d79164d --- /dev/null +++ b/client/ios/NetBirdSDK/managed_config_test.go @@ -0,0 +1,223 @@ +//go:build ios + +package NetBirdSDK + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/netbirdio/netbird/client/internal/profilemanager" +) + +func TestManagedConfig_NewIsEmpty(t *testing.T) { + m := NewManagedConfig() + if m.HasConfig() { + t.Error("new ManagedConfig should not have config") + } + if m.HasSetupKey() { + t.Error("new ManagedConfig should not have setup key") + } + if m.GetSetupKey() != "" { + t.Error("new ManagedConfig setup key should be empty") + } +} + +func TestManagedConfig_SettersMarkHasConfig(t *testing.T) { + tests := []struct { + name string + setter func(*ManagedConfig) + }{ + {"managementURL", func(m *ManagedConfig) { m.SetManagementURL("https://example.com") }}, + {"setupKey", func(m *ManagedConfig) { m.SetSetupKey("test-key") }}, + {"adminURL", func(m *ManagedConfig) { m.SetAdminURL("https://admin.example.com") }}, + {"preSharedKey", func(m *ManagedConfig) { m.SetPreSharedKey("psk123") }}, + {"rosenpassEnabled", func(m *ManagedConfig) { m.SetRosenpassEnabled(true) }}, + {"rosenpassPermissive", func(m *ManagedConfig) { m.SetRosenpassPermissive(true) }}, + {"disableAutoConnect", func(m *ManagedConfig) { m.SetDisableAutoConnect(true) }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := NewManagedConfig() + tt.setter(m) + if !m.HasConfig() { + t.Errorf("HasConfig() should be true after setting %s", tt.name) + } + }) + } +} + +func TestManagedConfig_SetupKey(t *testing.T) { + m := NewManagedConfig() + m.SetSetupKey("my-setup-key") + if !m.HasSetupKey() { + t.Error("HasSetupKey() should be true") + } + if m.GetSetupKey() != "my-setup-key" { + t.Errorf("GetSetupKey() = %q, want %q", m.GetSetupKey(), "my-setup-key") + } +} + +func TestManagedConfig_GetManagementURL(t *testing.T) { + m := NewManagedConfig() + if m.GetManagementURL() != "" { + t.Error("GetManagementURL() should be empty on new config") + } + m.SetManagementURL("https://mgmt.example.com:443") + if m.GetManagementURL() != "https://mgmt.example.com:443" { + t.Errorf("GetManagementURL() = %q, want %q", m.GetManagementURL(), "https://mgmt.example.com:443") + } +} + +func TestManagedConfig_ApplyEmpty(t *testing.T) { + m := NewManagedConfig() + cfgFile := filepath.Join(t.TempDir(), "netbird.json") + err := m.Apply(cfgFile) + if err != nil { + t.Fatalf("Apply on empty config should not error: %v", err) + } +} + +func TestManagedConfig_ApplyManagementURL(t *testing.T) { + cfgFile := filepath.Join(t.TempDir(), "netbird.json") + + m := NewManagedConfig() + m.SetManagementURL("https://custom.mgmt.example.com:443") + if err := m.Apply(cfgFile); err != nil { + t.Fatalf("Apply failed: %v", err) + } + + cfg, err := profilemanager.ReadConfig(cfgFile) + if err != nil { + t.Fatalf("ReadConfig failed: %v", err) + } + if cfg.ManagementURL.String() != "https://custom.mgmt.example.com:443" { + t.Errorf("ManagementURL = %q, want %q", cfg.ManagementURL.String(), "https://custom.mgmt.example.com:443") + } +} + +func TestManagedConfig_ApplyOverridesExisting(t *testing.T) { + dir := t.TempDir() + cfgFile := filepath.Join(dir, "netbird.json") + stateFile := filepath.Join(dir, "state.json") + + // Create initial config with a default URL via Preferences + p := NewPreferences(cfgFile, stateFile) + p.SetManagementURL("https://original.example.com:443") + if err := p.Commit(); err != nil { + t.Fatalf("initial Commit failed: %v", err) + } + + // Apply MDM config that overrides the URL + m := NewManagedConfig() + m.SetManagementURL("https://mdm-managed.example.com:443") + m.SetRosenpassEnabled(true) + if err := m.Apply(cfgFile); err != nil { + t.Fatalf("Apply failed: %v", err) + } + + cfg, err := profilemanager.ReadConfig(cfgFile) + if err != nil { + t.Fatalf("ReadConfig failed: %v", err) + } + if cfg.ManagementURL.String() != "https://mdm-managed.example.com:443" { + t.Errorf("ManagementURL = %q, want %q", cfg.ManagementURL.String(), "https://mdm-managed.example.com:443") + } + if !cfg.RosenpassEnabled { + t.Error("RosenpassEnabled should be true after MDM apply") + } +} + +func TestManagedConfig_ApplyPreSharedKey(t *testing.T) { + cfgFile := filepath.Join(t.TempDir(), "netbird.json") + + m := NewManagedConfig() + m.SetPreSharedKey("mdm-psk-value") + if err := m.Apply(cfgFile); err != nil { + t.Fatalf("Apply failed: %v", err) + } + + cfg, err := profilemanager.ReadConfig(cfgFile) + if err != nil { + t.Fatalf("ReadConfig failed: %v", err) + } + if cfg.PreSharedKey != "mdm-psk-value" { + t.Errorf("PreSharedKey = %q, want %q", cfg.PreSharedKey, "mdm-psk-value") + } +} + +func TestManagedConfig_SetupKeyNotWrittenToConfig(t *testing.T) { + cfgFile := filepath.Join(t.TempDir(), "netbird.json") + + m := NewManagedConfig() + m.SetSetupKey("secret-setup-key") + m.SetManagementURL("https://example.com:443") + if err := m.Apply(cfgFile); err != nil { + t.Fatalf("Apply failed: %v", err) + } + + cfg, err := profilemanager.ReadConfig(cfgFile) + if err != nil { + t.Fatalf("ReadConfig failed: %v", err) + } + if cfg.ManagementURL.String() != "https://example.com:443" { + t.Errorf("ManagementURL = %q, want %q", cfg.ManagementURL.String(), "https://example.com:443") + } + + // Verify via raw bytes that the setup key is NOT persisted to disk + raw, err := os.ReadFile(cfgFile) + if err != nil { + t.Fatalf("ReadFile failed: %v", err) + } + if bytes.Contains(raw, []byte("secret-setup-key")) { + t.Error("setup key should not appear in the config file") + } +} + +func TestManagedConfig_EmptyPreSharedKeyIgnored(t *testing.T) { + m := NewManagedConfig() + m.SetPreSharedKey("") + if m.preSharedKey != nil { + t.Error("empty pre-shared key should not be set") + } + if m.HasConfig() { + t.Error("HasConfig() should be false when only empty PSK was set") + } +} + +func TestManagedConfig_SetupKeyOnlyDoesNotCreateConfig(t *testing.T) { + cfgFile := filepath.Join(t.TempDir(), "netbird.json") + + m := NewManagedConfig() + m.SetSetupKey("some-key") + if err := m.Apply(cfgFile); err != nil { + t.Fatalf("Apply failed: %v", err) + } + + // Config file should NOT be created since setup key is not persisted + if _, err := os.Stat(cfgFile); err == nil { + t.Error("config file should not be created when only setup key is set") + } +} + +func TestManagedConfig_KeyConstants(t *testing.T) { + tests := []struct { + getter func() string + want string + }{ + {GetManagedConfigKeyManagementURL, "managementUrl"}, + {GetManagedConfigKeySetupKey, "setupKey"}, + {GetManagedConfigKeyAdminURL, "adminUrl"}, + {GetManagedConfigKeyPreSharedKey, "preSharedKey"}, + {GetManagedConfigKeyRosenpassEnabled, "rosenpassEnabled"}, + {GetManagedConfigKeyRosenpassPermissive, "rosenpassPermissive"}, + {GetManagedConfigKeyDisableAutoConnect, "disableAutoConnect"}, + } + for _, tt := range tests { + if got := tt.getter(); got != tt.want { + t.Errorf("key getter returned %q, want %q", got, tt.want) + } + } +}