Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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>
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="Management URL"
android:description="NetBird management server URL (e.g., https://api.netbird.io:443)"
android:restrictionType="string"
android:defaultValue="" />

<restriction
android:key="setupKey"
android:title="Setup Key"
android:description="Setup key for automatic device registration. Used once during initial enrollment."
android:restrictionType="string"
android:defaultValue="" />

<restriction
android:key="adminUrl"
android:title="Admin URL"
android:description="NetBird admin dashboard URL (e.g., https://app.netbird.io:443)"
android:restrictionType="string"
android:defaultValue="" />

<restriction
android:key="preSharedKey"
android:title="Pre-Shared Key"
android:description="WireGuard pre-shared key for additional encryption layer"
android:restrictionType="string"
android:defaultValue="" />

<restriction
android:key="rosenpassEnabled"
android:title="Enable Rosenpass"
android:description="Enable Rosenpass post-quantum encryption"
android:restrictionType="bool"
android:defaultValue="false" />

<restriction
android:key="rosenpassPermissive"
android:title="Rosenpass Permissive Mode"
android:description="Allow connections to peers that do not support Rosenpass"
android:restrictionType="bool"
android:defaultValue="false" />

<restriction
android:key="disableAutoConnect"
android:title="Disable Auto-Connect"
android:description="Prevent the VPN from automatically connecting on app launch"
android:restrictionType="bool"
android:defaultValue="false" />

</restrictions>
29 changes: 29 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 @@ -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");
}
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 | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Try to locate the generated binding signature or any other call sites of newAuth.
rg -nP -C3 '\bnewAuth\s*\('
rg -nP --type=go -C3 '\bfunc\s+NewAuth\b|\bAuth\s+struct\b' || true

Repository: netbirdio/android-client

Length of output: 2016


🏁 Script executed:

#!/bin/bash
# Check mdmConfig to see if it contains server address
rg -nP -C5 'mdmConfig' tool/src/main/java/io/netbird/client/tool/EngineRunner.java | head -100

# Look for any server address constants or defaults
rg -nP 'managementServer|serverAddress|baseURL|apiUrl' app/src/main/java/ --max-count 20

# Check if there are other Android.newAuth calls
rg -nP 'newAuth' tool/src/main/java/ app/src/main/java/ | wc -l
rg -nP 'newAuth' --context 5 tool/src/main/java/ app/src/main/java/

Repository: netbirdio/android-client

Length of output: 7692


🏁 Script executed:

#!/bin/bash
# Check if mdmConfig has any server address field
rg -nP 'class ManagedConfig|interface ManagedConfig' -A 20

# Look for Preferences.defaultServer()
rg -nP 'defaultServer|LOGTAG' app/src/main/java/io/netbird/client/ui/ tool/src/main/java/ --max-count 15

# Search for ManagedConfigReader and see what it reads
rg -nP 'class ManagedConfigReader' -A 30 

# Check if there's any documentation about the second parameter
rg -nP 'newAuth.*comment|newAuth.*//|newAuth.*\*' -B 2 -A 2

Repository: netbirdio/android-client

Length of output: 17522


🏁 Script executed:

#!/bin/bash
# Check what Preferences.defaultServer() returns
rg -nP 'public static String defaultServer' -A 5 app/src/main/java/io/netbird/client/tool/Preferences.java

# Look for any comments about empty string in MDM flow
rg -nP 'MDM|setup.?key' -B 2 -A 2 tool/src/main/java/io/netbird/client/tool/EngineRunner.java

# Check if ManagedConfig has any server-related fields/methods
rg -nP 'getManagementServer|serverAddress|server' tool/src/main/java/io/netbird/client/tool/ --type=java

# Look for any explanation of the second parameter in comments/docs
rg -nP '//.*newAuth|/\*.*newAuth' -B 2 -A 2

Repository: netbirdio/android-client

Length of output: 2345


🏁 Script executed:

