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..322c0c790a5 --- /dev/null +++ b/client/android/managed_config.go @@ -0,0 +1,195 @@ +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. +// An empty string is treated as absent (no override). +func (m *ManagedConfig) SetPreSharedKey(key string) { + if key == "" { + return + } + 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 +} + +// 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 != "" || + m.setupKey != "" || + m.adminURL != "" || + m.preSharedKey != nil || + m.rosenpassEnabled != nil || + m.rosenpassPerm != nil || + 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.hasPersistentConfig() { + return nil + } + + log.Info("Applying MDM managed configuration") + + input := profilemanager.ConfigInput{ + ConfigPath: configPath, + } + + if m.managementURL != "" { + input.ManagementURL = m.managementURL + log.Info("MDM: setting management URL") + } + + if m.adminURL != "" { + input.AdminURL = m.adminURL + log.Info("MDM: setting admin URL") + } + + if m.preSharedKey != nil { + input.PreSharedKey = m.preSharedKey + log.Info("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..ea6048bfcc6 --- /dev/null +++ b/client/android/managed_config_test.go @@ -0,0 +1,218 @@ +package android + +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_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) + } + + // Verify via structured read that the config was written correctly + 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 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) { + 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..8976a9a9035 --- /dev/null +++ b/client/ios/NetBirdSDK/managed_config.go @@ -0,0 +1,199 @@ +//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. +// An empty string is treated as absent (no override). +func (m *ManagedConfig) SetPreSharedKey(key string) { + if key == "" { + return + } + 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 +} + +// 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 != "" || + m.setupKey != "" || + m.adminURL != "" || + m.preSharedKey != nil || + m.rosenpassEnabled != nil || + m.rosenpassPerm != nil || + 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.hasPersistentConfig() { + return nil + } + + log.Info("Applying MDM managed configuration") + + input := profilemanager.ConfigInput{ + ConfigPath: configPath, + } + + if m.managementURL != "" { + input.ManagementURL = m.managementURL + log.Info("MDM: setting management URL") + } + + if m.adminURL != "" { + input.AdminURL = m.adminURL + log.Info("MDM: setting admin URL") + } + + if m.preSharedKey != nil { + input.PreSharedKey = m.preSharedKey + log.Info("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 +}