From 045d922c8de454ca5ee98bcc9c24725b96d54047 Mon Sep 17 00:00:00 2001
From: David Brieck
Date: Fri, 24 Apr 2026 13:38:47 +0000
Subject: [PATCH 1/3] feat: add MDM managed app configuration support
Integrate Android Enterprise managed configurations to allow MDM
solutions (Intune, Google Workspace, etc.) to push NetBird configuration
to managed devices.
- Add app_restrictions.xml schema defining 7 MDM configuration keys
- Add ManagedConfigReader to read RestrictionsManager values
- Add ManagedConfigReceiver for runtime MDM config change events
- Register restrictions metadata and receiver in AndroidManifest.xml
- Apply MDM config and auto-login with setup key in EngineRunner
Related to netbirdio/netbird#1918
---
app/src/main/AndroidManifest.xml | 12 ++
app/src/main/res/xml/app_restrictions.xml | 61 +++++++++
.../io/netbird/client/tool/EngineRunner.java | 29 +++++
.../client/tool/ManagedConfigReader.java | 120 ++++++++++++++++++
.../client/tool/ManagedConfigReceiver.java | 48 +++++++
5 files changed, 270 insertions(+)
create mode 100644 app/src/main/res/xml/app_restrictions.xml
create mode 100644 tool/src/main/java/io/netbird/client/tool/ManagedConfigReader.java
create mode 100644 tool/src/main/java/io/netbird/client/tool/ManagedConfigReceiver.java
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 897d5fdf..c2a38614 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -31,6 +31,10 @@
android:theme="@style/Theme.NetBird"
tools:targetApi="31">
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/app_restrictions.xml b/app/src/main/res/xml/app_restrictions.xml
new file mode 100644
index 00000000..cf589a18
--- /dev/null
+++ b/app/src/main/res/xml/app_restrictions.xml
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tool/src/main/java/io/netbird/client/tool/EngineRunner.java b/tool/src/main/java/io/netbird/client/tool/EngineRunner.java
index dfdbf846..199ebb57 100644
--- a/tool/src/main/java/io/netbird/client/tool/EngineRunner.java
+++ b/tool/src/main/java/io/netbird/client/tool/EngineRunner.java
@@ -94,6 +94,35 @@ private synchronized void runClient(@Nullable URLOpener urlOpener, boolean isAnd
var platformFiles = new AndroidPlatformFiles(configurationFilePath, stateFilePath, context.getCacheDir().getAbsolutePath());
Log.d(LOGTAG, "Running engine with config: " + configurationFilePath + ", state: " + stateFilePath);
+ // Apply MDM managed configuration before starting the engine.
+ // MDM values override user-set preferences on every launch.
+ try {
+ io.netbird.gomobile.android.ManagedConfig mdmConfig = ManagedConfigReader.read(context);
+ if (mdmConfig != null && mdmConfig.hasConfig()) {
+ mdmConfig.apply(configurationFilePath);
+ Log.i(LOGTAG, "MDM managed configuration applied");
+
+ // If MDM provides a setup key and the engine needs login,
+ // perform silent registration with the setup key
+ if (mdmConfig.hasSetupKey()) {
+ try {
+ io.netbird.gomobile.android.Auth auth =
+ io.netbird.gomobile.android.Android.newAuth(configurationFilePath, "");
+ if (auth != null) {
+ auth.loginWithSetupKeySync(mdmConfig.getSetupKey(), DeviceName.getDeviceName());
+ Log.i(LOGTAG, "MDM: silent setup key registration completed");
+ }
+ } catch (Exception e) {
+ // Setup key login may fail if already registered or key expired.
+ // This is not fatal — continue with normal flow.
+ Log.w(LOGTAG, "MDM: setup key login skipped or failed: " + e.getMessage());
+ }
+ }
+ }
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Failed to apply MDM config, continuing with existing config", e);
+ }
+
try {
notifyServiceStateListeners(true);
if (urlOpener == null) {
diff --git a/tool/src/main/java/io/netbird/client/tool/ManagedConfigReader.java b/tool/src/main/java/io/netbird/client/tool/ManagedConfigReader.java
new file mode 100644
index 00000000..044efd3a
--- /dev/null
+++ b/tool/src/main/java/io/netbird/client/tool/ManagedConfigReader.java
@@ -0,0 +1,120 @@
+package io.netbird.client.tool;
+
+import android.content.Context;
+import android.content.RestrictionsManager;
+import android.os.Bundle;
+import android.util.Log;
+
+import io.netbird.gomobile.android.Android;
+import io.netbird.gomobile.android.ManagedConfig;
+
+/**
+ * Reads MDM-managed configuration from Android Enterprise managed configurations
+ * (app restrictions). Configuration is pushed by EMM/MDM solutions such as
+ * Microsoft Intune, VMware Workspace ONE, or Google Admin Console.
+ *
+ * The key names match those defined in res/xml/app_restrictions.xml and the
+ * Go SDK's ManagedConfig key constants.
+ */
+public class ManagedConfigReader {
+
+ private static final String TAG = "ManagedConfigReader";
+
+ private ManagedConfigReader() {
+ // utility class
+ }
+
+ /**
+ * Reads managed configuration from RestrictionsManager and returns a populated
+ * ManagedConfig instance. Returns null if no managed configuration is available
+ * or the RestrictionsManager service is unavailable.
+ *
+ * @param context Android context
+ * @return populated ManagedConfig, or null if no MDM config is present
+ */
+ public static ManagedConfig read(Context context) {
+ RestrictionsManager restrictionsManager =
+ (RestrictionsManager) context.getSystemService(Context.RESTRICTIONS_SERVICE);
+ if (restrictionsManager == null) {
+ Log.d(TAG, "RestrictionsManager not available");
+ return null;
+ }
+
+ Bundle restrictions = restrictionsManager.getApplicationRestrictions();
+ if (restrictions == null || restrictions.isEmpty()) {
+ Log.d(TAG, "No managed configuration found");
+ return null;
+ }
+
+ ManagedConfig config = Android.newManagedConfig();
+
+ String managementUrl = restrictions.getString(
+ Android.getManagedConfigKeyManagementURL(), "");
+ if (!managementUrl.isEmpty()) {
+ config.setManagementURL(managementUrl);
+ Log.i(TAG, "MDM: management URL configured");
+ }
+
+ String setupKey = restrictions.getString(
+ Android.getManagedConfigKeySetupKey(), "");
+ if (!setupKey.isEmpty()) {
+ config.setSetupKey(setupKey);
+ // Do not log the setup key value for security
+ Log.i(TAG, "MDM: setup key configured");
+ }
+
+ String adminUrl = restrictions.getString(
+ Android.getManagedConfigKeyAdminURL(), "");
+ if (!adminUrl.isEmpty()) {
+ config.setAdminURL(adminUrl);
+ Log.i(TAG, "MDM: admin URL configured");
+ }
+
+ String preSharedKey = restrictions.getString(
+ Android.getManagedConfigKeyPreSharedKey(), "");
+ if (!preSharedKey.isEmpty()) {
+ config.setPreSharedKey(preSharedKey);
+ Log.i(TAG, "MDM: pre-shared key configured");
+ }
+
+ if (restrictions.containsKey(Android.getManagedConfigKeyRosenpassEnabled())) {
+ boolean rosenpassEnabled = restrictions.getBoolean(
+ Android.getManagedConfigKeyRosenpassEnabled(), false);
+ config.setRosenpassEnabled(rosenpassEnabled);
+ Log.i(TAG, "MDM: Rosenpass enabled=" + rosenpassEnabled);
+ }
+
+ if (restrictions.containsKey(Android.getManagedConfigKeyRosenpassPermissive())) {
+ boolean rosenpassPermissive = restrictions.getBoolean(
+ Android.getManagedConfigKeyRosenpassPermissive(), false);
+ config.setRosenpassPermissive(rosenpassPermissive);
+ Log.i(TAG, "MDM: Rosenpass permissive=" + rosenpassPermissive);
+ }
+
+ if (restrictions.containsKey(Android.getManagedConfigKeyDisableAutoConnect())) {
+ boolean disableAutoConnect = restrictions.getBoolean(
+ Android.getManagedConfigKeyDisableAutoConnect(), false);
+ config.setDisableAutoConnect(disableAutoConnect);
+ Log.i(TAG, "MDM: disable auto-connect=" + disableAutoConnect);
+ }
+
+ if (!config.hasConfig()) {
+ Log.d(TAG, "MDM restrictions present but no NetBird keys configured");
+ return null;
+ }
+
+ Log.i(TAG, "MDM managed configuration loaded successfully");
+ return config;
+ }
+
+ /**
+ * Returns true if any MDM-managed configuration is available for this app.
+ *
+ * @param context Android context
+ * @return true if managed config has values
+ */
+ public static boolean hasManagedConfig(Context context) {
+ ManagedConfig config = read(context);
+ return config != null && config.hasConfig();
+ }
+}
diff --git a/tool/src/main/java/io/netbird/client/tool/ManagedConfigReceiver.java b/tool/src/main/java/io/netbird/client/tool/ManagedConfigReceiver.java
new file mode 100644
index 00000000..aa63807c
--- /dev/null
+++ b/tool/src/main/java/io/netbird/client/tool/ManagedConfigReceiver.java
@@ -0,0 +1,48 @@
+package io.netbird.client.tool;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.util.Log;
+
+import io.netbird.gomobile.android.ManagedConfig;
+
+/**
+ * Receives {@code Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED} broadcasts
+ * when the MDM/EMM pushes updated managed configuration to the device.
+ *
+ * This receiver re-reads the managed configuration and applies it to the
+ * Go SDK config file. If the VPN engine is running and the management URL changed,
+ * the engine should be restarted (handled by the EngineRunner via its existing
+ * restart mechanism).
+ *
+ * Register this receiver in AndroidManifest.xml or dynamically in the VPNService.
+ */
+public class ManagedConfigReceiver extends BroadcastReceiver {
+
+ private static final String TAG = "ManagedConfigReceiver";
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (!Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED.equals(intent.getAction())) {
+ return;
+ }
+
+ Log.i(TAG, "Application restrictions changed, re-reading MDM config");
+
+ ManagedConfig config = ManagedConfigReader.read(context);
+ if (config == null || !config.hasConfig()) {
+ Log.d(TAG, "No MDM config after restrictions change");
+ return;
+ }
+
+ try {
+ ProfileManagerWrapper profileManager = new ProfileManagerWrapper(context);
+ String configPath = profileManager.getActiveConfigPath();
+ config.apply(configPath);
+ Log.i(TAG, "MDM config re-applied after restrictions change");
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to apply MDM config after restrictions change", e);
+ }
+ }
+}
From 6ec507373301365136756cb9dcf13684fec5466f Mon Sep 17 00:00:00 2001
From: David Brieck
Date: Fri, 24 Apr 2026 14:51:55 +0000
Subject: [PATCH 2/3] fix: address CodeRabbit review feedback on MDM managed
config
- EngineRunner: add MDM_CONFIG_LOCK to serialize config apply operations
- EngineRunner: pass management URL (not empty string) to newAuth
- ManagedConfigReceiver: use goAsync() + executor to avoid main-thread ANR
- ManagedConfigReceiver: use shared MDM_CONFIG_LOCK for concurrency safety
- app_restrictions.xml: use @string/ resource references for localization
- strings.xml: add MDM restriction title/description string resources
---
app/src/main/res/values/strings.xml | 16 ++++++
app/src/main/res/xml/app_restrictions.xml | 28 +++++------
.../io/netbird/client/tool/EngineRunner.java | 50 ++++++++++++-------
.../client/tool/ManagedConfigReceiver.java | 44 +++++++++-------
4 files changed, 89 insertions(+), 49 deletions(-)
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index d6b1f51b..ec73533f 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -145,4 +145,20 @@
Switched to profile \'%s\'
Logged out from profile \'%s\'
Profile \'%s\' removed successfully
+
+
+ Management URL
+ NetBird management server URL (e.g., https://api.netbird.io:443)
+ Setup Key
+ Setup key for automatic device registration. Used once during initial enrollment.
+ Admin URL
+ NetBird admin dashboard URL (e.g., https://app.netbird.io:443)
+ Pre-Shared Key
+ WireGuard pre-shared key for additional encryption layer
+ Enable Rosenpass
+ Enable Rosenpass post-quantum encryption
+ Rosenpass Permissive Mode
+ Allow connections to peers that do not support Rosenpass
+ Disable Auto-Connect
+ Prevent the VPN from automatically connecting on app launch
diff --git a/app/src/main/res/xml/app_restrictions.xml b/app/src/main/res/xml/app_restrictions.xml
index cf589a18..8ef73071 100644
--- a/app/src/main/res/xml/app_restrictions.xml
+++ b/app/src/main/res/xml/app_restrictions.xml
@@ -11,50 +11,50 @@
diff --git a/tool/src/main/java/io/netbird/client/tool/EngineRunner.java b/tool/src/main/java/io/netbird/client/tool/EngineRunner.java
index 199ebb57..d1f204cc 100644
--- a/tool/src/main/java/io/netbird/client/tool/EngineRunner.java
+++ b/tool/src/main/java/io/netbird/client/tool/EngineRunner.java
@@ -23,6 +23,12 @@
class EngineRunner {
private static final String LOGTAG = "EngineRunner";
+
+ /**
+ * Lock to serialise MDM config apply operations between EngineRunner.runClient()
+ * and ManagedConfigReceiver.onReceive().
+ */
+ static final Object MDM_CONFIG_LOCK = new Object();
private final Context context;
private final boolean isDebuggable;
private final ProfileManagerWrapper profileManager;
@@ -97,25 +103,33 @@ private synchronized void runClient(@Nullable URLOpener urlOpener, boolean isAnd
// Apply MDM managed configuration before starting the engine.
// MDM values override user-set preferences on every launch.
try {
- io.netbird.gomobile.android.ManagedConfig mdmConfig = ManagedConfigReader.read(context);
- if (mdmConfig != null && mdmConfig.hasConfig()) {
- mdmConfig.apply(configurationFilePath);
- Log.i(LOGTAG, "MDM managed configuration applied");
-
- // If MDM provides a setup key and the engine needs login,
- // perform silent registration with the setup key
- if (mdmConfig.hasSetupKey()) {
- try {
- io.netbird.gomobile.android.Auth auth =
- io.netbird.gomobile.android.Android.newAuth(configurationFilePath, "");
- if (auth != null) {
- auth.loginWithSetupKeySync(mdmConfig.getSetupKey(), DeviceName.getDeviceName());
- Log.i(LOGTAG, "MDM: silent setup key registration completed");
+ synchronized (MDM_CONFIG_LOCK) {
+ io.netbird.gomobile.android.ManagedConfig mdmConfig = ManagedConfigReader.read(context);
+ if (mdmConfig != null && mdmConfig.hasConfig()) {
+ mdmConfig.apply(configurationFilePath);
+ Log.i(LOGTAG, "MDM managed configuration applied");
+
+ // If MDM provides a setup key, attempt silent registration.
+ // loginWithSetupKeySync will call the management server which
+ // returns success (already registered) or registers the peer.
+ // The server is the authority — no local enrollment flag needed.
+ if (mdmConfig.hasSetupKey()) {
+ try {
+ io.netbird.gomobile.android.Auth auth =
+ io.netbird.gomobile.android.Android.newAuth(
+ configurationFilePath,
+ mdmConfig.getSetupKey());
+ if (auth != null) {
+ auth.loginWithSetupKeySync(
+ mdmConfig.getSetupKey(),
+ DeviceName.getDeviceName());
+ Log.i(LOGTAG, "MDM: silent setup key registration completed");
+ }
+ } catch (Exception e) {
+ // Setup key login may fail if already registered or key expired.
+ // This is not fatal — continue with normal flow.
+ Log.w(LOGTAG, "MDM: setup key login skipped or failed: " + e.getMessage());
}
- } catch (Exception e) {
- // Setup key login may fail if already registered or key expired.
- // This is not fatal — continue with normal flow.
- Log.w(LOGTAG, "MDM: setup key login skipped or failed: " + e.getMessage());
}
}
}
diff --git a/tool/src/main/java/io/netbird/client/tool/ManagedConfigReceiver.java b/tool/src/main/java/io/netbird/client/tool/ManagedConfigReceiver.java
index aa63807c..5d3ffaae 100644
--- a/tool/src/main/java/io/netbird/client/tool/ManagedConfigReceiver.java
+++ b/tool/src/main/java/io/netbird/client/tool/ManagedConfigReceiver.java
@@ -5,6 +5,9 @@
import android.content.Intent;
import android.util.Log;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
import io.netbird.gomobile.android.ManagedConfig;
/**
@@ -12,15 +15,15 @@
* when the MDM/EMM pushes updated managed configuration to the device.
*
* This receiver re-reads the managed configuration and applies it to the
- * Go SDK config file. If the VPN engine is running and the management URL changed,
- * the engine should be restarted (handled by the EngineRunner via its existing
- * restart mechanism).
+ * Go SDK config file. Work is performed off the main thread via {@code goAsync()}
+ * to avoid ANRs.
*
* Register this receiver in AndroidManifest.xml or dynamically in the VPNService.
*/
public class ManagedConfigReceiver extends BroadcastReceiver {
private static final String TAG = "ManagedConfigReceiver";
+ private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor();
@Override
public void onReceive(Context context, Intent intent) {
@@ -30,19 +33,26 @@ public void onReceive(Context context, Intent intent) {
Log.i(TAG, "Application restrictions changed, re-reading MDM config");
- ManagedConfig config = ManagedConfigReader.read(context);
- if (config == null || !config.hasConfig()) {
- Log.d(TAG, "No MDM config after restrictions change");
- return;
- }
-
- try {
- ProfileManagerWrapper profileManager = new ProfileManagerWrapper(context);
- String configPath = profileManager.getActiveConfigPath();
- config.apply(configPath);
- Log.i(TAG, "MDM config re-applied after restrictions change");
- } catch (Exception e) {
- Log.e(TAG, "Failed to apply MDM config after restrictions change", e);
- }
+ final PendingResult pendingResult = goAsync();
+ EXECUTOR.execute(() -> {
+ try {
+ synchronized (EngineRunner.MDM_CONFIG_LOCK) {
+ ManagedConfig config = ManagedConfigReader.read(context);
+ if (config == null || !config.hasConfig()) {
+ Log.d(TAG, "No MDM config after restrictions change");
+ return;
+ }
+
+ ProfileManagerWrapper profileManager = new ProfileManagerWrapper(context);
+ String configPath = profileManager.getActiveConfigPath();
+ config.apply(configPath);
+ Log.i(TAG, "MDM config re-applied after restrictions change");
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to apply MDM config after restrictions change", e);
+ } finally {
+ pendingResult.finish();
+ }
+ });
}
}
From 4a5ba6b5c4f369b1c9d2b5412122fd6758e96b59 Mon Sep 17 00:00:00 2001
From: David Brieck
Date: Fri, 24 Apr 2026 15:04:36 +0000
Subject: [PATCH 3/3] fix: pass management URL (not setup key) to newAuth
Use mdmConfig.getManagementURL() so NewAuth connects to the correct
MDM-specified server instead of using the setup key as the URL.
---
tool/src/main/java/io/netbird/client/tool/EngineRunner.java | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tool/src/main/java/io/netbird/client/tool/EngineRunner.java b/tool/src/main/java/io/netbird/client/tool/EngineRunner.java
index d1f204cc..f3e4ddfd 100644
--- a/tool/src/main/java/io/netbird/client/tool/EngineRunner.java
+++ b/tool/src/main/java/io/netbird/client/tool/EngineRunner.java
@@ -118,7 +118,7 @@ private synchronized void runClient(@Nullable URLOpener urlOpener, boolean isAnd
io.netbird.gomobile.android.Auth auth =
io.netbird.gomobile.android.Android.newAuth(
configurationFilePath,
- mdmConfig.getSetupKey());
+ mdmConfig.getManagementURL());
if (auth != null) {
auth.loginWithSetupKeySync(
mdmConfig.getSetupKey(),