-
Notifications
You must be signed in to change notification settings - Fork 66
feat: add MDM managed app configuration support #170
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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()); | ||
| } | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Setup-key login is re-attempted on every engine start. Because MDM config is applied on each 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 🤖 Prompt for AI Agents |
||
| } | ||
| } catch (Exception e) { | ||
| Log.e(LOGTAG, "Failed to apply MDM config, continuing with existing config", e); | ||
| } | ||
|
Comment on lines
+103
to
+138
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Race between
Consider serializing MDM application through a shared lock (or a single-threaded queue/handler) used by both 🔒 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 🤖 Prompt for AI Agents |
||
|
|
||
| try { | ||
| notifyServiceStateListeners(true); | ||
| if (urlOpener == null) { | ||
|
|
||
| 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> | ||
|
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); | ||
| } | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: netbirdio/android-client
Length of output: 2016
🏁 Script executed:
Repository: netbirdio/android-client
Length of output: 7692
🏁 Script executed:
Repository: netbirdio/android-client
Length of output: 17522
🏁 Script executed:
Repository: netbirdio/android-client
Length of output: 2345
🏁 Script executed:
Repository: netbirdio/android-client
Length of output: 3484
🏁 Script executed:
Repository: netbirdio/android-client
Length of output: 124
🏁 Script executed:
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 usesPreferences.defaultServer()("https://api.netbird.io") when enrolling with a setup key. Either pass the default server address directly: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