From 5d80148cfc53c2b4ebf8d7c788beae7ec992cc61 Mon Sep 17 00:00:00 2001 From: evgeniyChepelev <68751844+evgeniyChepelev@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:14:59 +0200 Subject: [PATCH 1/5] Add MDM support to the Android client --- app/src/main/AndroidManifest.xml | 5 + .../java/io/netbird/client/MainActivity.java | 78 +++++++ .../java/io/netbird/client/ui/MDMLock.java | 147 ++++++++++++++ .../client/ui/advanced/AdvancedFragment.java | 190 +++++++++++------- .../ui/fistinstall/FirstInstallFragment.java | 29 ++- .../client/ui/home/NetworksFragment.java | 36 ++++ .../ui/profile/ProfileEditorDialog.java | 50 ++++- .../client/ui/profile/ProfilesFragment.java | 25 +++ .../client/ui/settings/SettingsFragment.java | 28 +++ .../ui/splittunneling/AppListAdapter.java | 23 ++- .../SplitTunnelingFragment.java | 33 ++- .../ui/troubleshoot/TroubleshootFragment.java | 3 +- app/src/main/res/layout/fragment_networks.xml | 19 ++ app/src/main/res/values-de/strings.xml | 7 + app/src/main/res/values-es/strings.xml | 7 + app/src/main/res/values-fr/strings.xml | 7 + app/src/main/res/values-hu/strings.xml | 7 + app/src/main/res/values-it/strings.xml | 7 + app/src/main/res/values-ja/strings.xml | 7 + app/src/main/res/values-pt/strings.xml | 7 + app/src/main/res/values-ru/strings.xml | 7 + app/src/main/res/values-zh-rCN/strings.xml | 7 + app/src/main/res/values/arrays.xml | 13 ++ app/src/main/res/values/strings.xml | 7 + .../res/values/strings_app_restrictions.xml | 54 +++++ app/src/main/res/xml/app_restrictions.xml | 109 ++++++++++ gradle/libs.versions.toml | 3 + netbird | 2 +- tool/build.gradle.kts | 1 + .../io/netbird/client/tool/EngineRunner.java | 41 ++++ .../java/io/netbird/client/tool/IFace.java | 23 ++- .../io/netbird/client/tool/MDMBridge.java | 117 +++++++++++ .../netbird/client/tool/MDMPolicyFetcher.java | 100 +++++++++ .../netbird/client/tool/MDMRestrictions.java | 179 +++++++++++++++++ .../netbird/client/tool/MDMSplitTunnel.java | 109 ++++++++++ .../client/tool/ManagedConfiguration.java | 104 ++++++++++ .../client/tool/ProfileManagerWrapper.java | 3 + .../io/netbird/client/tool/VPNService.java | 55 +++++ .../client/tool/MDMRestrictionsTest.java | 101 ++++++++++ .../client/tool/MDMSplitTunnelTest.java | 89 ++++++++ .../client/tool/ManagedConfigurationTest.java | 100 +++++++++ 41 files changed, 1848 insertions(+), 91 deletions(-) create mode 100644 app/src/main/java/io/netbird/client/ui/MDMLock.java create mode 100644 app/src/main/res/values/arrays.xml create mode 100644 app/src/main/res/values/strings_app_restrictions.xml create mode 100644 app/src/main/res/xml/app_restrictions.xml create mode 100644 tool/src/main/java/io/netbird/client/tool/MDMBridge.java create mode 100644 tool/src/main/java/io/netbird/client/tool/MDMPolicyFetcher.java create mode 100644 tool/src/main/java/io/netbird/client/tool/MDMRestrictions.java create mode 100644 tool/src/main/java/io/netbird/client/tool/MDMSplitTunnel.java create mode 100644 tool/src/main/java/io/netbird/client/tool/ManagedConfiguration.java create mode 100644 tool/src/test/java/io/netbird/client/tool/MDMRestrictionsTest.java create mode 100644 tool/src/test/java/io/netbird/client/tool/MDMSplitTunnelTest.java create mode 100644 tool/src/test/java/io/netbird/client/tool/ManagedConfigurationTest.java diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 257d36ab..a099501b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -44,6 +44,11 @@ android:theme="@style/Theme.NetBird" tools:targetApi="33"> + + + { int destId = destination.getId(); @@ -451,6 +461,10 @@ public void onConfigurationChanged(@NonNull Configuration newConfig) { @Override protected void onPause() { super.onPause(); + if (mdmPolicyReceiver != null) { + unregisterReceiver(mdmPolicyReceiver); + mdmPolicyReceiver = null; + } } @Override @@ -459,6 +473,70 @@ protected void onResume() { // Profiles are switched and deleted from a fragment, which reports // neither, so re-read the active one whenever we come back into view. syncSshSessionProfile(); + + // A policy can change while the app is in the background, where the + // service's receiver acts on the engine but no screen is there to react. + listenForMDMPolicy(); + MDMBridge.refresh(this); + rebuildIfMDMPolicyChanged(); + } + + /** + * Takes the screen down to what the policy leaves of it. + * + * Networks is a whole tab, so it goes from the bar rather than staying as a + * destination the user can reach and find empty. Everything finer-grained is + * each screen's own business. + */ + private void applyMDMPolicy(NavigationBarView bottomNav) { + appliedMDMSnapshot = MDMBridge.snapshotToken(this); + MDMRestrictions restrictions = MDMBridge.restrictions(this); + if (!restrictions.features.disableNetworks) { + return; + } + bottomNav.getMenu().findItem(R.id.nav_networks).setVisible(false); + NavDestination current = navController.getCurrentDestination(); + if (current != null && current.getId() == R.id.nav_networks) { + navController.navigate(R.id.nav_home); + } + } + + /** + * Rebuilds the screen when the policy is not the one it was built from — + * which is also how a withdrawn policy gives the user their settings back. + */ + private void rebuildIfMDMPolicyChanged() { + if (appliedMDMSnapshot.equals(MDMBridge.snapshotToken(this))) { + return; + } + Log.d(LOGTAG, "MDM policy changed, rebuilding the screen"); + recreate(); + } + + /** + * The service applies a changed policy to the engine and says so; this is + * the half the user sees. Registered only while the screen is in view — a + * toast has nobody to reach otherwise, and onResume catches up on anything + * missed. + */ + private void listenForMDMPolicy() { + if (mdmPolicyReceiver != null) { + return; + } + mdmPolicyReceiver = new android.content.BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + Toast.makeText(MainActivity.this, R.string.mdm_policy_applied, Toast.LENGTH_LONG).show(); + MDMBridge.refresh(MainActivity.this); + rebuildIfMDMPolicyChanged(); + } + }; + ContextCompat.registerReceiver( + this, + mdmPolicyReceiver, + new android.content.IntentFilter(VPNService.ACTION_MDM_POLICY_APPLIED), + ContextCompat.RECEIVER_NOT_EXPORTED + ); } private void syncSshSessionProfile() { diff --git a/app/src/main/java/io/netbird/client/ui/MDMLock.java b/app/src/main/java/io/netbird/client/ui/MDMLock.java new file mode 100644 index 00000000..d326274b --- /dev/null +++ b/app/src/main/java/io/netbird/client/ui/MDMLock.java @@ -0,0 +1,147 @@ +package io.netbird.client.ui; + +import android.content.Context; +import android.util.TypedValue; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewParent; +import android.widget.LinearLayout; +import android.widget.TextView; + +import androidx.core.content.ContextCompat; + +import io.netbird.client.R; + +/** + * How a screen shows that an administrator decided a setting. + * + * One convention, applied everywhere, so the app cannot drift into several + * answers to the same situation: + * + * + * The locking is done in code rather than in the layouts because the same rows + * are ordinary, editable settings on an unmanaged device, which is the common + * case. + */ +public final class MDMLock { + + private static final float DIMMED = 0.5f; + private static final String NOTICE_TAG = "mdm_managed_notice"; + + private MDMLock() { + } + + /** + * Locks a settings row: the row stops responding, the controls in it are + * disabled, and a line naming the organisation is added underneath. + * + * @param row the tappable row, whose click listener is dropped + * @param controls the switches, fields or buttons inside it + */ + public static void lock(View row, View... controls) { + if (row == null) { + return; + } + row.setOnClickListener(null); + row.setClickable(false); + row.setFocusable(false); + row.setAlpha(DIMMED); + disable(controls); + addNotice(row); + } + + /** + * Locks controls that do not sit in a row of their own — a text field with + * its save button, say — and explains it underneath the last of them. + */ + public static void lockControls(View... controls) { + disable(controls); + if (controls.length > 0) { + addNotice(controls[controls.length - 1]); + } + } + + /** + * Hides a row the policy takes away, along with the divider that follows it, + * which the layouts include without an id of its own. + */ + public static void hide(View row) { + if (row == null) { + return; + } + row.setVisibility(View.GONE); + View next = nextSibling(row); + // Only a bare View: every other row and section header is some subclass, + // so this cannot swallow the heading of the section that comes next. + if (next != null && next.getClass() == View.class) { + next.setVisibility(View.GONE); + } + } + + private static void disable(View... controls) { + for (View control : controls) { + if (control != null) { + control.setEnabled(false); + control.setAlpha(DIMMED); + } + } + } + + /** + * Adds the "managed by your organization" line under a view. + * + * Only where the layout is a vertical column, which is what the settings + * screens are; anywhere else the caption is left out rather than dropped into + * a layout that would place it somewhere surprising. + */ + private static void addNotice(View anchor) { + ViewParent parent = anchor.getParent(); + if (!(parent instanceof LinearLayout)) { + return; + } + LinearLayout column = (LinearLayout) parent; + if (column.getOrientation() != LinearLayout.VERTICAL) { + return; + } + int at = column.indexOfChild(anchor) + 1; + if (at < column.getChildCount() && NOTICE_TAG.equals(column.getChildAt(at).getTag())) { + // Already explained: the screens re-apply the policy on every resume. + return; + } + + Context context = column.getContext(); + TextView notice = new TextView(context); + notice.setTag(NOTICE_TAG); + notice.setText(R.string.mdm_managed_by_organization); + notice.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12); + notice.setTextColor(ContextCompat.getColor(context, R.color.nb_txt_light)); + + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + int side = dp(context, 20); + params.setMargins(side, dp(context, 6), side, dp(context, 8)); + notice.setLayoutParams(params); + + column.addView(notice, at); + } + + private static View nextSibling(View view) { + ViewParent parent = view.getParent(); + if (!(parent instanceof ViewGroup)) { + return null; + } + ViewGroup group = (ViewGroup) parent; + int at = group.indexOfChild(view) + 1; + return at < group.getChildCount() ? group.getChildAt(at) : null; + } + + private static int dp(Context context, int value) { + return Math.round(value * context.getResources().getDisplayMetrics().density); + } +} diff --git a/app/src/main/java/io/netbird/client/ui/advanced/AdvancedFragment.java b/app/src/main/java/io/netbird/client/ui/advanced/AdvancedFragment.java index 9bdd253d..a7756d19 100644 --- a/app/src/main/java/io/netbird/client/ui/advanced/AdvancedFragment.java +++ b/app/src/main/java/io/netbird/client/ui/advanced/AdvancedFragment.java @@ -17,8 +17,11 @@ import io.netbird.client.R; import io.netbird.client.databinding.FragmentAdvancedBinding; +import io.netbird.client.tool.MDMBridge; +import io.netbird.client.tool.MDMRestrictions; import io.netbird.client.tool.Preferences; import io.netbird.client.tool.ProfileManagerWrapper; +import io.netbird.client.ui.MDMLock; public class AdvancedFragment extends Fragment implements ThemePickerSheet.OnThemeChangedListener { @@ -28,6 +31,7 @@ public class AdvancedFragment extends Fragment implements ThemePickerSheet.OnThe private FragmentAdvancedBinding binding; private io.netbird.gomobile.android.Preferences goPreferences; + private MDMRestrictions mdm = MDMRestrictions.EMPTY; private void showReconnectionNeededWarningDialog() { final View dialogView = getLayoutInflater().inflate(R.layout.dialog_simple_alert_message, null); @@ -66,7 +70,8 @@ public View onCreateView(@NonNull LayoutInflater inflater, } catch (Exception e) { throw new RuntimeException("Failed to get config path: " + e.getMessage(), e); } - goPreferences = new io.netbird.gomobile.android.Preferences(configFilePath); + goPreferences = MDMBridge.openPreferences(inflater.getContext(), configFilePath); + mdm = MDMBridge.restrictions(inflater.getContext()); binding = FragmentAdvancedBinding.inflate(inflater, container, false); View root = binding.getRoot(); @@ -98,9 +103,8 @@ public View onCreateView(@NonNull LayoutInflater inflater, // Rosenpass settings try { binding.switchRosenpass.setChecked(goPreferences.getRosenpassEnabled()); - if (!binding.switchRosenpass.isChecked()) { - binding.switchRosenpassPermissive.setEnabled(false); - } else { + setPermissiveEnabled(binding.switchRosenpass.isChecked()); + if (binding.switchRosenpass.isChecked()) { binding.switchRosenpassPermissive.setChecked(goPreferences.getRosenpassPermissive()); } @@ -108,38 +112,23 @@ public View onCreateView(@NonNull LayoutInflater inflater, Log.e(LOGTAG, "Error getting Rosenpass settings", e); Toast.makeText(inflater.getContext(), getString(R.string.error_generic, e.toString()), Toast.LENGTH_SHORT).show(); binding.switchRosenpass.setChecked(false); - binding.switchRosenpassPermissive.setEnabled(false); + setPermissiveEnabled(false); } binding.switchRosenpass.setOnCheckedChangeListener((buttonView, isChecked) -> { - if (isChecked) { - goPreferences.setRosenpassEnabled(true); - binding.switchRosenpassPermissive.setEnabled(true); - - } else { - goPreferences.setRosenpassEnabled(false); - binding.switchRosenpassPermissive.setEnabled(false); + goPreferences.setRosenpassEnabled(isChecked); + setPermissiveEnabled(isChecked); + if (!isChecked) { binding.switchRosenpassPermissive.setChecked(false); } - - try { - goPreferences.commit(); - } catch (Exception e) { - Log.e(LOGTAG, "Error committing Rosenpass settings", e); - Toast.makeText(inflater.getContext(), getString(R.string.error_generic, e.toString()), Toast.LENGTH_SHORT).show(); - } + commit(); }); binding.layoutRosenpas.setOnClickListener(v -> binding.switchRosenpass.toggle()); binding.switchRosenpassPermissive.setOnCheckedChangeListener((buttonView, isChecked) -> { goPreferences.setRosenpassPermissive(isChecked); - try { - goPreferences.commit(); - } catch (Exception e) { - Log.e(LOGTAG, "Error committing Rosenpass settings", e); - Toast.makeText(inflater.getContext(), getString(R.string.error_generic, e.toString()), Toast.LENGTH_SHORT).show(); - } + commit(); }); binding.layoutRosenpassPermissive.setOnClickListener(v -> binding.switchRosenpassPermissive.toggle()); @@ -159,9 +148,92 @@ public View onCreateView(@NonNull LayoutInflater inflater, sheet.show(getChildFragmentManager(), "ThemePickerSheet"); }); + // Last: locking a row takes away the listeners installed above. + applyMDMPolicy(); + return root; } + /** + * Locks what an administrator decided. + * + * The switches already show the enforced values — the Go preferences answer + * with the policy's value for a managed key — so this only has to stop the + * user changing them and say why. disableUpdateSettings is the blunt case: + * the organisation allows no configuration changes at all, so every setting + * that reaches the config goes read-only. + * + * The theme row is left alone. It is how the screen looks to this user on + * this device, not configuration the organisation is managing. + */ + private void applyMDMPolicy() { + boolean everything = mdm.features.disableUpdateSettings; + + if (everything || mdm.mdm.preSharedKey) { + MDMLock.lockControls(binding.presharedKey, binding.btnSave); + } + lockRow(everything || mdm.mdm.rosenpassEnabled, + binding.layoutRosenpas, binding.switchRosenpass); + lockRow(everything || mdm.mdm.rosenpassPermissive, + binding.layoutRosenpassPermissive, binding.switchRosenpassPermissive); + lockRow(everything || mdm.mdm.allowServerSSH != null, + binding.layoutAllowSsh, binding.switchAllowSsh); + lockRow(everything || mdm.mdm.disableClientRoutes, + binding.layoutDisableClientRoutes, binding.switchDisableClientRoutes); + lockRow(everything || mdm.mdm.disableServerRoutes, + binding.layoutDisableServerRoutes, binding.switchDisableServerRoutes); + lockRow(everything || mdm.mdm.blockInbound, + binding.layoutBlockInbound, binding.switchBlockInbound); + lockRow(everything, binding.layoutDisableDns, binding.switchDisableDns); + lockRow(everything, binding.layoutDisableFirewall, binding.switchDisableFirewall); + lockRow(everything, binding.layoutDisableIpv6, binding.switchDisableIpv6); + lockRow(everything, binding.layoutForceRelayConnection, binding.switchForceRelayConnection); + } + + private void lockRow(boolean managed, View row, View control) { + if (managed) { + MDMLock.lock(row, control); + } + } + + /** + * Rosenpass permissive follows Rosenpass itself — off with it, on with it — + * unless the policy holds it, in which case it stays where the policy put it. + */ + private void setPermissiveEnabled(boolean enabled) { + boolean managed = mdm.features.disableUpdateSettings || mdm.mdm.rosenpassPermissive; + binding.switchRosenpassPermissive.setEnabled(enabled && !managed); + } + + /** Writes the staged settings, reporting a policy refusal as one. */ + private void commit() { + try { + goPreferences.commit(); + } catch (Exception e) { + reportWriteFailure(requireContext(), e); + } + } + + /** + * A write the policy refused is not an error the user can do anything about, + * so it is named as what it is. Go reports it with the offending keys, which + * are worth repeating: the screen locks what it knows is managed, and a + * refusal here means something managed that it did not know about. + */ + private void reportWriteFailure(Context context, Exception e) { + String keys = MDMRestrictions.rejectedKeys(e.getMessage()); + if (keys == null) { + Log.e(LOGTAG, "Failed to save the setting", e); + Toast.makeText(context, getString(R.string.error_generic, e.toString()), + Toast.LENGTH_SHORT).show(); + return; + } + Toast.makeText(context, keys.isEmpty() + ? getString(R.string.mdm_managed_setting) + : getString(R.string.mdm_managed_setting_keys, keys), + Toast.LENGTH_LONG).show(); + } + @Override public void onThemeChanged(int mode) { if (binding != null) { @@ -182,66 +254,38 @@ private void initializeEngineConfigSwitches() { // Set up change listeners binding.switchDisableClientRoutes.setOnCheckedChangeListener((buttonView, isChecked) -> { - try { - goPreferences.setDisableClientRoutes(isChecked); - goPreferences.commit(); - } catch (Exception e) { - Log.e(LOGTAG, "Failed to set disable client routes", e); - } + goPreferences.setDisableClientRoutes(isChecked); + commit(); }); binding.switchDisableServerRoutes.setOnCheckedChangeListener((buttonView, isChecked) -> { - try { - goPreferences.setDisableServerRoutes(isChecked); - goPreferences.commit(); - } catch (Exception e) { - Log.e(LOGTAG, "Failed to set disable server routes", e); - } + goPreferences.setDisableServerRoutes(isChecked); + commit(); }); binding.switchDisableDns.setOnCheckedChangeListener((buttonView, isChecked) -> { - try { - goPreferences.setDisableDNS(isChecked); - goPreferences.commit(); - } catch (Exception e) { - Log.e(LOGTAG, "Failed to set disable DNS", e); - } + goPreferences.setDisableDNS(isChecked); + commit(); }); binding.switchDisableFirewall.setOnCheckedChangeListener((buttonView, isChecked) -> { - try { - goPreferences.setDisableFirewall(isChecked); - goPreferences.commit(); - } catch (Exception e) { - Log.e(LOGTAG, "Failed to set disable firewall", e); - } + goPreferences.setDisableFirewall(isChecked); + commit(); }); binding.switchAllowSsh.setOnCheckedChangeListener((buttonView, isChecked) -> { - try { - goPreferences.setServerSSHAllowed(isChecked); - goPreferences.commit(); - } catch (Exception e) { - Log.e(LOGTAG, "Failed to set server SSH allowed", e); - } + goPreferences.setServerSSHAllowed(isChecked); + commit(); }); binding.switchBlockInbound.setOnCheckedChangeListener((buttonView, isChecked) -> { - try { - goPreferences.setBlockInbound(isChecked); - goPreferences.commit(); - } catch (Exception e) { - Log.e(LOGTAG, "Failed to set block inbound", e); - } + goPreferences.setBlockInbound(isChecked); + commit(); }); binding.switchDisableIpv6.setOnCheckedChangeListener((buttonView, isChecked) -> { - try { - goPreferences.setDisableIPv6(isChecked); - goPreferences.commit(); - } catch (Exception e) { - Log.e(LOGTAG, "Failed to set disable IPv6", e); - } + goPreferences.setDisableIPv6(isChecked); + commit(); }); // Make parent rows clickable to toggle switches (for TV remote) @@ -289,14 +333,13 @@ private void setPreSharedKey(String key, Context context) { Toast.makeText(context, context.getString(R.string.error_config_path, e.getMessage()), Toast.LENGTH_LONG).show(); return; } - io.netbird.gomobile.android.Preferences preferences = new io.netbird.gomobile.android.Preferences(configFilePath); + io.netbird.gomobile.android.Preferences preferences = MDMBridge.openPreferences(context, configFilePath); try { preferences.setPreSharedKey(key); preferences.commit(); Toast.makeText(context, R.string.advanced_presharedkey_saved_success, Toast.LENGTH_SHORT).show(); } catch (Exception e) { - Log.e(LOGTAG, "Failed to save pre-shared key", e); - Toast.makeText(context, R.string.advanced_presharedkey_save_error + ": " + e.getMessage(), Toast.LENGTH_LONG).show(); + reportWriteFailure(context, e); } } @@ -309,9 +352,12 @@ private boolean hasPreSharedKey(Context context) { Log.e(LOGTAG, "Failed to get config path", e); return false; } - io.netbird.gomobile.android.Preferences preferences = new io.netbird.gomobile.android.Preferences(configFilePath); + io.netbird.gomobile.android.Preferences preferences = MDMBridge.openPreferences(context, configFilePath); try { - return !preferences.getPreSharedKey().isEmpty(); + // Asks whether there is a key rather than for the key itself: a + // policy-supplied one is never handed to the native layer, and the + // field only ever shows the placeholder anyway. + return preferences.hasPreSharedKey(); } catch (Exception e) { return false; } diff --git a/app/src/main/java/io/netbird/client/ui/fistinstall/FirstInstallFragment.java b/app/src/main/java/io/netbird/client/ui/fistinstall/FirstInstallFragment.java index 6a4dc142..f6b90129 100644 --- a/app/src/main/java/io/netbird/client/ui/fistinstall/FirstInstallFragment.java +++ b/app/src/main/java/io/netbird/client/ui/fistinstall/FirstInstallFragment.java @@ -23,7 +23,10 @@ import io.netbird.client.PlatformUtils; import io.netbird.client.R; import io.netbird.client.databinding.FragmentFirstinstallBinding; +import io.netbird.client.tool.MDMBridge; +import io.netbird.client.tool.MDMRestrictions; import io.netbird.client.tool.ProfileManagerWrapper; +import io.netbird.client.ui.MDMLock; import io.netbird.client.ui.PreferenceUI; import io.netbird.client.ui.server.ManagementServerSwitch; import io.netbird.client.ui.server.ManagementUrl; @@ -85,6 +88,28 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat binding.txtAndroidtvBeta.setVisibility(View.VISIBLE); binding.btnContinue.postDelayed(() -> binding.btnContinue.requestFocus(), 200); } + + applyMDMPolicy(view); + } + + /** + * On a device that is enrolled before it is handed over, the server is + * already decided. Showing it and locking it is more honest than an empty + * field the user fills in only for the policy to overrule them at the first + * connection. + */ + private void applyMDMPolicy(View root) { + MDMRestrictions restrictions = MDMBridge.restrictions(requireContext()); + if (!restrictions.mdm.managesManagementURL()) { + return; + } + if (!ManagementUrl.isCloud(restrictions.mdm.managementURL)) { + serverSwitch.setSelfHostedSilently(true); + binding.editTextServerUrl.setText(restrictions.mdm.managementURL); + binding.editTextServerUrl.setVisibility(View.VISIBLE); + } + serverSwitch.setEnabled(false); + MDMLock.lockControls(root.findViewById(R.id.toggle_server_mode), binding.editTextServerUrl); } private void onModeChanged(boolean selfHosted) { @@ -170,7 +195,7 @@ private void applyServer(String managementUrl, String setupKey) { } try { - Preferences preferences = Android.newPreferences(configPath); + Preferences preferences = MDMBridge.openPreferences(requireContext(), configPath); preferences.setManagementURL(managementUrl); preferences.commit(); } catch (Exception e) { @@ -188,7 +213,7 @@ private void applyServer(String managementUrl, String setupKey) { setBusy(true); Auth auth; try { - auth = Android.newAuth(configPath, managementUrl); + auth = MDMBridge.newAuth(requireContext(), configPath, managementUrl); } catch (Exception e) { Log.e(TAG, "Failed to create authenticator", e); setBusy(false); diff --git a/app/src/main/java/io/netbird/client/ui/home/NetworksFragment.java b/app/src/main/java/io/netbird/client/ui/home/NetworksFragment.java index 05b3be16..9269e4ce 100644 --- a/app/src/main/java/io/netbird/client/ui/home/NetworksFragment.java +++ b/app/src/main/java/io/netbird/client/ui/home/NetworksFragment.java @@ -27,6 +27,8 @@ import io.netbird.client.ServiceAccessor; import io.netbird.client.StateListenerRegistry; import io.netbird.client.databinding.FragmentNetworksBinding; +import io.netbird.client.tool.MDMBridge; +import io.netbird.client.tool.ProfileManagerWrapper; public class NetworksFragment extends Fragment { @@ -84,6 +86,11 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat resourcesRecyclerView.setAdapter(adapter); resourcesRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext())); + if (clientRoutesDisabledByPolicy()) { + showRoutesManagedByPolicy(); + return; + } + model.getUiState().observe(getViewLifecycleOwner(), uiState -> { resources.clear(); resources.addAll(uiState.getResources()); @@ -125,6 +132,35 @@ public void onDestroyView() { super.onDestroyView(); } + /** + * Whether the policy has turned client routes off altogether. + * + * The managed flag alone is not enough: an administrator can manage the key + * and leave routes on, and the list is perfectly useful then. The value is + * read through the Go preferences, which answer with the policy's. + */ + private boolean clientRoutesDisabledByPolicy() { + if (!MDMBridge.restrictions(requireContext()).mdm.disableClientRoutes) { + return false; + } + try { + String configPath = new ProfileManagerWrapper(requireContext()).getActiveConfigPath(); + return MDMBridge.openPreferences(requireContext(), configPath).getDisableClientRoutes(); + } catch (Exception e) { + return false; + } + } + + /** + * Says so instead of offering switches that select routes the engine has + * been told not to use. + */ + private void showRoutesManagedByPolicy() { + binding.networksList.setVisibility(View.GONE); + binding.zeroPeerLayout.getRoot().setVisibility(View.GONE); + binding.routesManagedNotice.setVisibility(View.VISIBLE); + } + private void updateResourcesCounter(List resources) { TextView textPeersCount = binding.textOpenPanel; int connected = 0; diff --git a/app/src/main/java/io/netbird/client/ui/profile/ProfileEditorDialog.java b/app/src/main/java/io/netbird/client/ui/profile/ProfileEditorDialog.java index 47f3280b..f6cb03fc 100644 --- a/app/src/main/java/io/netbird/client/ui/profile/ProfileEditorDialog.java +++ b/app/src/main/java/io/netbird/client/ui/profile/ProfileEditorDialog.java @@ -23,10 +23,13 @@ import io.netbird.client.R; import io.netbird.client.tool.Profile; import io.netbird.client.tool.ProfileManagerWrapper; +import io.netbird.client.ui.MDMLock; import io.netbird.client.ui.server.ManagementServerSwitch; import io.netbird.client.ui.server.ManagementUrl; import io.netbird.client.ui.server.SetupKeySection; import io.netbird.gomobile.android.Android; +import io.netbird.client.tool.MDMBridge; +import io.netbird.client.tool.MDMRestrictions; import io.netbird.gomobile.android.Auth; import io.netbird.gomobile.android.ErrListener; import io.netbird.gomobile.android.Preferences; @@ -132,12 +135,34 @@ private void showDialog() { cancelButton.setOnClickListener(v -> dialog.dismiss()); okButton.setOnClickListener(v -> onSubmit()); + applyMDMPolicy(dialogView); + dialog.show(); nameInput.requestFocus(); // Editing usually means overwriting the name, so pre-select it. nameInput.selectAll(); } + /** + * A management server the organisation decided is not the profile's to + * change. The field already shows the enforced value — the Go preferences + * answer with it for a managed key — so this only ends the editing of it + * and says why. + */ + private void applyMDMPolicy(View dialogView) { + MDMRestrictions restrictions = MDMBridge.restrictions(context); + if (!restrictions.mdm.managesManagementURL()) { + return; + } + if (!ManagementUrl.isCloud(restrictions.mdm.managementURL)) { + serverSwitch.setSelfHostedSilently(true); + urlInput.setText(restrictions.mdm.managementURL); + urlInput.setVisibility(View.VISIBLE); + } + serverSwitch.setEnabled(false); + MDMLock.lockControls(dialogView.findViewById(R.id.toggle_server_mode), urlInput); + } + private int submitLabel() { return isEditing() ? R.string.profiles_dialog_edit_submit : R.string.profiles_add; } @@ -177,7 +202,7 @@ private void seedFromProfile() { private String readManagementUrl() { try { String configPath = profileManager.getConfigPath(editing.getID()); - return Android.newPreferences(configPath).getManagementURL(); + return MDMBridge.openPreferences(context, configPath).getManagementURL(); } catch (Exception e) { // A profile that has never connected may not have a URL stored yet; // fall back to Cloud rather than blocking the edit. @@ -282,8 +307,7 @@ private void updateProfile(String name, String managementUrl) { Log.e(TAG, "Failed to update management URL", e); // The rename above may already have gone through; report the // server failure rather than silently keeping the old URL. - urlInput.setError(context.getString(R.string.profiles_dialog_url_invalid)); - urlInput.requestFocus(); + showUrlWriteFailure(e); return; } } @@ -294,9 +318,22 @@ private void updateProfile(String name, String managementUrl) { } } + /** + * A URL the policy refused is not a malformed one. Saying which it was is + * the difference between the user hunting for a typo that is not there and + * understanding that the server is not theirs to choose. + */ + private void showUrlWriteFailure(Exception e) { + boolean refused = MDMRestrictions.rejectedKeys(e.getMessage()) != null; + urlInput.setError(context.getString(refused + ? R.string.mdm_managed_setting + : R.string.profiles_dialog_url_invalid)); + urlInput.requestFocus(); + } + private void writeManagementUrl(String profileId, String managementUrl) throws Exception { String configPath = profileManager.getConfigPath(profileId); - Preferences preferences = Android.newPreferences(configPath); + Preferences preferences = MDMBridge.openPreferences(context, configPath); preferences.setManagementURL(managementUrl); preferences.commit(); } @@ -319,8 +356,7 @@ private void createProfile(String name, String managementUrl) { // Roll back: don't leave behind a profile pointing at the // cloud server when the user asked for a self-hosted one. rollBack(created); - urlInput.setError(context.getString(R.string.profiles_dialog_url_invalid)); - urlInput.requestFocus(); + showUrlWriteFailure(e); return; } } @@ -357,7 +393,7 @@ private void enrollWithSetupKey(Profile created, String managementUrl, String ke setChecking(true); Auth auth; try { - auth = Android.newAuth(configPath, managementUrl); + auth = MDMBridge.newAuth(context, configPath, managementUrl); } catch (Exception e) { Log.e(TAG, "Failed to create authenticator", e); setChecking(false); diff --git a/app/src/main/java/io/netbird/client/ui/profile/ProfilesFragment.java b/app/src/main/java/io/netbird/client/ui/profile/ProfilesFragment.java index 7f181dcf..d120acf4 100644 --- a/app/src/main/java/io/netbird/client/ui/profile/ProfilesFragment.java +++ b/app/src/main/java/io/netbird/client/ui/profile/ProfilesFragment.java @@ -24,6 +24,7 @@ import java.util.List; import io.netbird.client.R; +import io.netbird.client.tool.MDMBridge; import io.netbird.client.tool.Profile; import io.netbird.client.tool.ProfileManagerWrapper; @@ -35,6 +36,8 @@ public class ProfilesFragment extends Fragment { private ProfileManagerWrapper profileManager; private ProfileUsageTracker usageTracker; private final List profiles = new ArrayList<>(); + /** True while the organisation pins one profile and offers no others. */ + private boolean managed; @Nullable @Override @@ -48,24 +51,30 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c recyclerView = view.findViewById(R.id.recycler_profiles); recyclerView.setLayoutManager(new LinearLayoutManager(requireContext())); + managed = MDMBridge.restrictions(requireContext()).features.disableProfiles; + adapter = new ProfilesAdapter(profiles, new ProfilesAdapter.ProfileActionListener() { @Override public void onSwitchProfile(Profile profile) { + if (refused()) return; showSwitchDialog(profile); } @Override public void onEditProfile(Profile profile) { + if (refused()) return; showEditDialog(profile); } @Override public void onLogoutProfile(Profile profile) { + if (refused()) return; showLogoutDialog(profile); } @Override public void onRemoveProfile(Profile profile) { + if (refused()) return; showRemoveDialog(profile); } }); @@ -73,12 +82,28 @@ public void onRemoveProfile(Profile profile) { FloatingActionButton btnAdd = view.findViewById(R.id.btn_add_profile); btnAdd.setOnClickListener(v -> showAddDialog()); + if (managed) { + btnAdd.setVisibility(GONE); + } loadProfiles(); return view; } + /** + * Settings does not offer this screen under the policy, but the back stack + * can still land on it, and switching or deleting a profile is not an action + * to leave half-guarded. + */ + private boolean refused() { + if (!managed) { + return false; + } + Toast.makeText(requireContext(), R.string.mdm_managed_setting, Toast.LENGTH_SHORT).show(); + return true; + } + private void loadProfiles() { profiles.clear(); List loadedProfiles = profileManager.listProfiles(); diff --git a/app/src/main/java/io/netbird/client/ui/settings/SettingsFragment.java b/app/src/main/java/io/netbird/client/ui/settings/SettingsFragment.java index 6e88456d..8980e04d 100644 --- a/app/src/main/java/io/netbird/client/ui/settings/SettingsFragment.java +++ b/app/src/main/java/io/netbird/client/ui/settings/SettingsFragment.java @@ -18,8 +18,11 @@ import io.netbird.client.R; import io.netbird.client.databinding.FragmentSettingsBinding; +import io.netbird.client.tool.MDMBridge; +import io.netbird.client.tool.MDMRestrictions; import io.netbird.client.tool.Profile; import io.netbird.client.tool.ProfileManagerWrapper; +import io.netbird.client.ui.MDMLock; import io.netbird.client.ui.profile.ProfileEditorDialog; public class SettingsFragment extends Fragment { @@ -77,6 +80,31 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat }); setVersionText(); + + // After the listeners above: locking a row takes its listener away. + applyMDMPolicy(); + } + + /** + * What an administrator has taken off this screen. + * + * Profiles and the advanced section go away entirely — an organisation that + * pins one configuration has no use for a screen offering others. The server + * row stays: it is where the user sees which server they are on, so it is + * locked rather than hidden. + */ + private void applyMDMPolicy() { + MDMRestrictions restrictions = MDMBridge.restrictions(requireContext()); + + if (restrictions.features.disableProfiles) { + MDMLock.hide(binding.rowProfiles); + } + if (restrictions.mdm.hidesAdvancedView()) { + MDMLock.hide(binding.rowAdvanced); + } + if (restrictions.mdm.managesManagementURL() || restrictions.features.disableUpdateSettings) { + MDMLock.lock(binding.rowChangeServer); + } } @Override diff --git a/app/src/main/java/io/netbird/client/ui/splittunneling/AppListAdapter.java b/app/src/main/java/io/netbird/client/ui/splittunneling/AppListAdapter.java index 10129015..cb1085c5 100644 --- a/app/src/main/java/io/netbird/client/ui/splittunneling/AppListAdapter.java +++ b/app/src/main/java/io/netbird/client/ui/splittunneling/AppListAdapter.java @@ -29,6 +29,7 @@ public interface OnAppToggledListener { private Set selected; private SplitTunnelConfig.Mode mode; private String filterQueryString = ""; + private boolean readOnly; public AppListAdapter(Set selected, SplitTunnelConfig.Mode mode, OnAppToggledListener toggleListener) { @@ -50,6 +51,15 @@ public void setSelected(Set selected, SplitTunnelConfig.Mode mode) { notifyDataSetChanged(); } + /** + * Shows the selection without offering to change it, for when an + * administrator decided which applications the tunnel carries. + */ + public void setReadOnly(boolean readOnly) { + this.readOnly = readOnly; + notifyDataSetChanged(); + } + public void filterBySearchQuery(String query) { filterQueryString = query == null ? "" : query; applyFilter(); @@ -112,10 +122,21 @@ void bind(AppEntry app) { } binding.switchControl.setChecked(selected.contains(app.getPackageName())); + binding.appNote.setVisibility(View.GONE); + + if (readOnly) { + // The selection belongs to the policy: the row reports what the + // tunnel does with this app instead of offering a choice. + binding.switchControl.setEnabled(false); + binding.getRoot().setAlpha(0.6f); + binding.getRoot().setOnClickListener(null); + binding.getRoot().setClickable(false); + return; + } + binding.switchControl.setEnabled(true); binding.switchControl.setOnCheckedChangeListener((buttonView, isChecked) -> toggleListener.onAppToggled(app.getPackageName(), isChecked)); - binding.appNote.setVisibility(View.GONE); binding.getRoot().setAlpha(1f); binding.getRoot().setOnClickListener(v -> binding.switchControl.toggle()); } diff --git a/app/src/main/java/io/netbird/client/ui/splittunneling/SplitTunnelingFragment.java b/app/src/main/java/io/netbird/client/ui/splittunneling/SplitTunnelingFragment.java index 1fe398bc..f3f9b316 100644 --- a/app/src/main/java/io/netbird/client/ui/splittunneling/SplitTunnelingFragment.java +++ b/app/src/main/java/io/netbird/client/ui/splittunneling/SplitTunnelingFragment.java @@ -22,8 +22,10 @@ import io.netbird.client.R; import io.netbird.client.ServiceAccessor; import io.netbird.client.databinding.FragmentSplitTunnelingBinding; +import io.netbird.client.tool.MDMBridge; import io.netbird.client.tool.SplitTunnelConfig; import io.netbird.client.tool.SplitTunnelStore; +import io.netbird.client.ui.MDMLock; /** * Lets the user say which applications the tunnel carries. @@ -46,6 +48,8 @@ public class SplitTunnelingFragment extends Fragment private SplitTunnelConfig.Mode mode = SplitTunnelConfig.Mode.OFF; private final Set excluded = new HashSet<>(); private final Set included = new HashSet<>(); + /** True while an administrator decides the selection instead of the user. */ + private boolean managed; @Override public void onAttach(@NonNull Context context) { @@ -70,18 +74,23 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat super.onViewCreated(view, savedInstanceState); store = new SplitTunnelStore(requireContext()); - SplitTunnelConfig stored = store.load(); + SplitTunnelConfig stored = managedOrStored(); mode = stored.getMode(); excluded.addAll(stored.getExcluded()); included.addAll(stored.getIncluded()); pruneAlwaysExcluded(); adapter = new AppListAdapter(activeSelection(), mode, this); + adapter.setReadOnly(managed); binding.appsRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext())); binding.appsRecyclerView.setAdapter(adapter); - binding.rowMode.setOnClickListener(v -> - SplitTunnelModeSheet.newInstance(mode).show(getChildFragmentManager(), "split_tunnel_mode")); + if (managed) { + MDMLock.lock(binding.rowMode); + } else { + binding.rowMode.setOnClickListener(v -> + SplitTunnelModeSheet.newInstance(mode).show(getChildFragmentManager(), "split_tunnel_mode")); + } binding.searchView.addTextChangedListener(new TextWatcher() { @Override @@ -123,7 +132,7 @@ public void onModeChanged(SplitTunnelConfig.Mode newMode) { @Override public void onAppToggled(String packageName, boolean selected) { - if (SplitTunnelConfig.ALWAYS_EXCLUDED.contains(packageName)) { + if (managed || SplitTunnelConfig.ALWAYS_EXCLUDED.contains(packageName)) { return; } Set selection = activeSelection(); @@ -140,7 +149,23 @@ private Set activeSelection() { return mode == SplitTunnelConfig.Mode.INCLUDE ? included : excluded; } + /** + * The policy's selection when an administrator decided it, the user's own + * otherwise — the same answer the tunnel acts on, so the screen shows what + * is actually applied rather than a stored choice that is being overruled. + */ + private SplitTunnelConfig managedOrStored() { + SplitTunnelConfig policy = MDMBridge.managedSplitTunnel(requireContext()); + managed = policy != null; + return managed ? policy : store.load(); + } + private void save() { + // A managed selection is not the user's to keep: writing it into their + // store would leave it behind as their own choice once the policy goes. + if (managed) { + return; + } if (persist()) { serviceAccessor.applySplitTunneling(); } diff --git a/app/src/main/java/io/netbird/client/ui/troubleshoot/TroubleshootFragment.java b/app/src/main/java/io/netbird/client/ui/troubleshoot/TroubleshootFragment.java index 2a6edff7..96f4aade 100644 --- a/app/src/main/java/io/netbird/client/ui/troubleshoot/TroubleshootFragment.java +++ b/app/src/main/java/io/netbird/client/ui/troubleshoot/TroubleshootFragment.java @@ -66,7 +66,8 @@ public void onDestroyView() { private void initializeRemoteJobsSwitch(Context context) { try { String configFilePath = new ProfileManagerWrapper(context).getActiveConfigPath(); - io.netbird.gomobile.android.Preferences goPreferences = new io.netbird.gomobile.android.Preferences(configFilePath); + io.netbird.gomobile.android.Preferences goPreferences = + io.netbird.client.tool.MDMBridge.openPreferences(requireContext(), configFilePath); binding.switchAllowRemoteJobs.setChecked(goPreferences.getRemoteJobsAllowed()); binding.switchAllowRemoteJobs.setOnCheckedChangeListener((buttonView, isChecked) -> { try { diff --git a/app/src/main/res/layout/fragment_networks.xml b/app/src/main/res/layout/fragment_networks.xml index 347c8ccf..9db9a9c4 100644 --- a/app/src/main/res/layout/fragment_networks.xml +++ b/app/src/main/res/layout/fragment_networks.xml @@ -18,6 +18,25 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" /> + + + Keine App ausgewählt, daher nutzen weiterhin alle Apps das VPN. Wählen Sie mindestens eine aus. Wird immer vom VPN ausgenommen Kann das VPN nicht nutzen + + + Von Ihrer Organisation verwaltet + Diese Einstellung wird von Ihrer Organisation verwaltet und kann nicht geändert werden. + Diese Einstellung wird von Ihrer Organisation verwaltet und kann nicht geändert werden. (%s) + Netzwerkrouten werden von Ihrer Organisation verwaltet + Die NetBird-Konfiguration wurde durch Ihre IT-Richtlinie aktualisiert. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 2bfbc8bc..d8abc8ca 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -310,4 +310,11 @@ No hay ninguna aplicación seleccionada, así que todas siguen usando la VPN. Elija al menos una. Siempre fuera de la VPN No puede usar la VPN + + + Gestionado por su organización + Esta configuración está gestionada por su organización y no se puede cambiar. + Esta configuración está gestionada por su organización y no se puede cambiar. (%s) + Las rutas de red están gestionadas por su organización + La configuración de NetBird se actualizó según su política de TI. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index a37df577..0b736815 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -310,4 +310,11 @@ Aucune application sélectionnée, toutes continuent donc d\'utiliser le VPN. Choisissez-en au moins une. Toujours exclu du VPN Ne peut pas utiliser le VPN + + + Géré par votre organisation + Ce paramètre est géré par votre organisation et ne peut pas être modifié. + Ce paramètre est géré par votre organisation et ne peut pas être modifié. (%s) + Les routes réseau sont gérées par votre organisation + La configuration NetBird a été mise à jour par votre politique informatique. diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index e054a07c..9658c866 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -307,4 +307,11 @@ Nincs kijelölt alkalmazás, ezért továbbra is mind a VPN-t használja. Jelöljön ki legalább egyet. Mindig kimarad a VPN-ből Nem használhatja a VPN-t + + + A szervezete kezeli + Ezt a beállítást a szervezete kezeli, ezért nem módosítható. + Ezt a beállítást a szervezete kezeli, ezért nem módosítható. (%s) + A hálózati útvonalakat a szervezete kezeli + A NetBird beállításait az informatikai szabályzat frissítette. diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index a1d09564..825a3821 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -310,4 +310,11 @@ Nessuna app selezionata, quindi tutte continuano a usare la VPN. Scegline almeno una. Sempre esclusa dalla VPN Non può usare la VPN + + + Gestito dalla tua organizzazione + Questa impostazione è gestita dalla tua organizzazione e non può essere modificata. + Questa impostazione è gestita dalla tua organizzazione e non può essere modificata. (%s) + Le route di rete sono gestite dalla tua organizzazione + La configurazione di NetBird è stata aggiornata dai criteri IT. diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index daff79b5..5b3e0f93 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -308,4 +308,11 @@ アプリが選択されていないため、すべてのアプリが VPN を使用したままです。1 つ以上選択してください。 常に VPN から除外されます VPN を使用できません + + + 組織によって管理されています + この設定は組織によって管理されているため、変更できません。 + この設定は組織によって管理されているため、変更できません。(%s) + ネットワークルートは組織によって管理されています + NetBird の設定が IT ポリシーによって更新されました。 diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index c5c602b6..7c3d3d9e 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -310,4 +310,11 @@ Nenhum aplicativo selecionado, então todos continuam usando a VPN. Escolha pelo menos um. Sempre fora da VPN Não pode usar a VPN + + + Gerenciado pela sua organização + Esta configuração é gerenciada pela sua organização e não pode ser alterada. + Esta configuração é gerenciada pela sua organização e não pode ser alterada. (%s) + As rotas de rede são gerenciadas pela sua organização + A configuração do NetBird foi atualizada pela política de TI. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 8ed43a21..37ad1f7b 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -316,4 +316,11 @@ Приложения не выбраны, поэтому VPN продолжают использовать все. Выберите хотя бы одно. Всегда исключено из VPN Не может использовать VPN + + + Управляется вашей организацией + Эта настройка управляется вашей организацией и не может быть изменена. + Эта настройка управляется вашей организацией и не может быть изменена. (%s) + Сетевые маршруты управляются вашей организацией + Конфигурация NetBird обновлена ИТ-политикой. diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 4b40dce6..c4415a1b 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -307,4 +307,11 @@ 未选择任何应用,因此所有应用仍会使用 VPN。请至少选择一个。 始终不使用 VPN 无法使用 VPN + + + 由您的组织管理 + 此设置由您的组织管理,无法更改。 + 此设置由您的组织管理,无法更改。(%s) + 网络路由由您的组织管理 + NetBird 配置已由您的 IT 策略更新。 diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml new file mode 100644 index 00000000..82f2ec75 --- /dev/null +++ b/app/src/main/res/values/arrays.xml @@ -0,0 +1,13 @@ + + + + + @string/restriction_split_tunnel_mode_allow + @string/restriction_split_tunnel_mode_disallow + + + allow + disallow + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d9873929..1b22c997 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -313,4 +313,11 @@ No app selected, so every app keeps using the VPN. Pick at least one. Always kept out of the VPN Cannot use the VPN + + + Managed by your organization + This setting is managed by your organization and cannot be changed. + This setting is managed by your organization and cannot be changed. (%s) + Network routes are managed by your organization + NetBird configuration was updated by your IT policy. diff --git a/app/src/main/res/values/strings_app_restrictions.xml b/app/src/main/res/values/strings_app_restrictions.xml new file mode 100644 index 00000000..c73c0743 --- /dev/null +++ b/app/src/main/res/values/strings_app_restrictions.xml @@ -0,0 +1,54 @@ + + + + Management server URL + The NetBird management server every profile connects to. Leave unset to let the user choose. + + Pre-shared key + WireGuard pre-shared key applied to peer connections. The user never sees the value. + + Post-quantum encryption + Enables Rosenpass key exchange in addition to WireGuard. + + Allow peers without post-quantum encryption + Connects to peers that do not offer Rosenpass, instead of refusing them. + + Disable client routes + Stops the device from using routes other peers advertise. + + Disable server routes + Stops the device from advertising routes to other peers. + + Block inbound connections + Drops connections opened by other peers towards this device. + + Allow the SSH server + Lets other peers open an SSH session to this device. + + Allow remote debug bundles + Lets the management server ask this device for a debug bundle. + + Split tunnelling mode + Whether the applications listed below are the only ones in the tunnel (allow), or the only ones kept out of it (disallow). + Only the listed applications use the VPN + The listed applications bypass the VPN + + Split tunnelling applications + Package names, separated by commas. Applications not installed on the device are ignored. + + Hide profiles + Removes profile switching, creation and removal from the app. + + Hide networks + Removes the Networks tab, leaving routing to the policy. + + Hide advanced settings + Removes the advanced settings screen from the app. + + Prevent changing settings + Leaves every setting visible but read-only. + diff --git a/app/src/main/res/xml/app_restrictions.xml b/app/src/main/res/xml/app_restrictions.xml new file mode 100644 index 00000000..ab4179e4 --- /dev/null +++ b/app/src/main/res/xml/app_restrictions.xml @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b70eec17..da45fa75 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,6 +2,7 @@ agp = "8.13.0" browser = "1.8.0" junit = "4.13.2" +json = "20240303" junitVersion = "1.2.1" espressoCore = "3.6.1" uiautomator = "2.3.0" @@ -21,6 +22,8 @@ zxing = "4.3.0" [libraries] browser = { module = "androidx.browser:browser", version.ref = "browser" } junit = { group = "junit", name = "junit", version.ref = "junit" } +# Real org.json for JVM unit tests: the one in android.jar is a stub that throws. +json = { group = "org.json", name = "json", version.ref = "json" } ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" } diff --git a/netbird b/netbird index 76ea7223..d2e62e35 160000 --- a/netbird +++ b/netbird @@ -1 +1 @@ -Subproject commit 76ea72237f3346ff157aa8374b9fa800d6498976 +Subproject commit d2e62e358a07333462fa1e60587ef2964af85b1f diff --git a/tool/build.gradle.kts b/tool/build.gradle.kts index 72c62f71..7f02851c 100644 --- a/tool/build.gradle.kts +++ b/tool/build.gradle.kts @@ -34,6 +34,7 @@ dependencies { implementation(libs.appcompat) implementation(libs.material) testImplementation(libs.junit) + testImplementation(libs.json) androidTestImplementation(libs.ext.junit) androidTestImplementation(libs.espresso.core) } \ No newline at end of file diff --git a/tool/src/main/java/io/netbird/client/tool/EngineRunner.java b/tool/src/main/java/io/netbird/client/tool/EngineRunner.java index ec68f97e..07b41cb1 100644 --- a/tool/src/main/java/io/netbird/client/tool/EngineRunner.java +++ b/tool/src/main/java/io/netbird/client/tool/EngineRunner.java @@ -37,6 +37,7 @@ class EngineRunner { private volatile SessionMonitor sessionMonitor; private final Client goClient; private ConnectionListener connectionListener; + private volatile boolean restartPending; public EngineRunner(Context context, NetworkChangeListener networkChangeListener, TunAdapter tunAdapter, IFaceDiscover iFaceDiscover, String versionName, boolean isTraceLogEnabled, boolean isDebuggable, @@ -53,6 +54,11 @@ public EngineRunner(Context context, NetworkChangeListener networkChangeListener iFaceDiscover, networkChangeListener); + // The engine reads MDM-managed values through this. Registering it on the + // client covers every config read the run loop makes, including the ones + // that happen before any screen has asked about the policy. + goClient.setMDMPolicyFetcher(MDMBridge.fetcher(context)); + updateLogLevel(isTraceLogEnabled, isDebuggable); // The Go-side subscriptions are client-scoped and survive engine @@ -196,6 +202,14 @@ private synchronized void runClient(@Nullable URLOpener urlOpener, boolean isAnd } Log.e(LOGTAG, "service stopped"); + if (restartPending) { + restartPending = false; + // Deliberately not an interactive start: a policy change must not + // pop a browser at a user who did nothing. If the new policy needs + // a login the run loop reports NeedsLogin, and the notification + // says so. + runClient(null, false); + } }; new Thread(r).start(); } @@ -347,6 +361,33 @@ public synchronized void removeServiceStateListener(ServiceStateListener service } public synchronized void stop() { + // A stop the user asked for outranks a restart waiting to happen: the + // engine coming back by itself after they turned it off would be the + // worst way to find out a policy had changed. + restartPending = false; + goClient.stop(); + } + + /** + * Re-reads the managed configuration and reports whether it changed since it + * was last asked. The comparison is the Go side's, so what counts as a change + * is decided in one place for every platform. + */ + public boolean hasMDMPolicyChanged() { + return goClient.hasMDMPolicyChanged(); + } + + /** + * Stops the engine and brings it back up, so a changed policy takes hold on a + * running tunnel. A no-op while the engine is down: the policy is read again + * when it next starts. + */ + public synchronized void restart() { + if (!engineIsRunning) { + return; + } + Log.d(LOGTAG, "restarting the engine to apply a new MDM policy"); + restartPending = true; goClient.stop(); } diff --git a/tool/src/main/java/io/netbird/client/tool/IFace.java b/tool/src/main/java/io/netbird/client/tool/IFace.java index 1635acac..9f06a10e 100644 --- a/tool/src/main/java/io/netbird/client/tool/IFace.java +++ b/tool/src/main/java/io/netbird/client/tool/IFace.java @@ -152,12 +152,13 @@ private void prepareDnsSetting(VpnService.Builder builder, String dns) { * The selection is read here rather than passed in because the tunnel is * also rebuilt from VPNService without going through the Go engine, and both * paths must see the same stored answer. It belongs to the active profile, - * so switching profile switches which applications the tunnel carries. + * so switching profile switches which applications the tunnel carries — + * unless an administrator has decided it, in which case the policy's + * selection is the one that reaches the interface. */ private void applyAppFilter(VpnService.Builder builder) { PackageManager packageManager = vpnService.getPackageManager(); - SplitTunnelConfig.Resolution resolution = new SplitTunnelStore(vpnService) - .load() + SplitTunnelConfig.Resolution resolution = effectiveSplitTunnel() .resolve(vpnService.getPackageName(), packageName -> { try { packageManager.getApplicationInfo(packageName, 0); @@ -185,6 +186,22 @@ private void applyAppFilter(VpnService.Builder builder) { + resolution.getPackages().size() + " package(s)"); } + /** + * The selection a policy imposes, or the user's own when it imposes none. + * + * Read on every tunnel build rather than cached: the tunnel is rebuilt when + * the policy changes, and that rebuild is the moment the new list has to take + * effect. + */ + private SplitTunnelConfig effectiveSplitTunnel() { + SplitTunnelConfig managed = MDMBridge.managedSplitTunnel(vpnService); + if (managed != null) { + Log.d(LOGTAG, "app filter is managed by the MDM policy"); + return managed; + } + return new SplitTunnelStore(vpnService).load(); + } + @SuppressLint("DefaultLocale") @Override public void updateAddr(String s) throws Exception { diff --git a/tool/src/main/java/io/netbird/client/tool/MDMBridge.java b/tool/src/main/java/io/netbird/client/tool/MDMBridge.java new file mode 100644 index 00000000..331ea00d --- /dev/null +++ b/tool/src/main/java/io/netbird/client/tool/MDMBridge.java @@ -0,0 +1,117 @@ +package io.netbird.client.tool; + +import android.content.Context; +import android.util.Log; + +import io.netbird.gomobile.android.Android; +import io.netbird.gomobile.android.Auth; +import io.netbird.gomobile.android.Preferences; + +/** + * The single place the app reaches the MDM layer. + * + * Two jobs. It hands the policy source to every Go object that makes a decision + * from it — a client, a profile manager, a preferences instance, a login — so a + * new call site cannot quietly end up without one and read unmanaged values. And + * it holds the enforcement snapshot the screens render from, re-read when the app + * comes back into view and when the OS reports the policy changed, rather than on + * every question a layout asks. + */ +public final class MDMBridge { + + private static final String LOGTAG = "MDMBridge"; + + private static volatile MDMPolicyFetcher fetcher; + private static volatile MDMRestrictions cached; + private static volatile String cachedToken = ""; + + private MDMBridge() { + } + + /** The process-wide policy source; the bundle behind it is re-read per call. */ + public static synchronized MDMPolicyFetcher fetcher(Context context) { + if (fetcher == null) { + fetcher = new MDMPolicyFetcher(context); + } + return fetcher; + } + + /** + * Opens the Go preferences for a config file with the policy source attached. + * Use this instead of {@code Android.newPreferences} — a bare instance reads + * and writes as though no policy existed, which would both show the user + * unmanaged values and let a write past a managed key. + */ + public static Preferences openPreferences(Context context, String configPath) { + Preferences preferences = Android.newPreferences(configPath); + preferences.setMDMPolicyFetcher(fetcher(context)); + return preferences; + } + + /** + * Builds an authenticator under the active policy. A managed management URL + * wins over the one passed in, which is decided on the Go side. + */ + public static Auth newAuth(Context context, String configPath, String managementUrl) throws Exception { + return Android.newAuth(configPath, managementUrl, fetcher(context)); + } + + /** + * The enforcement snapshot the screens render from. Cheap to call: the + * snapshot is kept until something says it may have changed. + */ + public static MDMRestrictions restrictions(Context context) { + MDMRestrictions snapshot = cached; + return snapshot != null ? snapshot : refresh(context); + } + + /** + * Re-reads the snapshot through the Go bridge. + * + * A throwaway preferences instance is used rather than a shared one because + * getRestrictionsJSON() consults only the policy loader — it never reads or + * writes the config file — so this is free of side effects on the profile. + */ + public static MDMRestrictions refresh(Context context) { + String json = read(context); + cachedToken = json; + cached = MDMRestrictions.decode(json); + return cached; + } + + /** + * A value that changes exactly when the snapshot does. + * + * The screens lock and hide their controls in code, which only goes one way: + * a screen already built cannot tell that a setting became editable again. + * Comparing this against the value a screen was built from says when it has + * to be built afresh, and covers a policy being withdrawn as well as applied. + */ + public static String snapshotToken(Context context) { + restrictions(context); + return cachedToken; + } + + /** + * The applications the policy says the tunnel carries, or null when it does + * not say. Read straight from the managed configuration rather than from the + * snapshot, which reports that the keys are managed but not which packages + * they name. + */ + public static SplitTunnelConfig managedSplitTunnel(Context context) { + return MDMSplitTunnel.fromManagedConfiguration(fetcher(context).fetchJSON()); + } + + private static String read(Context context) { + try { + String configPath = new ProfileManagerWrapper(context).getActiveConfigPath(); + return openPreferences(context, configPath).getRestrictionsJSON(); + } catch (Exception e) { + // No active profile yet, or a bridge that could not answer. Reporting + // "nothing is managed" leaves the app usable; claiming the opposite + // would lock a user out of settings over a transient failure. + Log.w(LOGTAG, "could not read the MDM restrictions", e); + return ""; + } + } +} diff --git a/tool/src/main/java/io/netbird/client/tool/MDMPolicyFetcher.java b/tool/src/main/java/io/netbird/client/tool/MDMPolicyFetcher.java new file mode 100644 index 00000000..87f8bb51 --- /dev/null +++ b/tool/src/main/java/io/netbird/client/tool/MDMPolicyFetcher.java @@ -0,0 +1,100 @@ +package io.netbird.client.tool; + +import android.content.Context; +import android.content.RestrictionsManager; +import android.os.Bundle; +import android.os.Parcelable; +import android.util.Log; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import io.netbird.gomobile.android.PolicyFetcher; + +/** + * Hands the Go MDM layer the configuration a device owner or EMM set for this + * app. + * + * Android delivers managed configuration per app, into the app's own process, + * so unlike iOS there is no second process to mirror it into: the service that + * runs the engine reads the same bundle the UI does. No managed configuration + * means an empty answer, and the client then behaves as if the feature did not + * exist. + * + * One instance can serve every Go object that needs a policy source — the + * bundle is re-read on each call, so a fetcher registered once keeps answering + * with the current policy rather than the one that was live at registration. + */ +public class MDMPolicyFetcher implements PolicyFetcher { + + private static final String LOGTAG = "MDMPolicyFetcher"; + + private final Context context; + + public MDMPolicyFetcher(Context context) { + // The application context: this outlives any activity, and the Go side + // keeps the fetcher for as long as the client lives. + this.context = context.getApplicationContext(); + } + + @Override + public String fetchJSON() { + try { + RestrictionsManager manager = + (RestrictionsManager) context.getSystemService(Context.RESTRICTIONS_SERVICE); + if (manager == null) { + return ""; + } + return ManagedConfiguration.encode(toMap(manager.getApplicationRestrictions())); + } catch (RuntimeException e) { + // Called from Go, sometimes on the engine's own goroutine: an + // exception escaping here would take the process down over a policy + // we could not read. An empty answer means the same as no MDM. + Log.w(LOGTAG, "could not read the managed configuration", e); + return ""; + } + } + + /** + * Flattens a restrictions bundle into a plain map, so the encoding rules can + * be tested without an Android runtime. + * + * Nested bundles and bundle arrays are what the OS produces for the + * {@code bundle} and {@code bundle_array} restriction types; they are + * carried across as objects and arrays of objects even though no key uses + * them yet, because dropping them silently would be the harder failure to + * explain later. + */ + @SuppressWarnings("deprecation") // Bundle.get: the typed getters cannot be + // used without knowing each key's type, which is exactly what this does not + // want to hardcode. + static Map toMap(Bundle bundle) { + Map out = new LinkedHashMap<>(); + if (bundle == null) { + return out; + } + for (String key : bundle.keySet()) { + Object value = bundle.get(key); + if (value instanceof Bundle) { + out.put(key, toMap((Bundle) value)); + } else if (value instanceof Parcelable[]) { + out.put(key, toList((Parcelable[]) value)); + } else if (value != null) { + out.put(key, value); + } + } + return out; + } + + private static List toList(Parcelable[] items) { + List out = new ArrayList<>(items.length); + for (Parcelable item : items) { + if (item instanceof Bundle) { + out.add(toMap((Bundle) item)); + } + } + return out; + } +} diff --git a/tool/src/main/java/io/netbird/client/tool/MDMRestrictions.java b/tool/src/main/java/io/netbird/client/tool/MDMRestrictions.java new file mode 100644 index 00000000..cb766050 --- /dev/null +++ b/tool/src/main/java/io/netbird/client/tool/MDMRestrictions.java @@ -0,0 +1,179 @@ +package io.netbird.client.tool; + +import org.json.JSONObject; + +import java.util.Locale; + +/** + * Java mirror of the UI enforcement snapshot returned by + * {@code getRestrictionsJSON()} — the same JSON shape the desktop frontend and + * the iOS client consume. Every MDM decision is made in Go; this type only + * carries the rendered answer so a screen can hide or lock a control. + * + * Semantics: + *
    + *
  • {@code mdm.managementURL} — the enforced value ("" = not managed)
  • + *
  • other {@code mdm.*} flags — true = the key is managed, lock the control
  • + *
  • {@code mdm.allowServerSSH}, {@code mdm.disableAdvancedView} — tri-state, + * null = not managed
  • + *
  • {@code features.*} — the enforced value of that gate
  • + *
+ * + * The fields are read directly rather than through getters: this is a carrier + * for a JSON shape defined elsewhere, and the names have to stay recognisable + * against the Go struct and the Swift mirror. + */ +public final class MDMRestrictions { + + /** + * The no-policy snapshot: nothing managed, nothing gated. Also the fallback + * whenever the bridge cannot be read, so a failure leaves the app fully + * usable instead of locking the user out of their own settings. + */ + public static final MDMRestrictions EMPTY = new MDMRestrictions(null); + + public static final class Fields { + public final String managementURL; + public final boolean preSharedKey; + public final boolean wireguardPort; + public final boolean rosenpassEnabled; + public final boolean rosenpassPermissive; + public final boolean disableClientRoutes; + public final boolean disableServerRoutes; + /** Tri-state: null = not managed. */ + public final Boolean allowServerSSH; + public final boolean disableAutoConnect; + public final boolean disableAutostart; + public final boolean blockInbound; + public final boolean disableMetricsCollection; + public final boolean splitTunnelMode; + public final boolean splitTunnelApps; + /** + * Tri-state, like {@link #allowServerSSH}: null means the key is not + * managed, and an explicit false means the section is allowed — only + * true hides it. + */ + public final Boolean disableAdvancedView; + + Fields(JSONObject json) { + if (json == null) { + json = new JSONObject(); + } + managementURL = json.optString("managementURL", ""); + preSharedKey = json.optBoolean("preSharedKey", false); + wireguardPort = json.optBoolean("wireguardPort", false); + rosenpassEnabled = json.optBoolean("rosenpassEnabled", false); + rosenpassPermissive = json.optBoolean("rosenpassPermissive", false); + disableClientRoutes = json.optBoolean("disableClientRoutes", false); + disableServerRoutes = json.optBoolean("disableServerRoutes", false); + allowServerSSH = triState(json, "allowServerSSH"); + disableAutoConnect = json.optBoolean("disableAutoConnect", false); + disableAutostart = json.optBoolean("disableAutostart", false); + blockInbound = json.optBoolean("blockInbound", false); + disableMetricsCollection = json.optBoolean("disableMetricsCollection", false); + splitTunnelMode = json.optBoolean("splitTunnelMode", false); + splitTunnelApps = json.optBoolean("splitTunnelApps", false); + disableAdvancedView = triState(json, "disableAdvancedView"); + } + + /** True when a management URL is enforced by policy. */ + public boolean managesManagementURL() { + return !managementURL.isEmpty(); + } + + /** + * Whether the advanced section must be hidden. Folds the tri-state so + * callers do not each have to decide what null means. + */ + public boolean hidesAdvancedView() { + return Boolean.TRUE.equals(disableAdvancedView); + } + + /** True when the policy dictates which applications the tunnel carries. */ + public boolean managesSplitTunnel() { + return splitTunnelMode || splitTunnelApps; + } + } + + public static final class Features { + public final boolean disableProfiles; + public final boolean disableNetworks; + public final boolean disableUpdateSettings; + + Features(JSONObject json) { + if (json == null) { + json = new JSONObject(); + } + disableProfiles = json.optBoolean("disableProfiles", false); + disableNetworks = json.optBoolean("disableNetworks", false); + disableUpdateSettings = json.optBoolean("disableUpdateSettings", false); + } + } + + public final Fields mdm; + public final Features features; + + private MDMRestrictions(JSONObject json) { + if (json == null) { + json = new JSONObject(); + } + mdm = new Fields(json.optJSONObject("mdm")); + features = new Features(json.optJSONObject("features")); + } + + /** + * Parses a snapshot produced by {@code getRestrictionsJSON()}. + * + * Never throws, and reads key by key rather than requiring the whole shape: + * a snapshot from a Go layer that has added or dropped a key still yields + * the keys this build knows, and anything unreadable degrades to + * {@link #EMPTY} so a malformed policy cannot brick the settings screens. + */ + public static MDMRestrictions decode(String json) { + if (json == null || json.isEmpty()) { + return EMPTY; + } + try { + return new MDMRestrictions(new JSONObject(json)); + } catch (Exception e) { + return EMPTY; + } + } + + /** + * Turns a rejected write into the keys the policy refused. + * + * Go wraps its MDM error with the offending keys — "fields managed by MDM + * cannot be modified: [rosenpassEnabled]" — so name them instead of + * discarding the half of the message that says which setting was refused. + * + * @return the keys, "" when the message names none, or null when the failure + * was not a policy refusal at all and the caller should report it as + * an ordinary error. The wording shown to the user lives in the + * string resources, not here. + */ + public static String rejectedKeys(String reason) { + if (reason == null || !reason.toLowerCase(Locale.ROOT).contains("managed by mdm")) { + return null; + } + int separator = reason.lastIndexOf(": "); + if (separator < 0) { + return ""; + } + String keys = reason.substring(separator + 2).trim(); + while (keys.startsWith("[")) { + keys = keys.substring(1); + } + while (keys.endsWith("]")) { + keys = keys.substring(0, keys.length() - 1); + } + return keys.trim(); + } + + private static Boolean triState(JSONObject json, String key) { + if (!json.has(key) || json.isNull(key)) { + return null; + } + return json.optBoolean(key, false); + } +} diff --git a/tool/src/main/java/io/netbird/client/tool/MDMSplitTunnel.java b/tool/src/main/java/io/netbird/client/tool/MDMSplitTunnel.java new file mode 100644 index 00000000..a6e5c189 --- /dev/null +++ b/tool/src/main/java/io/netbird/client/tool/MDMSplitTunnel.java @@ -0,0 +1,109 @@ +package io.netbird.client.tool; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; + +/** + * The split tunnelling selection a policy imposes, if any. + * + * This is the one MDM key Android has and iOS does not: the platform lets a VPN + * name the applications that stay out of the tunnel, or the only ones in it, so + * an administrator can decide it instead of the user. The policy shape is the + * desktop one — a mode discriminator plus a list of package names — mapped onto + * the modes the Android client already has. + * + * Only the mapping lives here. Whether the keys are managed at all is read from + * the Go snapshot ({@link MDMRestrictions.Fields#managesSplitTunnel()}), and how + * the resolved packages reach the interface stays in {@link SplitTunnelConfig}. + */ +public final class MDMSplitTunnel { + + static final String KEY_MODE = "splitTunnelMode"; + static final String KEY_APPS = "splitTunnelApps"; + + /** Only the named applications go through the tunnel. */ + static final String MODE_ALLOW = "allow"; + /** The named applications stay out of the tunnel; everything else goes in. */ + static final String MODE_DISALLOW = "disallow"; + + private MDMSplitTunnel() { + } + + /** + * Reads the enforced selection out of the managed configuration — the same + * JSON the Go loader is handed, so both sides act on one source. + * + * The values are read here rather than taken from the Go snapshot because + * the snapshot reports only that the keys are managed, not which + * applications they name: the desktop clients apply the list themselves and + * have no interface to hand it across. + * + * @return null when the policy does not dictate the selection, in which case + * the user's own stored choice stands. + */ + public static SplitTunnelConfig fromManagedConfiguration(String json) { + if (json == null || json.isEmpty()) { + return null; + } + try { + JSONObject policy = new JSONObject(json); + return of(policy.optString(KEY_MODE, ""), packagesOf(policy.opt(KEY_APPS))); + } catch (Exception e) { + // Unreadable policy: the user's own selection stands, which is the + // same answer as no policy at all. + return null; + } + } + + /** + * @return null for a mode this build does not know, so a value written for a + * newer client leaves the tunnel as the user configured it rather + * than silently taking some other mode's behaviour. + */ + static SplitTunnelConfig of(String mode, List packages) { + String normalized = mode == null ? "" : mode.trim().toLowerCase(Locale.ROOT); + if (MODE_ALLOW.equals(normalized)) { + return new SplitTunnelConfig(SplitTunnelConfig.Mode.INCLUDE, null, packages); + } + if (MODE_DISALLOW.equals(normalized)) { + return new SplitTunnelConfig(SplitTunnelConfig.Mode.EXCLUDE, packages, null); + } + return null; + } + + /** + * Accepts both shapes an administrator can end up sending: the list of + * strings an EMM console produces from a multi-select restriction, and the + * comma-separated string the desktop registry and plist keys use. + */ + static List packagesOf(Object value) { + if (value instanceof JSONArray) { + JSONArray array = (JSONArray) value; + List out = new ArrayList<>(array.length()); + for (int i = 0; i < array.length(); i++) { + add(out, array.optString(i, "")); + } + return out; + } + if (value instanceof String) { + List out = new ArrayList<>(); + for (String item : ((String) value).split(",")) { + add(out, item); + } + return out; + } + return Collections.emptyList(); + } + + private static void add(List out, String packageName) { + String trimmed = packageName == null ? "" : packageName.trim(); + if (!trimmed.isEmpty()) { + out.add(trimmed); + } + } +} diff --git a/tool/src/main/java/io/netbird/client/tool/ManagedConfiguration.java b/tool/src/main/java/io/netbird/client/tool/ManagedConfiguration.java new file mode 100644 index 00000000..24ff1b5a --- /dev/null +++ b/tool/src/main/java/io/netbird/client/tool/ManagedConfiguration.java @@ -0,0 +1,104 @@ +package io.netbird.client.tool; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.Map; + +/** + * Encodes an OS-managed configuration as the JSON object string the Go MDM + * loader reads. + * + * Deliberately free of Android types: the conversion rules are the part worth + * testing on the JVM, away from a device. Reading the values out of + * RestrictionsManager is {@link MDMPolicyFetcher}'s job. + * + * Every decision about what a key means stays in Go. This only carries the + * values across, keeping the types the managed configuration was delivered in — + * a boolean stays a boolean, a number stays a number — because the Go side + * already accepts each of them for the keys it knows. + */ +public final class ManagedConfiguration { + + private ManagedConfiguration() { + } + + /** + * @return the values as a JSON object, or "" when there is nothing to carry. + * An empty answer is what the Go side reads as "no MDM source + * present", which is also the honest reading on Android: an app with + * no managed configuration and an app on an unmanaged device are + * handed the same empty bundle. + */ + public static String encode(Map values) { + if (values == null || values.isEmpty()) { + return ""; + } + JSONObject encoded = objectOf(values); + return encoded.length() == 0 ? "" : encoded.toString(); + } + + private static JSONObject objectOf(Map values) { + JSONObject out = new JSONObject(); + for (Map.Entry entry : values.entrySet()) { + if (!(entry.getKey() instanceof String)) { + continue; + } + String key = (String) entry.getKey(); + if (key.isEmpty()) { + continue; + } + Object value = encodeValue(entry.getValue()); + if (value == null) { + continue; + } + try { + out.put(key, value); + } catch (JSONException e) { + // A value JSON cannot represent (a NaN, say). Dropping the one + // key leaves the rest of the policy usable, which beats + // rejecting the whole configuration over it. + } + } + return out; + } + + /** + * @return the value in a form JSON can hold, or null for anything the + * managed configuration should not have contained in the first + * place. An unknown type is dropped rather than stringified: a + * "java.lang.Object@1f2e3d" reaching a policy key would read as a + * deliberate value on the Go side. + */ + private static Object encodeValue(Object value) { + if (value instanceof String || value instanceof Boolean) { + return value; + } + if (value instanceof Integer || value instanceof Long || value instanceof Short + || value instanceof Byte || value instanceof Double || value instanceof Float) { + return value; + } + if (value instanceof Map) { + return objectOf((Map) value); + } + if (value instanceof Iterable) { + return arrayOf((Iterable) value); + } + if (value instanceof Object[]) { + return arrayOf(java.util.Arrays.asList((Object[]) value)); + } + return null; + } + + private static JSONArray arrayOf(Iterable items) { + JSONArray out = new JSONArray(); + for (Object item : items) { + Object value = encodeValue(item); + if (value != null) { + out.put(value); + } + } + return out; + } +} diff --git a/tool/src/main/java/io/netbird/client/tool/ProfileManagerWrapper.java b/tool/src/main/java/io/netbird/client/tool/ProfileManagerWrapper.java index e36f686a..7b6f585d 100644 --- a/tool/src/main/java/io/netbird/client/tool/ProfileManagerWrapper.java +++ b/tool/src/main/java/io/netbird/client/tool/ProfileManagerWrapper.java @@ -25,6 +25,9 @@ public ProfileManagerWrapper(Context context) { // Android always uses app's files directory for config String configDir = context.getFilesDir().getPath(); this.profileManager = io.netbird.gomobile.android.Android.newProfileManager(configDir); + // Profile operations read the policy too: a managed management URL has to + // win over whatever a profile has stored for it. + this.profileManager.setMDMPolicyFetcher(MDMBridge.fetcher(context)); } /** diff --git a/tool/src/main/java/io/netbird/client/tool/VPNService.java b/tool/src/main/java/io/netbird/client/tool/VPNService.java index eed792d9..1821d8fe 100644 --- a/tool/src/main/java/io/netbird/client/tool/VPNService.java +++ b/tool/src/main/java/io/netbird/client/tool/VPNService.java @@ -34,6 +34,9 @@ public class VPNService extends android.net.VpnService { // Launches MainActivity to run the interactive session-extend flow; set // on the persistent notification's "Extend session" action. public static final String ACTION_EXTEND_SESSION = "io.netbird.client.intent.action.EXTEND_SESSION"; + // Announces that a changed MDM policy has been applied, so a bound UI can + // say so and re-read what it is now allowed to show. + public static final String ACTION_MDM_POLICY_APPLIED = "io.netbird.client.intent.action.MDM_POLICY_APPLIED"; private static final String INTENT_ALWAYS_ON_START = "android.net.VpnService"; // Run-loop status labels, as returned by EngineRunner.status(); they come // from internal.StatusType on the Go side. @@ -56,6 +59,7 @@ public class VPNService extends android.net.VpnService { private ConcreteNetworkAvailabilityListener networkAvailabilityListener; private NetworkSwitchNotifier networkSwitchNotifier; private android.content.BroadcastReceiver stopEngineReceiver; + private android.content.BroadcastReceiver mdmPolicyReceiver; @Override public void onCreate() { @@ -140,6 +144,49 @@ public void onReceive(Context context, Intent intent) { filter, Context.RECEIVER_NOT_EXPORTED ); + + // The OS reports a changed managed configuration only to a registered + // receiver, and only while the process is alive. This is the process that + // owns the engine, so it is the one that can act on it; a policy that + // changes while nothing runs is picked up when the engine next starts and + // when a screen next reads the snapshot. + mdmPolicyReceiver = new android.content.BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + onManagedConfigurationChanged(); + } + }; + androidx.core.content.ContextCompat.registerReceiver( + this, + mdmPolicyReceiver, + new android.content.IntentFilter(Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED), + Context.RECEIVER_NOT_EXPORTED + ); + } + + /** + * Applies a changed managed configuration. + * + * Whether anything actually changed is the Go side's answer: the broadcast + * fires on every push, and dropping the tunnel because an administrator + * re-saved an unchanged configuration would be a disconnection the user + * cannot explain. + */ + private void onManagedConfigurationChanged() { + if (engineRunner == null || !engineRunner.hasMDMPolicyChanged()) { + Log.d(LOGTAG, "managed configuration pushed, nothing changed"); + return; + } + Log.d(LOGTAG, "managed configuration changed, applying the new policy"); + MDMBridge.refresh(this); + + Intent applied = new Intent(ACTION_MDM_POLICY_APPLIED); + applied.setPackage(getPackageName()); + sendBroadcast(applied); + + // Rebuilds the tunnel with it, which is also what applies a changed + // application filter. A no-op while the engine is down. + engineRunner.restart(); } @Override @@ -201,6 +248,14 @@ public void onDestroy() { } } + if (mdmPolicyReceiver != null) { + try { + unregisterReceiver(mdmPolicyReceiver); + } catch (IllegalArgumentException e) { + Log.w(LOGTAG, "MDM receiver not registered", e); + } + } + networkAvailabilityListener.unsubscribe(); networkChangeDetector.unsubscribe(); networkChangeDetector.unregisterNetworkCallback(); diff --git a/tool/src/test/java/io/netbird/client/tool/MDMRestrictionsTest.java b/tool/src/test/java/io/netbird/client/tool/MDMRestrictionsTest.java new file mode 100644 index 00000000..401f33f9 --- /dev/null +++ b/tool/src/test/java/io/netbird/client/tool/MDMRestrictionsTest.java @@ -0,0 +1,101 @@ +package io.netbird.client.tool; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class MDMRestrictionsTest { + + private static final String FULL = "{" + + "\"mdm\":{" + + "\"managementURL\":\"https://vpn.example.com:443\"," + + "\"preSharedKey\":true," + + "\"rosenpassEnabled\":true," + + "\"rosenpassPermissive\":false," + + "\"allowServerSSH\":false," + + "\"splitTunnelMode\":true," + + "\"splitTunnelApps\":true," + + "\"disableAdvancedView\":true}," + + "\"features\":{\"disableProfiles\":true,\"disableNetworks\":false}}"; + + @Test + public void readsTheSnapshot() { + MDMRestrictions restrictions = MDMRestrictions.decode(FULL); + + assertEquals("https://vpn.example.com:443", restrictions.mdm.managementURL); + assertTrue(restrictions.mdm.managesManagementURL()); + assertTrue(restrictions.mdm.preSharedKey); + assertTrue(restrictions.mdm.rosenpassEnabled); + assertFalse(restrictions.mdm.rosenpassPermissive); + assertTrue(restrictions.features.disableProfiles); + assertFalse(restrictions.features.disableNetworks); + } + + @Test + public void triStatesTellManagedFromUnmanaged() { + MDMRestrictions managedFalse = MDMRestrictions.decode(FULL); + assertEquals(Boolean.FALSE, managedFalse.mdm.allowServerSSH); + + MDMRestrictions unmanaged = MDMRestrictions.decode("{\"mdm\":{}}"); + assertNull(unmanaged.mdm.allowServerSSH); + assertNull(unmanaged.mdm.disableAdvancedView); + + MDMRestrictions explicitNull = MDMRestrictions.decode("{\"mdm\":{\"allowServerSSH\":null}}"); + assertNull(explicitNull.mdm.allowServerSSH); + } + + @Test + public void onlyAnExplicitTrueHidesTheAdvancedSection() { + assertTrue(MDMRestrictions.decode(FULL).mdm.hidesAdvancedView()); + assertFalse(MDMRestrictions.decode("{\"mdm\":{\"disableAdvancedView\":false}}") + .mdm.hidesAdvancedView()); + assertFalse(MDMRestrictions.EMPTY.mdm.hidesAdvancedView()); + } + + @Test + public void eitherSplitTunnelKeyMeansTheSelectionIsManaged() { + assertTrue(MDMRestrictions.decode(FULL).mdm.managesSplitTunnel()); + assertTrue(MDMRestrictions.decode("{\"mdm\":{\"splitTunnelApps\":true}}") + .mdm.managesSplitTunnel()); + assertFalse(MDMRestrictions.EMPTY.mdm.managesSplitTunnel()); + } + + @Test + public void missingAndUnknownKeysAreSurvivable() { + MDMRestrictions restrictions = MDMRestrictions.decode( + "{\"mdm\":{\"somethingNewer\":true},\"features\":{}}"); + + assertFalse(restrictions.mdm.preSharedKey); + assertFalse(restrictions.features.disableProfiles); + assertEquals("", restrictions.mdm.managementURL); + } + + @Test + public void anUnreadableSnapshotManagesNothing() { + assertFalse(MDMRestrictions.decode("not json").mdm.preSharedKey); + assertFalse(MDMRestrictions.decode("").features.disableNetworks); + assertFalse(MDMRestrictions.decode(null).features.disableNetworks); + } + + @Test + public void namesTheKeysAPolicyRefused() { + assertEquals("rosenpassEnabled", MDMRestrictions.rejectedKeys( + "fields managed by MDM cannot be modified: [rosenpassEnabled]")); + assertEquals("preSharedKey, managementURL", MDMRestrictions.rejectedKeys( + "fields managed by MDM cannot be modified: [preSharedKey, managementURL]")); + } + + @Test + public void aRefusalWithoutKeysIsStillARefusal() { + assertEquals("", MDMRestrictions.rejectedKeys("fields managed by MDM cannot be modified")); + } + + @Test + public void anOrdinaryFailureIsNotARefusal() { + assertNull(MDMRestrictions.rejectedKeys("permission denied: /data/config.json")); + assertNull(MDMRestrictions.rejectedKeys(null)); + } +} diff --git a/tool/src/test/java/io/netbird/client/tool/MDMSplitTunnelTest.java b/tool/src/test/java/io/netbird/client/tool/MDMSplitTunnelTest.java new file mode 100644 index 00000000..5b5dcbf1 --- /dev/null +++ b/tool/src/test/java/io/netbird/client/tool/MDMSplitTunnelTest.java @@ -0,0 +1,89 @@ +package io.netbird.client.tool; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.Arrays; + +public class MDMSplitTunnelTest { + + @Test + public void allowMeansOnlyTheseApplicationsUseTheTunnel() { + SplitTunnelConfig config = MDMSplitTunnel.fromManagedConfiguration( + "{\"splitTunnelMode\":\"allow\",\"splitTunnelApps\":[\"com.example.one\"]}"); + + assertEquals(SplitTunnelConfig.Mode.INCLUDE, config.getMode()); + assertTrue(config.getIncluded().contains("com.example.one")); + assertTrue(config.getExcluded().isEmpty()); + } + + @Test + public void disallowMeansTheseApplicationsStayOut() { + SplitTunnelConfig config = MDMSplitTunnel.fromManagedConfiguration( + "{\"splitTunnelMode\":\"disallow\",\"splitTunnelApps\":\"com.example.one,com.example.two\"}"); + + assertEquals(SplitTunnelConfig.Mode.EXCLUDE, config.getMode()); + assertEquals(2, config.getExcluded().size()); + assertTrue(config.getExcluded().contains("com.example.two")); + } + + @Test + public void theTunnelAppliesAnAllowListAsAnAllowFilter() { + SplitTunnelConfig config = MDMSplitTunnel.fromManagedConfiguration( + "{\"splitTunnelMode\":\"allow\",\"splitTunnelApps\":[\"com.example.one\"]}"); + + SplitTunnelConfig.Resolution resolution = config.resolve("io.netbird.client"); + + assertEquals(SplitTunnelConfig.Filter.ALLOW, resolution.getFilter()); + assertTrue(resolution.getPackages().contains("com.example.one")); + // The app's own SSH client reaches peers through the tunnel. + assertTrue(resolution.getPackages().contains("io.netbird.client")); + } + + @Test + public void noModeMeansTheUsersOwnSelectionStands() { + assertNull(MDMSplitTunnel.fromManagedConfiguration( + "{\"splitTunnelApps\":[\"com.example.one\"]}")); + assertNull(MDMSplitTunnel.fromManagedConfiguration("{}")); + assertNull(MDMSplitTunnel.fromManagedConfiguration("")); + assertNull(MDMSplitTunnel.fromManagedConfiguration(null)); + } + + @Test + public void aModeThisBuildDoesNotKnowChangesNothing() { + assertNull(MDMSplitTunnel.fromManagedConfiguration( + "{\"splitTunnelMode\":\"quarantine\",\"splitTunnelApps\":\"com.example.one\"}")); + } + + @Test + public void anUnreadablePolicyChangesNothing() { + assertNull(MDMSplitTunnel.fromManagedConfiguration("not json")); + } + + @Test + public void aModeWithoutApplicationsIsStillTheModeThePolicySet() { + SplitTunnelConfig config = MDMSplitTunnel.fromManagedConfiguration( + "{\"splitTunnelMode\":\"disallow\"}"); + + assertEquals(SplitTunnelConfig.Mode.EXCLUDE, config.getMode()); + assertTrue(config.getExcluded().isEmpty()); + } + + @Test + public void readsTheModeCaseAndSpaceInsensitively() { + assertEquals(SplitTunnelConfig.Mode.INCLUDE, + MDMSplitTunnel.of(" Allow ", Arrays.asList("com.example.one")).getMode()); + } + + @Test + public void dropsBlankPackageNames() { + SplitTunnelConfig config = MDMSplitTunnel.fromManagedConfiguration( + "{\"splitTunnelMode\":\"disallow\",\"splitTunnelApps\":\" com.example.one , ,\"}"); + + assertEquals(1, config.getExcluded().size()); + assertTrue(config.getExcluded().contains("com.example.one")); + } +} diff --git a/tool/src/test/java/io/netbird/client/tool/ManagedConfigurationTest.java b/tool/src/test/java/io/netbird/client/tool/ManagedConfigurationTest.java new file mode 100644 index 00000000..6ab04a19 --- /dev/null +++ b/tool/src/test/java/io/netbird/client/tool/ManagedConfigurationTest.java @@ -0,0 +1,100 @@ +package io.netbird.client.tool; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.json.JSONObject; +import org.junit.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +public class ManagedConfigurationTest { + + @Test + public void noConfigurationEncodesToNothing() { + assertEquals("", ManagedConfiguration.encode(null)); + assertEquals("", ManagedConfiguration.encode(Collections.emptyMap())); + } + + @Test + public void keepsTheTypesTheConfigurationWasDeliveredIn() throws Exception { + Map values = new LinkedHashMap<>(); + values.put("managementURL", "https://vpn.example.com"); + values.put("disableProfiles", true); + values.put("wireguardPort", 51820); + + JSONObject encoded = new JSONObject(ManagedConfiguration.encode(values)); + + assertEquals("https://vpn.example.com", encoded.getString("managementURL")); + assertTrue(encoded.getBoolean("disableProfiles")); + assertEquals(51820, encoded.getInt("wireguardPort")); + } + + @Test + public void carriesListsAcross() throws Exception { + Map values = new LinkedHashMap<>(); + values.put("splitTunnelApps", new String[]{"com.example.one", "com.example.two"}); + + JSONObject encoded = new JSONObject(ManagedConfiguration.encode(values)); + + assertEquals(2, encoded.getJSONArray("splitTunnelApps").length()); + assertEquals("com.example.two", encoded.getJSONArray("splitTunnelApps").getString(1)); + } + + @Test + public void carriesNestedBundlesAsObjects() throws Exception { + Map nested = new LinkedHashMap<>(); + nested.put("inner", "value"); + Map values = new LinkedHashMap<>(); + values.put("outer", nested); + + JSONObject encoded = new JSONObject(ManagedConfiguration.encode(values)); + + assertEquals("value", encoded.getJSONObject("outer").getString("inner")); + } + + @Test + public void dropsWhatJsonCannotHold() throws Exception { + Map values = new LinkedHashMap<>(); + values.put("disableNetworks", true); + values.put("mystery", new Object()); + values.put("notANumber", Double.NaN); + + JSONObject encoded = new JSONObject(ManagedConfiguration.encode(values)); + + assertTrue(encoded.getBoolean("disableNetworks")); + assertFalse(encoded.has("mystery")); + assertFalse(encoded.has("notANumber")); + } + + @Test + public void aConfigurationOfNothingUsableIsNoConfiguration() { + assertEquals("", ManagedConfiguration.encode( + Collections.singletonMap("mystery", new Object()))); + } + + @Test + public void ignoresEmptyKeys() throws Exception { + Map values = new LinkedHashMap<>(); + values.put("", "orphan"); + values.put("disableNetworks", true); + + JSONObject encoded = new JSONObject(ManagedConfiguration.encode(values)); + + assertEquals(1, encoded.length()); + } + + @Test + public void listsDropTheirUnusableEntries() throws Exception { + Map values = new LinkedHashMap<>(); + values.put("splitTunnelApps", Arrays.asList("com.example.one", new Object())); + + JSONObject encoded = new JSONObject(ManagedConfiguration.encode(values)); + + assertEquals(1, encoded.getJSONArray("splitTunnelApps").length()); + } +} From 9874a71393a7e01868faf6a12a560d670d522d87 Mon Sep 17 00:00:00 2001 From: evgeniyChepelev <68751844+evgeniyChepelev@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:00:13 +0200 Subject: [PATCH 2/5] Keep the busy-state helpers from unlocking a managed server applyMDMPolicy disables the server switch and the URL field, but setChecking and setBusy re-enable them when a reachability probe or a setup-key login finishes, handing back a control the policy owns. Both now remember that the server is locked and leave it alone. --- .../client/ui/fistinstall/FirstInstallFragment.java | 9 +++++++-- .../netbird/client/ui/profile/ProfileEditorDialog.java | 9 +++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/io/netbird/client/ui/fistinstall/FirstInstallFragment.java b/app/src/main/java/io/netbird/client/ui/fistinstall/FirstInstallFragment.java index f6b90129..00769f77 100644 --- a/app/src/main/java/io/netbird/client/ui/fistinstall/FirstInstallFragment.java +++ b/app/src/main/java/io/netbird/client/ui/fistinstall/FirstInstallFragment.java @@ -59,6 +59,10 @@ public class FirstInstallFragment extends Fragment { // Set after a failed reachability check so a second tap continues anyway. private boolean unreachable; + // True once the policy has taken the management server over, so the + // busy-state helper below cannot hand it back when a login finishes. + private boolean serverLocked; + @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { @@ -108,6 +112,7 @@ private void applyMDMPolicy(View root) { binding.editTextServerUrl.setText(restrictions.mdm.managementURL); binding.editTextServerUrl.setVisibility(View.VISIBLE); } + serverLocked = true; serverSwitch.setEnabled(false); MDMLock.lockControls(root.findViewById(R.id.toggle_server_mode), binding.editTextServerUrl); } @@ -258,8 +263,8 @@ private void setBusy(boolean busy) { binding.btnContinue.setText(busy ? R.string.profiles_dialog_checking : R.string.fragment_firstinstall_continue); - binding.editTextServerUrl.setEnabled(!busy); - serverSwitch.setEnabled(!busy); + binding.editTextServerUrl.setEnabled(!busy && !serverLocked); + serverSwitch.setEnabled(!busy && !serverLocked); setupKeySection.setEnabled(!busy); } diff --git a/app/src/main/java/io/netbird/client/ui/profile/ProfileEditorDialog.java b/app/src/main/java/io/netbird/client/ui/profile/ProfileEditorDialog.java index f6cb03fc..7dff3017 100644 --- a/app/src/main/java/io/netbird/client/ui/profile/ProfileEditorDialog.java +++ b/app/src/main/java/io/netbird/client/ui/profile/ProfileEditorDialog.java @@ -74,6 +74,10 @@ public interface OnProfileSavedListener { // the user can save anyway (soft warning, desktop parity). private boolean unreachable; + // True once the policy has taken the management server over, so the + // busy-state helper below cannot hand it back when a check finishes. + private boolean serverLocked; + private ProfileEditorDialog(Context context, ProfileManagerWrapper profileManager, Profile editing, OnProfileSavedListener listener) { this.context = context; @@ -159,6 +163,7 @@ private void applyMDMPolicy(View dialogView) { urlInput.setText(restrictions.mdm.managementURL); urlInput.setVisibility(View.VISIBLE); } + serverLocked = true; serverSwitch.setEnabled(false); MDMLock.lockControls(dialogView.findViewById(R.id.toggle_server_mode), urlInput); } @@ -463,8 +468,8 @@ private void setChecking(boolean checking) { okButton.setEnabled(!checking); cancelButton.setEnabled(!checking); nameInput.setEnabled(!checking); - urlInput.setEnabled(!checking); - serverSwitch.setEnabled(!checking); + urlInput.setEnabled(!checking && !serverLocked); + serverSwitch.setEnabled(!checking && !serverLocked); setupKeySection.setEnabled(!checking); // Pin the width before swapping in the shorter "Checking…" label so the From 9a065d7cb7a76a0bb51f9c70249e6f0ea27ff221 Mon Sep 17 00:00:00 2001 From: evgeniyChepelev <68751844+evgeniyChepelev@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:06:10 +0200 Subject: [PATCH 3/5] Address the MDM review feedback --- .../java/io/netbird/client/MainActivity.java | 17 ++++++++++++-- .../client/ui/advanced/AdvancedFragment.java | 18 +++++++++++---- .../ui/fistinstall/FirstInstallFragment.java | 5 ++-- .../ui/profile/ProfileEditorDialog.java | 5 ++-- .../SplitTunnelingFragment.java | 7 ++++-- .../ui/troubleshoot/TroubleshootFragment.java | 18 ++++++++++++++- .../res/values/strings_app_restrictions.xml | 9 ++++++++ app/src/main/res/xml/app_restrictions.xml | 23 +++++++++++++++++++ .../io/netbird/client/tool/MDMBridge.java | 22 ++++++++++++++++++ .../io/netbird/client/tool/VPNService.java | 14 ++++++++++- 10 files changed, 124 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/io/netbird/client/MainActivity.java b/app/src/main/java/io/netbird/client/MainActivity.java index ba541cc1..51b3de96 100644 --- a/app/src/main/java/io/netbird/client/MainActivity.java +++ b/app/src/main/java/io/netbird/client/MainActivity.java @@ -526,15 +526,28 @@ private void listenForMDMPolicy() { mdmPolicyReceiver = new android.content.BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { - Toast.makeText(MainActivity.this, R.string.mdm_policy_applied, Toast.LENGTH_LONG).show(); MDMBridge.refresh(MainActivity.this); + boolean announced = VPNService.ACTION_MDM_POLICY_APPLIED.equals(intent.getAction()); + if (!announced && appliedMDMSnapshot.equals(MDMBridge.snapshotToken(MainActivity.this))) { + // The OS pushed a policy that changes nothing this screen + // shows; saying so would be noise. + return; + } + Toast.makeText(MainActivity.this, R.string.mdm_policy_applied, Toast.LENGTH_LONG).show(); rebuildIfMDMPolicyChanged(); } }; + android.content.IntentFilter mdmFilter = + new android.content.IntentFilter(VPNService.ACTION_MDM_POLICY_APPLIED); + // The service announces a policy it has applied to the engine, but it is + // only alive while the tunnel is. Listening for the OS notification as + // well means a screen reacts to a policy pushed with the VPN off, rather + // than waiting for the next time it comes into view. + mdmFilter.addAction(Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED); ContextCompat.registerReceiver( this, mdmPolicyReceiver, - new android.content.IntentFilter(VPNService.ACTION_MDM_POLICY_APPLIED), + mdmFilter, ContextCompat.RECEIVER_NOT_EXPORTED ); } diff --git a/app/src/main/java/io/netbird/client/ui/advanced/AdvancedFragment.java b/app/src/main/java/io/netbird/client/ui/advanced/AdvancedFragment.java index a7756d19..1c71c5d8 100644 --- a/app/src/main/java/io/netbird/client/ui/advanced/AdvancedFragment.java +++ b/app/src/main/java/io/netbird/client/ui/advanced/AdvancedFragment.java @@ -31,6 +31,7 @@ public class AdvancedFragment extends Fragment implements ThemePickerSheet.OnThe private FragmentAdvancedBinding binding; private io.netbird.gomobile.android.Preferences goPreferences; + private String configFilePath; private MDMRestrictions mdm = MDMRestrictions.EMPTY; private void showReconnectionNeededWarningDialog() { @@ -64,7 +65,6 @@ public View onCreateView(@NonNull LayoutInflater inflater, // Get config path from ProfileManager instead of constructing it ProfileManagerWrapper profileManager = new ProfileManagerWrapper(inflater.getContext()); - String configFilePath; try { configFilePath = profileManager.getActiveConfigPath(); } catch (Exception e) { @@ -118,7 +118,9 @@ public View onCreateView(@NonNull LayoutInflater inflater, binding.switchRosenpass.setOnCheckedChangeListener((buttonView, isChecked) -> { goPreferences.setRosenpassEnabled(isChecked); setPermissiveEnabled(isChecked); - if (!isChecked) { + // Only when it is ours to drive: toggling a managed switch would fire + // its listener and stage a write the policy is about to refuse. + if (!isChecked && !permissiveManaged()) { binding.switchRosenpassPermissive.setChecked(false); } commit(); @@ -201,8 +203,11 @@ private void lockRow(boolean managed, View row, View control) { * unless the policy holds it, in which case it stays where the policy put it. */ private void setPermissiveEnabled(boolean enabled) { - boolean managed = mdm.features.disableUpdateSettings || mdm.mdm.rosenpassPermissive; - binding.switchRosenpassPermissive.setEnabled(enabled && !managed); + binding.switchRosenpassPermissive.setEnabled(enabled && !permissiveManaged()); + } + + private boolean permissiveManaged() { + return mdm.features.disableUpdateSettings || mdm.mdm.rosenpassPermissive; } /** Writes the staged settings, reporting a policy refusal as one. */ @@ -210,6 +215,11 @@ private void commit() { try { goPreferences.commit(); } catch (Exception e) { + // A refused write stays staged on the Go side, so every later commit + // through this instance would be refused for the same reason, long + // after the user moved on to another setting. Start again from what + // is actually on disk. + goPreferences = MDMBridge.openPreferences(requireContext(), configFilePath); reportWriteFailure(requireContext(), e); } } diff --git a/app/src/main/java/io/netbird/client/ui/fistinstall/FirstInstallFragment.java b/app/src/main/java/io/netbird/client/ui/fistinstall/FirstInstallFragment.java index 00769f77..3cc75d8c 100644 --- a/app/src/main/java/io/netbird/client/ui/fistinstall/FirstInstallFragment.java +++ b/app/src/main/java/io/netbird/client/ui/fistinstall/FirstInstallFragment.java @@ -104,10 +104,11 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat */ private void applyMDMPolicy(View root) { MDMRestrictions restrictions = MDMBridge.restrictions(requireContext()); - if (!restrictions.mdm.managesManagementURL()) { + if (!restrictions.mdm.managesManagementURL() && !restrictions.features.disableUpdateSettings) { return; } - if (!ManagementUrl.isCloud(restrictions.mdm.managementURL)) { + if (restrictions.mdm.managesManagementURL() + && !ManagementUrl.isCloud(restrictions.mdm.managementURL)) { serverSwitch.setSelfHostedSilently(true); binding.editTextServerUrl.setText(restrictions.mdm.managementURL); binding.editTextServerUrl.setVisibility(View.VISIBLE); diff --git a/app/src/main/java/io/netbird/client/ui/profile/ProfileEditorDialog.java b/app/src/main/java/io/netbird/client/ui/profile/ProfileEditorDialog.java index 7dff3017..db72d90f 100644 --- a/app/src/main/java/io/netbird/client/ui/profile/ProfileEditorDialog.java +++ b/app/src/main/java/io/netbird/client/ui/profile/ProfileEditorDialog.java @@ -155,10 +155,11 @@ private void showDialog() { */ private void applyMDMPolicy(View dialogView) { MDMRestrictions restrictions = MDMBridge.restrictions(context); - if (!restrictions.mdm.managesManagementURL()) { + if (!restrictions.mdm.managesManagementURL() && !restrictions.features.disableUpdateSettings) { return; } - if (!ManagementUrl.isCloud(restrictions.mdm.managementURL)) { + if (restrictions.mdm.managesManagementURL() + && !ManagementUrl.isCloud(restrictions.mdm.managementURL)) { serverSwitch.setSelfHostedSilently(true); urlInput.setText(restrictions.mdm.managementURL); urlInput.setVisibility(View.VISIBLE); diff --git a/app/src/main/java/io/netbird/client/ui/splittunneling/SplitTunnelingFragment.java b/app/src/main/java/io/netbird/client/ui/splittunneling/SplitTunnelingFragment.java index f3f9b316..96a0a3bc 100644 --- a/app/src/main/java/io/netbird/client/ui/splittunneling/SplitTunnelingFragment.java +++ b/app/src/main/java/io/netbird/client/ui/splittunneling/SplitTunnelingFragment.java @@ -156,8 +156,11 @@ private Set activeSelection() { */ private SplitTunnelConfig managedOrStored() { SplitTunnelConfig policy = MDMBridge.managedSplitTunnel(requireContext()); - managed = policy != null; - return managed ? policy : store.load(); + // disableUpdateSettings leaves the user's own selection on screen but + // takes the editing away, the same as it does on the advanced screen. + managed = policy != null + || MDMBridge.restrictions(requireContext()).features.disableUpdateSettings; + return policy != null ? policy : store.load(); } private void save() { diff --git a/app/src/main/java/io/netbird/client/ui/troubleshoot/TroubleshootFragment.java b/app/src/main/java/io/netbird/client/ui/troubleshoot/TroubleshootFragment.java index 96f4aade..dd41225c 100644 --- a/app/src/main/java/io/netbird/client/ui/troubleshoot/TroubleshootFragment.java +++ b/app/src/main/java/io/netbird/client/ui/troubleshoot/TroubleshootFragment.java @@ -17,12 +17,16 @@ import io.netbird.client.R; import io.netbird.client.ServiceAccessor; import io.netbird.client.databinding.FragmentTroubleshootBinding; +import io.netbird.client.tool.MDMBridge; import io.netbird.client.tool.Preferences; import io.netbird.client.tool.ProfileManagerWrapper; +import io.netbird.client.ui.MDMLock; public class TroubleshootFragment extends Fragment { private static final String LOGTAG = "TroubleshootFragment"; + // Not in the Go enforcement snapshot; see initializeRemoteJobsSwitch. + private static final String KEY_ALLOW_REMOTE_JOBS = "allowRemoteJobs"; private FragmentTroubleshootBinding binding; @Override @@ -67,7 +71,7 @@ private void initializeRemoteJobsSwitch(Context context) { try { String configFilePath = new ProfileManagerWrapper(context).getActiveConfigPath(); io.netbird.gomobile.android.Preferences goPreferences = - io.netbird.client.tool.MDMBridge.openPreferences(requireContext(), configFilePath); + MDMBridge.openPreferences(requireContext(), configFilePath); binding.switchAllowRemoteJobs.setChecked(goPreferences.getRemoteJobsAllowed()); binding.switchAllowRemoteJobs.setOnCheckedChangeListener((buttonView, isChecked) -> { try { @@ -78,6 +82,18 @@ private void initializeRemoteJobsSwitch(Context context) { } }); binding.allowRemoteJobsLayout.setOnClickListener(v -> binding.switchAllowRemoteJobs.toggle()); + + // Last: locking the row takes the listeners above away. The switch + // already shows the enforced value — the Go preferences answer with + // the policy's — so this only ends the editing of it. + // + // allowRemoteJobs is asked of the managed configuration directly + // because the Go enforcement snapshot has no field for it: the + // desktop clients apply the key without offering a control. + if (MDMBridge.restrictions(context).features.disableUpdateSettings + || MDMBridge.manages(context, KEY_ALLOW_REMOTE_JOBS)) { + MDMLock.lock(binding.allowRemoteJobsLayout, binding.switchAllowRemoteJobs); + } } catch (Exception e) { Log.e(LOGTAG, "Failed to initialize remote jobs switch", e); } diff --git a/app/src/main/res/values/strings_app_restrictions.xml b/app/src/main/res/values/strings_app_restrictions.xml index c73c0743..1e1033bd 100644 --- a/app/src/main/res/values/strings_app_restrictions.xml +++ b/app/src/main/res/values/strings_app_restrictions.xml @@ -49,6 +49,15 @@ Hide advanced settings Removes the advanced settings screen from the app. + WireGuard port + UDP port the tunnel binds to. Must be between 1 and 65535. + + Lazy connections + Connects to a peer on first use instead of at startup, overriding the setting the management server sends. + + Debug bundle upload URL + Where remote debug bundles are uploaded. Must be an https URL. Leave unset to use the service the management server names. + Prevent changing settings Leaves every setting visible but read-only. diff --git a/app/src/main/res/xml/app_restrictions.xml b/app/src/main/res/xml/app_restrictions.xml index ab4179e4..2f5c0e84 100644 --- a/app/src/main/res/xml/app_restrictions.xml +++ b/app/src/main/res/xml/app_restrictions.xml @@ -7,6 +7,11 @@ Go MDM layer's job, and it reads whatever arrives — but it turns the console into a form with types and descriptions instead of hand-written JSON. + The set mirrors what this client actually honours. Keys the Go layer reads + for the desktop clients but nothing on Android acts on — disableAutoConnect, + enableLocalMetrics, localMetricsAddress — are left out rather than offered to + an administrator as controls that would do nothing here. + No key carries a default value on purpose. A declared default is delivered by some consoles as though an administrator had set it, and a key that arrives is a key the client treats as managed: every setting would lock itself to its @@ -82,6 +87,24 @@ android:description="@string/restriction_split_tunnel_apps_description" android:restrictionType="string" /> + + + + + + Date: Fri, 2 Oct 2026 15:21:06 +0200 Subject: [PATCH 4/5] Update VPNService.java --- .../io/netbird/client/tool/VPNService.java | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/tool/src/main/java/io/netbird/client/tool/VPNService.java b/tool/src/main/java/io/netbird/client/tool/VPNService.java index d7b32c64..2d8f05a4 100644 --- a/tool/src/main/java/io/netbird/client/tool/VPNService.java +++ b/tool/src/main/java/io/netbird/client/tool/VPNService.java @@ -60,11 +60,12 @@ public class VPNService extends android.net.VpnService { private NetworkSwitchNotifier networkSwitchNotifier; private android.content.BroadcastReceiver stopEngineReceiver; private android.content.BroadcastReceiver mdmPolicyReceiver; - // Set while a policy change brings the engine back up. The stop in between - // tears the foreground notification down, and nothing else on the restart - // path would raise it again — unlike every other start, which goes through - // onStartCommand or the binder. - private volatile boolean restoreForegroundAfterRestart; + // Set while a policy change takes the engine down and brings it straight + // back. The service stays in the foreground across it: dropping out and + // promoting again would be a startForeground from the background, which + // Android 12 and later can refuse outright and would leave the tunnel down + // with nothing to restart it. + private volatile boolean policyRestartInProgress; @Override public void onCreate() { @@ -191,8 +192,8 @@ private void onManagedConfigurationChanged() { // Rebuilds the tunnel with it, which is also what applies a changed // application filter. A no-op while the engine is down, which is also - // when there is no foreground state to restore. - restoreForegroundAfterRestart = engineRunner.isRunning(); + // when there is no foreground state to hold on to. + policyRestartInProgress = engineRunner.isRunning(); engineRunner.restart(); } @@ -534,16 +535,21 @@ public void onPeersListChanged(long count) { public ServiceStateListener serviceStateListener = new ServiceStateListener() { @Override public void onStarted() { - if (restoreForegroundAfterRestart) { - restoreForegroundAfterRestart = false; - fgNotification.setState(ForegroundNotification.State.CONNECTING); - fgNotification.startForeground(); - } + policyRestartInProgress = false; sessionMonitor.onStateChanged(); } @Override public void onStopped() { + if (policyRestartInProgress) { + // Our own stop, with the start already queued behind it. The + // notification stays up and only says what is happening; the + // tunnel is coming back without the user having asked for + // anything. + fgNotification.setState(ForegroundNotification.State.CONNECTING); + sessionMonitor.onStateChanged(); + return; + } // Set before tearing the notification down: stopForeground can // leave the notification on screen briefly (and does leave it when // the service keeps running for a rebind), so it must not linger @@ -562,6 +568,9 @@ public void onStopped() { @Override public void onError(String msg) { + // The engine is not coming back on its own, so the stop that follows + // has to tear the notification down like any other failure. + policyRestartInProgress = false; // An expired session surfaces here first (the run loop gives up // with PermissionDenied), so sample the status right away instead // of waiting for the monitor's next tick. From 82b8391cff51d0aad7f1df4697009da3789d33b7 Mon Sep 17 00:00:00 2001 From: evgeniyChepelev Date: Fri, 9 Oct 2026 14:59:36 +0200 Subject: [PATCH 5/5] Decide the restart and the stop in one step The end of a run read restartPending and then started the engine as two separate steps. A stop arriving in between cleared the flag and stopped an engine that was already down, and the block went on to start it anyway, against the user's request. The same split let VPNService.onStopped see a pending restart that was cancelled straight after, leaving the notification on "connecting" with nothing coming to correct it. Both halves are now decided in finishRun(), under the lock stop() takes: either the engine goes back up, or the stop is announced. A stop lands before the decision, cancelling the restart so the stop is announced like any other, or after the new run has begun, where it stops that run. Listeners therefore never hear a stop that is about to be undone, so the service no longer needs to know a restart is in flight at all. It sets the connecting state when it asks for one and stays in the foreground across it. Co-Authored-By: Claude Opus 5 --- .../io/netbird/client/tool/EngineRunner.java | 48 +++++++++++-------- .../io/netbird/client/tool/VPNService.java | 21 ++++---- 2 files changed, 37 insertions(+), 32 deletions(-) diff --git a/tool/src/main/java/io/netbird/client/tool/EngineRunner.java b/tool/src/main/java/io/netbird/client/tool/EngineRunner.java index bb9f0952..b4b0ac0b 100644 --- a/tool/src/main/java/io/netbird/client/tool/EngineRunner.java +++ b/tool/src/main/java/io/netbird/client/tool/EngineRunner.java @@ -191,22 +191,41 @@ private synchronized void runClient(@Nullable URLOpener urlOpener, boolean isAnd } finally { engineIsRunning = false; dnsWatch.removeDNSChangeListener(); - notifyServiceStateListeners(false); } Log.e(LOGTAG, "service stopped"); - if (restartPending) { - restartPending = false; - // Deliberately not an interactive start: a policy change must not - // pop a browser at a user who did nothing. If the new policy needs - // a login the run loop reports NeedsLogin, and the notification - // says so. - runClient(null, false); - } + finishRun(); }; new Thread(r).start(); } + /** + * Ends a run, in one step: either the engine goes back up for a pending + * restart, or the stop is announced. + * + * Both halves are decided here, under the same lock {@link #stop()} takes, + * because they are one lifecycle transition and splitting them leaves two + * ways to go wrong. A stop that arrives while the run is winding down either + * lands before the decision, cancelling the restart so the stop is announced + * like any other, or after the new run has begun, where it stops that run. + * Neither order can start an engine the user has just turned off. + * + * Listeners therefore never see a stop that is about to be undone, which is + * what let a notification sit on "connecting" with nothing coming. + */ + private synchronized void finishRun() { + if (restartPending) { + restartPending = false; + // Deliberately not an interactive start: a policy change must not + // pop a browser at a user who did nothing. If the new policy needs + // a login the run loop reports NeedsLogin, and the notification + // says so. + runClient(null, false); + return; + } + notifyServiceStateListeners(false); + } + private void changed(DNSList dnsServers) throws Exception { goClient.onUpdatedHostDNS(dnsServers); } @@ -370,17 +389,6 @@ public boolean hasMDMPolicyChanged() { return goClient.hasMDMPolicyChanged(); } - /** - * True between a restart request and the start that answers it, including - * the moment the engine reports itself stopped in between. The service asks - * this rather than tracking the restart itself: a stop the user asked for - * cancels the restart here, and a second flag elsewhere would not hear about - * it. - */ - public boolean isRestartPending() { - return restartPending; - } - /** * Stops the engine and brings it back up, so a changed policy takes hold on a * running tunnel. A no-op while the engine is down: the policy is read again diff --git a/tool/src/main/java/io/netbird/client/tool/VPNService.java b/tool/src/main/java/io/netbird/client/tool/VPNService.java index 16ce5da5..86b92e77 100644 --- a/tool/src/main/java/io/netbird/client/tool/VPNService.java +++ b/tool/src/main/java/io/netbird/client/tool/VPNService.java @@ -187,6 +187,15 @@ private void onManagedConfigurationChanged() { // Rebuilds the tunnel with it, which is also what applies a changed // application filter. A no-op while the engine is down. + // + // The engine does not report itself stopped in between, so the service + // stays in the foreground across the restart: dropping out and promoting + // again would be a startForeground from the background, which Android 12 + // and later can refuse outright. Only the wording changes, and only while + // there is a tunnel to say it about. + if (engineRunner.isRunning()) { + fgNotification.setState(ForegroundNotification.State.CONNECTING); + } engineRunner.restart(); } @@ -542,18 +551,6 @@ public void onStarted() { @Override public void onStopped() { - if (engineRunner.isRestartPending()) { - // Our own stop, with the start already queued behind it. The - // service stays in the foreground across it: dropping out and - // promoting again would be a startForeground from the - // background, which Android 12 and later can refuse outright. - // The engine owns this answer — a stop the user asked for in the - // meantime cancels the restart, and then this is an ordinary - // stop again. - fgNotification.setState(ForegroundNotification.State.CONNECTING); - sessionMonitor.onStateChanged(); - return; - } // Set before tearing the notification down: stopForeground can // leave the notification on screen briefly (and does leave it when // the service keeps running for a rebind), so it must not linger