Skip to content

feat: add MDM managed app configuration support for Android and iOS#5986

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

feat: add MDM managed app configuration support for Android and iOS#5986
dbrieck wants to merge 3 commits intonetbirdio:mainfrom
dbrieck:feat/mdm-managed-config

Conversation

@dbrieck
Copy link
Copy Markdown

@dbrieck dbrieck commented Apr 24, 2026

Summary

Add MDM (Mobile Device Management) managed app configuration support to the Go SDK, enabling Android and iOS native apps to receive and apply configuration from enterprise MDM solutions (Intune, Google Workspace, Jamf, etc.).

Fixes #1918

Changes

Go SDK - Android (client/android/)

  • managed_config.go — New ManagedConfig struct with gomobile-compatible setter API for 7 MDM keys: managementUrl, setupKey, adminUrl, preSharedKey, rosenpassEnabled, rosenpassPermissive, disableAutoConnect
  • managed_config_test.go — Unit tests covering setters, Apply(), config override, key constants
  • login.go — Added LoginWithSetupKeySync() for silent setup key registration from MDM

Go SDK - iOS (client/ios/NetBirdSDK/)

  • managed_config.go — iOS variant using DirectUpdateOrCreateConfig() for tvOS sandbox compatibility
  • login.go — Added LoginWithSetupKeySync() matching Android API

How It Works

  1. Native app reads MDM config (Android: RestrictionsManager, iOS: UserDefaults managed domain)
  2. Populates ManagedConfig via setter methods (gomobile-compatible)
  3. Calls Apply(configPath) to write values to NetBird client config
  4. Optionally calls LoginWithSetupKeySync() for zero-touch enrollment

MDM values are authoritative — they override any existing user configuration on every app launch.

Companion PRs

  • android-client: netbirdio/android-client — Android Enterprise integration (app_restrictions.xml, ManagedConfigReader, EngineRunner integration)
  • ios-client: netbirdio/ios-client — Apple Managed App Configuration integration (ManagedConfigReader.swift, NetworkExtensionAdapter/PacketTunnelProvider integration)

Summary by CodeRabbit

  • New Features

    • Added synchronous login entry points for Android and iOS.
    • Added managed configuration support for Android Enterprise and iOS AppConfig, including handling for management URL, setup key, admin URL, pre-shared key, Rosenpass flags, and disable-auto-connect.
  • Tests

    • Added comprehensive tests validating managed-configuration state, persistence, and setup-key handling.

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#1918
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 24, 2026

📝 Walkthrough

Walkthrough

Adds synchronous setup-key login methods for Android and iOS and introduces ManagedConfig for both platforms to accept MDM/EMM-provided values and persist persistent fields via the profile manager (setup key treated as non-persistent).

Changes

Cohort / File(s) Summary
Android Login
client/android/login.go
Added LoginWithSetupKeySync(setupKey, deviceName) error that calls the existing internal login+save helper and returns its error.
Android Managed Config
client/android/managed_config.go, client/android/managed_config_test.go
New ManagedConfig type, exported key-name getters, setters for managed fields (management/admin URL, setup key, pre-shared key, Rosenpass flags, disable auto-connect), presence helpers, and Apply(configPath) that persists only persistent fields via profilemanager.UpdateOrCreateConfig. Tests cover persistence, non-persistence of setup key, empty-PSK handling, and constant getters.
iOS Login
client/ios/NetBirdSDK/login.go
Added LoginWithSetupKeySync(setupKey, deviceName) error that reuses the internal login+save flow and returns its error.
iOS Managed Config
client/ios/NetBirdSDK/managed_config.go
New ManagedConfig type and exported key-name getters mirroring Android; setters/getters, HasConfig/HasSetupKey, and Apply(configPath) that persists provided persistent values via profilemanager.DirectUpdateOrCreateConfig (setup key non-persistent).

Sequence Diagram(s)