#!/bin/bash
# Find Preferences.java
fd -type f -name 'Preferences.java'

# Search for defaultServer method anywhere
rg -nP 'defaultServer' -B 2 -A 5

Repository: netbirdio/android-client

Length of output: 3484


🏁 Script executed:

#!/bin/bash
# Look for git history of this line
git log -p --follow -S 'newAuth(configurationFilePath, "")' -- tool/src/main/java/io/netbird/client/tool/EngineRunner.java 2>/dev/null | head -60 || echo "No git history found"

# Look for any TODOs or FIXMEs around the MDM code
rg -nP 'TODO|FIXME|XXX|HACK' -B 2 -A 2 tool/src/main/java/io/netbird/client/tool/EngineRunner.java

# Check git log summary for this file
git log --oneline tool/src/main/java/io/netbird/client/tool/EngineRunner.java 2>/dev/null | head -20 || true

Repository: netbirdio/android-client

Length of output: 124


🏁 Script executed:

#!/bin/bash
# Check the specific commit that added this code
git show 045d922 --stat | head -40

# Also show the full diff of that commit for EngineRunner.java
git show 045d922 -- tool/src/main/java/io/netbird/client/tool/EngineRunner.java | head -150

Repository: netbirdio/android-client

Length of output: 9100


Use Preferences.defaultServer() for the management server address in MDM setup-key enrollment.

Passing an empty string here is inconsistent with ChangeServerFragmentViewModel, which explicitly uses Preferences.defaultServer() ("https://api.netbird.io") when enrolling with a setup key. Either pass the default server address directly:

io.netbird.gomobile.android.Auth auth =
    io.netbird.gomobile.android.Android.newAuth(configurationFilePath, Preferences.defaultServer());

Or document in code why the empty string is intentional and what the Go SDK does with it (e.g., whether it has a built-in fallback).

🤖 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 109
- 114, The MDM setup-key enrollment is calling
Android.newAuth(configurationFilePath, "") which passes an empty server address;
update EngineRunner to use Preferences.defaultServer() instead (i.e., call
Android.newAuth with Preferences.defaultServer()) so behavior matches
ChangeServerFragmentViewModel, or add a brief comment next to the
Android.newAuth call documenting why an empty string is intentional and what the
Go SDK fallback behavior is; locate the call in EngineRunner where
io.netbird.gomobile.android.Auth auth is created and adjust the second argument
accordingly while leaving the subsequent
auth.loginWithSetupKeySync(mdmConfig.getSetupKey(), DeviceName.getDeviceName())
unchanged.

} 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());
}
}
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 | 🟡 Minor

Setup-key login is re-attempted on every engine start.

Because MDM config is applied on each runClient() call and mdmConfig.hasSetupKey() will stay true as long as the admin leaves the key configured, loginWithSetupKeySync will be invoked every time the VPN service starts. The failure path is caught, but this generates a round-trip to the management server (and log noise) on every launch even for already-enrolled devices.

Consider gating the login attempt on "engine is not yet enrolled" state (e.g., check device registration / presence of peer key in the active profile) rather than solely on hasSetupKey().

🤖 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 107
- 120, The setup-key login is currently attempted on every
EngineRunner.runClient() because it only checks mdmConfig.hasSetupKey(); change
this to first detect whether the engine/device is already enrolled (e.g., check
the active profile for an existing peer key or a device registration flag) and
only call
io.netbird.gomobile.android.Android.newAuth(...).loginWithSetupKeySync(...) when
not enrolled. In practice modify the block referencing mdmConfig.hasSetupKey()
in EngineRunner to add a precondition like "if mdmConfig.hasSetupKey() &&
!isEnrolled()" (or an equivalent check against the active profile/peer key)
before invoking loginWithSetupKeySync so successful prior enrollments skip the
remote round-trip and log noise.

}
} 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,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.
*
* <p>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).</p>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
*
* <p>Register this receiver in AndroidManifest.xml or dynamically in the VPNService.</p>
*/
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);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}