Skip to content
7 changes: 7 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@
android:theme="@style/Theme.NetBird"
tools:targetApi="31">

<!-- Managed-configuration schema. Read by Device Owner / Profile
Owner controllers (Intune, MobileIron, TestDPC, etc.) so the
admin UI can render proper inputs for each MDM-managed key. -->
<meta-data
android:name="android.content.APP_RESTRICTIONS"
android:resource="@xml/app_restrictions" />

<activity
android:name=".MainActivity"
android:launchMode="singleTask"
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/java/io/netbird/client/MyApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,10 @@ public void onCreate() {
SharedPreferences prefs = getSharedPreferences("settings", MODE_PRIVATE);
int themeMode = prefs.getInt("theme_mode", AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM);
AppCompatDelegate.setDefaultNightMode(themeMode);

// NOTE: the MDM policy fetcher is registered on the goClient
// instance inside EngineRunner — see EngineRunner constructor.
// Process-wide registration was removed when the Go side moved
// to per-Client DI for the Loader.
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
package io.netbird.client.ui.advanced;

import android.content.Context;
import android.content.RestrictionsManager;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.util.Log;
import android.widget.CompoundButton;
import android.widget.EditText;
import android.view.View;
import android.view.LayoutInflater;
import android.view.View;
Comment on lines +10 to 12

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 | ⚡ Quick win

Duplicate import of android.view.View.

Line 10 and line 12 both import android.view.View.

🧹 Proposed fix
 import android.widget.CompoundButton;
 import android.widget.EditText;
-import android.view.View;
 import android.view.LayoutInflater;
 import android.view.View;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import android.view.View;
import android.view.LayoutInflater;
import android.view.View;
import android.view.LayoutInflater;
import android.view.View;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/io/netbird/client/ui/advanced/AdvancedFragment.java` around
lines 10 - 12, Remove the duplicate import statement for android.view.View in
the AdvancedFragment.java file. The import android.view.View appears twice
consecutively in the import block and only one instance should be retained.
Delete the second occurrence of the duplicate import android.view.View
statement, keeping the first one.

import android.view.ViewGroup;
Expand Down Expand Up @@ -292,11 +296,79 @@ private void initializeEngineConfigSwitches() {
binding.switchDisableIpv6.toggle();
});

applyMDMLocks();

} catch (Exception e) {
Log.e(LOGTAG, "Failed to initialize engine config switches", e);
}
}

/**
* Lock and align every UI control whose corresponding key is currently
* MDM-enforced. The list of managed keys + their enforced values is
* read directly from RestrictionsManager — the same OS-native source
* the Go layer uses (via MDMPolicyFetcher). No round-trip to Go is
* needed, and the two sides cannot diverge.
*
* For each managed key:
* - the switch is forced to the MDM value (overrides the user's
* on-disk preference);
* - the switch + its surrounding clickable layout are disabled so
* the user cannot toggle them.
*/
private void applyMDMLocks() {
Context ctx = getContext();
if (ctx == null) {
return;
}
RestrictionsManager rm = (RestrictionsManager) ctx.getSystemService(Context.RESTRICTIONS_SERVICE);
if (rm == null) {
return;
}
android.os.Bundle restrictions = rm.getApplicationRestrictions();
if (restrictions == null || restrictions.isEmpty()) {
return;
}

lockSwitchIfManaged(restrictions, "rosenpassEnabled", binding.switchRosenpass, binding.layoutRosenpas);
lockSwitchIfManaged(restrictions, "rosenpassPermissive", binding.switchRosenpassPermissive, binding.layoutRosenpassPermissive);
lockSwitchIfManaged(restrictions, "allowServerSSH", binding.switchAllowSsh, binding.layoutAllowSsh);
lockSwitchIfManaged(restrictions, "blockInbound", binding.switchBlockInbound, binding.layoutBlockInbound);
lockSwitchIfManaged(restrictions, "disableClientRoutes", binding.switchDisableClientRoutes, binding.layoutDisableClientRoutes);
lockSwitchIfManaged(restrictions, "disableServerRoutes", binding.switchDisableServerRoutes, binding.layoutDisableServerRoutes);

// PreSharedKey is a string, not a bool; lock the field if managed.
if (restrictions.containsKey("preSharedKey")) {
EditText psk = binding.presharedKey;
psk.setEnabled(false);
// Show the redaction sentinel so the actual MDM value is never
// leaked into the UI — matches the daemon-side behavior of
// GetConfig.
psk.setText(hiddenKey);
binding.btnSave.setEnabled(false);
}
}

/**
* Helper: if `key` is present in the OS-pushed restrictions, force the
* switch to its enforced bool value and disable the switch and its
* parent layout. The parent layout must be disabled too, otherwise
* the TV-remote "tap layout to toggle switch" path remains active.
*/
private void lockSwitchIfManaged(android.os.Bundle restrictions, String key,
CompoundButton switchCtrl, View parentLayout) {
if (switchCtrl == null || !restrictions.containsKey(key)) {
return;
}
boolean value = restrictions.getBoolean(key);
switchCtrl.setChecked(value);
switchCtrl.setEnabled(false);
if (parentLayout != null) {
parentLayout.setEnabled(false);
parentLayout.setClickable(false);
}
}
Comment on lines +358 to +370

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 | 🟠 Major | ⚡ Quick win

Listener side-effects fire during MDM lock application.

setChecked(value) at line 364 triggers the OnCheckedChangeListener before setEnabled(false) runs at line 365. Every MDM-locked switch will execute its listener, causing:

  • Unnecessary goPreferences.commit() calls for each locked switch
  • For the Rosenpass switch, the listener also manipulates the permissive switch's enabled/checked state (lines 131-134), potentially conflicting with MDM enforcement of rosenpassPermissive

Fix by disabling the switch before setting its value, then add guards in listeners:

🔧 Proposed fix for lockSwitchIfManaged
     private void lockSwitchIfManaged(android.os.Bundle restrictions, String key,
                                      CompoundButton switchCtrl, View parentLayout) {
         if (switchCtrl == null || !restrictions.containsKey(key)) {
             return;
         }
         boolean value = restrictions.getBoolean(key);
+        switchCtrl.setEnabled(false);
         switchCtrl.setChecked(value);
-        switchCtrl.setEnabled(false);
         if (parentLayout != null) {
             parentLayout.setEnabled(false);
             parentLayout.setClickable(false);
         }
     }

Then add an early-exit guard to each listener (example for one switch):

         binding.switchDisableClientRoutes.setOnCheckedChangeListener((buttonView, isChecked) -> {
+            if (!buttonView.isEnabled()) return; // Skip writes when MDM-locked
             try {
                 goPreferences.setDisableClientRoutes(isChecked);
                 goPreferences.commit();
             } catch (Exception e) {
                 Log.e(LOGTAG, "Failed to set disable client routes", e);
             }
         });

Apply the same guard to all listeners in initializeEngineConfigSwitches() and the Rosenpass listeners.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/io/netbird/client/ui/advanced/AdvancedFragment.java` around
lines 358 - 370, In the lockSwitchIfManaged method, reorder the operations to
call setEnabled(false) before setChecked(value) so that the
OnCheckedChangeListener does not fire during MDM lock application. Additionally,
add an early-exit guard at the beginning of each OnCheckedChangeListener
implementation (in initializeEngineConfigSwitches and the Rosenpass listeners)
that checks if the switch/control is enabled and returns immediately if it is
disabled, preventing unnecessary preference commits and state manipulations.


@Override
public void onDestroyView() {
super.onDestroyView();
Expand Down
13 changes: 13 additions & 0 deletions app/src/main/res/values/arrays.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Display labels for the splitTunnelMode managed restriction. -->
<string-array name="restriction_splitTunnelMode_entries">
<item>Allow only listed apps (everything else bypasses)</item>
<item>Disallow listed apps (everything else routes)</item>
</string-array>
<!-- Raw values written into RestrictionsManager for splitTunnelMode. -->
<string-array name="restriction_splitTunnelMode_values">
<item>allow</item>
<item>disallow</item>
</string-array>
</resources>
54 changes: 54 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -148,4 +148,58 @@
<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 application restrictions: titles + descriptions surfaced to the
Device Owner / Profile Owner UI (Intune, MobileIron, TestDPC) when
the admin configures NetBird via managed config. -->
<string name="restriction_managementURL_title">Management URL</string>
<string name="restriction_managementURL_description">URL of the NetBird management server. Format https://host[:port].</string>

<string name="restriction_preSharedKey_title">Pre-shared key</string>
<string name="restriction_preSharedKey_description">WireGuard pre-shared key used as an additional symmetric secret. Secret value.</string>

<string name="restriction_disableAutoConnect_title">Disable auto-connect</string>
<string name="restriction_disableAutoConnect_description">When enabled, the tunnel does not auto-connect at app start.</string>

<string name="restriction_disableClientRoutes_title">Disable client routes</string>
<string name="restriction_disableClientRoutes_description">When enabled, this client does not consume routes advertised by routing peers.</string>

<string name="restriction_disableServerRoutes_title">Disable server routes</string>
<string name="restriction_disableServerRoutes_description">When enabled, this client does not act as a routing peer for other clients.</string>

<string name="restriction_blockInbound_title">Block inbound</string>
<string name="restriction_blockInbound_description">When enabled, the client blocks all inbound peer traffic on the WireGuard interface.</string>

<string name="restriction_allowServerSSH_title">Allow server SSH</string>
<string name="restriction_allowServerSSH_description">When enabled, this client accepts incoming SSH sessions via NetBird SSH.</string>

<string name="restriction_rosenpassEnabled_title">Enable Rosenpass</string>
<string name="restriction_rosenpassEnabled_description">Enables Rosenpass post-quantum key exchange on WireGuard tunnels.</string>

<string name="restriction_rosenpassPermissive_title">Rosenpass permissive</string>
<string name="restriction_rosenpassPermissive_description">When enabled, falls back to plain WireGuard if a peer does not support Rosenpass.</string>

<string name="restriction_wireguardPort_title">WireGuard port</string>
<string name="restriction_wireguardPort_description">UDP port for the local WireGuard interface. Allowed range 1-65535.</string>

<string name="restriction_splitTunnelMode_title">Split tunnel mode</string>
<string name="restriction_splitTunnelMode_description">Choose allow (only listed apps route through NetBird) or disallow (listed apps bypass NetBird).</string>

<string name="restriction_splitTunnelApps_title">Split tunnel apps</string>
<string name="restriction_splitTunnelApps_description">Comma-separated list of package names used by the selected split-tunnel mode.</string>

<string name="restriction_disableUpdateSettings_title">Disable update settings</string>
<string name="restriction_disableUpdateSettings_description">When enabled, blocks every configuration change from the UI and CLI.</string>

<string name="restriction_disableProfiles_title">Disable profiles</string>
<string name="restriction_disableProfiles_description">When enabled, the client cannot list, create, switch or remove NetBird connection profiles.</string>

<string name="restriction_disableNetworks_title">Disable networks</string>
<string name="restriction_disableNetworks_description">When enabled, the client UI cannot list, select or deselect NetBird networks.</string>

<string name="restriction_disableAdvancedView_title">Disable advanced view</string>
<string name="restriction_disableAdvancedView_description">When enabled, the new UI hides the advanced-view section.</string>

<string name="restriction_disableMetricsCollection_title">Disable metrics collection</string>
<string name="restriction_disableMetricsCollection_description">When enabled, the client does not collect or report local usage metrics.</string>
</resources>
135 changes: 135 additions & 0 deletions app/src/main/res/xml/app_restrictions.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Managed-configuration schema for the NetBird Android client.
Read by Device Owner / Profile Owner (Intune, MobileIron, Workspace ONE,
JumpCloud, TestDPC) to render a configuration UI; pushed values land in
RestrictionsManager.getApplicationRestrictions() and are surfaced to the
Go layer via MDMPolicyFetcher.fetchJSON().

Key names mirror the canonical mdm.Key* constants in
client/mdm/policy.go (lowerCamelCase). Adding a key here is purely
discovery for the MDM admin UI — the Go side determines which keys
actually take effect.
-->
<restrictions xmlns:android="http://schemas.android.com/apk/res/android">

<restriction
android:key="managementURL"
android:title="@string/restriction_managementURL_title"
android:description="@string/restriction_managementURL_description"
android:restrictionType="string"
android:defaultValue="https://api.netbird.io:443" />

<restriction
android:key="preSharedKey"
android:title="@string/restriction_preSharedKey_title"
android:description="@string/restriction_preSharedKey_description"
android:restrictionType="string" />

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

<restriction
android:key="disableClientRoutes"
android:title="@string/restriction_disableClientRoutes_title"
android:description="@string/restriction_disableClientRoutes_description"
android:restrictionType="bool"
android:defaultValue="false" />

<restriction
android:key="disableServerRoutes"
android:title="@string/restriction_disableServerRoutes_title"
android:description="@string/restriction_disableServerRoutes_description"
android:restrictionType="bool"
android:defaultValue="false" />

<restriction
android:key="blockInbound"
android:title="@string/restriction_blockInbound_title"
android:description="@string/restriction_blockInbound_description"
android:restrictionType="bool"
android:defaultValue="false" />

<restriction
android:key="allowServerSSH"
android:title="@string/restriction_allowServerSSH_title"
android:description="@string/restriction_allowServerSSH_description"
android:restrictionType="bool"
android:defaultValue="false" />

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

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

<restriction
android:key="wireguardPort"
android:title="@string/restriction_wireguardPort_title"
android:description="@string/restriction_wireguardPort_description"
android:restrictionType="integer"
android:defaultValue="51820" />

<restriction
android:key="splitTunnelMode"
android:title="@string/restriction_splitTunnelMode_title"
android:description="@string/restriction_splitTunnelMode_description"
android:restrictionType="choice"
android:entries="@array/restriction_splitTunnelMode_entries"
android:entryValues="@array/restriction_splitTunnelMode_values"
android:defaultValue="allow" />

<restriction
android:key="splitTunnelApps"
android:title="@string/restriction_splitTunnelApps_title"
android:description="@string/restriction_splitTunnelApps_description"
android:restrictionType="string" />

<restriction
android:key="disableUpdateSettings"
android:title="@string/restriction_disableUpdateSettings_title"
android:description="@string/restriction_disableUpdateSettings_description"
android:restrictionType="bool"
android:defaultValue="false" />

<restriction
android:key="disableProfiles"
android:title="@string/restriction_disableProfiles_title"
android:description="@string/restriction_disableProfiles_description"
android:restrictionType="bool"
android:defaultValue="false" />

<restriction
android:key="disableNetworks"
android:title="@string/restriction_disableNetworks_title"
android:description="@string/restriction_disableNetworks_description"
android:restrictionType="bool"
android:defaultValue="false" />

<restriction
android:key="disableAdvancedView"
android:title="@string/restriction_disableAdvancedView_title"
android:description="@string/restriction_disableAdvancedView_description"
android:restrictionType="bool"
android:defaultValue="false" />

<restriction
android:key="disableMetricsCollection"
android:title="@string/restriction_disableMetricsCollection_title"
android:description="@string/restriction_disableMetricsCollection_description"
android:restrictionType="bool"
android:defaultValue="false" />

</restrictions>
2 changes: 1 addition & 1 deletion netbird
Submodule netbird updated 339 files
16 changes: 16 additions & 0 deletions tool/src/main/java/io/netbird/client/tool/EngineRestarter.java
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,22 @@ public void onNetworkTypeChanged() {
}
}

/**
* Triggers the same stop + restart sequence used by the network-change
* path, but without the debounce delay. Used by the MDM policy-change
* broadcast receiver: when an admin pushes a new managed config the
* engine must restart immediately so the new values take effect on the
* next Run (which re-reads MDM via MDMPolicyFetcher).
*/
public void requestRestartNow() {
Log.d(LOGTAG, "explicit restart requested (no debounce)");
synchronized (restartLock) {
restartScheduled = true;
handler.removeCallbacks(restartRunnable);
handler.post(restartRunnable);
}
}
Comment on lines +318 to +325

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 | 🟠 Major | 🏗️ Heavy lift

Immediate MDM restart requests can be dropped during an in-flight restart.

If requestRestartNow() is called while a restart is already in progress, the posted runnable can execute, clear the scheduled flag in restartEngine(), then exit early on isRestartInProgress. That drops the newer policy-change request instead of replaying it after the current cycle.

Queue the pending request and drain it when the current restart completes (success/error/timeout) so MDM changes converge deterministically.

Suggested direction
+// keep a pending-restart intent if a request arrives mid-cycle
+private boolean restartRequestedDuringInFlight = false;

 public void requestRestartNow() {
     Log.d(LOGTAG, "explicit restart requested (no debounce)");
     synchronized (restartLock) {
-        restartScheduled = true;
-        handler.removeCallbacks(restartRunnable);
-        handler.post(restartRunnable);
+        restartScheduled = true;
+        if (isRestartInProgress) {
+            restartRequestedDuringInFlight = true;
+            return;
+        }
+        handler.removeCallbacks(restartRunnable);
+        handler.post(restartRunnable);
     }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tool/src/main/java/io/netbird/client/tool/EngineRestarter.java` around lines
318 - 325, The requestRestartNow() method can drop pending restart requests when
a restart is already in progress because it immediately posts the runnable,
which then clears the restartScheduled flag in restartEngine() and exits early
without replaying the request. Instead of directly posting the runnable, check
if a restart is currently in progress using isRestartInProgress, and if so,
queue the request rather than posting it immediately. Add a queue data structure
to track pending restart requests, and modify restartEngine() to drain and
process any queued requests when the current restart cycle completes (regardless
of success, error, or timeout). This ensures that MDM policy changes are not
dropped and converge deterministically by replaying queued requests after each
restart cycle finishes.


/**
* Cancels any pending debounced restart. Called whenever an external
* actor (typically a user-driven Connect/Disconnect) takes over the
Expand Down
6 changes: 6 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 @@ -48,6 +48,12 @@ public EngineRunner(Context context, NetworkChangeListener networkChangeListener
iFaceDiscover,
networkChangeListener);

// Per-Client MDM policy fetcher (DI on the goClient side).
// The Go layer holds the *mdm.Loader on this Client instance;
// every Run/RunWithoutLogin call overlays the latest MDM
// policy on top of the freshly resolved Config.
goClient.setMDMPolicyFetcher(new MDMPolicyFetcher(context));

updateLogLevel(isTraceLogEnabled, isDebuggable);
}

Expand Down
Loading