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 6ea2c69b..8b397dda 100644 --- a/app/src/main/java/com/gatecontrol/android/service/TunnelSupervisor.kt +++ b/app/src/main/java/com/gatecontrol/android/service/TunnelSupervisor.kt @@ -7,6 +7,7 @@ import com.gatecontrol.android.common.HostnameSanitizer import com.gatecontrol.android.data.SetupRepository import com.gatecontrol.android.network.ApiClientProvider import com.gatecontrol.android.network.HeartbeatRequest +import com.gatecontrol.android.support.SupportRequestHolder import com.gatecontrol.android.tunnel.TunnelManager import com.gatecontrol.android.tunnel.TunnelMonitor import com.gatecontrol.android.tunnel.TunnelState @@ -144,6 +145,9 @@ class TunnelSupervisor @Inject constructor( hostname = HostnameSanitizer.sanitize(android.os.Build.MODEL).orEmpty(), ), ) + // 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) if (response.ok && response.peerEnabled == false) { Timber.w("Peer disabled on server — disconnecting tunnel") tunnelManager.disconnect() diff --git a/app/src/main/java/com/gatecontrol/android/support/SupportBundleCollector.kt b/app/src/main/java/com/gatecontrol/android/support/SupportBundleCollector.kt new file mode 100644 index 00000000..652e88bd --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/support/SupportBundleCollector.kt @@ -0,0 +1,194 @@ +package com.gatecontrol.android.support + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.Build +import com.gatecontrol.android.common.SupportRedactor +import com.gatecontrol.android.data.SetupRepository +import com.gatecontrol.android.tunnel.TunnelManager +import com.gatecontrol.android.tunnel.TunnelState +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.io.File +import java.net.NetworkInterface +import java.time.Instant +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Open admin request for a support bundle, reported by the heartbeat + * (TunnelSupervisor). Value: request timestamp, "requested" for servers + * without one, or null. Settings shows a hint and the user decides. + */ +object SupportRequestHolder { + private val _request = MutableStateFlow(null) + val request: StateFlow = _request.asStateFlow() + + fun update(requested: Boolean?, requestedAt: String?) { + if (requested == null) return // older server: no information + _request.value = if (requested) requestedAt?.takeIf { it.isNotBlank() } ?: "requested" else null + } + + fun clear() { _request.value = null } +} + +/** + * Builds the support bundle (schema 1, gatecontrol docs/feature-support-bundle.md) + * as a plain value tree: + * client — product android, app version, Android version / API level, device, ABI, locale + * tunnel — state, connected since, last handshake age, traffic + * settings — snapshot handed in by the caller, WITHOUT the API token + * wireguardConfig — stored config with PrivateKey / PresharedKey masked + * network — interfaces (no MAC), active network: transports, DNS, routes + * logs — last [MAX_LOG_LINES] lines of cacheDir/logs (FileLoggingTree) + * errors — recent W/E lines + * Every string passes [SupportRedactor]; the uploader redacts once more. + */ +@Singleton +class SupportBundleCollector @Inject constructor( + @ApplicationContext private val context: Context, + private val setupRepository: SetupRepository, + private val tunnelManager: TunnelManager, +) { + + fun collect( + appVersion: String, + locale: String, + settings: Map, + reason: String = "user", + nowMillis: Long = System.currentTimeMillis(), + ): Map { + val notes = mutableListOf() + val logs = runCatching { readLogs(File(context.cacheDir, "logs")) } + .onFailure { notes.add("logs: ${it.javaClass.simpleName}") } + .getOrDefault(LogTail(emptyList(), 0, false)) + val lines = logs.lines.map { truncate(SupportRedactor.redactText(it), MAX_LINE_LENGTH) } + + val bundle = linkedMapOf( + "schema" to SCHEMA_VERSION, + "createdAt" to Instant.ofEpochMilli(nowMillis).toString(), + "reason" to if (reason == "admin_request") "admin_request" else "user", + "client" to clientInfo(appVersion, locale), + "tunnel" to runCatching { tunnelInfo(nowMillis) }.onFailure { notes.add("tunnel: ${it.javaClass.simpleName}") }.getOrNull(), + "settings" to settingsSnapshot(settings), + "wireguardConfig" to runCatching { + setupRepository.getWireGuardConfig().takeIf { it.isNotBlank() }?.let { truncate(SupportRedactor.redactText(it), MAX_TEXT) } + }.getOrNull(), + "network" to networkInfo(notes), + "logs" to mapOf("lines" to lines, "totalLines" to logs.totalLines, "truncated" to logs.truncated), + "errors" to lines.filter { ERROR_LINE.containsMatchIn(it) }.takeLast(MAX_ERROR_LINES), + "notes" to notes, + "redaction" to "android-v1", + ) + @Suppress("UNCHECKED_CAST") + return SupportRedactor.redactValue(bundle) as Map + } + + private fun clientInfo(appVersion: String, locale: String): Map = mapOf( + "product" to "android", + "version" to appVersion, + "platform" to "android", + "os" to "Android ${Build.VERSION.RELEASE.orEmpty()} (API ${Build.VERSION.SDK_INT})", + "device" to listOfNotNull(Build.MANUFACTURER, Build.MODEL).joinToString(" ").ifBlank { null }, + "arch" to Build.SUPPORTED_ABIS?.firstOrNull(), + "locale" to locale, + ) + + private fun tunnelInfo(nowMillis: Long): Map { + val state = tunnelManager.state.value + val stats = tunnelManager.stats.value + return mapOf( + "state" to state.javaClass.simpleName, + "connected" to (state is TunnelState.Connected), + "connectedSince" to (state as? TunnelState.Connected)?.connectedSince?.let { Instant.ofEpochMilli(it).toString() }, + "error" to (state as? TunnelState.Error)?.message, + "lastHandshakeAgeSec" to stats.lastHandshakeEpoch.takeIf { it > 0 }?.let { (nowMillis / 1000 - it).coerceAtLeast(0) }, + "rxBytes" to stats.rxBytes, + "txBytes" to stats.txBytes, + "peerId" to setupRepository.getPeerId(), + ) + } + + private fun networkInfo(notes: MutableList): Map { + val interfaces = runCatching { + NetworkInterface.getNetworkInterfaces()?.toList().orEmpty().map { nif -> + mapOf( + "name" to nif.name, + "up" to nif.isUp, + "mtu" to nif.mtu, + "addresses" to nif.interfaceAddresses.map { "${it.address.hostAddress}/${it.networkPrefixLength}" }, + ) + } + }.onFailure { notes.add("interfaces: ${it.javaClass.simpleName}") }.getOrDefault(emptyList()) + + val active = runCatching { + val cm = context.getSystemService(ConnectivityManager::class.java) ?: return@runCatching null + val network = cm.activeNetwork ?: return@runCatching null + val caps = cm.getNetworkCapabilities(network) + val lp = cm.getLinkProperties(network) + mapOf( + "transports" to listOfNotNull( + "wifi".takeIf { caps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true }, + "cellular".takeIf { caps?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true }, + "ethernet".takeIf { caps?.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) == true }, + "vpn".takeIf { caps?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true }, + ), + "validated" to (caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) == true), + "interface" to lp?.interfaceName, + "dnsServers" to lp?.dnsServers?.mapNotNull { it.hostAddress }.orEmpty(), + "privateDns" to lp?.isPrivateDnsActive, + "routes" to lp?.routes?.map { it.toString() }.orEmpty().take(MAX_ROUTES), + ) + }.onFailure { notes.add("activeNetwork: ${it.javaClass.simpleName}") }.getOrNull() + + return mapOf("interfaces" to interfaces, "activeNetwork" to active) + } + + /** Settings without credentials: token-like keys are dropped by the redactor. */ + private fun settingsSnapshot(settings: Map): Map = + settings.filterKeys { !SupportRedactor.isSecretKey(it) } + + data class LogTail(val lines: List, val totalLines: Int, val truncated: Boolean) + + companion object { + const val SCHEMA_VERSION = 1 + const val MAX_LOG_LINES = 2000 + private const val MAX_LINE_LENGTH = 2000 + private const val MAX_ERROR_LINES = 100 + private const val MAX_TEXT = 64 * 1024 + private const val MAX_ROUTES = 200 + private const val MAX_READ_BYTES = 2L * 1024 * 1024 + private val ERROR_LINE = Regex(" [EWA]/|\\b(error|exception|failed|fatal)\\b", RegexOption.IGNORE_CASE) + + private fun truncate(s: String, max: Int) = + if (s.length > max) s.take(max) + "… [${s.length - max} chars truncated]" else s + + /** + * Last [maxLines] lines of the app log: gatecontrol.log.1 (rotated) + * followed by gatecontrol.log, at most [MAX_READ_BYTES] from each. + */ + fun readLogs(logDir: File, maxLines: Int = MAX_LOG_LINES): LogTail { + val files = listOf(File(logDir, "gatecontrol.log.1"), File(logDir, "gatecontrol.log")).filter { it.isFile } + var truncated = false + val all = mutableListOf() + for (f in files) { + val len = f.length() + val text = f.inputStream().use { input -> + if (len > MAX_READ_BYTES) { + truncated = true + input.skip(len - MAX_READ_BYTES) + input.readBytes().toString(Charsets.UTF_8).substringAfter('\n') + } else { + input.readBytes().toString(Charsets.UTF_8) + } + } + all += text.lines().filter { it.isNotBlank() } + } + if (all.size > maxLines) truncated = true + return LogTail(all.takeLast(maxLines), all.size, truncated) + } + } +} 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 42f26e54..a8605fe5 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 @@ -12,10 +12,13 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Icon +import androidx.compose.material3.TextButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -198,6 +201,18 @@ fun SettingsScreen( onClick = { viewModel.exportLogs(context.cacheDir) }, trailing = { Icon(GcIcons.Share, contentDescription = null, tint = extra.faint, modifier = Modifier.size(20.dp)) }, ) + // "Support-Paket senden": confirmation dialog → redacted bundle → server + GcListRow( + title = stringResource(R.string.support_send), + description = when { + uiState.supportSending -> stringResource(R.string.support_sending) + uiState.supportRequested -> stringResource(R.string.support_requested) + else -> stringResource(R.string.support_send_desc) + }, + titleColor = if (uiState.supportRequested) extra.accentText else MaterialTheme.colorScheme.onSurface, + onClick = if (uiState.supportSending) null else { { viewModel.requestSupportBundle() } }, + trailing = { Icon(GcIcons.Logs, contentDescription = null, tint = extra.faint, modifier = Modifier.size(20.dp)) }, + ) UpdateRow( versionName = versionName, uiState = uiState, @@ -215,6 +230,24 @@ fun SettingsScreen( ) } + if (uiState.supportDialogVisible) { + SupportBundleDialog( + host = host, + requestedByAdmin = uiState.supportRequested, + onSend = { viewModel.sendSupportBundle(versionName) }, + onDismiss = { viewModel.dismissSupportDialog() }, + ) + } + + // Result of the upload as a toast (resolved in the app language). + val supportMessage = uiState.supportMessage?.asString() + LaunchedEffect(supportMessage) { + if (supportMessage != null) { + android.widget.Toast.makeText(context, supportMessage, android.widget.Toast.LENGTH_LONG).show() + viewModel.consumeSupportMessage() + } + } + uiState.error?.let { error -> Text( text = error.asString(), @@ -300,3 +333,30 @@ private fun UpdateRow( } } } + +/** "Diagnosedaten an senden?" — lists what is (not) included. */ +@Composable +private fun SupportBundleDialog( + host: String, + requestedByAdmin: Boolean, + onSend: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + containerColor = MaterialTheme.colorScheme.surface, + shape = MaterialTheme.shapes.extraLarge, + title = { Text(stringResource(R.string.support_dialog_title, host), style = MaterialTheme.typography.titleLarge) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + if (requestedByAdmin) { + Text(stringResource(R.string.support_dialog_admin), style = MaterialTheme.typography.bodyMedium, color = GateControlTheme.extraColors.accentText) + } + Text(stringResource(R.string.support_dialog_includes), style = MaterialTheme.typography.bodyMedium) + Text(stringResource(R.string.support_dialog_excludes), style = MaterialTheme.typography.bodyMedium, color = GateControlTheme.extraColors.muted) + } + }, + confirmButton = { TextButton(onClick = onSend) { Text(stringResource(R.string.support_dialog_send)) } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.support_dialog_cancel)) } }, + ) +} 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 d9793e1b..31843449 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,12 @@ 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.network.SupportBundleUploader +import com.gatecontrol.android.support.SupportBundleCollector +import com.gatecontrol.android.support.SupportRequestHolder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import retrofit2.HttpException import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -51,6 +57,13 @@ data class SettingsUiState( val error: UiText? = null, val isPro: Boolean = false, val peerId: Int = 0, + /** Confirmation dialog for "Support-Paket senden" is open. */ + val supportDialogVisible: Boolean = false, + val supportSending: Boolean = false, + /** One-shot result of the last upload (shown as a toast, then consumed). */ + val supportMessage: UiText? = null, + /** An admin asked for a support bundle (heartbeat). */ + val supportRequested: Boolean = false, ) @HiltViewModel @@ -58,7 +71,9 @@ class SettingsViewModel @Inject constructor( private val setupRepository: SetupRepository, private val settingsRepository: SettingsRepository, private val apiClientProvider: ApiClientProvider, - private val licenseRepository: LicenseRepository + private val licenseRepository: LicenseRepository, + private val supportBundleCollector: SupportBundleCollector, + private val supportBundleUploader: SupportBundleUploader, ) : ViewModel() { private val _uiState = MutableStateFlow(SettingsUiState()) @@ -127,6 +142,12 @@ class SettingsViewModel @Inject constructor( + viewModelScope.launch { + SupportRequestHolder.request.collect { request -> + _uiState.update { it.copy(supportRequested = request != null) } + } + } + _uiState.update { it.copy( serverUrl = setupRepository.getServerUrl(), @@ -382,6 +403,89 @@ class SettingsViewModel @Inject constructor( } } + // ── Support bundle ("Support-Paket senden") ─────────────────────────── + + /** Opens the confirmation dialog (server and peer must be set up). */ + fun requestSupportBundle() { + if (setupRepository.getServerUrl().isBlank() || setupRepository.getPeerId() <= 0) { + _uiState.update { it.copy(supportMessage = UiText.Res(R.string.support_not_configured)) } + return + } + _uiState.update { it.copy(supportDialogVisible = true) } + } + + fun dismissSupportDialog() { + _uiState.update { it.copy(supportDialogVisible = false) } + } + + fun consumeSupportMessage() { + _uiState.update { it.copy(supportMessage = null) } + } + + /** + * Collects the redacted bundle and uploads it — only called from the + * confirmation dialog. + */ + fun sendSupportBundle(appVersion: String) { + if (_uiState.value.supportSending) return + val state = _uiState.value + val serverUrl = setupRepository.getServerUrl() + val peerId = setupRepository.getPeerId() + if (serverUrl.isBlank() || peerId <= 0) { + _uiState.update { it.copy(supportDialogVisible = false, supportMessage = UiText.Res(R.string.support_not_configured)) } + return + } + val reason = if (state.supportRequested) "admin_request" else "user" + _uiState.update { it.copy(supportDialogVisible = false, supportSending = true) } + viewModelScope.launch { + val message = try { + val bundle = withContext(Dispatchers.IO) { + supportBundleCollector.collect( + appVersion = appVersion, + locale = state.locale, + settings = supportSettingsSnapshot(state), + reason = reason, + ) + } + val response = supportBundleUploader.upload(serverUrl, peerId, bundle) + if (response.ok) { + SupportRequestHolder.clear() + Timber.i("Support bundle sent (id %s)", response.bundle?.id) + UiText.Res(R.string.support_success) + } else { + UiText.Res(R.string.support_failed, response.error ?: "") + } + } catch (e: HttpException) { + Timber.w("Support bundle upload rejected: HTTP %d", e.code()) + 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) + 404 -> UiText.Res(R.string.support_unsupported) + else -> UiText.Res(R.string.support_failed, "HTTP ${e.code()}") + } + } catch (e: Exception) { + Timber.w("Support bundle upload failed: %s", e.javaClass.simpleName) + UiText.Res(R.string.support_failed, e.localizedMessage ?: e.javaClass.simpleName) + } + _uiState.update { it.copy(supportSending = false, supportMessage = message) } + } + } + + /** Settings for the bundle — never the API token. */ + private fun supportSettingsSnapshot(state: SettingsUiState): Map = mapOf( + "serverUrl" to state.serverUrl, + "peerId" to state.peerId, + "theme" to state.theme, + "locale" to state.locale, + "autoConnect" to state.autoConnect, + "splitTunnelMode" to state.splitTunnelMode.name, + "splitTunnelNetworks" to state.splitTunnelNetworks.map { mapOf("cidr" to it.cidr, "label" to it.label) }, + "splitTunnelApps" to state.splitTunnelAppsV2, + "splitTunnelAdminLocked" to state.splitTunnelAdminLocked, + "isPro" to state.isPro, + ) + fun exportLogs(cacheDir: File): File? { return try { val logFile = File(File(cacheDir, "export").apply { mkdirs() }, "gatecontrol-logs.txt") diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index ae9eca5d..30283f28 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -304,4 +304,23 @@ Ungültiger API-Token Update-Prüfung fehlgeschlagen: %1$s Lizenz konnte nicht aktualisiert werden: %1$s + + + Support-Paket senden + Bereinigte Diagnosedaten für deinen Administrator + Dein Administrator hat ein Support-Paket angefordert + Diagnosedaten an %1$s senden? + Enthalten: App- und Android-Version, Gerät, Tunnelstatus und letzter Handshake, Einstellungen, WireGuard-Konfiguration ohne Schlüssel, Netzwerkschnittstellen, DNS-Server und Routen, die letzten 2000 Logzeilen und aktuelle Fehler. + Nicht enthalten: private Schlüssel, Preshared Keys, API-Tokens, Passwörter, Einrichtungscodes und Cookies – sie werden vor dem Senden entfernt. + Dein Administrator hat dieses Paket angefordert. + Senden + Abbrechen + Wird gesendet… + Support-Paket gesendet + Support-Paket konnte nicht gesendet werden: %1$s + Zu viele Support-Pakete – bitte später erneut versuchen. + Das Support-Paket ist zu groß. + Der Server hat das Support-Paket abgelehnt (Token und Gerät prüfen). + Der Server unterstützt noch keine Support-Pakete. + Erst einen Server einrichten – dann kann ein Support-Paket gesendet werden. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 536f1e38..657302cd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -304,4 +304,23 @@ Invalid API token Update check failed: %1$s License refresh failed: %1$s + + + Send support bundle + Redacted diagnostics for your administrator + Your administrator requested a support bundle + Send diagnostics to %1$s? + Included: app and Android version, device, tunnel state and last handshake, settings, WireGuard config without keys, network interfaces, DNS servers and routes, the last 2000 log lines and recent errors. + Not included: private keys, preshared keys, API tokens, passwords, setup codes and cookies – they are removed before sending. + Your administrator asked for this bundle. + Send + Cancel + Sending… + Support bundle sent + Support bundle could not be sent: %1$s + Too many support bundles – please try again later. + The support bundle is too large. + The server rejected the support bundle (check token and device). + The server does not support support bundles yet. + Set up a server first – then a support bundle can be sent. diff --git a/app/src/test/java/com/gatecontrol/android/support/SupportBundleCollectorTest.kt b/app/src/test/java/com/gatecontrol/android/support/SupportBundleCollectorTest.kt new file mode 100644 index 00000000..2a40de2e --- /dev/null +++ b/app/src/test/java/com/gatecontrol/android/support/SupportBundleCollectorTest.kt @@ -0,0 +1,133 @@ +package com.gatecontrol.android.support + +import android.content.Context +import android.net.ConnectivityManager +import com.gatecontrol.android.data.SetupRepository +import com.gatecontrol.android.network.SupportBundleUploader +import com.gatecontrol.android.tunnel.TunnelManager +import com.gatecontrol.android.tunnel.TunnelState +import com.gatecontrol.android.tunnel.TunnelStats +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +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 +import org.junit.jupiter.api.io.TempDir +import java.io.File + +class SupportBundleCollectorTest { + + private val wgKey = "yAnz5TF+lXXJte14tji3zlMNq+hd2rYUIgJBgB3fBmk=" + private val psk = "FpCyhws9cxwWoV4xELtfJvjJN+zQVRPISllRWgeopVE=" + private val token = "gc_" + "f00dfeed".repeat(6) + + @TempDir + lateinit var tmp: File + + private fun collector(connected: Boolean = true): SupportBundleCollector { + val logDir = File(tmp, "logs").apply { mkdirs() } + File(logDir, "gatecontrol.log.1").writeText((0 until 1500).joinToString("\n") { "2026-10-02 09:00:00.000 I/Old: line $it" } + "\n") + File(logDir, "gatecontrol.log").writeText( + (0 until 1000).joinToString("\n") { "2026-10-02 10:00:00.000 I/Tunnel: line $it" } + + "\n2026-10-02 10:00:01.000 W/Api: X-API-Token: $token rejected" + + "\n2026-10-02 10:00:02.000 E/TunnelManager: connect failed: no handshake\n", + ) + val context = mockk { + every { cacheDir } returns tmp + every { getSystemService(ConnectivityManager::class.java) } returns null + } + val setup = mockk { + every { getWireGuardConfig() } returns "[Interface]\nPrivateKey = $wgKey\nAddress = 10.8.0.9/32\n[Peer]\nPresharedKey = $psk\nEndpoint = gate.example.com:51820" + every { getPeerId() } returns 7 + } + val tunnel = mockk { + every { state } returns MutableStateFlow(if (connected) TunnelState.Connected(connectedSince = 1_790_000_000_000L) else TunnelState.Disconnected) + every { stats } returns MutableStateFlow(TunnelStats(rxBytes = 10, txBytes = 20, lastHandshakeEpoch = 1_790_000_000L - 42)) + } + return SupportBundleCollector(context, setup, tunnel) + } + + private val settings = mapOf( + "serverUrl" to "https://gate.example.com", + "apiToken" to token, + "peerId" to 7, + "theme" to "dark", + "splitTunnelMode" to "OFF", + ) + + @Test + fun `collects all sections and leaks no secret`() { + val b = collector().collect("1.5.0", "de", settings, nowMillis = 1_790_000_000_000L) + + assertEquals(1, b["schema"]) + assertEquals("user", b["reason"]) + val client = b["client"] as Map<*, *> + assertEquals("android", client["product"]) + assertEquals("1.5.0", client["version"]) + assertEquals("android", client["platform"]) + assertEquals("de", client["locale"]) + + val tunnel = b["tunnel"] as Map<*, *> + assertEquals(true, tunnel["connected"]) + assertEquals(42L, tunnel["lastHandshakeAgeSec"]) + assertEquals(10L, tunnel["rxBytes"]) + + val s = b["settings"] as Map<*, *> + assertFalse(s.containsKey("apiToken")) + assertEquals("https://gate.example.com", s["serverUrl"]) + + val wg = b["wireguardConfig"] as String + assertTrue(wg.contains("PrivateKey = [REDACTED]"), wg) + assertTrue(wg.contains("Address = 10.8.0.9/32"), wg) + + val logs = b["logs"] as Map<*, *> + val lines = logs["lines"] as List<*> + assertEquals(SupportBundleCollector.MAX_LOG_LINES, lines.size) + assertEquals(true, logs["truncated"]) + assertTrue((lines.last() as String).contains("connect failed")) + val errors = b["errors"] as List<*> + assertTrue(errors.any { (it as String).contains("connect failed") }) + assertTrue(errors.any { (it as String).contains("W/Api") }) + + val network = b["network"] as Map<*, *> + assertTrue(network["interfaces"] is List<*>) + assertNull(network["activeNetwork"]) + + val json = SupportBundleUploader.toJson(b) + for (secret in listOf(wgKey, psk, token)) assertFalse(json.contains(secret), "leak: $secret") + } + + @Test + fun `admin request reason and disconnected tunnel`() { + val b = collector(connected = false).collect("1.5.0", "en", emptyMap(), reason = "admin_request") + assertEquals("admin_request", b["reason"]) + assertEquals(false, (b["tunnel"] as Map<*, *>)["connected"]) + } + + @Test + fun `readLogs takes the newest lines across rotation`() { + val dir = File(tmp, "l").apply { mkdirs() } + File(dir, "gatecontrol.log.1").writeText("a\nb\n") + File(dir, "gatecontrol.log").writeText("c\nd\n") + val tail = SupportBundleCollector.readLogs(dir, maxLines = 3) + assertEquals(listOf("b", "c", "d"), tail.lines) + assertEquals(4, tail.totalLines) + assertTrue(tail.truncated) + } + + @Test + fun `request holder follows the heartbeat`() { + SupportRequestHolder.clear() + SupportRequestHolder.update(true, "2026-10-02 10:00:00") + assertEquals("2026-10-02 10:00:00", SupportRequestHolder.request.value) + SupportRequestHolder.update(null, null) // older server: unchanged + assertEquals("2026-10-02 10:00:00", SupportRequestHolder.request.value) + SupportRequestHolder.update(true, null) + assertEquals("requested", SupportRequestHolder.request.value) + SupportRequestHolder.update(false, null) + assertNull(SupportRequestHolder.request.value) + } +} 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 d2b17854..9ba4af12 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 @@ -8,6 +8,17 @@ import com.gatecontrol.android.network.ApiClient import com.gatecontrol.android.network.ApiClientProvider import com.gatecontrol.android.network.PingResponse import com.gatecontrol.android.network.UpdateCheckResponse +import com.gatecontrol.android.R +import com.gatecontrol.android.network.SupportBundleInfo +import com.gatecontrol.android.network.SupportBundleUploadResponse +import com.gatecontrol.android.network.SupportBundleUploader +import com.gatecontrol.android.support.SupportBundleCollector +import com.gatecontrol.android.support.SupportRequestHolder +import com.gatecontrol.android.ui.UiText +import io.mockk.slot +import okhttp3.ResponseBody.Companion.toResponseBody +import retrofit2.HttpException +import retrofit2.Response import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -21,6 +32,7 @@ import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue @@ -38,6 +50,8 @@ class SettingsViewModelTest { private lateinit var licenseRepository: LicenseRepository private lateinit var apiClient: ApiClient private lateinit var viewModel: SettingsViewModel + private lateinit var supportBundleCollector: SupportBundleCollector + private lateinit var supportBundleUploader: SupportBundleUploader @BeforeEach fun setUp() { @@ -63,7 +77,16 @@ class SettingsViewModelTest { } licenseRepository = mockk() - viewModel = SettingsViewModel(setupRepository, settingsRepository, apiClientProvider, licenseRepository) + supportBundleCollector = mockk { + every { collect(any(), any(), any(), any(), any()) } returns mapOf("schema" to 1) + } + supportBundleUploader = mockk() + SupportRequestHolder.clear() + + viewModel = SettingsViewModel( + setupRepository, settingsRepository, apiClientProvider, licenseRepository, + supportBundleCollector, supportBundleUploader, + ) } @AfterEach @@ -223,4 +246,83 @@ class SettingsViewModelTest { assertNotNull(viewModel.uiState.value.error) } + + // ── Support bundle ─────────────────────────────────────────────── + + private suspend fun awaitSupportResult(): UiText? { + repeat(200) { + testDispatcher.scheduler.advanceUntilIdle() + val state = viewModel.uiState.value + if (!state.supportSending && state.supportMessage != null) return state.supportMessage + Thread.sleep(10) // collect() runs on Dispatchers.IO + } + return viewModel.uiState.value.supportMessage + } + + @Test + fun `support bundle - dialog first, upload only after confirm`() = runTest { + testDispatcher.scheduler.advanceUntilIdle() + viewModel.requestSupportBundle() + assertTrue(viewModel.uiState.value.supportDialogVisible) + coVerify(exactly = 0) { supportBundleUploader.upload(any(), any(), any()) } + + viewModel.dismissSupportDialog() + assertFalse(viewModel.uiState.value.supportDialogVisible) + coVerify(exactly = 0) { supportBundleUploader.upload(any(), any(), any()) } + } + + @Test + fun `support bundle - sends redacted settings without the token`() = runTest { + testDispatcher.scheduler.advanceUntilIdle() + val settings = slot>() + every { supportBundleCollector.collect(any(), any(), capture(settings), any(), any()) } returns mapOf("schema" to 1) + coEvery { supportBundleUploader.upload("https://gate.example.com", 1, any()) } returns + SupportBundleUploadResponse(ok = true, bundle = SupportBundleInfo(id = 5)) + + viewModel.requestSupportBundle() + viewModel.sendSupportBundle("1.5.0") + val msg = awaitSupportResult() + + assertEquals(UiText.Res(R.string.support_success), msg) + assertFalse(viewModel.uiState.value.supportDialogVisible) + 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"]) + + viewModel.consumeSupportMessage() + assertNull(viewModel.uiState.value.supportMessage) + } + + @Test + fun `support bundle - 429 maps to rate limited message`() = runTest { + testDispatcher.scheduler.advanceUntilIdle() + coEvery { supportBundleUploader.upload(any(), any(), any()) } throws + HttpException(Response.error(429, "{\"ok\":false}".toResponseBody(null))) + viewModel.sendSupportBundle("1.5.0") + assertEquals(UiText.Res(R.string.support_rate_limited), awaitSupportResult()) + } + + @Test + fun `support bundle - admin request is shown and cleared by a successful upload`() = runTest { + testDispatcher.scheduler.advanceUntilIdle() + SupportRequestHolder.update(true, "2026-10-02 10:00:00") + testDispatcher.scheduler.advanceUntilIdle() + assertTrue(viewModel.uiState.value.supportRequested) + + coEvery { supportBundleUploader.upload(any(), any(), any()) } returns SupportBundleUploadResponse(ok = true) + viewModel.sendSupportBundle("1.5.0") + awaitSupportResult() + testDispatcher.scheduler.advanceUntilIdle() + assertFalse(viewModel.uiState.value.supportRequested) + io.mockk.verify { supportBundleCollector.collect(any(), any(), any(), "admin_request", any()) } + } + + @Test + fun `support bundle - not configured shows a message, no dialog`() = runTest { + every { setupRepository.getPeerId() } returns -1 + testDispatcher.scheduler.advanceUntilIdle() + viewModel.requestSupportBundle() + assertFalse(viewModel.uiState.value.supportDialogVisible) + assertEquals(UiText.Res(R.string.support_not_configured), viewModel.uiState.value.supportMessage) + } } diff --git a/core/common/src/main/java/com/gatecontrol/android/common/SupportRedactor.kt b/core/common/src/main/java/com/gatecontrol/android/common/SupportRedactor.kt new file mode 100644 index 00000000..2d789cf1 --- /dev/null +++ b/core/common/src/main/java/com/gatecontrol/android/common/SupportRedactor.kt @@ -0,0 +1,93 @@ +package com.gatecontrol.android.common + +/** + * Redaction for support bundles ("Support-Paket senden"). + * + * Every string that goes into a bundle passes through [redactText] before it + * leaves the device; object keys that name a secret lose their value + * ([isSecretKey]). Same rule set as gatecontrol-client-core + * `src/support/redact.js` and the server pass (`src/utils/supportRedact.js`): + * + * - WireGuard `PrivateKey = …` / `PresharedKey = …` lines + * - Authorization / Proxy-Authorization / Cookie / Set-Cookie / + * X-API-Token / X-API-Key header values + * - `key=value` / `"key": "value"` pairs with a secret-like key name + * - GateControl API tokens (gc_…), JWTs, PEM private keys, WireGuard-style + * base64 keys (44 chars, "=" padded), hex strings of 32+ chars, setup + * codes XXXX-XXXX-XXXX-XXXX + * + * All patterns are linear-time (no nested or unbounded lazy quantifiers). + */ +object SupportRedactor { + + const val MASK = "[REDACTED]" + + private val SECRET_KEY = Regex( + "(pass(word|wd|phrase)?|pwd|secret|token|api[-_]?key|apikey|private[-_]?key|preshared[-_]?key|psk|cookie|" + + "authori[sz]ation|credential|enrol(l)?ment[-_]?code|setup[-_]?code|machine[-_]?key|session[-_]?id)", + RegexOption.IGNORE_CASE, + ) + + /** Keys never copied into a bundle (prototype pollution on JS consumers). */ + val UNSAFE_KEYS = setOf("__proto__", "constructor", "prototype") + + private const val M = "\\[REDACTED\\]" + + // Order matters: specific structures first, generic patterns last. + private val RULES: List> = listOf( + // PEM private keys: base64 after the BEGIN line, long base64 lines + Regex("(-----BEGIN [A-Z0-9 ]{0,40}PRIVATE KEY-----)[A-Za-z0-9+/=\\s]*") to "$1$M\n", + Regex("^[A-Za-z0-9+/]{60,}={0,2}$", RegexOption.MULTILINE) to M, + // WireGuard config secrets + Regex("^(\\s*(?:PrivateKey|PresharedKey)\\s*=\\s*).*$", setOf(RegexOption.MULTILINE, RegexOption.IGNORE_CASE)) to "$1$M", + // HTTP auth / cookie headers + Regex("((?:proxy-)?authorization[\"']?\\s*[:=]\\s*[\"']?)(?:(?:bearer|basic|digest|token)\\s+)?[^\\s\"',;]+", RegexOption.IGNORE_CASE) to "$1$M", + Regex("((?:set-)?cookie[\"']?\\s*[:=]\\s*[\"']?)[^\\r\\n\"']+", RegexOption.IGNORE_CASE) to "$1$M", + Regex("(x-api-(?:token|key)[\"']?\\s*[:=]\\s*[\"']?)[^\\s\"',;]+", RegexOption.IGNORE_CASE) to "$1$M", + // key=value and "key": "value" with a secret-like key name + Regex( + "\\b([A-Za-z0-9_-]{0,40}?(?:password|passwd|pwd|secret|token|api[-_]?key|apikey|private[-_]?key|preshared[-_]?key|psk|" + + "enrol(?:l)?ment[-_]?code|setup[-_]?code|credential)s?[\"']?\\s*[:=]\\s*[\"']?)(?!\\[REDACTED\\])[^\\s\"'&,;}]+", + RegexOption.IGNORE_CASE, + ) to "$1$M", + // Free-standing secrets + Regex("\\bgc_[A-Za-z0-9_-]{6,}") to "gc_$M", + Regex("\\beyJ[A-Za-z0-9_-]{5,}\\.[A-Za-z0-9_-]{5,}\\.[A-Za-z0-9_-]{5,}") to M, + Regex("(^|[^A-Za-z0-9+/])[A-Za-z0-9+/]{42}[AEIMQUYcgkosw048]=(?![A-Za-z0-9+/=])") to "$1$M", + Regex("\\b[A-Fa-f0-9]{32,}\\b") to M, + Regex("\\b[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}\\b") to M, + ) + + /** Masks every secret in [input]; ordinary diagnostics stay untouched. */ + fun redactText(input: String): String { + if (input.isEmpty()) return input + var out = input + for ((re, replacement) in RULES) out = re.replace(out, replacement) + return out + } + + /** True when an object key names a secret (its value is masked). */ + fun isSecretKey(key: String): Boolean = SECRET_KEY.containsMatchIn(key) + + /** + * Redacts a simple value tree (String / Number / Boolean / null / List / Map) + * as built for a bundle: secret-named keys lose non-empty values, unsafe + * keys are dropped, all strings pass [redactText]. + */ + fun redactValue(value: Any?, depth: Int = 0): Any? = when { + depth > 32 -> MASK + value is String -> redactText(value) + value is List<*> -> value.map { redactValue(it, depth + 1) } + value is Map<*, *> -> buildMap { + for ((k, v) in value) { + val key = k?.toString() ?: continue + if (key in UNSAFE_KEYS) continue + put( + key, + if (isSecretKey(key) && v != null && v != "" && v !is Boolean) MASK else redactValue(v, depth + 1), + ) + } + } + else -> value + } +} diff --git a/core/common/src/test/java/com/gatecontrol/android/common/SupportRedactorTest.kt b/core/common/src/test/java/com/gatecontrol/android/common/SupportRedactorTest.kt new file mode 100644 index 00000000..9e3ddab6 --- /dev/null +++ b/core/common/src/test/java/com/gatecontrol/android/common/SupportRedactorTest.kt @@ -0,0 +1,114 @@ +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.assertTrue +import org.junit.jupiter.api.Test + +class SupportRedactorTest { + + private val wgKey = "yAnz5TF+lXXJte14tji3zlMNq+hd2rYUIgJBgB3fBmk=" + private val psk = "FpCyhws9cxwWoV4xELtfJvjJN+zQVRPISllRWgeopVE=" + private val mask = SupportRedactor.MASK + + private fun r(s: String) = SupportRedactor.redactText(s) + + @Test + fun `masks PrivateKey and PresharedKey, keeps the rest of the config`() { + val conf = "[Interface]\nPrivateKey = $wgKey\nAddress = 10.8.0.2/32\nDNS = 10.8.0.1\n\n" + + "[Peer]\npresharedkey=$psk\nEndpoint = vpn.example.com:51820\nAllowedIPs = 0.0.0.0/0" + val out = r(conf) + assertFalse(out.contains(wgKey)) + assertFalse(out.contains(psk)) + assertTrue(Regex("(?m)^PrivateKey = \\[REDACTED]$").containsMatchIn(out), out) + assertTrue(Regex("(?m)^presharedkey=\\[REDACTED]$").containsMatchIn(out), out) + for (keep in listOf("Address = 10.8.0.2/32", "DNS = 10.8.0.1", "Endpoint = vpn.example.com:51820", "AllowedIPs = 0.0.0.0/0")) { + assertTrue(out.contains(keep), keep) + } + } + + @Test + fun `masks auth headers, cookies and API tokens`() { + val cases = listOf( + "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.c2lnbmF0dXJl" to listOf("c2lnbmF0dXJl", "eyJzdWIi"), + "authorization=Basic dXNlcjpwYXNz" to listOf("dXNlcjpwYXNz"), + "--> X-API-Token: gc_live_0123456789abcdef" to listOf("0123456789abcdef"), + "X-API-Key: secretvalue123" to listOf("secretvalue123"), + "Cookie: gc.sid=s%3Aabc; other=1" to listOf("gc.sid", "other=1"), + "Set-Cookie: session=xyz; Path=/" to listOf("session=xyz"), + "I/Api: token gc_0123456789abcdef0123 rejected" to listOf("gc_0123456789abcdef0123"), + ) + for ((line, leaks) in cases) { + val out = r(line) + assertTrue(out.contains(mask), "not masked: $line") + for (leak in leaks) assertFalse(out.contains(leak), "leak $leak in $out") + } + } + + @Test + fun `masks key=value and JSON pairs with secret names`() { + val out = r("password=hunter2&user=bob {\"apiKey\": \"k-123\", \"client_secret\":\"s3\", \"rdpPassword\":\"pw!\"} setupCode: AB12-CD34-EF56-7890") + for (s in listOf("hunter2", "k-123", "\"s3\"", "pw!", "AB12-CD34-EF56-7890")) assertFalse(out.contains(s), "leak $s: $out") + assertTrue(out.contains("user=bob")) + } + + @Test + fun `masks key-like values`() { + val pem = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXk=\nAAAAB3NzaC1yc2EAAAADAQABAAABAQ\n-----END OPENSSH PRIVATE KEY-----" + val hex = "ab".repeat(32) + val out = r("peer $wgKey fp $hex code 1a2b-3c4d-5e6f-7a8b\n$pem") + for (s in listOf(wgKey, hex, "1a2b-3c4d-5e6f-7a8b", "b3BlbnNzaC1rZXk=", "AAAAB3NzaC1yc2E")) assertFalse(out.contains(s), "leak $s: $out") + } + + @Test + fun `keeps ordinary log lines`() { + for (line in listOf( + "2026-10-02 10:00:00.123 I/TunnelManager: Tunnel connected to 203.0.113.5:51820 after 2 attempts", + "2026-10-02 10:00:01.000 W/TunnelMonitor: handshake too old (200 s)", + "uuid 123e4567-e89b-12d3-a456-426614174000", + )) assertEquals(line, r(line)) + } + + @Test + fun `linear time on hostile input`() { + for (s in listOf("-a".repeat(50_000), "-----BEGIN RSA PRIVATE KEY-----".repeat(3_000), "Authorization: ".repeat(9_000), "password".repeat(20_000))) { + val t0 = System.currentTimeMillis() + r(s) + assertTrue(System.currentTimeMillis() - t0 < 3_000, "slow on ${s.take(20)}") + } + } + + @Test + fun `redactValue masks secret keys at any depth and drops unsafe keys`() { + @Suppress("UNCHECKED_CAST") + val out = SupportRedactor.redactValue( + mapOf( + "server" to mapOf("url" to "https://gate.example.com", "apiToken" to "gc_abcdefgh", "peerId" to 7), + "list" to listOf(mapOf("password" to "p"), "PrivateKey = $wgKey"), + "hasToken" to false, + "emptySecret" to "", + "__proto__" to mapOf("polluted" to true), + ), + ) as Map + val server = out["server"] as Map<*, *> + assertEquals(mask, server["apiToken"]) + assertEquals("https://gate.example.com", server["url"]) + assertEquals(7, server["peerId"]) + val list = out["list"] as List<*> + assertEquals(mask, (list[0] as Map<*, *>)["password"]) + assertEquals("PrivateKey = $mask", list[1]) + assertEquals(false, out["hasToken"]) + assertEquals("", out["emptySecret"]) + assertFalse(out.containsKey("__proto__")) + } + + @Test + fun `isSecretKey`() { + for (k in listOf("apiKey", "api_key", "apiToken", "privateKey", "PresharedKey", "password", "token", "X-API-Token", "cookie", "Authorization", "enrollmentCode", "setup_code")) { + assertTrue(SupportRedactor.isSecretKey(k), k) + } + for (k in listOf("serverUrl", "peerId", "autoConnect", "endpoint", "version", "dnsServers", "splitTunnelMode")) { + assertFalse(SupportRedactor.isSecretKey(k), k) + } + } +} 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 d2a535c8..7f0caab0 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 okhttp3.RequestBody import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.Header @@ -43,6 +44,16 @@ interface ApiClient { @POST("api/v1/client/peer/hostname") suspend fun reportHostname(@Body request: HostnameReportRequest): HostnameReportResponse + /** + * Upload a redacted support bundle (gzip JSON, see [SupportBundleUploader]). + * Server: gatecontrol docs/feature-support-bundle.md. + */ + @POST("api/v1/client/support-bundle") + suspend fun uploadSupportBundle( + @Query("peerId") peerId: Int, + @Body body: RequestBody, + ): SupportBundleUploadResponse + @GET("api/v1/client/peer-info") suspend fun getPeerInfo(@Query("peerId") peerId: Int): PeerInfoResponse 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 f9ba4040..bd4e4bbc 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 @@ -269,6 +269,22 @@ data class RdpRouteStatusResponse( data class HeartbeatResponse( val ok: Boolean, val peerEnabled: Boolean? = 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, +) + +data class SupportBundleUploadResponse( + val ok: Boolean, + val bundle: SupportBundleInfo? = null, + val error: String? = null, +) + +data class SupportBundleInfo( + val id: Long, + @SerializedName("created_at") val createdAt: String? = null, + @SerializedName("size_bytes") val sizeBytes: Long = 0, ) data class SimpleResponse( diff --git a/core/network/src/main/java/com/gatecontrol/android/network/SupportBundleUploader.kt b/core/network/src/main/java/com/gatecontrol/android/network/SupportBundleUploader.kt new file mode 100644 index 00000000..e0fec841 --- /dev/null +++ b/core/network/src/main/java/com/gatecontrol/android/network/SupportBundleUploader.kt @@ -0,0 +1,40 @@ +package com.gatecontrol.android.network + +import com.gatecontrol.android.common.SupportRedactor +import com.google.gson.GsonBuilder +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.ByteArrayOutputStream +import java.util.zip.GZIPOutputStream +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Sends a support bundle (value tree of String / Number / Boolean / List / + * Map, schema 1) to POST /api/v1/client/support-bundle: one more full + * redaction pass ([SupportRedactor.redactValue]), JSON, gzip. + */ +@Singleton +class SupportBundleUploader @Inject constructor( + private val apiClientProvider: ApiClientProvider, +) { + suspend fun upload(serverUrl: String, peerId: Int, bundle: Map): SupportBundleUploadResponse { + val body = encode(bundle).toRequestBody(GZIP) + return apiClientProvider.getClient(serverUrl).uploadSupportBundle(peerId, body) + } + + companion object { + private val GZIP = "application/gzip".toMediaType() + private val gson = GsonBuilder().disableHtmlEscaping().serializeNulls().create() + + /** Redacted JSON of [bundle]. */ + fun toJson(bundle: Map): String = gson.toJson(SupportRedactor.redactValue(bundle)) + + /** gzip(toJson(bundle)). */ + fun encode(bundle: Map): ByteArray { + val out = ByteArrayOutputStream() + GZIPOutputStream(out).use { it.write(toJson(bundle).toByteArray(Charsets.UTF_8)) } + return out.toByteArray() + } + } +} diff --git a/core/network/src/test/java/com/gatecontrol/android/network/SupportBundleUploaderTest.kt b/core/network/src/test/java/com/gatecontrol/android/network/SupportBundleUploaderTest.kt new file mode 100644 index 00000000..4eb71773 --- /dev/null +++ b/core/network/src/test/java/com/gatecontrol/android/network/SupportBundleUploaderTest.kt @@ -0,0 +1,89 @@ +package com.gatecontrol.android.network + +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.util.zip.GZIPInputStream + +class SupportBundleUploaderTest { + + private lateinit var server: MockWebServer + private lateinit var uploader: SupportBundleUploader + + private val token = "gc_" + "f00dfeed".repeat(6) + private val wgKey = "yAnz5TF+lXXJte14tji3zlMNq+hd2rYUIgJBgB3fBmk=" + + @BeforeEach + fun setUp() { + server = MockWebServer() + server.start() + val api = Retrofit.Builder() + .baseUrl(server.url("/")) + .client(OkHttpClient.Builder().build()) + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(ApiClient::class.java) + val provider = mockk { every { getClient(any()) } returns api } + uploader = SupportBundleUploader(provider) + } + + @AfterEach + fun tearDown() = server.shutdown() + + private fun bundle() = mapOf( + "schema" to 1, + "client" to mapOf("product" to "android", "version" to "1.5.0"), + "settings" to mapOf("serverUrl" to "https://gate.example.com", "apiToken" to token, "theme" to "dark"), + "wireguardConfig" to "[Interface]\nPrivateKey = $wgKey\nAddress = 10.8.0.9/32", + "logs" to mapOf("lines" to listOf("I/Api: X-API-Token: $token", "W/Tunnel: handshake 200 s")), + ) + + @Test + fun `uploads gzip JSON with peerId, redacted`() = runTest { + server.enqueue(MockResponse().setResponseCode(201).setBody("""{"ok":true,"bundle":{"id":42,"created_at":"2026-10-02 10:00:00","size_bytes":123}}""")) + val res = uploader.upload(server.url("/").toString(), 7, bundle()) + assertTrue(res.ok) + assertEquals(42L, res.bundle?.id) + + val req = server.takeRequest() + assertEquals("POST", req.method) + assertEquals("/api/v1/client/support-bundle?peerId=7", req.path) + assertEquals("application/gzip", req.getHeader("Content-Type")) + val json = GZIPInputStream(req.body.inputStream()).bufferedReader().readText() + assertFalse(json.contains(token), json) + assertFalse(json.contains(wgKey), json) + assertTrue(json.contains("\"apiToken\":\"[REDACTED]\""), json) + assertTrue(json.contains("PrivateKey = [REDACTED]"), json) + assertTrue(json.contains("Address = 10.8.0.9/32"), json) + assertTrue(json.contains("\"schema\":1"), json) + } + + @Test + fun `server errors surface as HttpException`() = runTest { + server.enqueue(MockResponse().setResponseCode(429).setBody("""{"ok":false,"error":"rate_limited"}""")) + val err = runCatching { uploader.upload(server.url("/").toString(), 7, bundle()) }.exceptionOrNull() + assertTrue(err is retrofit2.HttpException) + assertEquals(429, (err as retrofit2.HttpException).code()) + } + + @Test + fun `heartbeat response carries the support request`() { + val gson = com.google.gson.Gson() + val r = gson.fromJson("""{"ok":true,"peerEnabled":true,"supportBundleRequested":true,"supportBundleRequestedAt":"2026-10-02 10:00:00"}""", HeartbeatResponse::class.java) + assertEquals(true, r.supportBundleRequested) + assertEquals("2026-10-02 10:00:00", r.supportBundleRequestedAt) + val old = gson.fromJson("""{"ok":true}""", HeartbeatResponse::class.java) + assertEquals(null, old.supportBundleRequested) + } +}