Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ClientPolicy> = 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}$")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {

/**
Expand All @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -88,13 +101,29 @@ 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
* right away; on failure the tile is refreshed to show the real state.
*/
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) {
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading