Skip to content

feat: add MDM managed app configuration support for iOS#105

Open
dbrieck wants to merge 3 commits into
netbirdio:mainfrom
dbrieck:feat/mdm-managed-config
Open

feat: add MDM managed app configuration support for iOS#105
dbrieck wants to merge 3 commits into
netbirdio:mainfrom
dbrieck:feat/mdm-managed-config

Conversation

@dbrieck

@dbrieck dbrieck commented Apr 24, 2026

Copy link
Copy Markdown

Summary

Add Apple Managed App Configuration support, allowing MDM solutions (Jamf, Intune, Mosyle, etc.) to push NetBird configuration to managed iOS devices for zero-touch deployment.

Related to netbirdio/netbird#1918

Changes

  • NetbirdKit/ManagedConfigReader.swift — Reads MDM config from UserDefaults(suiteName: "com.apple.configuration.managed"), populates Go SDK ManagedConfig, and handles setup key auto-login
  • NetbirdKit/NetworkExtensionAdapter.swift — Calls applyManagedConfig() in start() before login check (iOS only)
  • NetbirdNetworkExtension/PacketTunnelProvider.swift — Applies MDM config in startTunnel() before VPN connection (iOS only)

Supported MDM Keys

Key Type Description
managementUrl String Management server URL
setupKey String Setup key for zero-touch enrollment
adminUrl String Admin panel URL
preSharedKey String Pre-shared key for Wireguard
rosenpassEnabled Bool Enable Rosenpass
rosenpassPermissive Bool Rosenpass permissive mode
disableAutoConnect Bool Disable auto-connect

How It Works

  1. MDM pushes managed app configuration via Apple MDM profile
  2. On VPN start (both app and Network Extension), ManagedConfigReader reads from managed UserDefaults
  3. Config is applied to NetBird client configuration (overrides user settings)
  4. If a setup key is provided, automatic enrollment is attempted

Dependencies

Summary by CodeRabbit

  • New Features
    • Added Mobile Device Management (MDM) support for iOS, enabling administrators to remotely configure NetBird settings including management server, authentication credentials, and VPN preferences.

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
@coderabbitai

coderabbitai Bot commented Apr 24, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds MDM (Mobile Device Management) configuration support to NetBird iOS. It introduces a new reader that extracts managed settings from Apple's device configuration domain, applies them to SDK configuration, optionally attempts silent setup-key registration, and integrates the application at both the main app startup and network extension tunnel startup points.

Changes

MDM Managed Configuration

Layer / File(s) Summary
MDM Configuration Reader Implementation
NetbirdKit/ManagedConfigReader.swift
Reads managed configuration from com.apple.configuration.managed, normalizes string values (whitespace trim, empty-string handling), constructs NetBirdSDKManagedConfig via SDK factory, applies to config file, and if a setup key is present, attempts silent registration via SDK auth—treating registration failures as non-fatal.
NetworkExtensionAdapter Integration
NetbirdKit/NetworkExtensionAdapter.swift
New iOS-only applyManagedConfig() method reads app config path and device name from preferences, invokes ManagedConfigReader.applyIfAvailable(), and is called in start() after config restore and before login checks.
PacketTunnelProvider Extension Integration
NetbirdNetworkExtension/PacketTunnelProvider.swift
startTunnel applies managed config at the extension level on iOS by reading extension config path, fetching device name, and conditionally calling ManagedConfigReader.applyIfAvailable() before tunnel startup.
App Activation Flow
NetBird/Source/App/NetBirdApp.swift
startActivation() calls applyManagedConfig() on the network extension adapter (iOS only) before loading connection state and updating VPN display.
Xcode Project Build Wiring
NetBird.xcodeproj/project.pbxproj
Adds ManagedConfigReader.swift via PBXBuildFile entries, PBXFileReference, NetbirdKit group membership, and target-specific PBXSourcesBuildPhase "files" lists for multi-target compilation.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • netbirdio/ios-client#85: Both PRs modify the iOS network-extension startup/login path—main PR injects ManagedConfigReader.applyIfAvailable via NetworkExtensionAdapter.applyManagedConfig()/PacketTunnelProvider.startTunnel before login, while retrieved PR changes the adapter's loginIfRequired/login-required signaling behavior in the same flow.
  • netbirdio/ios-client#48: Both PRs modify the iOS activation/start flow in NetBirdApp.swift around establishing the extension/VPN connection state—main PR injects applyManagedConfig() before connection-state loading, while retrieved PR adds NetworkExtensionAdapter.loadCurrentConnectionState() to fix the UI state—so the changes are directly intertwined in the same initialization sequence.
  • netbirdio/ios-client#125: Both PRs modify NetBird/Source/App/NetBirdApp.swift in startActivation(viewModel:), with the main PR inserting MDM managed-config application while the retrieved PR adjusts widget/VPN transition flag handling and timeline refresh behavior in that same activation flow.

