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
69 changes: 69 additions & 0 deletions app/src/main/java/io/netbird/client/MDMPolicyFetcher.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package io.netbird.client;

import android.content.Context;
import android.content.RestrictionsManager;
import android.os.Bundle;
import android.util.Log;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import io.netbird.gomobile.android.PolicyFetcher;

/**
* MDMPolicyFetcher reads the current Android managed-config snapshot from
* RestrictionsManager and returns it as a JSON-encoded string to the Go
* layer. Registered once at app start via Android.setMobilePolicyFetcher;
* the Go side invokes fetchJSON() on every LoadPolicy call so the response
* is always fresh.
*
* Returns an empty string when no managed config is set — the daemon side
* treats that as the "no MDM source present" sentinel.
*/
public class MDMPolicyFetcher implements PolicyFetcher {
private static final String TAG = "MDMPolicyFetcher";

private final Context context;

public MDMPolicyFetcher(Context context) {
this.context = context.getApplicationContext();
}

@Override
public String fetchJSON() {
RestrictionsManager rm = (RestrictionsManager) context.getSystemService(Context.RESTRICTIONS_SERVICE);
if (rm == null) {
return "";
}
Bundle restrictions = rm.getApplicationRestrictions();
if (restrictions == null || restrictions.isEmpty()) {
return "";
}
try {
return bundleToJSON(restrictions).toString();
} catch (JSONException e) {
Log.w(TAG, "Failed to serialize managed restrictions to JSON: " + e);
return "";
}
}

private static JSONObject bundleToJSON(Bundle bundle) throws JSONException {
JSONObject obj = new JSONObject();
for (String key : bundle.keySet()) {
Object value = bundle.get(key);
if (value instanceof Bundle) {
obj.put(key, bundleToJSON((Bundle) value));
} else if (value instanceof Object[]) {
JSONArray arr = new JSONArray();
for (Object item : (Object[]) value) {
arr.put(item);
}
obj.put(key, arr);
} else {
obj.put(key, value);
}
}
return obj;
}
}
7 changes: 7 additions & 0 deletions app/src/main/java/io/netbird/client/MyApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

import androidx.appcompat.app.AppCompatDelegate;

import io.netbird.gomobile.android.Android;

public class MyApplication extends Application {

@Override
Expand All @@ -14,5 +16,10 @@ public void onCreate() {
SharedPreferences prefs = getSharedPreferences("settings", MODE_PRIVATE);
int themeMode = prefs.getInt("theme_mode", AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM);
AppCompatDelegate.setDefaultNightMode(themeMode);

// Register the MDM policy fetcher exactly once for the process
// lifetime. The Go side invokes fetchJSON() on every LoadPolicy
// call so the returned snapshot is always fresh — no caching here.
Android.setMobilePolicyFetcher(new MDMPolicyFetcher(this));
}
}
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>
Loading
Loading