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(),