Suggested reviewers

  • pascal-fischer
  • mlsmaycon

Poem

🐰 A rabbit's MDM dance so fine,
Config reads from Apple's design,
Setup keys unlock without a sigh,
Extensions start, and VPNs fly,
NetBird now bows to admin's line!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 55.56% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat: add MDM managed app configuration support for iOS' directly and clearly describes the main feature being added—MDM managed app configuration support specifically for iOS.
Description check ✅ Passed The PR description is comprehensive, including a summary, detailed changes list, supported MDM keys table, implementation overview, and relevant dependencies, exceeding the minimal template requirements.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@dbrieck dbrieck marked this pull request as ready for review April 24, 2026 13:47
@dbrieck

dbrieck commented Apr 24, 2026

Copy link
Copy Markdown
Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Apr 24, 2026

Copy link
Copy Markdown

Only users with a collaborator, contributor, member, or owner role can interact with CodeRabbit.

dbrieck-pengate and others added 2 commits April 24, 2026 15:04
Use config.getManagementURL() so NewAuth connects to the correct
MDM-specified server instead of passing an empty string which
defaults to api.netbird.io.
@dbrieck

dbrieck commented Jun 4, 2026

Copy link
Copy Markdown
Author

Update pushed in dbrieck:feat/mdm-managed-config at 3b41a30:

  • Read iOS managed AppConfig from UserDefaults.standard key com.apple.configuration.managed instead of treating it as a separate suite/domain.
  • Trim whitespace/newlines from managed string values before applying them, which handles copied Intune values like a leading-space managementUrl.
  • Apply managed config during app activation before login-required checks run.
  • Add ManagedConfigReader.swift to the app and extension source phases so tunnel startup builds cleanly.

Validation:

  • Confirmed on a managed Intune install in a downstream build that managementUrl and setupKey are delivered and read from the standard defaults key.
  • Clean PR worktree simulator build succeeds after supplying local-only generated inputs (NetBirdSDK.xcframework, GoogleService-Info.plist). Remaining warnings are existing Xcode/SDK warnings, not from this change.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
NetbirdKit/ManagedConfigReader.swift (1)

131-134: 💤 Low value

Simplify redundant hasConfig() check.

The read() method already returns nil when config.hasConfig() is false (line 121-124). The second hasConfig() call here is redundant since a non-nil return from read() guarantees hasConfig() is true.

