diff --git a/README.md b/README.md index 0f74c91c..e429e427 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,27 @@ WireGuard VPN Client for Android with integrated RDP management (Pro license). - Multi-language (DE/EN) - Dark/Light theme +## Client policies from the server + +Admins can define client policies on the GateControl server (Settings → Client-Richtlinien, globally, per peer group or per peer). The app loads them from `GET /api/v1/client/policy` and refreshes them when a heartbeat or permissions answer carries a new `policyVersion`. The last known policy is stored and also applies offline. If the server is unreachable, the cached policy stays in force. A policy that was never fetched means no restriction. + +What the app enforces: + +| Policy | Android client | +|---|---| +| Auto-connect `required` | "Connect automatically" forced on and locked. The app connects on boot, on app start and when the policy arrives (only once VPN consent has been granted) | +| Auto-connect `always_on` | Same as `required`, plus no disconnect from the app or the Quick Settings tile | +| Autostart `required` / `forbidden` | Maps to connecting on boot: forced on or off | +| Split-tunnel modes | Only the allowed modes can be selected. A stored mode that is no longer allowed is clamped when connecting (full tunnel if allowed). A locked server preset keeps priority | +| Lock settings | Auto-connect, split-tunnel mode, networks and apps are locked. Theme and language stay free | +| Lock server | Server change and config import are hidden and refused | + +Locked settings show "Vom Administrator festgelegt" / "Set by your administrator". + +**Android limits:** an app cannot switch on the system **Always-on VPN** or **Block connections without VPN** (Android's real kill switch); only the user or an MDM / device owner can. If the policy requires a kill switch or always-on, the app shows a prominent hint with a button to the system VPN settings, on the main screen and in the settings. Enrollment through an external setup link is still possible under "lock server", because it needs a fresh setup code from the admin anyway. + +The policy is applied on the device. It is a management convenience and **not a security boundary** against the device owner. + ## License MIT diff --git a/app/src/main/java/com/gatecontrol/android/receiver/BootReceiver.kt b/app/src/main/java/com/gatecontrol/android/receiver/BootReceiver.kt index 7f7b9382..1d036f78 100644 --- a/app/src/main/java/com/gatecontrol/android/receiver/BootReceiver.kt +++ b/app/src/main/java/com/gatecontrol/android/receiver/BootReceiver.kt @@ -7,6 +7,7 @@ import android.net.VpnService import com.gatecontrol.android.data.SettingsRepository import com.gatecontrol.android.data.SetupRepository import com.gatecontrol.android.network.ApiClientProvider +import com.gatecontrol.android.service.ClientPolicyManager import com.gatecontrol.android.service.TunnelConnector import com.gatecontrol.android.tunnel.TunnelManager import com.gatecontrol.android.tunnel.TunnelState @@ -27,6 +28,7 @@ class BootReceiver : BroadcastReceiver() { @Inject lateinit var apiClientProvider: ApiClientProvider @Inject lateinit var tunnelConnector: TunnelConnector @Inject lateinit var tunnelManager: TunnelManager + @Inject lateinit var clientPolicyManager: ClientPolicyManager override fun onReceive(context: Context, intent: Intent) { if (intent.action != Intent.ACTION_BOOT_COMPLETED) return @@ -36,7 +38,9 @@ class BootReceiver : BroadcastReceiver() { CoroutineScope(Dispatchers.IO).launch { try { - val autoConnect = settingsRepository.getAutoConnect().first() + // Client policy (last known, works offline) may force it. + val autoConnect = clientPolicyManager.current().forcedAutoConnect + ?: settingsRepository.getAutoConnect().first() val isConfigured = setupRepository.isConfigured() if (autoConnect && isConfigured) { diff --git a/app/src/main/java/com/gatecontrol/android/service/ClientPolicyManager.kt b/app/src/main/java/com/gatecontrol/android/service/ClientPolicyManager.kt new file mode 100644 index 00000000..8a0fe906 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/service/ClientPolicyManager.kt @@ -0,0 +1,136 @@ +package com.gatecontrol.android.service + +import com.gatecontrol.android.common.ClientPolicy +import com.gatecontrol.android.data.ClientPolicyRepository +import com.gatecontrol.android.data.SettingsRepository +import com.gatecontrol.android.data.SetupRepository +import com.gatecontrol.android.network.ApiClientProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Client policy from the server ("Client-Richtlinien"): fetch, cache and + * apply what Android lets an app enforce. + * + * - [policy] is the last known policy, [ClientPolicy.UNRESTRICTED] when none + * was ever fetched. It survives restarts and applies offline. + * - [refresh] asks the server (If-None-Match with the cached version, 304 = + * unchanged). Any failure keeps the last known policy. + * - [noteVersion] is fed with the policyVersion of heartbeat/permissions + * answers and refreshes when it differs. + * - [applyToSettings] forces the stored settings the policy fixes + * (auto-connect, allowed split-tunnel mode). + */ +@Singleton +class ClientPolicyManager @Inject constructor( + private val repository: ClientPolicyRepository, + private val settingsRepository: SettingsRepository, + private val setupRepository: SetupRepository, + private val apiClientProvider: ApiClientProvider, +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val refreshLock = Mutex() + + /** Effective policy (unrestricted until one was fetched). */ + val policy: StateFlow = repository.cached() + .map { it?.policy ?: ClientPolicy.UNRESTRICTED } + .stateIn(scope, SharingStarted.Eagerly, ClientPolicy.UNRESTRICTED) + + /** Policy as stored right now (reads the store, not the eager state). */ + suspend fun current(): ClientPolicy = repository.current()?.policy ?: ClientPolicy.UNRESTRICTED + + enum class Result { UPDATED, UNCHANGED, UNAVAILABLE } + + suspend fun refresh(): Result = refreshLock.withLock { + val serverUrl = setupRepository.getServerUrl() + if (serverUrl.isBlank()) return Result.UNAVAILABLE + val cached = repository.current() + val response = try { + apiClientProvider.getClient(serverUrl) + .getClientPolicy(cached?.version?.let { "\"$it\"" }) + } catch (e: Exception) { + Timber.d("Client policy fetch failed, keeping last known: %s", e.message) + return Result.UNAVAILABLE + } + if (response.code() == 304) return Result.UNCHANGED + val body = response.body() + val p = body?.policy + if (!response.isSuccessful || body == null || !body.ok || p == null) { + // Old server without the endpoint (404), errors: keep what we have. + return Result.UNAVAILABLE + } + val fresh = ClientPolicy.from( + killSwitch = p.killSwitch, + autoConnect = p.autoConnect, + autostart = p.autostart, + splitTunnelModes = p.splitTunnelModes, + splitTunnelLocked = p.splitTunnelLocked, + lockSettings = p.lockSettings, + lockServer = p.lockServer, + ) + val version = body.version?.takeIf { VERSION_RE.matches(it) } + if (cached != null && cached.policy == fresh && cached.version == version) return Result.UNCHANGED + repository.save(fresh, version) + applyToSettings(fresh) + Timber.i("Client policy %s (version %s)", if (cached == null) "loaded" else "updated", version ?: "-") + Result.UPDATED + } + + /** A server answer carried a policy version: refresh when it differs. */ + suspend fun noteVersion(version: String?): Result { + if (version.isNullOrBlank() || !VERSION_RE.matches(version)) return Result.UNCHANGED + if (repository.current()?.version == version) return Result.UNCHANGED + return refresh() + } + + fun noteVersionAsync(version: String?) { + launchSafely { noteVersion(version) } + } + + fun refreshAsync() { + launchSafely { refresh() } + } + + /** New server / token: the old server's policy must not stick. */ + suspend fun reset() { + repository.clear() + refresh() + } + + /** Forces the stored settings the policy fixes. */ + suspend fun applyToSettings(policy: ClientPolicy) { + policy.forcedAutoConnect?.let { forced -> + if (settingsRepository.getAutoConnect().first() != forced) settingsRepository.setAutoConnect(forced) + } + if (!policy.splitTunnelLocked) { + val mode = settingsRepository.getSplitTunnelMode().first() + val clamped = policy.clampMode(mode) + if (clamped != mode) { + Timber.i("Client policy: split-tunnel mode %s not allowed, using %s", mode, clamped) + settingsRepository.setSplitTunnelMode(clamped) + } + } + } + + private fun launchSafely(block: suspend () -> Unit) { + scope.launch { + try { block() } catch (e: Exception) { Timber.w(e, "Client policy task failed") } + } + } + + private companion object { + val VERSION_RE = Regex("^[0-9a-fA-F]{1,64}$") + } +} diff --git a/app/src/main/java/com/gatecontrol/android/service/TileActionHandler.kt b/app/src/main/java/com/gatecontrol/android/service/TileActionHandler.kt index f746f935..a5ebb401 100644 --- a/app/src/main/java/com/gatecontrol/android/service/TileActionHandler.kt +++ b/app/src/main/java/com/gatecontrol/android/service/TileActionHandler.kt @@ -26,6 +26,7 @@ enum class TileAction(val wireValue: String) { class TileActionHandler @Inject constructor( private val tunnelConnector: TunnelConnector, private val tunnelManager: TunnelManager, + private val clientPolicyManager: ClientPolicyManager, ) { /** @@ -42,6 +43,11 @@ class TileActionHandler @Inject constructor( } suspend fun disconnect() { + // Client policy "always on": no manual disconnect. + if (!clientPolicyManager.current().canDisconnect) { + Timber.i("Tile disconnect refused: always-on client policy") + return + } try { tunnelManager.disconnect() Timber.d("Tile disconnect succeeded") diff --git a/app/src/main/java/com/gatecontrol/android/service/TunnelConnector.kt b/app/src/main/java/com/gatecontrol/android/service/TunnelConnector.kt index 91b3ccb1..4f7dc8c0 100644 --- a/app/src/main/java/com/gatecontrol/android/service/TunnelConnector.kt +++ b/app/src/main/java/com/gatecontrol/android/service/TunnelConnector.kt @@ -30,6 +30,7 @@ class TunnelConnector @Inject constructor( private val settingsRepository: SettingsRepository, private val apiClientProvider: ApiClientProvider, private val tunnelManager: TunnelManager, + private val clientPolicyManager: ClientPolicyManager, ) { suspend fun connectWithUserSettings(): Boolean { @@ -56,7 +57,7 @@ class TunnelConnector @Inject constructor( applyVpnSubnet(config) } - val splitTunnelConfig = resolveSplitTunnelConfig(serverUrl) + val splitTunnelConfig = applyPolicy(resolveSplitTunnelConfig(serverUrl)) return try { tunnelManager.connect(config, splitTunnelConfig) @@ -118,6 +119,23 @@ class TunnelConnector @Inject constructor( } } + /** + * Client policy: only the split-tunnel modes the admin allows. A locked + * server preset already carries the one allowed mode; anything else is + * clamped (full tunnel when allowed). Never-fetched policy = unchanged. + */ + private suspend fun applyPolicy(config: SplitTunnelConfig): SplitTunnelConfig { + val policy = try { clientPolicyManager.current() } catch (e: Exception) { + Timber.w(e, "TunnelConnector: client policy unavailable") + return config + } + val mode = policy.clampMode(config.mode) + if (mode == config.mode) return config + Timber.i("TunnelConnector: split-tunnel mode %s not allowed by policy, using %s", config.mode, mode) + if (mode == SplitTunnelMode.OFF) return SplitTunnelConfig() + return config.copy(mode = mode) + } + /** Tell the DNS workaround which subnet is VPN-internal for this server. */ private fun applyVpnSubnet(config: String) { val address = runCatching { TunnelConfig.parse(config).address }.getOrNull() ?: return diff --git a/app/src/main/java/com/gatecontrol/android/service/TunnelSupervisor.kt b/app/src/main/java/com/gatecontrol/android/service/TunnelSupervisor.kt index 8b397dda..e62d8be5 100644 --- a/app/src/main/java/com/gatecontrol/android/service/TunnelSupervisor.kt +++ b/app/src/main/java/com/gatecontrol/android/service/TunnelSupervisor.kt @@ -41,6 +41,7 @@ class TunnelSupervisor @Inject constructor( private val tunnelMonitor: TunnelMonitor, private val setupRepository: SetupRepository, private val apiClientProvider: ApiClientProvider, + private val clientPolicyManager: ClientPolicyManager, ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private var started = false @@ -55,6 +56,18 @@ class TunnelSupervisor @Inject constructor( connectIfIdle() } + // Client policy: last known one applies right away (persisted), then + // ask the server. Required auto-connect / always-on bring the tunnel up + // when the app starts or the policy arrives (VPN consent needed). + clientPolicyManager.refreshAsync() + scope.launch { + clientPolicyManager.policy.collect { policy -> + if (policy.autoConnect != com.gatecontrol.android.common.ClientPolicy.AutoConnect.USER) { + connectForPolicy() + } + } + } + scope.launch { tunnelManager.state.collect { state -> TunnelStateHolder.isConnected = state is TunnelState.Connected @@ -88,6 +101,17 @@ class TunnelSupervisor @Inject constructor( } } + /** Connect because the client policy requires it (configured + VPN consent only). */ + private fun connectForPolicy() { + if (!setupRepository.hasWireGuardConfig()) return + if (android.net.VpnService.prepare(context) != null) { + Timber.w("Client policy requires auto-connect, but VPN consent is missing") + return + } + Timber.i("Client policy requires auto-connect — connecting") + connectIfIdle() + } + /** * Disconnect on behalf of a short-lived caller (Quick Settings tile). Runs * in the app-wide scope so it completes even when the caller is unbound @@ -95,6 +119,11 @@ class TunnelSupervisor @Inject constructor( */ fun disconnect() { scope.launch { + if (!clientPolicyManager.current().canDisconnect) { + Timber.i("Disconnect refused: always-on client policy") + refreshTile() + return@launch + } try { tunnelManager.disconnect() } catch (e: Exception) { @@ -145,6 +174,7 @@ class TunnelSupervisor @Inject constructor( hostname = HostnameSanitizer.sanitize(android.os.Build.MODEL).orEmpty(), ), ) + clientPolicyManager.noteVersionAsync(response.policyVersion) // Admin asked for a support bundle: Settings shows it, // nothing is sent without the user's confirmation. if (response.ok) SupportRequestHolder.update(response.supportBundleRequested, response.supportBundleRequestedAt) diff --git a/app/src/main/java/com/gatecontrol/android/ui/components/GcPolicy.kt b/app/src/main/java/com/gatecontrol/android/ui/components/GcPolicy.kt new file mode 100644 index 00000000..e5af3f1d --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/components/GcPolicy.kt @@ -0,0 +1,72 @@ +package com.gatecontrol.android.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.gatecontrol.android.R +import com.gatecontrol.android.common.ClientPolicy +import com.gatecontrol.android.ui.theme.GateControlTheme +import com.gatecontrol.android.util.openSystemVpnSettings + +/** Small "Vom Administrator festgelegt" line under a locked setting. */ +@Composable +fun GcPolicyLockedHint(modifier: Modifier = Modifier, text: String = stringResource(R.string.policy_locked_hint)) { + val extra = GateControlTheme.extraColors + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon(GcIcons.Lock, contentDescription = null, tint = extra.muted, modifier = Modifier.size(14.dp)) + Text(text, style = MaterialTheme.typography.bodySmall, color = extra.muted) + } +} + +/** + * Prominent hint when the client policy requires a kill switch or an + * always-on VPN: Android lets only the user (or an MDM) switch on + * "Always-on VPN" and "Block connections without VPN" in the system + * settings, so the app explains it and opens those settings. + */ +@Composable +fun GcPolicySystemVpnCard(policy: ClientPolicy, modifier: Modifier = Modifier) { + if (!policy.needsSystemVpnSettings) return + val context = LocalContext.current + GcBanner(modifier = modifier, tone = GcTone.Warn, icon = GcIcons.Lock) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(R.string.policy_system_vpn_title), style = MaterialTheme.typography.titleSmall) + if (policy.autoConnect == ClientPolicy.AutoConnect.ALWAYS_ON) { + Text(stringResource(R.string.policy_system_vpn_always_on), style = MaterialTheme.typography.bodySmall) + } + if (policy.killSwitch == ClientPolicy.KillSwitch.REQUIRED) { + Text(stringResource(R.string.policy_system_vpn_kill_switch), style = MaterialTheme.typography.bodySmall) + } + GcOutlineButton( + text = stringResource(R.string.policy_open_vpn_settings), + onClick = { context.openSystemVpnSettings() }, + minHeight = 40.dp, + modifier = Modifier.padding(top = 2.dp), + ) + } + } +} + +/** "This device is managed" note for the settings page. */ +@Composable +fun GcPolicyManagedBanner(policy: ClientPolicy, modifier: Modifier = Modifier) { + if (!policy.managed) return + GcBanner(modifier = modifier, tone = GcTone.Info, icon = GcIcons.ShieldCheck) { + Text(stringResource(R.string.policy_managed_banner), style = MaterialTheme.typography.bodySmall) + } +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/settings/ServerSettingsScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/settings/ServerSettingsScreen.kt index 23f3c835..75358e4d 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/settings/ServerSettingsScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/settings/ServerSettingsScreen.kt @@ -79,6 +79,17 @@ fun ServerSettingsScreen( onBack = onBack, backDescription = stringResource(R.string.common_back), ) + if (uiState.policy.lockServer) { + // Client policy: server change / re-setup locked by the admin. + GcBanner( + tone = GcTone.Info, + icon = GcIcons.Lock, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 12.dp), + ) { + Text(stringResource(R.string.policy_server_locked), style = MaterialTheme.typography.bodyMedium) + } + return@Column + } Column( modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 24.dp), verticalArrangement = Arrangement.spacedBy(14.dp), diff --git a/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsScreen.kt index a8605fe5..e152caf9 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsScreen.kt @@ -37,6 +37,9 @@ import com.gatecontrol.android.ui.components.GcIconSquare import com.gatecontrol.android.ui.components.GcIcons import com.gatecontrol.android.ui.components.GcListRow import com.gatecontrol.android.ui.components.GcOutlineButton +import com.gatecontrol.android.ui.components.GcPolicyLockedHint +import com.gatecontrol.android.ui.components.GcPolicyManagedBanner +import com.gatecontrol.android.ui.components.GcPolicySystemVpnCard import com.gatecontrol.android.ui.components.GcScreenTitle import com.gatecontrol.android.ui.components.GcSecondaryButton import com.gatecontrol.android.ui.components.GcSectionLabel @@ -104,20 +107,37 @@ fun SettingsScreen( } } + // --- Client policy ---------------------------------------------- + val policy = uiState.policy + GcPolicyManagedBanner(policy) + GcPolicySystemVpnCard(policy) + // --- Verbindung ---------------------------------------------------- SettingsGroup(stringResource(R.string.settings_group_connection)) { - GcListRow( - title = stringResource(R.string.settings_server_row), - description = stringResource(R.string.settings_server_row_desc, host.ifBlank { "—" }), - onClick = onNavigateToServer, - trailing = { GcChevron() }, - ) + if (policy.lockServer) { + GcListRow( + title = stringResource(R.string.settings_server_row), + description = stringResource(R.string.policy_server_locked), + trailing = { Icon(GcIcons.Lock, contentDescription = null, tint = extra.muted, modifier = Modifier.size(18.dp)) }, + ) + } else { + GcListRow( + title = stringResource(R.string.settings_server_row), + description = stringResource(R.string.settings_server_row_desc, host.ifBlank { "—" }), + onClick = onNavigateToServer, + trailing = { GcChevron() }, + ) + } GcSwitchRow( label = stringResource(R.string.settings_auto_connect), description = stringResource(R.string.settings_auto_connect_desc), - checked = uiState.autoConnect, + checked = policy.forcedAutoConnect ?: uiState.autoConnect, onCheckedChange = { viewModel.setAutoConnect(it) }, + enabled = !policy.autoConnectLocked, ) + if (policy.autoConnectLocked) { + GcPolicyLockedHint(Modifier.padding(start = 16.dp, end = 16.dp, bottom = 10.dp)) + } GcListRow( title = stringResource(R.string.settings_split_tunnel), description = splitSummary(uiState), diff --git a/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsViewModel.kt index 31843449..27c4420e 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsViewModel.kt @@ -14,6 +14,8 @@ import com.gatecontrol.android.tunnel.TunnelConfig import com.gatecontrol.android.tunnel.WgConfigValidator import com.gatecontrol.android.network.UpdateCheckResponse import com.gatecontrol.android.common.Validation +import com.gatecontrol.android.common.ClientPolicy +import com.gatecontrol.android.service.ClientPolicyManager import com.gatecontrol.android.network.SupportBundleUploader import com.gatecontrol.android.support.SupportBundleCollector import com.gatecontrol.android.support.SupportRequestHolder @@ -57,6 +59,8 @@ data class SettingsUiState( val error: UiText? = null, val isPro: Boolean = false, val peerId: Int = 0, + /** Client policy from the server (unrestricted until one was fetched). */ + val policy: ClientPolicy = ClientPolicy.UNRESTRICTED, /** Confirmation dialog for "Support-Paket senden" is open. */ val supportDialogVisible: Boolean = false, val supportSending: Boolean = false, @@ -74,6 +78,7 @@ class SettingsViewModel @Inject constructor( private val licenseRepository: LicenseRepository, private val supportBundleCollector: SupportBundleCollector, private val supportBundleUploader: SupportBundleUploader, + private val clientPolicyManager: ClientPolicyManager, ) : ViewModel() { private val _uiState = MutableStateFlow(SettingsUiState()) @@ -134,6 +139,11 @@ class SettingsViewModel @Inject constructor( _uiState.update { it.copy(splitTunnelAppsV2 = apps) } } } + viewModelScope.launch { + clientPolicyManager.policy.collect { policy -> + _uiState.update { it.copy(policy = policy) } + } + } viewModelScope.launch { settingsRepository.getSplitTunnelAdminLocked().collect { locked -> _uiState.update { it.copy(splitTunnelAdminLocked = locked) } @@ -172,6 +182,7 @@ class SettingsViewModel @Inject constructor( } fun setAutoConnect(enabled: Boolean) { + if (_uiState.value.policy.autoConnectLocked) return viewModelScope.launch { settingsRepository.setAutoConnect(enabled) _uiState.update { it.copy(autoConnect = enabled) } @@ -227,6 +238,10 @@ class SettingsViewModel @Inject constructor( _uiState.update { it.copy(error = UiText.Res(R.string.settings_error_invalid_token)) } return } + if (_uiState.value.policy.lockServer) { + _uiState.update { it.copy(error = UiText.Res(R.string.policy_server_locked)) } + return + } viewModelScope.launch { _uiState.update { it.copy(isLoading = true, error = null) } @@ -240,6 +255,8 @@ class SettingsViewModel @Inject constructor( } setupRepository.save(url, token, peerId.coerceAtLeast(0)) + // New server / token: the old server's policy must not stick. + clientPolicyManager.reset() _uiState.update { it.copy( isLoading = false, @@ -278,11 +295,14 @@ class SettingsViewModel @Inject constructor( } fun setSplitTunnelMode(mode: SplitTunnelMode) { + val policy = _uiState.value.policy + if (policy.splitTunnelFrozen || !policy.isModeAllowed(mode)) return _uiState.update { it.copy(splitTunnelMode = mode) } viewModelScope.launch { settingsRepository.setSplitTunnelMode(mode) } } fun setSplitTunnelNetworks(networks: List) { + if (_uiState.value.policy.splitTunnelFrozen) return _uiState.update { it.copy(splitTunnelNetworks = networks) } viewModelScope.launch { settingsRepository.setSplitTunnelNetworks( @@ -292,6 +312,7 @@ class SettingsViewModel @Inject constructor( } fun setSplitTunnelAppsV2(apps: List) { + if (_uiState.value.policy.lockSettings) return _uiState.update { it.copy(splitTunnelAppsV2 = apps) } viewModelScope.launch { settingsRepository.setSplitTunnelAppsV2(SplitTunnelJson.encodeApps(apps)) @@ -347,6 +368,7 @@ class SettingsViewModel @Inject constructor( pihole = perms.pihole, piholeControl = perms.piholeControl, ) + clientPolicyManager.noteVersionAsync(response.policyVersion) val isPro = perms.rdp || perms.traffic || perms.dns _uiState.update { it.copy( @@ -377,6 +399,10 @@ class SettingsViewModel @Inject constructor( } fun importConfigFromUri(context: android.content.Context, uri: android.net.Uri) { + if (_uiState.value.policy.lockServer) { + _uiState.update { it.copy(error = UiText.Res(R.string.policy_server_locked)) } + return + } viewModelScope.launch { try { val input = context.contentResolver.openInputStream(uri) diff --git a/app/src/main/java/com/gatecontrol/android/ui/settings/SplitTunnelScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/settings/SplitTunnelScreen.kt index 925147cc..8255e364 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/settings/SplitTunnelScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/settings/SplitTunnelScreen.kt @@ -64,7 +64,13 @@ fun SplitTunnelScreen( val extra = GateControlTheme.extraColors var showAppPicker by remember { mutableStateOf(false) } val wifiSubnet = remember { WifiSubnetDetector.detect(context) } - val locked = uiState.splitTunnelAdminLocked + val policy = uiState.policy + // Admin preset lock (server split-tunnel preset) or client policy lock. + val locked = uiState.splitTunnelAdminLocked || policy.splitTunnelFrozen + val appsLocked = policy.lockSettings + @Composable + fun modeDesc(mode: SplitTunnelMode, default: String): String = + if (policy.isModeAllowed(mode)) default else stringResource(R.string.policy_split_mode_not_allowed) Column( modifier = Modifier @@ -81,26 +87,29 @@ fun SplitTunnelScreen( modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 24.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { + if (policy.splitTunnelFrozen || policy.splitTunnelModes.size < SplitTunnelMode.entries.size) { + com.gatecontrol.android.ui.components.GcPolicyLockedHint(Modifier.padding(horizontal = 4.dp)) + } Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { ModeCard( title = stringResource(R.string.split_tunnel_mode_off), - desc = stringResource(R.string.split_tunnel_off_desc), + desc = modeDesc(SplitTunnelMode.OFF, stringResource(R.string.split_tunnel_off_desc)), selected = uiState.splitTunnelMode == SplitTunnelMode.OFF, - enabled = !locked, + enabled = !locked && policy.isModeAllowed(SplitTunnelMode.OFF), onSelect = { viewModel.setSplitTunnelMode(SplitTunnelMode.OFF) }, ) ModeCard( title = stringResource(R.string.split_tunnel_mode_exclude), - desc = stringResource(R.string.split_tunnel_exclude_label), + desc = modeDesc(SplitTunnelMode.EXCLUDE, stringResource(R.string.split_tunnel_exclude_label)), selected = uiState.splitTunnelMode == SplitTunnelMode.EXCLUDE, - enabled = !locked, + enabled = !locked && policy.isModeAllowed(SplitTunnelMode.EXCLUDE), onSelect = { viewModel.setSplitTunnelMode(SplitTunnelMode.EXCLUDE) }, ) ModeCard( title = stringResource(R.string.split_tunnel_mode_include), - desc = stringResource(R.string.split_tunnel_include_label), + desc = modeDesc(SplitTunnelMode.INCLUDE, stringResource(R.string.split_tunnel_include_label)), selected = uiState.splitTunnelMode == SplitTunnelMode.INCLUDE, - enabled = !locked, + enabled = !locked && policy.isModeAllowed(SplitTunnelMode.INCLUDE), onSelect = { viewModel.setSplitTunnelMode(SplitTunnelMode.INCLUDE) }, ) } @@ -153,21 +162,25 @@ fun SplitTunnelScreen( } }, trailing = { - GcIconButton( - icon = GcIcons.Close, - contentDescription = stringResource(R.string.common_remove_named, appLabel), - onClick = { viewModel.setSplitTunnelAppsV2(uiState.splitTunnelAppsV2 - pkg) }, - iconSize = 18.dp, - ) + if (!appsLocked) { + GcIconButton( + icon = GcIcons.Close, + contentDescription = stringResource(R.string.common_remove_named, appLabel), + onClick = { viewModel.setSplitTunnelAppsV2(uiState.splitTunnelAppsV2 - pkg) }, + iconSize = 18.dp, + ) + } }, ) } - GcListRow( - title = stringResource(R.string.split_tunnel_pick_apps), - titleColor = extra.accentText, - onClick = { showAppPicker = true }, - leading = { Icon(GcIcons.Plus, contentDescription = null, tint = extra.accentText, modifier = Modifier.size(20.dp)) }, - ) + if (!appsLocked) { + GcListRow( + title = stringResource(R.string.split_tunnel_pick_apps), + titleColor = extra.accentText, + onClick = { showAppPicker = true }, + leading = { Icon(GcIcons.Plus, contentDescription = null, tint = extra.accentText, modifier = Modifier.size(20.dp)) }, + ) + } } } diff --git a/app/src/main/java/com/gatecontrol/android/ui/setup/SetupViewModel.kt b/app/src/main/java/com/gatecontrol/android/ui/setup/SetupViewModel.kt index 6789d5b4..354521ea 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/setup/SetupViewModel.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/setup/SetupViewModel.kt @@ -7,6 +7,7 @@ import com.gatecontrol.android.ui.UiText import com.gatecontrol.android.common.EnrollmentLink import com.gatecontrol.android.data.SetupRepository import com.gatecontrol.android.network.ApiClientProvider +import com.gatecontrol.android.service.ClientPolicyManager import com.gatecontrol.android.network.EnrollRequest import com.gatecontrol.android.network.RegisterRequest import com.gatecontrol.android.tunnel.TunnelConfig @@ -58,6 +59,7 @@ class SetupViewModel @Inject constructor( private val setupRepository: SetupRepository, private val apiClientProvider: ApiClientProvider, @ApplicationContext private val context: Context, + private val clientPolicyManager: ClientPolicyManager, ) : ViewModel() { private val appVersion: String by lazy { @@ -188,6 +190,8 @@ class SetupViewModel @Inject constructor( response.hash?.let { hash -> setupRepository.saveConfigHash(hash) } + // New server / token: the old server's client policy must not stick. + resetClientPolicy() _uiState.update { it.copy( @@ -282,6 +286,7 @@ class SetupViewModel @Inject constructor( setupRepository.saveWireGuardConfig(config) hash?.let { setupRepository.saveConfigHash(it) } apiClientProvider.invalidate() + resetClientPolicy() _uiState.update { it.copy( @@ -402,6 +407,14 @@ class SetupViewModel @Inject constructor( return TokenSetupLink(url.trim().trimEnd('/'), trimmedToken) } + private suspend fun resetClientPolicy() { + try { + clientPolicyManager.reset() + } catch (e: Exception) { + Timber.w(e, "Client policy reset failed") + } + } + private fun ensureHttps(url: String): String { if (url.isBlank()) return url if (url.startsWith("http://") || url.startsWith("https://")) return url diff --git a/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnScreen.kt index 3fe5c8d5..775f5d80 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnScreen.kt @@ -92,6 +92,7 @@ fun VpnScreen( val portalUrl by viewModel.portalUrl.collectAsState() val autoOpen by viewModel.autoOpenPortal.collectAsState() val expiresAt by viewModel.peerExpiresAt.collectAsState() + val clientPolicy by viewModel.clientPolicy.collectAsState() // Bandwidth history ring buffers (60 points each) val rxHistory = remember { mutableStateListOf() } @@ -256,13 +257,19 @@ fun VpnScreen( } } + // --- Client policy: kill switch / always-on need the system settings + com.gatecontrol.android.ui.components.GcPolicySystemVpnCard(clientPolicy) + // --- Orb ---------------------------------------------------------- Column( modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 4.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(14.dp), ) { + // Client policy "always on": no manual disconnect in the app. + val disconnectLocked = isConnected && !clientPolicy.canDisconnect val orbHint = when { + disconnectLocked -> stringResource(R.string.policy_disconnect_locked) isConnected -> stringResource(R.string.vpn_disconnect) isBusy -> stringResource(R.string.vpn_connecting) else -> stringResource(R.string.vpn_connect) @@ -273,6 +280,7 @@ fun VpnScreen( contentDescription = orbHint, onClick = { when { + disconnectLocked -> Unit isConnected -> viewModel.disconnect() isBusy -> viewModel.disconnect() else -> startConnect() diff --git a/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt b/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt index 44c75322..684fbe1d 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt @@ -10,6 +10,8 @@ import com.gatecontrol.android.network.ApiClientProvider import com.gatecontrol.android.network.PermissionFlags import com.gatecontrol.android.network.TrafficStats import com.gatecontrol.android.network.VpnService +import com.gatecontrol.android.common.ClientPolicy +import com.gatecontrol.android.service.ClientPolicyManager import com.gatecontrol.android.service.TunnelConnector import com.gatecontrol.android.tunnel.TunnelManager import com.gatecontrol.android.tunnel.TunnelState @@ -34,8 +36,12 @@ class VpnViewModel @Inject constructor( private val apiClientProvider: ApiClientProvider, private val tunnelManager: TunnelManager, private val tunnelConnector: TunnelConnector, + private val clientPolicyManager: ClientPolicyManager, ) : ViewModel() { + /** Client policy from the server (unrestricted until one was fetched). */ + val clientPolicy: StateFlow = clientPolicyManager.policy + val tunnelState: StateFlow = tunnelManager.state private val _stats = MutableStateFlow(TunnelStats()) @@ -199,6 +205,11 @@ class VpnViewModel @Inject constructor( fun disconnect() { viewModelScope.launch { + // Client policy "always on": no manual disconnect in the app. + if (!clientPolicyManager.current().canDisconnect) { + Timber.i("VpnViewModel: disconnect refused by always-on client policy") + return@launch + } try { tunnelManager.disconnect() _stats.value = TunnelStats() diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 30283f28..98051866 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -304,6 +304,16 @@ Ungültiger API-Token Update-Prüfung fehlgeschlagen: %1$s Lizenz konnte nicht aktualisiert werden: %1$s + + Vom Administrator festgelegt + Dieses Gerät wird verwaltet – einige Einstellungen sind vom Administrator festgelegt. + Serverwechsel und Neueinrichtung sind vom Administrator gesperrt. + Die Verbindung ist vom Administrator als „immer verbunden“ festgelegt. + Vom Administrator nicht erlaubt + Dein Administrator verlangt ein geschütztes VPN + Schalte in den Android-VPN-Einstellungen „Durchgehend aktives VPN“ für GateControl ein. Android erlaubt Apps nicht, das selbst zu tun. + Kill-Switch: Schalte in den Android-VPN-Einstellungen „Verbindungen ohne VPN blockieren“ für GateControl ein. + VPN-Einstellungen öffnen Support-Paket senden diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 657302cd..21fd42e8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -304,6 +304,16 @@ Invalid API token Update check failed: %1$s License refresh failed: %1$s + + Set by your administrator + This device is managed – some settings are set by your administrator. + Changing the server and re-setup are locked by your administrator. + Your administrator set the connection to “always connected”. + Not allowed by your administrator + Your administrator requires a protected VPN + Turn on “Always-on VPN” for GateControl in the Android VPN settings. Android does not let apps do this themselves. + Kill switch: turn on “Block connections without VPN” for GateControl in the Android VPN settings. + Open VPN settings Send support bundle diff --git a/app/src/test/java/com/gatecontrol/android/service/ClientPolicyManagerTest.kt b/app/src/test/java/com/gatecontrol/android/service/ClientPolicyManagerTest.kt new file mode 100644 index 00000000..b1153ac2 --- /dev/null +++ b/app/src/test/java/com/gatecontrol/android/service/ClientPolicyManagerTest.kt @@ -0,0 +1,125 @@ +package com.gatecontrol.android.service + +import com.gatecontrol.android.common.ClientPolicy +import com.gatecontrol.android.common.SplitTunnelMode +import com.gatecontrol.android.data.ClientPolicyRepository +import com.gatecontrol.android.data.SettingsRepository +import com.gatecontrol.android.data.SetupRepository +import com.gatecontrol.android.network.ApiClient +import com.gatecontrol.android.network.ApiClientProvider +import com.gatecontrol.android.network.ClientPolicyPayload +import com.gatecontrol.android.network.ClientPolicyResponse +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import retrofit2.Response + +class ClientPolicyManagerTest { + + private lateinit var repository: ClientPolicyRepository + private lateinit var settingsRepository: SettingsRepository + private lateinit var setupRepository: SetupRepository + private lateinit var apiClientProvider: ApiClientProvider + private lateinit var apiClient: ApiClient + private lateinit var manager: ClientPolicyManager + + private val stored = MutableStateFlow(null) + + @BeforeEach + fun setUp() { + stored.value = null + repository = mockk(relaxed = true) { + every { cached() } returns stored + coEvery { current() } answers { stored.value } + coEvery { save(any(), any()) } answers { stored.value = ClientPolicyRepository.Cached(firstArg(), secondArg()) } + coEvery { clear() } answers { stored.value = null } + } + settingsRepository = mockk(relaxed = true) { + every { getAutoConnect() } returns flowOf(false) + every { getSplitTunnelMode() } returns flowOf(SplitTunnelMode.INCLUDE) + } + setupRepository = mockk(relaxed = true) { + every { getServerUrl() } returns "https://gc.example" + } + apiClient = mockk(relaxed = true) + apiClientProvider = mockk(relaxed = true) { + every { getClient(any()) } returns apiClient + } + manager = ClientPolicyManager(repository, settingsRepository, setupRepository, apiClientProvider) + } + + private fun ok(version: String, payload: ClientPolicyPayload) = + Response.success(ClientPolicyResponse(ok = true, version = version, managed = true, policy = payload)) + + @Test + fun `never fetched means unrestricted`() = runTest { + assertEquals(ClientPolicy.UNRESTRICTED, manager.current()) + } + + @Test + fun `a fetched policy is stored and forces the settings`() = runTest { + coEvery { apiClient.getClientPolicy(null) } returns ok( + "abcd1234", + ClientPolicyPayload(autoConnect = "required", splitTunnelModes = listOf("off", "exclude")), + ) + assertEquals(ClientPolicyManager.Result.UPDATED, manager.refresh()) + assertEquals(ClientPolicy.AutoConnect.REQUIRED, manager.current().autoConnect) + assertEquals("abcd1234", stored.value?.version) + coVerify { settingsRepository.setAutoConnect(true) } + coVerify { settingsRepository.setSplitTunnelMode(SplitTunnelMode.OFF) } + } + + @Test + fun `cached version is sent and 304 keeps the policy`() = runTest { + stored.value = ClientPolicyRepository.Cached(ClientPolicy(lockServer = true), "beef") + coEvery { apiClient.getClientPolicy("\"beef\"") } returns + Response.error( + "".toResponseBody(null), + okhttp3.Response.Builder() + .code(304) + .message("Not Modified") + .protocol(okhttp3.Protocol.HTTP_1_1) + .request(okhttp3.Request.Builder().url("https://gc.example/api/v1/client/policy").build()) + .build(), + ) + assertEquals(ClientPolicyManager.Result.UNCHANGED, manager.refresh()) + assertEquals(true, manager.current().lockServer) + } + + @Test + fun `unreachable server or old server keeps the last known policy`() = runTest { + stored.value = ClientPolicyRepository.Cached(ClientPolicy(lockSettings = true), "beef") + coEvery { apiClient.getClientPolicy(any()) } throws java.io.IOException("offline") + assertEquals(ClientPolicyManager.Result.UNAVAILABLE, manager.refresh()) + coEvery { apiClient.getClientPolicy(any()) } returns Response.error(404, "{}".toResponseBody(null)) + assertEquals(ClientPolicyManager.Result.UNAVAILABLE, manager.refresh()) + assertEquals(true, manager.current().lockSettings) + } + + @Test + fun `noteVersion refreshes only on a different version`() = runTest { + stored.value = ClientPolicyRepository.Cached(ClientPolicy.UNRESTRICTED, "aaaa") + assertEquals(ClientPolicyManager.Result.UNCHANGED, manager.noteVersion("aaaa")) + assertEquals(ClientPolicyManager.Result.UNCHANGED, manager.noteVersion(null)) + coVerify(exactly = 0) { apiClient.getClientPolicy(any()) } + coEvery { apiClient.getClientPolicy("\"aaaa\"") } returns ok("bbbb", ClientPolicyPayload(killSwitch = "required")) + assertEquals(ClientPolicyManager.Result.UPDATED, manager.noteVersion("bbbb")) + assertEquals(ClientPolicy.KillSwitch.REQUIRED, manager.current().killSwitch) + } + + @Test + fun `reset drops the old policy`() = runTest { + stored.value = ClientPolicyRepository.Cached(ClientPolicy(lockServer = true), "aaaa") + coEvery { apiClient.getClientPolicy(any()) } throws java.io.IOException("offline") + manager.reset() + assertEquals(ClientPolicy.UNRESTRICTED, manager.current()) + } +} diff --git a/app/src/test/java/com/gatecontrol/android/service/FakeClientPolicy.kt b/app/src/test/java/com/gatecontrol/android/service/FakeClientPolicy.kt new file mode 100644 index 00000000..8f2e25e6 --- /dev/null +++ b/app/src/test/java/com/gatecontrol/android/service/FakeClientPolicy.kt @@ -0,0 +1,14 @@ +package com.gatecontrol.android.service + +import com.gatecontrol.android.common.ClientPolicy +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow + +/** A [ClientPolicyManager] double that always reports [policy]. */ +fun fakeClientPolicyManager(policy: ClientPolicy = ClientPolicy.UNRESTRICTED): ClientPolicyManager = + mockk(relaxed = true) { + every { this@mockk.policy } returns MutableStateFlow(policy) + coEvery { current() } returns policy + } diff --git a/app/src/test/java/com/gatecontrol/android/service/TileActionHandlerTest.kt b/app/src/test/java/com/gatecontrol/android/service/TileActionHandlerTest.kt index 1ead805f..0e02cd49 100644 --- a/app/src/test/java/com/gatecontrol/android/service/TileActionHandlerTest.kt +++ b/app/src/test/java/com/gatecontrol/android/service/TileActionHandlerTest.kt @@ -15,7 +15,7 @@ class TileActionHandlerTest { private val tunnelConnector: TunnelConnector = mockk(relaxed = true) private val tunnelManager: TunnelManager = mockk(relaxed = true) - private val handler = TileActionHandler(tunnelConnector, tunnelManager) + private val handler = TileActionHandler(tunnelConnector, tunnelManager, fakeClientPolicyManager()) @Test fun `only known tile actions are accepted`() { @@ -51,4 +51,15 @@ class TileActionHandlerTest { coVerify { tunnelManager.disconnect() } } + + @Test + fun `always-on client policy refuses the tile disconnect`() = kotlinx.coroutines.test.runTest { + val locked = TileActionHandler( + tunnelConnector, + tunnelManager, + fakeClientPolicyManager(com.gatecontrol.android.common.ClientPolicy(autoConnect = com.gatecontrol.android.common.ClientPolicy.AutoConnect.ALWAYS_ON)), + ) + locked.disconnect() + coVerify(exactly = 0) { tunnelManager.disconnect() } + } } diff --git a/app/src/test/java/com/gatecontrol/android/service/TunnelConnectorTest.kt b/app/src/test/java/com/gatecontrol/android/service/TunnelConnectorTest.kt index abde308d..ed4df266 100644 --- a/app/src/test/java/com/gatecontrol/android/service/TunnelConnectorTest.kt +++ b/app/src/test/java/com/gatecontrol/android/service/TunnelConnectorTest.kt @@ -79,6 +79,7 @@ class TunnelConnectorTest { settingsRepository, apiClientProvider, tunnelManager, + fakeClientPolicyManager(), ) } @@ -136,4 +137,21 @@ class TunnelConnectorTest { coVerify(exactly = 0) { apiClient.checkConfigUpdate(any(), any()) } coVerify { tunnelManager.connect(OLD_CONFIG, any()) } } + + @Test + fun `client policy clamps a split mode it does not allow`() = runTest { + every { settingsRepository.getSplitTunnelMode() } returns flowOf(SplitTunnelMode.INCLUDE) + every { settingsRepository.getSplitTunnelNetworks() } returns flowOf("[]") + every { settingsRepository.getSplitTunnelAppsV2() } returns flowOf("[]") + coEvery { apiClient.checkConfigUpdate(any(), any()) } returns + ConfigCheckResponse(ok = true, updated = false, config = null, hash = STORED_HASH) + val restricted = TunnelConnector( + setupRepository, settingsRepository, apiClientProvider, tunnelManager, + fakeClientPolicyManager(com.gatecontrol.android.common.ClientPolicy(splitTunnelModes = setOf(SplitTunnelMode.OFF, SplitTunnelMode.EXCLUDE))), + ) + + assertTrue(restricted.connectWithUserSettings()) + + coVerify { tunnelManager.connect(OLD_CONFIG, SplitTunnelConfig()) } + } } diff --git a/app/src/test/java/com/gatecontrol/android/ui/settings/SettingsViewModelTest.kt b/app/src/test/java/com/gatecontrol/android/ui/settings/SettingsViewModelTest.kt index 9ba4af12..a96dbfef 100644 --- a/app/src/test/java/com/gatecontrol/android/ui/settings/SettingsViewModelTest.kt +++ b/app/src/test/java/com/gatecontrol/android/ui/settings/SettingsViewModelTest.kt @@ -86,6 +86,7 @@ class SettingsViewModelTest { viewModel = SettingsViewModel( setupRepository, settingsRepository, apiClientProvider, licenseRepository, supportBundleCollector, supportBundleUploader, + com.gatecontrol.android.service.fakeClientPolicyManager(), ) } @@ -247,6 +248,31 @@ class SettingsViewModelTest { assertNotNull(viewModel.uiState.value.error) } + @Test + fun `client policy locks auto-connect, split mode and server change`() = runTest { + val policy = com.gatecontrol.android.common.ClientPolicy( + autoConnect = com.gatecontrol.android.common.ClientPolicy.AutoConnect.REQUIRED, + splitTunnelModes = setOf(com.gatecontrol.android.common.SplitTunnelMode.OFF), + lockServer = true, + ) + val vm = SettingsViewModel( + setupRepository, settingsRepository, apiClientProvider, licenseRepository, + supportBundleCollector, supportBundleUploader, + com.gatecontrol.android.service.fakeClientPolicyManager(policy), + ) + testDispatcher.scheduler.advanceUntilIdle() + + vm.setAutoConnect(false) + vm.setSplitTunnelMode(com.gatecontrol.android.common.SplitTunnelMode.INCLUDE) + vm.saveServer("https://other.example.com", "gc_othertoken123456") + testDispatcher.scheduler.advanceUntilIdle() + + coVerify(exactly = 0) { settingsRepository.setAutoConnect(any()) } + coVerify(exactly = 0) { settingsRepository.setSplitTunnelMode(any()) } + io.mockk.verify(exactly = 0) { setupRepository.save(any(), any(), any()) } + org.junit.jupiter.api.Assertions.assertEquals(policy, vm.uiState.value.policy) + } + // ── Support bundle ─────────────────────────────────────────────── private suspend fun awaitSupportResult(): UiText? { diff --git a/app/src/test/java/com/gatecontrol/android/ui/setup/SetupViewModelTest.kt b/app/src/test/java/com/gatecontrol/android/ui/setup/SetupViewModelTest.kt index a3a79697..0e3ad01f 100644 --- a/app/src/test/java/com/gatecontrol/android/ui/setup/SetupViewModelTest.kt +++ b/app/src/test/java/com/gatecontrol/android/ui/setup/SetupViewModelTest.kt @@ -68,7 +68,7 @@ class SetupViewModelTest { every { context.getString(any(), *anyVararg()) } answers { "res-${firstArg()}" } every { apiClientProvider.invalidate() } returns Unit - viewModel = SetupViewModel(setupRepository, apiClientProvider, context) + viewModel = SetupViewModel(setupRepository, apiClientProvider, context, com.gatecontrol.android.service.fakeClientPolicyManager()) } @AfterEach @@ -351,7 +351,7 @@ class SetupViewModelTest { every { setupRepository.isConfigured() } returns true every { setupRepository.hasWireGuardConfig() } returns false - val vm = SetupViewModel(setupRepository, apiClientProvider, context) + val vm = SetupViewModel(setupRepository, apiClientProvider, context, com.gatecontrol.android.service.fakeClientPolicyManager()) assertTrue(vm.uiState.value.isSetupComplete) } @@ -361,7 +361,7 @@ class SetupViewModelTest { every { setupRepository.isConfigured() } returns false every { setupRepository.hasWireGuardConfig() } returns true - val vm = SetupViewModel(setupRepository, apiClientProvider, context) + val vm = SetupViewModel(setupRepository, apiClientProvider, context, com.gatecontrol.android.service.fakeClientPolicyManager()) assertTrue(vm.uiState.value.isSetupComplete) } @@ -371,7 +371,7 @@ class SetupViewModelTest { every { setupRepository.isConfigured() } returns false every { setupRepository.hasWireGuardConfig() } returns false - val vm = SetupViewModel(setupRepository, apiClientProvider, context) + val vm = SetupViewModel(setupRepository, apiClientProvider, context, com.gatecontrol.android.service.fakeClientPolicyManager()) assertFalse(vm.uiState.value.isSetupComplete) } diff --git a/app/src/test/java/com/gatecontrol/android/ui/vpn/VpnViewModelTest.kt b/app/src/test/java/com/gatecontrol/android/ui/vpn/VpnViewModelTest.kt index 72d7820b..098c7be1 100644 --- a/app/src/test/java/com/gatecontrol/android/ui/vpn/VpnViewModelTest.kt +++ b/app/src/test/java/com/gatecontrol/android/ui/vpn/VpnViewModelTest.kt @@ -81,7 +81,9 @@ class VpnViewModelTest { // Real connector on the same mocks: connect() must go through the shared path. tunnelConnector = com.gatecontrol.android.service.TunnelConnector( setupRepository, settingsRepository, apiClientProvider, tunnelManager, + com.gatecontrol.android.service.fakeClientPolicyManager(), ), + clientPolicyManager = com.gatecontrol.android.service.fakeClientPolicyManager(), ) } @@ -244,4 +246,25 @@ class VpnViewModelTest { coVerify(exactly = 0) { apiClient.getPermissions() } } + + @Test + fun `always-on client policy refuses the in-app disconnect`() = runTest { + val vm = VpnViewModel( + setupRepository = setupRepository, + settingsRepository = settingsRepository, + licenseRepository = licenseRepository, + apiClientProvider = apiClientProvider, + tunnelManager = tunnelManager, + tunnelConnector = com.gatecontrol.android.service.TunnelConnector( + setupRepository, settingsRepository, apiClientProvider, tunnelManager, + com.gatecontrol.android.service.fakeClientPolicyManager(), + ), + clientPolicyManager = com.gatecontrol.android.service.fakeClientPolicyManager( + com.gatecontrol.android.common.ClientPolicy(autoConnect = com.gatecontrol.android.common.ClientPolicy.AutoConnect.ALWAYS_ON), + ), + ) + vm.disconnect() + testDispatcher.scheduler.advanceUntilIdle() + coVerify(exactly = 0) { tunnelManager.disconnect() } + } } diff --git a/core/common/src/main/java/com/gatecontrol/android/common/ClientPolicy.kt b/core/common/src/main/java/com/gatecontrol/android/common/ClientPolicy.kt new file mode 100644 index 00000000..b605e339 --- /dev/null +++ b/core/common/src/main/java/com/gatecontrol/android/common/ClientPolicy.kt @@ -0,0 +1,138 @@ +package com.gatecontrol.android.common + +import org.json.JSONArray +import org.json.JSONObject + +/** + * Client policy defined by the administrator on the server + * (GET /api/v1/client/policy, "Client-Richtlinien"). + * + * Semantics on the client: + * - never fetched → [UNRESTRICTED] (no restriction at all), + * - fetched once → persisted, applies offline and while the server is + * unreachable (last known policy), + * - unknown / broken field values fall back to the unrestricted value of + * that field (a damaged answer must not lock the user out). + * + * Android limits: an app cannot switch on the system "Always-on VPN" or + * "Block connections without VPN" itself. When the policy requires a kill + * switch or always-on, the app shows a hint with a button to the system VPN + * settings ([needsSystemVpnSettings]); what the app can enforce it does + * (auto-connect on boot/app start, no in-app disconnect, split-tunnel and + * settings lock, hidden server change). + * + * This is a management convenience, not a security boundary against the + * device owner. + */ +data class ClientPolicy( + val killSwitch: KillSwitch = KillSwitch.USER, + val autoConnect: AutoConnect = AutoConnect.USER, + val autostart: Autostart = Autostart.USER, + val splitTunnelModes: Set = SplitTunnelMode.entries.toSet(), + val splitTunnelLocked: Boolean = false, + val lockSettings: Boolean = false, + val lockServer: Boolean = false, +) { + enum class KillSwitch(val wire: String) { USER("user"), REQUIRED("required") } + enum class AutoConnect(val wire: String) { USER("user"), REQUIRED("required"), ALWAYS_ON("always_on") } + enum class Autostart(val wire: String) { USER("user"), REQUIRED("required"), FORBIDDEN("forbidden") } + + /** Anything restricted compared to [UNRESTRICTED]. */ + val managed: Boolean get() = this != UNRESTRICTED + + /** The user may disconnect the tunnel in the app / tile. */ + val canDisconnect: Boolean get() = autoConnect != AutoConnect.ALWAYS_ON + + /** + * Forced value of the "connect automatically" (boot) setting, null when + * the user decides. Auto-connect required wins over autostart forbidden. + */ + val forcedAutoConnect: Boolean? + get() = when { + autoConnect != AutoConnect.USER -> true + autostart == Autostart.REQUIRED -> true + autostart == Autostart.FORBIDDEN -> false + else -> null + } + + /** The auto-connect switch is disabled in the settings. */ + val autoConnectLocked: Boolean get() = forcedAutoConnect != null || lockSettings + + /** Split-tunnel mode can not be changed by the user at all. */ + val splitTunnelFrozen: Boolean get() = lockSettings || splitTunnelLocked + + /** Kill switch / always-on can only be set by the user in the system settings. */ + val needsSystemVpnSettings: Boolean + get() = killSwitch == KillSwitch.REQUIRED || autoConnect == AutoConnect.ALWAYS_ON + + fun isModeAllowed(mode: SplitTunnelMode): Boolean = mode in splitTunnelModes + + /** + * [mode] if allowed, otherwise full tunnel when allowed, otherwise the + * first allowed mode (off → exclude → include). + */ + fun clampMode(mode: SplitTunnelMode): SplitTunnelMode = when { + isModeAllowed(mode) -> mode + isModeAllowed(SplitTunnelMode.OFF) -> SplitTunnelMode.OFF + else -> SplitTunnelMode.entries.first { isModeAllowed(it) } + } + + fun toJson(): String = JSONObject() + .put("killSwitch", killSwitch.wire) + .put("autoConnect", autoConnect.wire) + .put("autostart", autostart.wire) + .put("splitTunnelModes", JSONArray(SplitTunnelMode.entries.filter { it in splitTunnelModes }.map { it.wire })) + .put("splitTunnelLocked", splitTunnelLocked) + .put("lockSettings", lockSettings) + .put("lockServer", lockServer) + .toString() + + companion object { + val UNRESTRICTED = ClientPolicy() + + /** Builds a policy from raw server values; invalid values fall back to unrestricted. */ + fun from( + killSwitch: String?, + autoConnect: String?, + autostart: String?, + splitTunnelModes: List?, + splitTunnelLocked: Boolean?, + lockSettings: Boolean?, + lockServer: Boolean?, + ): ClientPolicy { + val modes = splitTunnelModes.orEmpty().mapNotNull { raw -> + SplitTunnelMode.entries.firstOrNull { it.wire == raw?.trim()?.lowercase() } + }.toSet() + return ClientPolicy( + killSwitch = KillSwitch.entries.firstOrNull { it.wire == killSwitch } ?: KillSwitch.USER, + autoConnect = AutoConnect.entries.firstOrNull { it.wire == autoConnect } ?: AutoConnect.USER, + autostart = Autostart.entries.firstOrNull { it.wire == autostart } ?: Autostart.USER, + splitTunnelModes = modes.ifEmpty { SplitTunnelMode.entries.toSet() }, + splitTunnelLocked = splitTunnelLocked == true, + lockSettings = lockSettings == true, + lockServer = lockServer == true, + ) + } + + /** Parses [toJson] output; null when the text is not a policy. */ + fun fromJson(json: String?): ClientPolicy? { + if (json.isNullOrBlank()) return null + return try { + val obj = JSONObject(json) + val modesArr = obj.optJSONArray("splitTunnelModes") + val modes = modesArr?.let { arr -> (0 until arr.length()).map { arr.optString(it) } } + from( + killSwitch = obj.optString("killSwitch", ""), + autoConnect = obj.optString("autoConnect", ""), + autostart = obj.optString("autostart", ""), + splitTunnelModes = modes, + splitTunnelLocked = obj.optBoolean("splitTunnelLocked", false), + lockSettings = obj.optBoolean("lockSettings", false), + lockServer = obj.optBoolean("lockServer", false), + ) + } catch (_: Exception) { + null + } + } + } +} diff --git a/core/common/src/test/java/com/gatecontrol/android/common/ClientPolicyTest.kt b/core/common/src/test/java/com/gatecontrol/android/common/ClientPolicyTest.kt new file mode 100644 index 00000000..5e0f97f4 --- /dev/null +++ b/core/common/src/test/java/com/gatecontrol/android/common/ClientPolicyTest.kt @@ -0,0 +1,89 @@ +package com.gatecontrol.android.common + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class ClientPolicyTest { + + @Test + fun `unrestricted policy restricts nothing`() { + val p = ClientPolicy.UNRESTRICTED + assertFalse(p.managed) + assertTrue(p.canDisconnect) + assertNull(p.forcedAutoConnect) + assertFalse(p.autoConnectLocked) + assertFalse(p.needsSystemVpnSettings) + SplitTunnelMode.entries.forEach { assertTrue(p.isModeAllowed(it)) } + } + + @Test + fun `from parses server values and falls back on invalid ones`() { + val p = ClientPolicy.from( + killSwitch = "required", + autoConnect = "sometimes", + autostart = "forbidden", + splitTunnelModes = listOf("include", "bogus", "OFF"), + splitTunnelLocked = null, + lockSettings = true, + lockServer = null, + ) + assertEquals(ClientPolicy.KillSwitch.REQUIRED, p.killSwitch) + assertEquals(ClientPolicy.AutoConnect.USER, p.autoConnect) + assertEquals(ClientPolicy.Autostart.FORBIDDEN, p.autostart) + assertEquals(setOf(SplitTunnelMode.OFF, SplitTunnelMode.INCLUDE), p.splitTunnelModes) + assertFalse(p.splitTunnelLocked) + assertTrue(p.lockSettings) + assertFalse(p.lockServer) + assertTrue(p.managed) + assertTrue(p.needsSystemVpnSettings) + + val empty = ClientPolicy.from(null, null, null, emptyList(), null, null, null) + assertEquals(ClientPolicy.UNRESTRICTED, empty) + } + + @Test + fun `auto-connect forcing - required wins over autostart forbidden`() { + assertEquals(true, ClientPolicy(autoConnect = ClientPolicy.AutoConnect.REQUIRED, autostart = ClientPolicy.Autostart.FORBIDDEN).forcedAutoConnect) + assertEquals(true, ClientPolicy(autostart = ClientPolicy.Autostart.REQUIRED).forcedAutoConnect) + assertEquals(false, ClientPolicy(autostart = ClientPolicy.Autostart.FORBIDDEN).forcedAutoConnect) + assertNull(ClientPolicy(lockSettings = true).forcedAutoConnect) + assertTrue(ClientPolicy(lockSettings = true).autoConnectLocked) + } + + @Test + fun `always-on blocks disconnect and needs the system settings`() { + val p = ClientPolicy(autoConnect = ClientPolicy.AutoConnect.ALWAYS_ON) + assertFalse(p.canDisconnect) + assertTrue(p.needsSystemVpnSettings) + assertTrue(ClientPolicy(autoConnect = ClientPolicy.AutoConnect.REQUIRED).canDisconnect) + } + + @Test + fun `clampMode keeps allowed modes and prefers full tunnel`() { + val offExclude = ClientPolicy(splitTunnelModes = setOf(SplitTunnelMode.OFF, SplitTunnelMode.EXCLUDE)) + assertEquals(SplitTunnelMode.EXCLUDE, offExclude.clampMode(SplitTunnelMode.EXCLUDE)) + assertEquals(SplitTunnelMode.OFF, offExclude.clampMode(SplitTunnelMode.INCLUDE)) + val includeOnly = ClientPolicy(splitTunnelModes = setOf(SplitTunnelMode.INCLUDE)) + assertEquals(SplitTunnelMode.INCLUDE, includeOnly.clampMode(SplitTunnelMode.OFF)) + } + + @Test + fun `json round trip and broken json`() { + val p = ClientPolicy( + killSwitch = ClientPolicy.KillSwitch.REQUIRED, + autoConnect = ClientPolicy.AutoConnect.ALWAYS_ON, + autostart = ClientPolicy.Autostart.REQUIRED, + splitTunnelModes = setOf(SplitTunnelMode.EXCLUDE), + splitTunnelLocked = true, + lockSettings = true, + lockServer = true, + ) + assertEquals(p, ClientPolicy.fromJson(p.toJson())) + assertNull(ClientPolicy.fromJson("not json")) + assertNull(ClientPolicy.fromJson("")) + assertNull(ClientPolicy.fromJson(null)) + } +} diff --git a/core/data/src/main/java/com/gatecontrol/android/data/ClientPolicyRepository.kt b/core/data/src/main/java/com/gatecontrol/android/data/ClientPolicyRepository.kt new file mode 100644 index 00000000..b8b6a2e3 --- /dev/null +++ b/core/data/src/main/java/com/gatecontrol/android/data/ClientPolicyRepository.kt @@ -0,0 +1,50 @@ +package com.gatecontrol.android.data + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.gatecontrol.android.common.ClientPolicy +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Last known client policy from the server (see [ClientPolicy]). Persisted so + * it applies offline and after a reboot; null = never fetched (no + * restriction). + */ +@Singleton +class ClientPolicyRepository @Inject constructor(private val dataStore: DataStore) { + + data class Cached(val policy: ClientPolicy, val version: String?) + + companion object { + val POLICY_JSON = stringPreferencesKey("client_policy_json") + val POLICY_VERSION = stringPreferencesKey("client_policy_version") + } + + /** Cached policy, null when none was ever fetched (or the cache is unreadable). */ + fun cached(): Flow = dataStore.data.map { prefs -> + ClientPolicy.fromJson(prefs[POLICY_JSON])?.let { Cached(it, prefs[POLICY_VERSION]) } + } + + suspend fun current(): Cached? = cached().first() + + suspend fun save(policy: ClientPolicy, version: String?) { + dataStore.edit { prefs -> + prefs[POLICY_JSON] = policy.toJson() + if (version.isNullOrBlank()) prefs.remove(POLICY_VERSION) else prefs[POLICY_VERSION] = version + } + } + + /** Drop the cache (server changed): back to "never fetched". */ + suspend fun clear() { + dataStore.edit { prefs -> + prefs.remove(POLICY_JSON) + prefs.remove(POLICY_VERSION) + } + } +} diff --git a/core/network/src/main/java/com/gatecontrol/android/network/ApiClient.kt b/core/network/src/main/java/com/gatecontrol/android/network/ApiClient.kt index 7f0caab0..3bcb4fd2 100644 --- a/core/network/src/main/java/com/gatecontrol/android/network/ApiClient.kt +++ b/core/network/src/main/java/com/gatecontrol/android/network/ApiClient.kt @@ -1,5 +1,6 @@ package com.gatecontrol.android.network +import retrofit2.Response import okhttp3.RequestBody import retrofit2.http.Body import retrofit2.http.GET @@ -69,6 +70,13 @@ interface ApiClient { @GET("api/v1/client/split-tunnel") suspend fun getSplitTunnelPreset(): SplitTunnelPresetResponse + /** + * Effective client policy. With the cached version as If-None-Match the + * server answers 304 when nothing changed (hence the raw [Response]). + */ + @GET("api/v1/client/policy") + suspend fun getClientPolicy(@Header("If-None-Match") ifNoneMatch: String? = null): Response + @GET("api/v1/client/rdp") suspend fun getRdpRoutes(): RdpRoutesResponse diff --git a/core/network/src/main/java/com/gatecontrol/android/network/ApiModels.kt b/core/network/src/main/java/com/gatecontrol/android/network/ApiModels.kt index bd4e4bbc..ef61741e 100644 --- a/core/network/src/main/java/com/gatecontrol/android/network/ApiModels.kt +++ b/core/network/src/main/java/com/gatecontrol/android/network/ApiModels.kt @@ -16,6 +16,8 @@ data class PermissionsResponse( val scopes: List, @SerializedName("portalUrl") val portalUrl: String? = null, @SerializedName("autoOpenPortal") val autoOpenPortal: Boolean = false, + /** Client policy version (see [ClientPolicyResponse]); null on old servers. */ + @SerializedName("policyVersion") val policyVersion: String? = null, ) data class PermissionFlags( @@ -269,12 +271,32 @@ data class RdpRouteStatusResponse( data class HeartbeatResponse( val ok: Boolean, val peerEnabled: Boolean? = null, + /** Client policy version; differs from the cached one → refetch the policy. */ + val policyVersion: String? = null, /** An admin asked this device for a support bundle (the user is asked first). */ val supportBundleRequested: Boolean? = null, /** When the admin asked (server time, UTC) — a new value means a new request. */ val supportBundleRequestedAt: String? = null, ) +/** Reply to /client/policy: the effective client policy for this token's peer. */ +data class ClientPolicyResponse( + val ok: Boolean, + val version: String? = null, + val managed: Boolean? = null, + val policy: ClientPolicyPayload? = null, +) + +data class ClientPolicyPayload( + val killSwitch: String? = null, + val autoConnect: String? = null, + val autostart: String? = null, + val splitTunnelModes: List? = null, + val splitTunnelLocked: Boolean? = null, + val lockSettings: Boolean? = null, + val lockServer: Boolean? = null, +) + data class SupportBundleUploadResponse( val ok: Boolean, val bundle: SupportBundleInfo? = null, diff --git a/core/network/src/test/java/com/gatecontrol/android/network/ApiClientTest.kt b/core/network/src/test/java/com/gatecontrol/android/network/ApiClientTest.kt index d0134778..dc2e082c 100644 --- a/core/network/src/test/java/com/gatecontrol/android/network/ApiClientTest.kt +++ b/core/network/src/test/java/com/gatecontrol/android/network/ApiClientTest.kt @@ -227,4 +227,40 @@ class ApiClientTest { assertTrue(response.available) assertEquals("2.0.0", response.version) } + + @Test + fun `client policy is fetched with If-None-Match and 304 maps to not modified`() = runTest { + server.enqueue( + MockResponse() + .setResponseCode(200) + .setBody( + """{"ok":true,"version":"ab12","managed":true,"policy":{"killSwitch":"required","autoConnect":"always_on","autostart":"user","splitTunnelModes":["off"],"splitTunnelLocked":false,"lockSettings":true,"lockServer":false}}""" + ) + ) + server.enqueue(MockResponse().setResponseCode(304)) + + val first = apiClient.getClientPolicy(null) + val rec1 = server.takeRequest() + assertEquals("/api/v1/client/policy", rec1.path) + assertEquals(null, rec1.getHeader("If-None-Match")) + assertTrue(first.isSuccessful) + val body = first.body() + assertNotNull(body) + assertEquals("ab12", body!!.version) + assertEquals("always_on", body.policy?.autoConnect) + assertEquals(listOf("off"), body.policy?.splitTunnelModes) + assertEquals(true, body.policy?.lockSettings) + + val second = apiClient.getClientPolicy("\"ab12\"") + val rec2 = server.takeRequest() + assertEquals("\"ab12\"", rec2.getHeader("If-None-Match")) + assertEquals(304, second.code()) + } + + @Test + fun `heartbeat carries the policy version`() = runTest { + server.enqueue(MockResponse().setResponseCode(200).setBody("""{"ok":true,"peerEnabled":true,"policyVersion":"cd34"}""")) + val response = apiClient.sendHeartbeat(HeartbeatRequest(1, true, 0, 0, 0, "phone")) + assertEquals("cd34", response.policyVersion) + } }