-
Notifications
You must be signed in to change notification settings - Fork 63
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 all commits
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="@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> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
|
@@ -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()); | ||
| 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
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,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(); | ||
| } | ||
| }); | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.