♻️ Proposed simplification
     static func hasManagedConfig() -> Bool {
-        guard let config = read() else { return false }
-        return config.hasConfig()
+        return read() != nil
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@NetbirdKit/ManagedConfigReader.swift` around lines 131 - 134, The
hasManagedConfig() implementation should be simplified by removing the redundant
config.hasConfig() check because read() already returns nil when hasConfig() is
false; change hasManagedConfig() (which currently calls read() and then
config.hasConfig()) to simply return whether read() returns non-nil (i.e.,
replace the current guard/return with a single boolean check based on read()) so
only read() is used to determine presence of managed config.
NetbirdNetworkExtension/PacketTunnelProvider.swift (1)

72-82: ⚡ Quick win

Consider logging when MDM config application is skipped or fails.

When ManagedConfigReader.applyIfAvailable() returns false (MDM config unavailable or application failed), there's no log message. Adding a log entry for the failure/skip case would improve observability when troubleshooting MDM configuration issues in production.

📝 Proposed logging enhancement
 `#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")
+    } else {
+        AppLogger.shared.log("PacketTunnelProvider: MDM managed config not available or failed to apply")
     }
 }
 `#endif`
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@NetbirdNetworkExtension/PacketTunnelProvider.swift` around lines 72 - 82, The
current PacketTunnelProvider startup only logs when
ManagedConfigReader.applyIfAvailable(...) returns true; add logging for the
skipped/failed cases: when Preferences.configFile() is nil log that no extension
config path was found, and when applyIfAvailable returns false log that MDM
config was unavailable or failed to apply (include deviceName and
extensionConfigPath). Update the block surrounding Preferences.configFile(),
Device.getName(), and ManagedConfigReader.applyIfAvailable(...) to emit these
failure/skip logs via AppLogger.shared.log so you can observe both missing
config path and unsuccessful application attempts.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@NetbirdNetworkExtension/PacketTunnelProvider.swift`:
- Around line 72-82: The PacketTunnelProvider.startTunnel block currently calls
ManagedConfigReader.applyIfAvailable with Preferences.configFile(); change it to
use the adapter's initialized config path (adapter.initializedConfigPath) or the
startVPNTunnel options-derived configPath that was used to create the adapter so
MDM is applied to the same profile the adapter will read (locate the code around
PacketTunnelProvider.startTunnel and the Adapter/adapter initialization path).
Also add a log path when ManagedConfigReader.applyIfAvailable(...) returns false
so failures to find/apply managed config are visible (use AppLogger.shared.log
or AppLogger.shared.error in the same conditional).

---

Nitpick comments:
In `@NetbirdKit/ManagedConfigReader.swift`:
- Around line 131-134: The hasManagedConfig() implementation should be
simplified by removing the redundant config.hasConfig() check because read()
already returns nil when hasConfig() is false; change hasManagedConfig() (which
currently calls read() and then config.hasConfig()) to simply return whether
read() returns non-nil (i.e., replace the current guard/return with a single
boolean check based on read()) so only read() is used to determine presence of
managed config.

In `@NetbirdNetworkExtension/PacketTunnelProvider.swift`:
- Around line 72-82: The current PacketTunnelProvider startup only logs when
ManagedConfigReader.applyIfAvailable(...) returns true; add logging for the
skipped/failed cases: when Preferences.configFile() is nil log that no extension
config path was found, and when applyIfAvailable returns false log that MDM
config was unavailable or failed to apply (include deviceName and
extensionConfigPath). Update the block surrounding Preferences.configFile(),
Device.getName(), and ManagedConfigReader.applyIfAvailable(...) to emit these
failure/skip logs via AppLogger.shared.log so you can observe both missing
config path and unsuccessful application attempts.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: fc692422-3b36-41dc-8e81-6df79391e5c1

📥 Commits

Reviewing files that changed from the base of the PR and between ab2a0d8 and 3b41a30.

📒 Files selected for processing (5)
  • NetBird.xcodeproj/project.pbxproj
  • NetBird/Source/App/NetBirdApp.swift
  • NetbirdKit/ManagedConfigReader.swift
  • NetbirdKit/NetworkExtensionAdapter.swift
  • NetbirdNetworkExtension/PacketTunnelProvider.swift

Comment on lines +72 to +82
// 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify NetBirdAdapter exposes initializedConfigPath and check for consistent usage

# Check if NetBirdAdapter has initializedConfigPath property
echo "=== Checking NetBirdAdapter.initializedConfigPath ==="
rg -n -A2 -B2 'var initializedConfigPath' --type=swift

# Find all usages of Preferences.configFile() to check for similar pattern issues
echo -e "\n=== All usages of Preferences.configFile() ==="
rg -n -C3 'Preferences\.configFile\(\)' --type=swift

