Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@
android:theme="@style/Theme.NetBird"
tools:targetApi="31">

<meta-data
android:name="android.content.APP_RESTRICTIONS"
android:resource="@xml/app_restrictions" />

<activity
android:name=".MainActivity"
android:launchMode="singleTask"
Expand All @@ -55,6 +59,14 @@
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>

<receiver
android:name="io.netbird.client.tool.ManagedConfigReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.APPLICATION_RESTRICTIONS_CHANGED" />
</intent-filter>
</receiver>
</application>

</manifest>
16 changes: 16 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -145,4 +145,20 @@
<string name="profiles_success_switched">Switched to profile \'%s\'</string>
<string name="profiles_success_logged_out">Logged out from profile \'%s\'</string>
<string name="profiles_success_removed">Profile \'%s\' removed successfully</string>

<!-- MDM managed configuration (app restrictions) titles and descriptions -->
<string name="mdm_management_url_title">Management URL</string>
<string name="mdm_management_url_desc">NetBird management server URL (e.g., https://api.netbird.io:443)</string>
<string name="mdm_setup_key_title">Setup Key</string>
<string name="mdm_setup_key_desc">Setup key for automatic device registration. Used once during initial enrollment.</string>
<string name="mdm_admin_url_title">Admin URL</string>
<string name="mdm_admin_url_desc">NetBird admin dashboard URL (e.g., https://app.netbird.io:443)</string>
<string name="mdm_pre_shared_key_title">Pre-Shared Key</string>
<string name="mdm_pre_shared_key_desc">WireGuard pre-shared key for additional encryption layer</string>
<string name="mdm_rosenpass_enabled_title">Enable Rosenpass</string>
<string name="mdm_rosenpass_enabled_desc">Enable Rosenpass post-quantum encryption</string>
<string name="mdm_rosenpass_permissive_title">Rosenpass Permissive Mode</string>
<string name="mdm_rosenpass_permissive_desc">Allow connections to peers that do not support Rosenpass</string>
<string name="mdm_disable_auto_connect_title">Disable Auto-Connect</string>
<string name="mdm_disable_auto_connect_desc">Prevent the VPN from automatically connecting on app launch</string>
</resources>
61 changes: 61 additions & 0 deletions app/src/main/res/xml/app_restrictions.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Android Enterprise Managed Configurations (App Restrictions) schema.
This defines the configuration keys that MDM/EMM solutions can push to the app.
Follows the AppConfig standard (appconfig.org) for key naming conventions.

Supported MDM platforms: Microsoft Intune, VMware Workspace ONE, Google Admin Console,
MobileIron, and any Android Enterprise-compatible EMM.
-->
<restrictions xmlns:android="http://schemas.android.com/apk/res/android">

<restriction
android:key="managementUrl"
android:title="@string/mdm_management_url_title"
android:description="@string/mdm_management_url_desc"
android:restrictionType="string"
android:defaultValue="" />

<restriction
android:key="setupKey"
android:title="@string/mdm_setup_key_title"
android:description="@string/mdm_setup_key_desc"
android:restrictionType="string"
android:defaultValue="" />

<restriction
android:key="adminUrl"
android:title="@string/mdm_admin_url_title"
android:description="@string/mdm_admin_url_desc"
android:restrictionType="string"
android:defaultValue="" />

<restriction
android:key="preSharedKey"
android:title="@string/mdm_pre_shared_key_title"
android:description="@string/mdm_pre_shared_key_desc"
android:restrictionType="string"
android:defaultValue="" />

<restriction
android:key="rosenpassEnabled"
android:title="@string/mdm_rosenpass_enabled_title"
android:description="@string/mdm_rosenpass_enabled_desc"
android:restrictionType="bool"
android:defaultValue="false" />

<restriction
android:key="rosenpassPermissive"
android:title="@string/mdm_rosenpass_permissive_title"
android:description="@string/mdm_rosenpass_permissive_desc"
android:restrictionType="bool"
android:defaultValue="false" />

<restriction
android:key="disableAutoConnect"
android:title="@string/mdm_disable_auto_connect_title"
android:description="@string/mdm_disable_auto_connect_desc"
android:restrictionType="bool"
android:defaultValue="false" />

</restrictions>
43 changes: 43 additions & 0 deletions tool/src/main/java/io/netbird/client/tool/EngineRunner.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -94,6 +100,43 @@ 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 {
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.getManagementURL());
if (auth != null) {
auth.loginWithSetupKeySync(
mdmConfig.getSetupKey(),
DeviceName.getDeviceName());
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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);
}
Comment on lines +103 to +138
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Race between runClient() MDM apply and ManagedConfigReceiver.onReceive().

ManagedConfigReceiver.onReceive() (main thread) also calls config.apply(configPath) against the same configuration file used here. There is no synchronization between the two paths, so:

  • If a restriction change is broadcast while runClient() is between mdmConfig.apply(configurationFilePath) (line 102) and goClient.run*(...) (lines 129/131), the config file the Go client ultimately reads may be a partially-updated mix of both writers.
  • On a hot config change mid-session, the receiver mutates the file the running engine is actively using, with no coordination.

Consider serializing MDM application through a shared lock (or a single-threaded queue/handler) used by both EngineRunner.runClient() and ManagedConfigReceiver.onReceive(), and skipping the apply in runClient() when the engine is already running. A simple fix:

🔒 Suggested synchronization
 class EngineRunner {
+    // Shared lock guarding any mutation of the active profile's managed-config state.
+    static final Object MDM_CONFIG_LOCK = new Object();
@@
-            try {
-                io.netbird.gomobile.android.ManagedConfig mdmConfig = ManagedConfigReader.read(context);
-                if (mdmConfig != null && mdmConfig.hasConfig()) {
-                    mdmConfig.apply(configurationFilePath);
+            try {
+                io.netbird.gomobile.android.ManagedConfig mdmConfig = ManagedConfigReader.read(context);
+                if (mdmConfig != null && mdmConfig.hasConfig()) {
+                    synchronized (MDM_CONFIG_LOCK) {
+                        mdmConfig.apply(configurationFilePath);
+                    }

And use the same MDM_CONFIG_LOCK around config.apply(configPath) in ManagedConfigReceiver.

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

In `@tool/src/main/java/io/netbird/client/tool/EngineRunner.java` around lines 97
- 124, There is a race between EngineRunner.runClient()'s call to
mdmConfig.apply(configurationFilePath) and ManagedConfigReceiver.onReceive()
mutating the same config; serialize these operations by introducing a shared
lock (e.g., a static final Object MDM_CONFIG_LOCK) used by both
EngineRunner.runClient() and ManagedConfigReceiver.onReceive() around any call
to mdmConfig.apply(configPath), and in runClient() check whether the engine is
already running (or a flag like engineStarted) and skip the apply when the
engine is active to avoid mid-run file writes; update references to
mdmConfig.apply(configurationFilePath), ManagedConfigReceiver.onReceive(), and
the engine start calls (goClient.runSync/goClient.runAsync) to use the
lock/flag.


try {
notifyServiceStateListeners(true);
if (urlOpener == null) {
Expand Down
120 changes: 120 additions & 0 deletions tool/src/main/java/io/netbird/client/tool/ManagedConfigReader.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>The key names match those defined in res/xml/app_restrictions.xml and the
* Go SDK's ManagedConfig key constants.</p>
*/
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();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package io.netbird.client.tool;

import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.util.Log;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

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.
*
* <p>This receiver re-reads the managed configuration and applies it to the
* Go SDK config file. Work is performed off the main thread via {@code goAsync()}
* to avoid ANRs.</p>
*
* <p>Register this receiver in AndroidManifest.xml or dynamically in the VPNService.</p>
*/
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) {
if (!Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED.equals(intent.getAction())) {
return;
}

Log.i(TAG, "Application restrictions changed, re-reading MDM config");

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();
}
});
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}