sequenceDiagram
    autonumber
    participant App as ManagedConfig
    participant Logger as Logger
    participant PM as profilemanager
    participant FS as FileSystem
    Note over App,Logger: Apply(configPath)
    App->>Logger: log "Applying managed config"
    App->>PM: build ConfigInput with only set persistent fields
    PM->>FS: read existing config (if present)
    PM->>FS: write/update config file
    FS-->>PM: ack / error
    PM-->>App: return success / error
    App->>Logger: log success or wrap error
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~40 minutes

Possibly related PRs

Suggested reviewers

  • lixmal
  • pascal-fischer

Poem

"🐰 I hop through code at morning's light,
Managed configs tucked in, everything right.
Setup keys whispered, not stored away,
Android and iOS now greet the day.
Syncs and saves — a small coder's delight."

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description covers changes, linked issue, and how it works, but missing checklist items and documentation section completion as required by template. Complete the checklist (mark appropriate items), and explicitly mark the 'Documentation' section by selecting one option and providing the required docs PR URL if applicable.
Docstring Coverage ⚠️ Warning Docstring coverage is 59.26% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main change: adding MDM managed app configuration support for Android and iOS platforms.
Linked Issues check ✅ Passed Changes implement MDM managed configuration [#1918] with ManagedConfig structs, setters, Apply() methods, and LoginWithSetupKeySync() for zero-touch enrollment as required.
Out of Scope Changes check ✅ Passed All changes (managed_config, login methods, test file) directly support MDM configuration intake and application objectives from #1918, with no out-of-scope additions detected.

✏️ 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.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (2)
client/ios/NetBirdSDK/managed_config.go (1)

134-168: Minor: log.Infof without format verbs.

Several of these calls (log.Infof("MDM: setting management URL"), log.Infof("MDM: setting admin URL"), log.Infof("MDM: setting pre-shared key")) have no format arguments and should just be log.Info(...). Go vet / govet.printf will not catch this, but some linters (e.g., staticcheck SA1006 variants) will. Trivial.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/ios/NetBirdSDK/managed_config.go` around lines 134 - 168, In the
managed_config.go code that builds profilemanager.ConfigInput (the block
referencing variable m and the ConfigInput struct), replace the log.Infof calls
that have no format verbs—specifically log.Infof("MDM: setting management URL"),
log.Infof("MDM: setting admin URL"), and log.Infof("MDM: setting pre-shared
key")—with log.Info(...) calls; keep the other log.Infof usages that include
formatting (e.g., Rosenpass and disableAutoConn) unchanged so formatting verbs
match their arguments.
client/android/managed_config.go (1)

1-174: Significant duplication between Android and iOS managed_config.go.

The two files are ~95% identical — same struct, setters, HasConfig, key constants, and Apply body. The only real difference is UpdateOrCreateConfig vs DirectUpdateOrCreateConfig. Consider extracting the shared logic into an internal unexported package (e.g. client/internal/managedconfig) or a shared file with build tags, with the platform files only providing the persistence callback. This will prevent the two implementations from drifting over time (e.g. when a new MDM field is added and one side is missed).

Not a blocker — fine to defer.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/android/managed_config.go` around lines 1 - 174, The Android
managed_config.go is nearly identical to the iOS version causing duplication;
refactor by extracting shared types and logic (ManagedConfig struct, setters,
HasConfig, Get* key getters, Apply logic minus persistence call) into an
internal package or shared file (e.g., client/internal/managedconfig) and keep
platform files minimal: each platform file should only call into the shared
package and provide its specific persistence function (Android uses
profilemanager.UpdateOrCreateConfig, iOS uses DirectUpdateOrCreateConfig) — move
constants and methods like NewManagedConfig, SetManagementURL, SetSetupKey,
SetAdminURL, SetPreSharedKey, SetRosenpassEnabled, SetRosenpassPermissive,
SetDisableAutoConnect, HasSetupKey, GetSetupKey, HasConfig, and the Apply
orchestration logic into the shared package, and replace the Apply persistence
call in android.ManagedConfig.Apply to invoke the platform-specific persistence
callback supplied by the shared package.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@client/android/managed_config_test.go`:
- Around line 137-157: The test TestManagedConfig_SetupKeyNotWrittenToConfig
currently reads back with profilemanager.ReadConfig which strips unknown JSON
fields, so change the assertion to read the raw file bytes (use os.ReadFile)
after calling m.Apply(cfgFile) and assert the literal setup key string
("secret-setup-key") is not present (use bytes.Contains and fail the test if it
is); keep the existing ManagementURL check via profilemanager.ReadConfig but add
the raw-file assertion to ensure Apply() doesn't write the SetupKey.

In `@client/android/managed_config.go`:
- Around line 82-85: The SetPreSharedKey method on ManagedConfig currently
stores a pointer even for an empty string which can overwrite an existing PSK in
Apply() via input.PreSharedKey; change SetPreSharedKey to treat empty-string as
"absent" by not setting m.preSharedKey (leave nil) when key == "" and only
assign a pointer when key is non-empty, and apply the same guard in the iOS
managed_config.go counterpart so Apply() only sees a non-nil PreSharedKey when a
real PSK was provided.
- Around line 113-132: HasConfig() currently returns true when only setupKey is
set, which causes ManagedConfig.Apply to call
profilemanager.UpdateOrCreateConfig and create a default config; change the
logic so Apply only proceeds when there is at least one field that will actually
be persisted. Concretely, update ManagedConfig.HasConfig or add a short guard in
ManagedConfig.Apply to ignore setupKey (i.e., treat setupKey as non-persistent)
so Apply returns early when the only set field is setupKey; ensure this same
change is applied to the iOS counterpart (client/ios/.../managed_config.go) so
profilemanager.UpdateOrCreateConfig / createNewConfig / config.apply are not
invoked for setup-key-only MDM payloads.

---

Nitpick comments:
In `@client/android/managed_config.go`:
- Around line 1-174: The Android managed_config.go is nearly identical to the
iOS version causing duplication; refactor by extracting shared types and logic
(ManagedConfig struct, setters, HasConfig, Get* key getters, Apply logic minus
persistence call) into an internal package or shared file (e.g.,
client/internal/managedconfig) and keep platform files minimal: each platform
file should only call into the shared package and provide its specific
persistence function (Android uses profilemanager.UpdateOrCreateConfig, iOS uses
DirectUpdateOrCreateConfig) — move constants and methods like NewManagedConfig,
SetManagementURL, SetSetupKey, SetAdminURL, SetPreSharedKey,
SetRosenpassEnabled, SetRosenpassPermissive, SetDisableAutoConnect, HasSetupKey,
GetSetupKey, HasConfig, and the Apply orchestration logic into the shared
package, and replace the Apply persistence call in android.ManagedConfig.Apply
to invoke the platform-specific persistence callback supplied by the shared
package.

In `@client/ios/NetBirdSDK/managed_config.go`:
- Around line 134-168: In the managed_config.go code that builds
profilemanager.ConfigInput (the block referencing variable m and the ConfigInput
struct), replace the log.Infof calls that have no format verbs—specifically
log.Infof("MDM: setting management URL"), log.Infof("MDM: setting admin URL"),
and log.Infof("MDM: setting pre-shared key")—with log.Info(...) calls; keep the
other log.Infof usages that include formatting (e.g., Rosenpass and
disableAutoConn) unchanged so formatting verbs match their arguments.
🪄 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: d43f7d00-3514-459e-9b92-b78f93081c77

📥 Commits

Reviewing files that changed from the base of the PR and between d6f08e4 and 5d8c87a.

📒 Files selected for processing (5)
  • client/android/login.go
  • client/android/managed_config.go
  • client/android/managed_config_test.go
  • client/ios/NetBirdSDK/login.go
  • client/ios/NetBirdSDK/managed_config.go

Comment thread client/android/managed_config_test.go
Comment thread client/android/managed_config.go Outdated
Comment thread client/android/managed_config.go
@dbrieck
Copy link
Copy Markdown
Author

dbrieck commented Apr 24, 2026

Thank you, I'll review these automated code review comments.

- 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
@CLAassistant
Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
client/android/managed_config.go (1)

116-137: Optional: derive HasConfig from hasPersistentConfig to avoid duplicated field lists.

The two predicates enumerate the same six persistent fields. If a new persistent field is ever added (e.g., another *bool override), both lists must be kept in sync or Apply() and external HasConfig() callers will diverge silently.

♻️ Proposed dedup
 // 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
+	return m.setupKey != "" || m.hasPersistentConfig()
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/android/managed_config.go` around lines 116 - 137, HasConfig currently
duplicates the persistent-field checks present in hasPersistentConfig; change
ManagedConfig.HasConfig to simply return m.hasPersistentConfig() || m.setupKey
!= "" so the persistent-field list is maintained in one place (keep function
names ManagedConfig.HasConfig and ManagedConfig.hasPersistentConfig and the
setupKey field check to preserve the original behavior).
client/android/managed_config_test.go (1)

25-48: Optional: extend Apply coverage to the remaining persistent fields.

The table-driven test confirms setters flip HasConfig(), but the Apply(...) → ReadConfig(...) round-trip is only exercised for ManagementURL, PreSharedKey, and RosenpassEnabled (within TestManagedConfig_ApplyOverridesExisting). A regression that fails to wire, e.g., input.AdminURL, input.RosenpassPermissive, or input.DisableAutoConnect from ManagedConfig into ConfigInput in Apply() would not be caught today.

🧪 Sketch of a table-driven persistence test
func TestManagedConfig_ApplyPersistsAllFields(t *testing.T) {
	cfgFile := filepath.Join(t.TempDir(), "netbird.json")

	m := NewManagedConfig()
	m.SetManagementURL("https://mgmt.example.com:443")
	m.SetAdminURL("https://admin.example.com:443")
	m.SetPreSharedKey("psk")
	m.SetRosenpassEnabled(true)
	m.SetRosenpassPermissive(true)
	m.SetDisableAutoConnect(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.AdminURL.String() != "https://admin.example.com:443" {
		t.Errorf("AdminURL = %q", cfg.AdminURL.String())
	}
	if !cfg.RosenpassPermissive {
		t.Error("RosenpassPermissive not persisted")
	}
	if !cfg.DisableAutoConnect {
		t.Error("DisableAutoConnect not persisted")
	}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/android/managed_config_test.go` around lines 25 - 48, The
Apply→ReadConfig round-trip test coverage is missing for AdminURL,
RosenpassPermissive, and DisableAutoConnect, so regressions in
ManagedConfig.Apply not copying those fields to ConfigInput can go unnoticed;
add/extend a table-driven test (e.g., TestManagedConfig_ApplyPersistsAllFields)
that sets m.SetAdminURL(...), m.SetRosenpassPermissive(true), and
m.SetDisableAutoConnect(true) on a NewManagedConfig(), calls m.Apply(cfgFile),
then uses profilemanager.ReadConfig(cfgFile) and asserts cfg.AdminURL,
cfg.RosenpassPermissive, and cfg.DisableAutoConnect match the values set to
ensure Apply() correctly persists these fields from ManagedConfig into
ConfigInput.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@client/android/managed_config_test.go`:
- Around line 25-48: The Apply→ReadConfig round-trip test coverage is missing
for AdminURL, RosenpassPermissive, and DisableAutoConnect, so regressions in
ManagedConfig.Apply not copying those fields to ConfigInput can go unnoticed;
add/extend a table-driven test (e.g., TestManagedConfig_ApplyPersistsAllFields)
that sets m.SetAdminURL(...), m.SetRosenpassPermissive(true), and
m.SetDisableAutoConnect(true) on a NewManagedConfig(), calls m.Apply(cfgFile),
then uses profilemanager.ReadConfig(cfgFile) and asserts cfg.AdminURL,
cfg.RosenpassPermissive, and cfg.DisableAutoConnect match the values set to
ensure Apply() correctly persists these fields from ManagedConfig into
ConfigInput.

In `@client/android/managed_config.go`:
- Around line 116-137: HasConfig currently duplicates the persistent-field
checks present in hasPersistentConfig; change ManagedConfig.HasConfig to simply
return m.hasPersistentConfig() || m.setupKey != "" so the persistent-field list
is maintained in one place (keep function names ManagedConfig.HasConfig and
ManagedConfig.hasPersistentConfig and the setupKey field check to preserve the
original behavior).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: de4eee7c-4107-48ec-a253-15300f53a199

📥 Commits

Reviewing files that changed from the base of the PR and between 5d8c87a and f216e7a.

📒 Files selected for processing (3)
  • client/android/managed_config.go
  • client/android/managed_config_test.go
  • client/ios/NetBirdSDK/managed_config.go
✅ Files skipped from review due to trivial changes (1)
  • client/ios/NetBirdSDK/managed_config.go

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.
@sonarqubecloud
Copy link
Copy Markdown

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
client/ios/NetBirdSDK/managed_config.go (1)

54-199: Optional: consider deduplicating the iOS/Android ManagedConfig implementations.

This file and client/android/managed_config.go are structurally identical — same key constants, same struct, same setters/getters, same HasConfig / hasPersistentConfig / Apply scaffolding. The only substantive difference is DirectUpdateOrCreateConfig vs UpdateOrCreateConfig at Line 192. Any future change to the managed-config schema (e.g., adding a new MDM key) has to be made in lockstep across both files, and the test file currently only covers the Android variant.

One approach: move the shared logic into a small internal package (e.g., client/internal/managedconfig) that takes the persistence function as a dependency, and keep the two platform files as thin gomobile-facing wrappers. Non-blocking; happy to leave as-is if you prefer to keep the platform SDK surfaces fully self-contained.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/ios/NetBirdSDK/managed_config.go` around lines 54 - 199, The
Android/iOS ManagedConfig implementations are duplicated; refactor by extracting
the shared ManagedConfig type and its logic (fields, NewManagedConfig,
Set*/Get*/HasConfig/hasPersistentConfig, Apply scaffolding) into a new internal
package (e.g., client/internal/managedconfig) that accepts the platform-specific
persistence function as a dependency; keep the iOS file's wrapper to construct
the shared ManagedConfig and call it with
profilemanager.DirectUpdateOrCreateConfig (and Android will call
UpdateOrCreateConfig), so only the persistence call differs between platform
files.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@client/ios/NetBirdSDK/managed_config.go`:
- Around line 54-199: The Android/iOS ManagedConfig implementations are
duplicated; refactor by extracting the shared ManagedConfig type and its logic
(fields, NewManagedConfig, Set*/Get*/HasConfig/hasPersistentConfig, Apply
scaffolding) into a new internal package (e.g., client/internal/managedconfig)
that accepts the platform-specific persistence function as a dependency; keep
the iOS file's wrapper to construct the shared ManagedConfig and call it with
profilemanager.DirectUpdateOrCreateConfig (and Android will call
UpdateOrCreateConfig), so only the persistence call differs between platform
files.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0cd11c02-20d3-492a-aa31-20129273c2e8

📥 Commits

Reviewing files that changed from the base of the PR and between f216e7a and b517419.

📒 Files selected for processing (2)
  • client/android/managed_config.go
  • client/ios/NetBirdSDK/managed_config.go

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.

Android/iOS managed configurations

3 participants