# Check if there are other places applying MDM config
echo -e "\n=== All ManagedConfigReader.applyIfAvailable call sites ==="
rg -n -C5 'ManagedConfigReader\.applyIfAvailable' --type=swift

Repository: netbirdio/ios-client

Length of output: 14124


🏁 Script executed:

#!/bin/bash
# Inspect the adapter initialization path in PacketTunnelProvider.swift
echo "=== PacketTunnelProvider.swift: startTunnel adapter init context (lines ~30-110) ==="
sed -n '1,200p' NetbirdNetworkExtension/PacketTunnelProvider.swift | nl -ba | sed -n '30,120p'

# Inspect NetBirdAdapter.swift around initializedConfigPath definition and init logic
echo -e "\n=== NetBirdAdapter.swift: initializedConfigPath + init implementation (lines ~230-360) ==="
sed -n '220,360p' NetbirdNetworkExtension/NetBirdAdapter.swift | nl -ba

# Find where adapter is created in PacketTunnelProvider.swift (search for NetBirdAdapter(...))
echo -e "\n=== PacketTunnelProvider.swift: NetBirdAdapter creation ==="
rg -n "NetBirdAdapter\\(" NetbirdNetworkExtension/PacketTunnelProvider.swift

Repository: netbirdio/ios-client

Length of output: 686


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== PacketTunnelProvider.swift: startTunnel context (around adapter creation) ==="
sed -n '1,230p' NetbirdNetworkExtension/PacketTunnelProvider.swift | cat -n | sed -n '20,220p'

echo -e "\n=== PacketTunnelProvider.swift: second adapter re-init context (around line ~160-220) ==="
sed -n '130,230p' NetbirdNetworkExtension/PacketTunnelProvider.swift | cat -n | sed -n '130,230p'

echo -e "\n=== NetBirdAdapter.swift: initializedConfigPath definition + init (around ~230-340) ==="
sed -n '220,360p' NetbirdNetworkExtension/NetBirdAdapter.swift | cat -n | sed -n '220,360p'

echo -e "\n=== NetBirdAdapter.swift: init sets configPath/statePath (search for initializedConfigPath assignment) ==="
rg -n "initializedConfigPath" NetbirdNetworkExtension/NetBirdAdapter.swift

echo -e "\n=== NetBirdAdapter.swift: init signature and how configPath is chosen ==="
sed -n '240,320p' NetbirdNetworkExtension/NetBirdAdapter.swift | cat -n

echo -e "\n=== PacketTunnelProvider.swift: current MDM block lines 60-95 ==="
sed -n '50,110p' NetbirdNetworkExtension/PacketTunnelProvider.swift | cat -n

Repository: netbirdio/ios-client

Length of output: 18715


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Find where startVPNTunnel passes configPath/statePath options (iOS app side) ==="
rg -n "startVPNTunnel\\(" -S --type=swift

echo -e "\n=== Find ManagedConfigReader implementation ==="
fd -a "ManagedConfigReader.swift" . 2>/dev/null || true
fd -a "ManagedConfigReader" . 2>/dev/null || true
rg -n "struct ManagedConfigReader|class ManagedConfigReader|ManagedConfigReader\\.applyIfAvailable" --type=swift

echo -e "\n=== Show ManagedConfigReader.applyIfAvailable implementation ==="
rg -n "applyIfAvailable\\(" --type=swift NetbirdKit NetbirdNetworkExtension . || true

Repository: netbirdio/ios-client

Length of output: 2342


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== NetbirdKit/ManagedConfigReader.swift: applyIfAvailable (around ~120-220) ==="
sed -n '1,240p' NetbirdKit/ManagedConfigReader.swift | cat -n

echo -e "\n=== PacketTunnelProvider.swift: adapter options configPath/statePath extraction (lines ~30-60) ==="
sed -n '30,90p' NetbirdNetworkExtension/PacketTunnelProvider.swift | cat -n

