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
16 changes: 16 additions & 0 deletions app/src/main/java/com/gatecontrol/android/ui/MachineBindingText.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.gatecontrol.android.ui

import androidx.annotation.StringRes
import com.gatecontrol.android.R
import com.gatecontrol.android.network.MachineBindingError

/** User-facing explanation of a machine-binding (Gerätebindung) rejection. */
@get:StringRes
val MachineBindingError.messageRes: Int
get() = when (this) {
MachineBindingError.MISMATCH -> R.string.binding_mismatch
MachineBindingError.REQUIRED -> R.string.binding_required
MachineBindingError.INVALID -> R.string.binding_invalid
}

fun MachineBindingError.toUiText(): UiText = UiText.Res(messageRes)
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,16 @@ fun SettingsScreen(
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
)
if (uiState.deviceIdShort.isNotEmpty()) {
// Matches the server's "Gebunden an Gerät ab12cd34…" for machine binding.
Text(
text = stringResource(R.string.settings_device_id, uiState.deviceIdShort),
style = MaterialTheme.typography.bodySmall.copy(fontFamily = MonoFontFamily),
color = extra.faint,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import androidx.lifecycle.viewModelScope
import com.gatecontrol.android.R
import com.gatecontrol.android.ui.UiText
import com.gatecontrol.android.data.LicenseRepository
import com.gatecontrol.android.data.MachineFingerprint
import com.gatecontrol.android.network.MachineBindingError
import com.gatecontrol.android.ui.toUiText
import com.gatecontrol.android.data.SetupRepository
import com.gatecontrol.android.data.SplitTunnelJson
import com.gatecontrol.android.data.SettingsRepository
Expand Down Expand Up @@ -68,6 +71,8 @@ data class SettingsUiState(
val supportMessage: UiText? = null,
/** An admin asked for a support bundle (heartbeat). */
val supportRequested: Boolean = false,
/** First 8 hex chars of the machine fingerprint, as the server shows it. */
val deviceIdShort: String = "",
)

@HiltViewModel
Expand All @@ -79,6 +84,7 @@ class SettingsViewModel @Inject constructor(
private val supportBundleCollector: SupportBundleCollector,
private val supportBundleUploader: SupportBundleUploader,
private val clientPolicyManager: ClientPolicyManager,
private val machineFingerprint: MachineFingerprint,
) : ViewModel() {

private val _uiState = MutableStateFlow(SettingsUiState())
Expand All @@ -90,6 +96,11 @@ class SettingsViewModel @Inject constructor(
}

private fun loadInitialState() {
viewModelScope.launch {
val shortId = runCatching { machineFingerprint.shortId() }.getOrDefault("")
_uiState.update { it.copy(deviceIdShort = shortId) }
}

viewModelScope.launch {
combine(
settingsRepository.getTheme(),
Expand Down Expand Up @@ -483,7 +494,8 @@ class SettingsViewModel @Inject constructor(
}
} catch (e: HttpException) {
Timber.w("Support bundle upload rejected: HTTP %d", e.code())
when (e.code()) {
val bindingError = MachineBindingError.from(e)
if (bindingError != null) bindingError.toUiText() else when (e.code()) {
429 -> UiText.Res(R.string.support_rate_limited)
413 -> UiText.Res(R.string.support_too_large)
401, 403 -> UiText.Res(R.string.support_forbidden)
Expand All @@ -502,6 +514,7 @@ class SettingsViewModel @Inject constructor(
private fun supportSettingsSnapshot(state: SettingsUiState): Map<String, Any?> = mapOf(
"serverUrl" to state.serverUrl,
"peerId" to state.peerId,
"deviceId" to state.deviceIdShort,
"theme" to state.theme,
"locale" to state.locale,
"autoConnect" to state.autoConnect,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import androidx.lifecycle.viewModelScope
import com.gatecontrol.android.R
import com.gatecontrol.android.ui.UiText
import com.gatecontrol.android.common.EnrollmentLink
import com.gatecontrol.android.data.MachineFingerprint
import com.gatecontrol.android.data.SetupRepository
import com.gatecontrol.android.network.MachineBindingError
import com.gatecontrol.android.ui.toUiText
import com.gatecontrol.android.network.ApiClientProvider
import com.gatecontrol.android.service.ClientPolicyManager
import com.gatecontrol.android.network.EnrollRequest
Expand Down Expand Up @@ -60,6 +63,7 @@ class SetupViewModel @Inject constructor(
private val apiClientProvider: ApiClientProvider,
@ApplicationContext private val context: Context,
private val clientPolicyManager: ClientPolicyManager,
private val machineFingerprint: MachineFingerprint,
) : ViewModel() {

private val appVersion: String by lazy {
Expand Down Expand Up @@ -207,10 +211,12 @@ class SetupViewModel @Inject constructor(
// Roll back to the previous configuration instead of clearing it
setupRepository.save(previousUrl, previousToken, previousPeerId)
apiClientProvider.invalidate()
val message = MachineBindingError.from(e)?.toUiText()
?: UiText.Res(R.string.setup_error, e.localizedMessage ?: "")
_uiState.update {
it.copy(
isLoading = false,
statusMessage = UiText.Res(R.string.setup_error, e.localizedMessage ?: ""),
statusMessage = message,
statusType = StatusType.ERROR,
)
}
Expand Down Expand Up @@ -260,6 +266,7 @@ class SetupViewModel @Inject constructor(
hostname = android.os.Build.MODEL ?: "android",
platform = "android",
clientVersion = appVersion,
fingerprint = machineFingerprint.get(),
),
)
val token = response.token
Expand Down Expand Up @@ -345,6 +352,8 @@ class SetupViewModel @Inject constructor(
} catch (_: Exception) {
""
}
// Binding errors also come from the register call of a token-wizard code.
MachineBindingError.fromResponse(e.code(), body)?.let { return it.toUiText() }
ERROR_CODE_RE.find(body)?.groupValues?.get(1).orEmpty()
}
} else {
Expand All @@ -355,6 +364,7 @@ class SetupViewModel @Inject constructor(
"user_disabled", "user_not_found", "no_valid_scopes" -> R.string.setup_enroll_forbidden
"limit_reached" -> R.string.setup_enroll_limit
"rate_limited" -> R.string.setup_enroll_rate_limited
"fingerprint_required" -> R.string.binding_required
else -> null
}
return if (res != null) {
Expand Down
14 changes: 14 additions & 0 deletions app/src/main/java/com/gatecontrol/android/ui/vpn/VpnScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import com.gatecontrol.android.util.openSystemVpnSettings
import com.gatecontrol.android.common.Formatters
import com.gatecontrol.android.tunnel.TunnelState
import com.gatecontrol.android.ui.components.GcBanner
import com.gatecontrol.android.ui.messageRes
import com.gatecontrol.android.ui.components.GcCard
import com.gatecontrol.android.ui.components.GcIconButton
import com.gatecontrol.android.ui.components.GcIcons
Expand Down Expand Up @@ -257,6 +258,19 @@ fun VpnScreen(
}
}

// --- Machine binding: the server rejected this device ------------
val bindingError by viewModel.machineBindingError.collectAsState()
bindingError?.let { err ->
GcBanner(tone = GcTone.Error) {
Text(
text = stringResource(err.messageRes),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
)
}
}

// --- Client policy: kill switch / always-on need the system settings
com.gatecontrol.android.ui.components.GcPolicySystemVpnCard(clientPolicy)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import com.gatecontrol.android.data.LicenseRepository
import com.gatecontrol.android.data.SettingsRepository
import com.gatecontrol.android.data.SetupRepository
import com.gatecontrol.android.network.ApiClientProvider
import com.gatecontrol.android.network.MachineBindingError
import com.gatecontrol.android.network.MachineBindingMonitor
import com.gatecontrol.android.network.PermissionFlags
import com.gatecontrol.android.network.TrafficStats
import com.gatecontrol.android.network.VpnService
Expand Down Expand Up @@ -37,8 +39,12 @@ class VpnViewModel @Inject constructor(
private val tunnelManager: TunnelManager,
private val tunnelConnector: TunnelConnector,
private val clientPolicyManager: ClientPolicyManager,
machineBindingMonitor: MachineBindingMonitor,
) : ViewModel() {

/** Last machine-binding rejection of any client API call (null = none). */
val machineBindingError: StateFlow<MachineBindingError?> = machineBindingMonitor.error

/** Client policy from the server (unrestricted until one was fetched). */
val clientPolicy: StateFlow<ClientPolicy> = clientPolicyManager.policy

Expand Down
6 changes: 6 additions & 0 deletions app/src/main/res/values-de/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -333,4 +333,10 @@
<string name="support_forbidden">Der Server hat das Support-Paket abgelehnt (Token und Gerät prüfen).</string>
<string name="support_unsupported">Der Server unterstützt noch keine Support-Pakete.</string>
<string name="support_not_configured">Erst einen Server einrichten – dann kann ein Support-Paket gesendet werden.</string>

<!-- Gerätebindung -->
<string name="binding_mismatch">Dieser Zugang ist an ein anderes Gerät gebunden. Bitte den Administrator, die Gerätebindung zurückzusetzen.</string>
<string name="binding_required">Der Server verlangt eine Gerätebindung, aber dieses Gerät hat keine Geräte-ID gesendet. Bitte die App aktualisieren oder den Administrator kontaktieren.</string>
<string name="binding_invalid">Der Server hat die Geräte-ID dieses Geräts abgelehnt. Bitte den Administrator kontaktieren.</string>
<string name="settings_device_id">Geräte-ID %1$s…</string>
</resources>
6 changes: 6 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -333,4 +333,10 @@
<string name="support_forbidden">The server rejected the support bundle (check token and device).</string>
<string name="support_unsupported">The server does not support support bundles yet.</string>
<string name="support_not_configured">Set up a server first – then a support bundle can be sent.</string>

<!-- Machine binding (device binding on the server) -->
<string name="binding_mismatch">This access is bound to a different device. Ask your administrator to reset the device binding.</string>
<string name="binding_required">The server requires device binding, but this device sent no device ID. Update the app or contact your administrator.</string>
<string name="binding_invalid">The server rejected this device\'s ID. Contact your administrator.</string>
<string name="settings_device_id">Device ID %1$s…</string>
</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ class SettingsViewModelTest {
private lateinit var viewModel: SettingsViewModel
private lateinit var supportBundleCollector: SupportBundleCollector
private lateinit var supportBundleUploader: SupportBundleUploader
private val machineFingerprint: com.gatecontrol.android.data.MachineFingerprint = mockk {
every { shortId() } returns "ab12cd34"
}

@BeforeEach
fun setUp() {
Expand Down Expand Up @@ -87,6 +90,7 @@ class SettingsViewModelTest {
setupRepository, settingsRepository, apiClientProvider, licenseRepository,
supportBundleCollector, supportBundleUploader,
com.gatecontrol.android.service.fakeClientPolicyManager(),
machineFingerprint,
)
}

Expand Down Expand Up @@ -259,6 +263,7 @@ class SettingsViewModelTest {
setupRepository, settingsRepository, apiClientProvider, licenseRepository,
supportBundleCollector, supportBundleUploader,
com.gatecontrol.android.service.fakeClientPolicyManager(policy),
machineFingerprint,
)
testDispatcher.scheduler.advanceUntilIdle()

Expand Down Expand Up @@ -314,11 +319,28 @@ class SettingsViewModelTest {
assertFalse(settings.captured.values.any { it == "gc_testtoken" })
assertFalse(settings.captured.keys.any { it.contains("token", ignoreCase = true) })
assertEquals("https://gate.example.com", settings.captured["serverUrl"])
assertEquals("ab12cd34", settings.captured["deviceId"])

viewModel.consumeSupportMessage()
assertNull(viewModel.uiState.value.supportMessage)
}

@Test
fun `device id short form is shown`() = runTest {
testDispatcher.scheduler.advanceUntilIdle()
assertEquals("ab12cd34", viewModel.uiState.value.deviceIdShort)
}

@Test
fun `support bundle - binding mismatch maps to the device binding message`() = runTest {
testDispatcher.scheduler.advanceUntilIdle()
coEvery { supportBundleUploader.upload(any(), any(), any()) } throws HttpException(
Response.error<Any>(403, "{\"ok\":false,\"error\":\"Token ist an eine andere Maschine gebunden\"}".toResponseBody(null)),
)
viewModel.sendSupportBundle("1.5.0")
assertEquals(UiText.Res(R.string.binding_mismatch), awaitSupportResult())
}

@Test
fun `support bundle - 429 maps to rate limited message`() = runTest {
testDispatcher.scheduler.advanceUntilIdle()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import com.gatecontrol.android.network.ApiClientProvider
import com.gatecontrol.android.network.PingResponse
import com.gatecontrol.android.network.RegisterRequest
import com.gatecontrol.android.network.RegisterResponse
import okhttp3.ResponseBody.Companion.toResponseBody
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
Expand Down Expand Up @@ -42,6 +43,10 @@ class SetupViewModelTest {
private lateinit var apiClient: ApiClient
private lateinit var context: Context
private lateinit var viewModel: SetupViewModel
private val fingerprint = "a".repeat(64)
private val machineFingerprint: com.gatecontrol.android.data.MachineFingerprint = mockk {
every { get() } returns fingerprint
}

@BeforeEach
fun setUp() {
Expand All @@ -68,7 +73,7 @@ class SetupViewModelTest {
every { context.getString(any(), *anyVararg()) } answers { "res-${firstArg<Int>()}" }
every { apiClientProvider.invalidate() } returns Unit

viewModel = SetupViewModel(setupRepository, apiClientProvider, context, com.gatecontrol.android.service.fakeClientPolicyManager())
viewModel = SetupViewModel(setupRepository, apiClientProvider, context, com.gatecontrol.android.service.fakeClientPolicyManager(), machineFingerprint)
}

@AfterEach
Expand Down Expand Up @@ -351,7 +356,7 @@ class SetupViewModelTest {
every { setupRepository.isConfigured() } returns true
every { setupRepository.hasWireGuardConfig() } returns false

val vm = SetupViewModel(setupRepository, apiClientProvider, context, com.gatecontrol.android.service.fakeClientPolicyManager())
val vm = SetupViewModel(setupRepository, apiClientProvider, context, com.gatecontrol.android.service.fakeClientPolicyManager(), machineFingerprint)

assertTrue(vm.uiState.value.isSetupComplete)
}
Expand All @@ -361,7 +366,7 @@ class SetupViewModelTest {
every { setupRepository.isConfigured() } returns false
every { setupRepository.hasWireGuardConfig() } returns true

val vm = SetupViewModel(setupRepository, apiClientProvider, context, com.gatecontrol.android.service.fakeClientPolicyManager())
val vm = SetupViewModel(setupRepository, apiClientProvider, context, com.gatecontrol.android.service.fakeClientPolicyManager(), machineFingerprint)

assertTrue(vm.uiState.value.isSetupComplete)
}
Expand All @@ -371,7 +376,7 @@ class SetupViewModelTest {
every { setupRepository.isConfigured() } returns false
every { setupRepository.hasWireGuardConfig() } returns false

val vm = SetupViewModel(setupRepository, apiClientProvider, context, com.gatecontrol.android.service.fakeClientPolicyManager())
val vm = SetupViewModel(setupRepository, apiClientProvider, context, com.gatecontrol.android.service.fakeClientPolicyManager(), machineFingerprint)

assertFalse(vm.uiState.value.isSetupComplete)
}
Expand Down Expand Up @@ -448,7 +453,9 @@ class SetupViewModelTest {
viewModel.confirmEnrollment()
testDispatcher.scheduler.advanceUntilIdle()

coVerify { apiClient.enroll(match { it.code == "AB12-CD34-EF56-7890" && it.platform == "android" }) }
coVerify {
apiClient.enroll(match { it.code == "AB12-CD34-EF56-7890" && it.platform == "android" && it.fingerprint == fingerprint })
}
verify { setupRepository.save("https://gate.example.com", "gc_enrolled", 42) }
verify { setupRepository.saveWireGuardConfig(enrollConfig) }
verify { setupRepository.saveConfigHash("abc") }
Expand All @@ -474,6 +481,45 @@ class SetupViewModelTest {
assertFalse(state.completedNow)
}

private fun httpError(code: Int, body: String) = retrofit2.HttpException(
retrofit2.Response.error<Any>(
code,
body.toResponseBody(null),
),
)

@Test
fun `enroll without fingerprint shows the device binding message`() = runTest {
coEvery { apiClient.enroll(any()) } throws httpError(400, "{\"ok\":false,\"error\":\"fingerprint_required\"}")

viewModel.onEnrollmentLink(link)
viewModel.confirmEnrollment()
testDispatcher.scheduler.advanceUntilIdle()

assertEquals(
com.gatecontrol.android.ui.UiText.Res(com.gatecontrol.android.R.string.binding_required),
viewModel.uiState.value.statusMessage,
)
}

@Test
fun `register on a token bound to another device shows the binding mismatch`() = runTest {
coEvery { apiClient.ping() } returns PingResponse(ok = true, version = "1.0", timestamp = "t")
coEvery { apiClient.register(any()) } throws
httpError(403, "{\"ok\":false,\"error\":\"Token is bound to a different machine\"}")

viewModel.onServerUrlChanged("https://gate.example.com")
viewModel.onApiTokenChanged("gc_bound_token_123456")
viewModel.saveAndRegister()
testDispatcher.scheduler.advanceUntilIdle()

assertEquals(StatusType.ERROR, viewModel.uiState.value.statusType)
assertEquals(
com.gatecontrol.android.ui.UiText.Res(com.gatecontrol.android.R.string.binding_mismatch),
viewModel.uiState.value.statusMessage,
)
}

@Test
fun `setup code typed into the token field is redeemed instead of registering`() = runTest {
coEvery { apiClient.enroll(any()) } returns EnrollResponse(
Expand Down
Loading
Loading