From 13d49e7bf941417d84915a3095aba6789db24a47 Mon Sep 17 00:00:00 2001 From: David Brieck Date: Fri, 24 Apr 2026 13:38:51 +0000 Subject: [PATCH 1/3] feat: add MDM managed app configuration support for iOS Integrate Apple Managed App Configuration to allow MDM solutions to push NetBird configuration to managed iOS devices. - Add ManagedConfigReader to read from com.apple.configuration.managed - Apply MDM config in NetworkExtensionAdapter.start() - Apply MDM config in PacketTunnelProvider.startTunnel() - Auto-login with setup key when provided by MDM Related to netbirdio/netbird#1918 --- NetbirdKit/ManagedConfigReader.swift | 149 ++++++++++++++++++ NetbirdKit/NetworkExtensionAdapter.swift | 19 +++ .../PacketTunnelProvider.swift | 12 ++ 3 files changed, 180 insertions(+) create mode 100644 NetbirdKit/ManagedConfigReader.swift diff --git a/NetbirdKit/ManagedConfigReader.swift b/NetbirdKit/ManagedConfigReader.swift new file mode 100644 index 0000000..2447f06 --- /dev/null +++ b/NetbirdKit/ManagedConfigReader.swift @@ -0,0 +1,149 @@ +// +// ManagedConfigReader.swift +// NetBird +// +// Reads MDM-managed app configuration pushed via Apple Managed App Configuration (AppConfig). +// Configuration is delivered through the com.apple.configuration.managed UserDefaults domain +// by MDM solutions such as Microsoft Intune, Jamf Pro, VMware Workspace ONE, or Mosyle. +// +// Key names match those defined in the Go SDK's ManagedConfig constants. +// + +import Foundation +import NetBirdSDK +import os + +/// Reads and applies MDM-managed app configuration from the Apple managed configuration domain. +/// +/// ## How it works +/// - MDM pushes key-value pairs to the `com.apple.configuration.managed` UserDefaults domain +/// - This reader checks that domain for NetBird-specific keys +/// - Values are applied to the Go SDK's config file, overriding user preferences +/// - Setup keys trigger silent device registration without user interaction +/// +/// ## Supported keys +/// - `managementUrl` — Management server URL +/// - `setupKey` — Setup key for silent device registration +/// - `adminUrl` — Admin dashboard URL +/// - `preSharedKey` — WireGuard pre-shared key +/// - `rosenpassEnabled` — Enable Rosenpass post-quantum encryption +/// - `rosenpassPermissive` — Allow non-Rosenpass peers +/// - `disableAutoConnect` — Prevent auto-connect on launch +class ManagedConfigReader { + + private static let logger = Logger(subsystem: "io.netbird.app", category: "ManagedConfigReader") + + /// The Apple-native MDM managed configuration domain + private static let managedDomain = "com.apple.configuration.managed" + + /// Reads managed configuration from the MDM domain. + /// Returns a populated ManagedConfig, or nil if no MDM config is available. + static func read() -> NetBirdSDKManagedConfig? { + guard let managedDefaults = UserDefaults(suiteName: managedDomain) else { + logger.debug("ManagedConfigReader: managed defaults domain not available") + return nil + } + + let dict = managedDefaults.dictionaryRepresentation() + + // Check if any NetBird keys are present + let managementUrlKey = NetBirdSDKGetManagedConfigKeyManagementURL() + let setupKeyKey = NetBirdSDKGetManagedConfigKeySetupKey() + let adminUrlKey = NetBirdSDKGetManagedConfigKeyAdminURL() + let preSharedKeyKey = NetBirdSDKGetManagedConfigKeyPreSharedKey() + let rosenpassEnabledKey = NetBirdSDKGetManagedConfigKeyRosenpassEnabled() + let rosenpassPermissiveKey = NetBirdSDKGetManagedConfigKeyRosenpassPermissive() + let disableAutoConnectKey = NetBirdSDKGetManagedConfigKeyDisableAutoConnect() + + guard let config = NetBirdSDKNewManagedConfig() else { + logger.error("ManagedConfigReader: failed to create ManagedConfig") + return nil + } + + if let managementUrl = dict[managementUrlKey] as? String, !managementUrl.isEmpty { + config.setManagementURL(managementUrl) + logger.info("ManagedConfigReader: management URL configured") + } + + if let setupKey = dict[setupKeyKey] as? String, !setupKey.isEmpty { + config.setSetupKey(setupKey) + // Do not log the setup key value for security + logger.info("ManagedConfigReader: setup key configured") + } + + if let adminUrl = dict[adminUrlKey] as? String, !adminUrl.isEmpty { + config.setAdminURL(adminUrl) + logger.info("ManagedConfigReader: admin URL configured") + } + + if let preSharedKey = dict[preSharedKeyKey] as? String, !preSharedKey.isEmpty { + config.setPreSharedKey(preSharedKey) + logger.info("ManagedConfigReader: pre-shared key configured") + } + + if let rosenpassEnabled = dict[rosenpassEnabledKey] as? Bool { + config.setRosenpassEnabled(rosenpassEnabled) + logger.info("ManagedConfigReader: Rosenpass enabled=\(rosenpassEnabled)") + } + + if let rosenpassPermissive = dict[rosenpassPermissiveKey] as? Bool { + config.setRosenpassPermissive(rosenpassPermissive) + logger.info("ManagedConfigReader: Rosenpass permissive=\(rosenpassPermissive)") + } + + if let disableAutoConnect = dict[disableAutoConnectKey] as? Bool { + config.setDisableAutoConnect(disableAutoConnect) + logger.info("ManagedConfigReader: disable auto-connect=\(disableAutoConnect)") + } + + guard config.hasConfig() else { + logger.debug("ManagedConfigReader: no NetBird keys found in managed config") + return nil + } + + logger.info("ManagedConfigReader: MDM managed configuration loaded successfully") + return config + } + + /// Returns true if any MDM-managed configuration is available. + static func hasManagedConfig() -> Bool { + guard let config = read() else { return false } + return config.hasConfig() + } + + /// Applies MDM config to the config file and optionally performs setup key registration. + /// - Parameters: + /// - configPath: Path to the NetBird config file + /// - deviceName: Device name for registration + /// - Returns: true if MDM config was applied + @discardableResult + static func applyIfAvailable(configPath: String, deviceName: String) -> Bool { + guard let config = read() else { return false } + + do { + try config.apply(configPath) + logger.info("ManagedConfigReader: MDM config applied to \(configPath)") + } catch { + logger.error("ManagedConfigReader: failed to apply MDM config: \(error.localizedDescription)") + return false + } + + // If MDM provides a setup key, attempt silent registration + if config.hasSetupKey() { + do { + guard let auth = NetBirdSDKNewAuth(configPath, "", nil) else { + logger.warning("ManagedConfigReader: failed to create Auth for setup key login") + return true + } + try auth.loginWithSetupKeySync(config.getSetupKey(), deviceName: deviceName) + logger.info("ManagedConfigReader: silent setup key registration completed") + } catch { + // Setup key login may fail if already registered or key expired. + // This is not fatal — continue with normal flow. + logger.warning("ManagedConfigReader: setup key login skipped or failed: \(error.localizedDescription)") + } + } + + return true + } +} diff --git a/NetbirdKit/NetworkExtensionAdapter.swift b/NetbirdKit/NetworkExtensionAdapter.swift index bcdce05..839ab3f 100644 --- a/NetbirdKit/NetworkExtensionAdapter.swift +++ b/NetbirdKit/NetworkExtensionAdapter.swift @@ -84,6 +84,10 @@ public class NetworkExtensionAdapter: ObservableObject { // This must happen in the main app — not via IPC — because the extension // process may not be running yet when start() is called. restoreConfigIfMissing() + + // Apply MDM managed configuration before login. + // MDM values override user-set preferences on every launch. + applyManagedConfig() #endif await loginIfRequired() logger.info("start: loginIfRequired() completed") @@ -93,6 +97,21 @@ public class NetworkExtensionAdapter: ObservableObject { logger.info("start: EXIT") } + #if os(iOS) + /// Reads and applies MDM-managed app configuration if available. + /// MDM config is delivered via the com.apple.configuration.managed UserDefaults domain. + private func applyManagedConfig() { + guard let configPath = Preferences.configFile() else { + logger.warning("applyManagedConfig: config path unavailable") + return + } + let deviceName = Device.getName() + if ManagedConfigReader.applyIfAvailable(configPath: configPath, deviceName: deviceName) { + logger.info("applyManagedConfig: MDM config applied successfully") + } + } + #endif + #if os(iOS) /// If the active profile's config file is missing (deleted after logout) but we have /// a saved management URL, write a minimal config so the SDK uses the correct server diff --git a/NetbirdNetworkExtension/PacketTunnelProvider.swift b/NetbirdNetworkExtension/PacketTunnelProvider.swift index 380ee20..e357eec 100644 --- a/NetbirdNetworkExtension/PacketTunnelProvider.swift +++ b/NetbirdNetworkExtension/PacketTunnelProvider.swift @@ -69,6 +69,18 @@ class PacketTunnelProvider: NEPacketTunnelProvider { return } + // Apply MDM managed configuration at the extension level. + // This ensures MDM values are applied even when the extension starts + // independently (e.g., via On Demand or Always-on VPN). + #if os(iOS) + if let extensionConfigPath = Preferences.configFile() { + let deviceName = Device.getName() + if ManagedConfigReader.applyIfAvailable(configPath: extensionConfigPath, deviceName: deviceName) { + AppLogger.shared.log("PacketTunnelProvider: MDM managed config applied") + } + } + #endif + if adapter.needsLogin() { signalLoginRequired() // Return the error immediately so iOS tears down the tunnel interface at once. From 5e6a59725ca7e70169f6efc13f00d6e2a5a4bf86 Mon Sep 17 00:00:00 2001 From: David Brieck Date: Fri, 24 Apr 2026 15:04:44 +0000 Subject: [PATCH 2/3] fix: pass MDM management URL to NewAuth for setup key login Use config.getManagementURL() so NewAuth connects to the correct MDM-specified server instead of passing an empty string which defaults to api.netbird.io. --- NetbirdKit/ManagedConfigReader.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/NetbirdKit/ManagedConfigReader.swift b/NetbirdKit/ManagedConfigReader.swift index 2447f06..c2632a3 100644 --- a/NetbirdKit/ManagedConfigReader.swift +++ b/NetbirdKit/ManagedConfigReader.swift @@ -128,10 +128,12 @@ class ManagedConfigReader { return false } - // If MDM provides a setup key, attempt silent registration + // If MDM provides a setup key, attempt silent registration. + // Pass the MDM management URL so NewAuth connects to the correct server. if config.hasSetupKey() { + let mgmtUrl = config.getManagementURL() ?? "" do { - guard let auth = NetBirdSDKNewAuth(configPath, "", nil) else { + guard let auth = NetBirdSDKNewAuth(configPath, mgmtUrl, nil) else { logger.warning("ManagedConfigReader: failed to create Auth for setup key login") return true } From 3b41a30b9e19c01067182b80b2447fe6e9750360 Mon Sep 17 00:00:00 2001 From: David Brieck Date: Thu, 4 Jun 2026 16:07:59 -0400 Subject: [PATCH 3/3] fix: read iOS managed app config from standard defaults --- NetBird.xcodeproj/project.pbxproj | 10 +++++ NetBird/Source/App/NetBirdApp.swift | 4 ++ NetbirdKit/ManagedConfigReader.swift | 52 +++++++++++++++++------- NetbirdKit/NetworkExtensionAdapter.swift | 4 +- 4 files changed, 53 insertions(+), 17 deletions(-) diff --git a/NetBird.xcodeproj/project.pbxproj b/NetBird.xcodeproj/project.pbxproj index c7732a2..a409b4f 100644 --- a/NetBird.xcodeproj/project.pbxproj +++ b/NetBird.xcodeproj/project.pbxproj @@ -162,6 +162,10 @@ A1C3D5EE2F000008001A2B3C /* CellularOnDemandPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C3D5E82F000002001A2B3C /* CellularOnDemandPolicy.swift */; }; A1C3D5EF2F000009001A2B3C /* CellularOnDemandPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C3D5E82F000002001A2B3C /* CellularOnDemandPolicy.swift */; }; A1C3D5F02F00000A001A2B3C /* CellularOnDemandPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C3D5E82F000002001A2B3C /* CellularOnDemandPolicy.swift */; }; + BB1100012F30000100000001 /* ManagedConfigReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB1100002F30000100000001 /* ManagedConfigReader.swift */; }; + BB1100022F30000100000001 /* ManagedConfigReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB1100002F30000100000001 /* ManagedConfigReader.swift */; }; + BB1100032F30000100000001 /* ManagedConfigReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB1100002F30000100000001 /* ManagedConfigReader.swift */; }; + BB1100042F30000100000001 /* ManagedConfigReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB1100002F30000100000001 /* ManagedConfigReader.swift */; }; AA0001012F22000100000001 /* ProfileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0001002F22000100000001 /* ProfileManager.swift */; }; AA0001022F22000100000001 /* ProfileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0001002F22000100000001 /* ProfileManager.swift */; }; AA0001032F22000100000001 /* ProfileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0001002F22000100000001 /* ProfileManager.swift */; }; @@ -359,6 +363,7 @@ AA0009002F22000900000001 /* ProfileConnectionCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileConnectionCache.swift; sourceTree = ""; }; AA1B2C012F4E5A0100D1E2F3 /* TVGradientBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVGradientBackground.swift; sourceTree = ""; }; B1A2C3D32F3A000100000001 /* PeerDetailSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerDetailSheet.swift; sourceTree = ""; }; + BB1100002F30000100000001 /* ManagedConfigReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedConfigReader.swift; sourceTree = ""; }; BB3D4E012F4E5A0200D1E2F3 /* TVPreSharedKeyButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVPreSharedKeyButton.swift; sourceTree = ""; }; C7A1CFF65CC44187912007EC /* iOSNetworksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSNetworksView.swift; sourceTree = ""; }; CC5F6A012F4E5A0300D1E2F3 /* TVQRCodeSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVQRCodeSheet.swift; sourceTree = ""; }; @@ -601,6 +606,7 @@ AA0001002F22000100000001 /* ProfileManager.swift */, AA0009002F22000900000001 /* ProfileConnectionCache.swift */, A1B2C3D42EEDF500001A2B3C /* ConfigurationProvider.swift */, + BB1100002F30000100000001 /* ManagedConfigReader.swift */, 50245A2D2A7BDC470034792B /* NetworkChangeListener.swift */, 50CD81612AD0595E00CF830B /* DNSManager.swift */, 50E608022A7950CB00BAF09B /* Device.swift */, @@ -1058,6 +1064,7 @@ BB3D4E022F4E5A0200D1E2F3 /* TVPreSharedKeyButton.swift in Sources */, CC5F6A022F4E5A0300D1E2F3 /* TVQRCodeSheet.swift in Sources */, 978FC4732EEDF167002D0EB8 /* AppLogger.swift in Sources */, + BB1100012F30000100000001 /* ManagedConfigReader.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1084,6 +1091,7 @@ 44F3E3942EE2151100C87FEC /* ConnectionListener.swift in Sources */, 44F3E38C2EE214E300C87FEC /* NetBirdAdapter.swift in Sources */, 978FC4722EEDF167002D0EB8 /* AppLogger.swift in Sources */, + BB1100022F30000100000001 /* ManagedConfigReader.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1111,6 +1119,7 @@ 505118CF2AD96ECA003027D3 /* x25519.c in Sources */, F1B292082EE0AC2A001D91B8 /* EnvVarPackager.swift in Sources */, 978FC4712EEDF167002D0EB8 /* AppLogger.swift in Sources */, + BB1100032F30000100000001 /* ManagedConfigReader.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1152,6 +1161,7 @@ AA0001042F22000100000001 /* ProfileManager.swift in Sources */, AA0009042F22000900000001 /* ProfileConnectionCache.swift in Sources */, A1B2C3D62EEDF502001A2B3C /* ConfigurationProvider.swift in Sources */, + BB1100042F30000100000001 /* ManagedConfigReader.swift in Sources */, 50CD81B02AD5B94D00CF830B /* PeerCard.swift in Sources */, B1A2C3D42F3A000100000001 /* PeerDetailSheet.swift in Sources */, 50003BCE2AFD405600E5EB6B /* ConnectionListener.swift in Sources */, diff --git a/NetBird/Source/App/NetBirdApp.swift b/NetBird/Source/App/NetBirdApp.swift index 54cf426..0f05390 100644 --- a/NetBird/Source/App/NetBirdApp.swift +++ b/NetBird/Source/App/NetBirdApp.swift @@ -109,6 +109,10 @@ struct NetBirdApp: App { activationTask = Task { @MainActor in guard isAppActive, !Task.isCancelled else { return } + #if os(iOS) + viewModel.networkExtensionAdapter.applyManagedConfig() + #endif + if let initialStatus = await viewModel.networkExtensionAdapter.loadCurrentConnectionState() { viewModel.extensionState = initialStatus viewModel.updateVPNDisplayState() diff --git a/NetbirdKit/ManagedConfigReader.swift b/NetbirdKit/ManagedConfigReader.swift index c2632a3..f10a859 100644 --- a/NetbirdKit/ManagedConfigReader.swift +++ b/NetbirdKit/ManagedConfigReader.swift @@ -3,7 +3,7 @@ // NetBird // // Reads MDM-managed app configuration pushed via Apple Managed App Configuration (AppConfig). -// Configuration is delivered through the com.apple.configuration.managed UserDefaults domain +// Configuration is delivered through the com.apple.configuration.managed UserDefaults key // by MDM solutions such as Microsoft Intune, Jamf Pro, VMware Workspace ONE, or Mosyle. // // Key names match those defined in the Go SDK's ManagedConfig constants. @@ -16,8 +16,8 @@ import os /// Reads and applies MDM-managed app configuration from the Apple managed configuration domain. /// /// ## How it works -/// - MDM pushes key-value pairs to the `com.apple.configuration.managed` UserDefaults domain -/// - This reader checks that domain for NetBird-specific keys +/// - MDM pushes key-value pairs to the `com.apple.configuration.managed` UserDefaults key +/// - This reader checks that dictionary for NetBird-specific keys /// - Values are applied to the Go SDK's config file, overriding user preferences /// - Setup keys trigger silent device registration without user interaction /// @@ -33,19 +33,41 @@ class ManagedConfigReader { private static let logger = Logger(subsystem: "io.netbird.app", category: "ManagedConfigReader") - /// The Apple-native MDM managed configuration domain - private static let managedDomain = "com.apple.configuration.managed" + /// The Apple-native MDM managed configuration key in UserDefaults.standard. + private static let managedConfigurationKey = "com.apple.configuration.managed" + + private static func managedConfigurationDictionary() -> [String: Any]? { + if let dict = UserDefaults.standard.dictionary(forKey: managedConfigurationKey) { + return dict + } + + if let value = UserDefaults.standard.object(forKey: managedConfigurationKey) { + logger.warning("ManagedConfigReader: managed configuration value has unsupported root type: \(String(describing: type(of: value)), privacy: .public)") + } else { + logger.debug("ManagedConfigReader: managed configuration dictionary not available") + } + + return nil + } + + private static func managedStringValue(from dict: [String: Any], key: String) -> String? { + guard let rawValue = dict[key] as? String else { return nil } + let trimmedValue = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + + if rawValue != trimmedValue { + logger.info("ManagedConfigReader: trimmed whitespace from MDM string value for key \(key, privacy: .public)") + } + + return trimmedValue.isEmpty ? nil : trimmedValue + } /// Reads managed configuration from the MDM domain. /// Returns a populated ManagedConfig, or nil if no MDM config is available. static func read() -> NetBirdSDKManagedConfig? { - guard let managedDefaults = UserDefaults(suiteName: managedDomain) else { - logger.debug("ManagedConfigReader: managed defaults domain not available") + guard let dict = managedConfigurationDictionary() else { return nil } - let dict = managedDefaults.dictionaryRepresentation() - // Check if any NetBird keys are present let managementUrlKey = NetBirdSDKGetManagedConfigKeyManagementURL() let setupKeyKey = NetBirdSDKGetManagedConfigKeySetupKey() @@ -60,23 +82,23 @@ class ManagedConfigReader { return nil } - if let managementUrl = dict[managementUrlKey] as? String, !managementUrl.isEmpty { + if let managementUrl = managedStringValue(from: dict, key: managementUrlKey) { config.setManagementURL(managementUrl) logger.info("ManagedConfigReader: management URL configured") } - if let setupKey = dict[setupKeyKey] as? String, !setupKey.isEmpty { + if let setupKey = managedStringValue(from: dict, key: setupKeyKey) { config.setSetupKey(setupKey) // Do not log the setup key value for security logger.info("ManagedConfigReader: setup key configured") } - if let adminUrl = dict[adminUrlKey] as? String, !adminUrl.isEmpty { + if let adminUrl = managedStringValue(from: dict, key: adminUrlKey) { config.setAdminURL(adminUrl) logger.info("ManagedConfigReader: admin URL configured") } - if let preSharedKey = dict[preSharedKeyKey] as? String, !preSharedKey.isEmpty { + if let preSharedKey = managedStringValue(from: dict, key: preSharedKeyKey) { config.setPreSharedKey(preSharedKey) logger.info("ManagedConfigReader: pre-shared key configured") } @@ -131,13 +153,13 @@ class ManagedConfigReader { // If MDM provides a setup key, attempt silent registration. // Pass the MDM management URL so NewAuth connects to the correct server. if config.hasSetupKey() { - let mgmtUrl = config.getManagementURL() ?? "" + let mgmtUrl = config.getManagementURL() do { guard let auth = NetBirdSDKNewAuth(configPath, mgmtUrl, nil) else { logger.warning("ManagedConfigReader: failed to create Auth for setup key login") return true } - try auth.loginWithSetupKeySync(config.getSetupKey(), deviceName: deviceName) + try auth.login(withSetupKeySync: config.getSetupKey(), deviceName: deviceName) logger.info("ManagedConfigReader: silent setup key registration completed") } catch { // Setup key login may fail if already registered or key expired. diff --git a/NetbirdKit/NetworkExtensionAdapter.swift b/NetbirdKit/NetworkExtensionAdapter.swift index 839ab3f..9a13081 100644 --- a/NetbirdKit/NetworkExtensionAdapter.swift +++ b/NetbirdKit/NetworkExtensionAdapter.swift @@ -99,8 +99,8 @@ public class NetworkExtensionAdapter: ObservableObject { #if os(iOS) /// Reads and applies MDM-managed app configuration if available. - /// MDM config is delivered via the com.apple.configuration.managed UserDefaults domain. - private func applyManagedConfig() { + /// MDM config is delivered via the com.apple.configuration.managed UserDefaults key. + public func applyManagedConfig() { guard let configPath = Preferences.configFile() else { logger.warning("applyManagedConfig: config path unavailable") return