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,83 @@ 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) { + 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, + mdmFilter, + 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 d5b68c3f..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 @@ -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,8 @@ 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() { final View dialogView = getLayoutInflater().inflate(R.layout.dialog_simple_alert_message, null); @@ -60,13 +65,13 @@ 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) { 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,25 @@ 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); + // 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); } - - 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 +150,100 @@ 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) { + 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. */ + 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); + } + } + + /** + * 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 +264,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 +343,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,8 +362,11 @@ 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 { + // 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 f8e4c9b7..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 @@ -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; @@ -56,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) { @@ -85,6 +92,30 @@ 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() && !restrictions.features.disableUpdateSettings) { + return; + } + if (restrictions.mdm.managesManagementURL() + && !ManagementUrl.isCloud(restrictions.mdm.managementURL)) { + serverSwitch.setSelfHostedSilently(true); + 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); } private void onModeChanged(boolean selfHosted) { @@ -170,7 +201,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 +219,7 @@ private void applyServer(String managementUrl, String setupKey) { setBusy(true); Auth auth; try { - auth = Android.newAuth(configPath, managementUrl, null); + auth = MDMBridge.newAuth(requireContext(), configPath, managementUrl); } catch (Exception e) { Log.e(TAG, "Failed to create authenticator", e); setBusy(false); @@ -233,8 +264,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/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 3e8cf91e..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 @@ -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; @@ -71,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; @@ -132,12 +139,36 @@ 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() && !restrictions.features.disableUpdateSettings) { + return; + } + if (restrictions.mdm.managesManagementURL() + && !ManagementUrl.isCloud(restrictions.mdm.managementURL)) { + serverSwitch.setSelfHostedSilently(true); + urlInput.setText(restrictions.mdm.managementURL); + urlInput.setVisibility(View.VISIBLE); + } + serverLocked = true; + 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 +208,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 +313,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 +324,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 +362,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 +399,7 @@ private void enrollWithSetupKey(Profile created, String managementUrl, String ke setChecking(true); Auth auth; try { - auth = Android.newAuth(configPath, managementUrl, null); + auth = MDMBridge.newAuth(context, configPath, managementUrl); } catch (Exception e) { Log.e(TAG, "Failed to create authenticator", e); setChecking(false); @@ -427,8 +469,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 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..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 @@ -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,26 @@ 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()); + // 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() { + // 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 9d02f216..3249824f 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 @@ -32,12 +32,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 implements AnonymizeLevelSheet.OnLevelChangedListener { private static final String LOGTAG = "TroubleshootFragment"; + // Not in the Go enforcement snapshot; see initializeRemoteJobsSwitch. + private static final String KEY_ALLOW_REMOTE_JOBS = "allowRemoteJobs"; private static final String STATE_PENDING_BUNDLE = "pendingBundlePath"; private FragmentTroubleshootBinding binding; @@ -154,7 +158,8 @@ private void updateAnonymizeValue() { 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 = + MDMBridge.openPreferences(requireContext(), configFilePath); binding.switchAllowRemoteJobs.setChecked(goPreferences.getRemoteJobsAllowed()); binding.switchAllowRemoteJobs.setOnCheckedChangeListener((buttonView, isChecked) -> { try { @@ -165,6 +170,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/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 e44d46be..586f1791 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -320,4 +320,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 7e8baa0f..d250f86a 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -320,4 +320,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 1004c96d..267e0700 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -317,4 +317,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 631cd69a..9e321075 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -320,4 +320,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 4e074098..949d387d 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -318,4 +318,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 045a9ea5..7e392b8a 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -320,4 +320,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 f2ad2714..a49e327d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -326,4 +326,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 41d02847..076785e7 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -317,4 +317,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 39f5d7c0..829bbefe 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -323,4 +323,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..1e1033bd --- /dev/null +++ b/app/src/main/res/values/strings_app_restrictions.xml @@ -0,0 +1,63 @@ + + + + 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. + + 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 new file mode 100644 index 00000000..2f5c0e84 --- /dev/null +++ b/app/src/main/res/xml/app_restrictions.xml @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8db3f2da..2f9efe35 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" @@ -22,6 +23,8 @@ work = "2.10.1" [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/tool/build.gradle.kts b/tool/build.gradle.kts index 34ec4384..595902b9 100644 --- a/tool/build.gradle.kts +++ b/tool/build.gradle.kts @@ -35,6 +35,7 @@ dependencies { implementation(libs.material) implementation(libs.work.runtime) testImplementation(libs.junit) + testImplementation(libs.json) androidTestImplementation(libs.ext.junit) androidTestImplementation(libs.espresso.core) androidTestImplementation(libs.work.testing) 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 cef67a8a..b4b0ac0b 100644 --- a/tool/src/main/java/io/netbird/client/tool/EngineRunner.java +++ b/tool/src/main/java/io/netbird/client/tool/EngineRunner.java @@ -38,6 +38,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, @@ -54,6 +55,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 subscription is client-scoped and survives engine @@ -185,14 +191,41 @@ private synchronized void runClient(@Nullable URLOpener urlOpener, boolean isAnd } finally { engineIsRunning = false; dnsWatch.removeDNSChangeListener(); - notifyServiceStateListeners(false); } Log.e(LOGTAG, "service stopped"); + 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); } @@ -340,6 +373,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..36fd763b --- /dev/null +++ b/tool/src/main/java/io/netbird/client/tool/MDMBridge.java @@ -0,0 +1,139 @@ +package io.netbird.client.tool; + +import android.content.Context; +import android.util.Log; + +import org.json.JSONObject; + +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; + } + + /** + * Whether the managed configuration carries this key at all. + * + * The Go snapshot is the authority for every key it reports, and screens + * should use it. This is for the few keys it does not carry — the ones the + * desktop clients enforce without a control of their own — and it reads the + * same managed configuration Go is handed, so the two cannot disagree. + */ + public static boolean manages(Context context, String key) { + String json = fetcher(context).fetchJSON(); + if (json.isEmpty()) { + return false; + } + try { + return new JSONObject(json).has(key); + } catch (Exception e) { + return false; + } + } + + /** + * 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 7c8703fb..2efa492b 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 42d0adef..86b92e77 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. @@ -57,6 +60,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() { @@ -141,6 +145,58 @@ 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. + // + // 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(); } @Override @@ -202,6 +258,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()); + } +}