Repository: netbirdio/ios-client

Length of output: 12429


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== NetworkExtensionAdapter.swift: options passed to startVPNTunnel ==="
sed -n '430,560p' NetbirdKit/NetworkExtensionAdapter.swift | cat -n

echo -e "\n=== Preferences.configFile() implementation ==="
rg -n "func configFile\\(" -S --type=swift NetbirdKit NetbirdNetworkExtension Netbird/NetBird* . || true

# Show the file(s) containing configFile()
files=$(rg -l "configFile\\(" --type=swift NetbirdKit NetbirdNetworkExtension NetBird/Source . || true)
echo -e "\n=== Files mentioning Preferences.configFile() ==="
echo "$files" | head -n 50

if [ -n "${files:-}" ]; then
  for f in $(echo "$files" | head -n 10); do
    echo -e "\n--- $f (around configFile) ---"
    rg -n "configFile\\(" "$f" --type=swift
    # Print a small window around the first hit
    line=$(rg -n "configFile\\(" "$f" --type=swift | head -n1 | cut -d: -f1)
    start=$((line-20)); end=$((line+40))
    sed -n "${start},${end}p" "$f" | cat -n
  done
fi

Repository: netbirdio/ios-client

Length of output: 41085


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== NetbirdKit/Preferences.swift: configFile() implementation (around ~60-120) ==="
sed -n '50,130p' NetbirdKit/Preferences.swift | cat -n

echo -e "\n=== NetbirdKit/ProfileManager.swift: activeConfigPath()/default config path derivation (around migrateIfNeeded references) ==="
rg -n "activeConfigPath|active.*configPath|default.*configPath|migrateIfNeeded\\(" NetbirdKit/ProfileManager.swift NetbirdKit -S --type=swift
sed -n '240,380p' NetbirdKit/ProfileManager.swift | cat -n

Repository: netbirdio/ios-client

Length of output: 11984


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== NetBirdAdapter.needsLogin() implementation ==="
rg -n "func needsLogin\\(" NetbirdNetworkExtension/NetBirdAdapter.swift --type=swift
# Print around the definition (best-effort line window)
line=$(rg -n "func needsLogin\\(" NetbirdNetworkExtension/NetBirdAdapter.swift --type=swift | head -n1 | cut -d: -f1)
start=$((line-40)); end=$((line+80))
sed -n "${start},${end}p" NetbirdNetworkExtension/NetBirdAdapter.swift | cat -n

Repository: netbirdio/ios-client

Length of output: 5728


Apply MDM managed config using the adapter’s initialized config path, not Preferences.configFile()

PacketTunnelProvider.startTunnel (lines 72-82) applies MDM via Preferences.configFile() (profile-aware via ProfileManager.shared.activeConfigPath()), while the adapter is (re)created using the startVPNTunnel options configPath and stores that in adapter.initializedConfigPath. If the active profile changes between startVPNTunnel(options:) and startTunnel, MDM settings (management URL / setup key, etc.) can be written to a different profile’s config than the adapter will read.

Use adapter.initializedConfigPath (or the options-derived configPath) as the configPath passed to ManagedConfigReader.applyIfAvailable(...).

Also consider logging when applyIfAvailable(...) returns false for easier diagnosis when no managed config is present.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@NetbirdNetworkExtension/PacketTunnelProvider.swift` around lines 72 - 82, The
PacketTunnelProvider.startTunnel block currently calls
ManagedConfigReader.applyIfAvailable with Preferences.configFile(); change it to
use the adapter's initialized config path (adapter.initializedConfigPath) or the
startVPNTunnel options-derived configPath that was used to create the adapter so
MDM is applied to the same profile the adapter will read (locate the code around
PacketTunnelProvider.startTunnel and the Adapter/adapter initialization path).
Also add a log path when ManagedConfigReader.applyIfAvailable(...) returns false
so failures to find/apply managed config are visible (use AppLogger.shared.log
or AppLogger.shared.error in the same conditional).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants