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
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.gatecontrol.android.network.ApiClientProvider
import com.gatecontrol.android.network.HostnameReportRequest
import com.gatecontrol.android.tunnel.SplitTunnelConfig
import com.gatecontrol.android.tunnel.TunnelManager
import com.gatecontrol.android.tunnel.WgConfigValidator
import kotlinx.coroutines.flow.first
import org.json.JSONArray
import org.json.JSONObject
Expand All @@ -30,7 +31,7 @@ class TunnelConnector @Inject constructor(
) {

suspend fun connectWithUserSettings(): Boolean {
val config = setupRepository.getWireGuardConfig()
var config = setupRepository.getWireGuardConfig()
if (config.isEmpty()) {
Timber.w("TunnelConnector: no WireGuard config available")
return false
Expand All @@ -48,6 +49,7 @@ class TunnelConnector @Inject constructor(
if (host != null) apiClientProvider.preResolveDns(host)
} catch (_: Exception) {
}
config = refreshConfig(serverUrl, config)
}

val splitTunnelConfig = resolveSplitTunnelConfig(serverUrl)
Expand All @@ -68,6 +70,46 @@ class TunnelConnector @Inject constructor(
}
}

/**
* Ask the server whether the stored WireGuard config is still current and
* adopt the new one if it is not. Runs right after the DNS pre-resolve —
* the last moment with working internet before the tunnel comes up.
*
* Best-effort by design: an unreachable server, a rejected token or a
* syntactically broken config must never block the connect, so every
* failure path returns the stored config unchanged.
*/
private suspend fun refreshConfig(serverUrl: String, current: String): String {
val peerId = setupRepository.getPeerId()
if (peerId <= 0) return current
return try {
val client = apiClientProvider.getClient(serverUrl)
val response = client.checkConfigUpdate(
peerId,
setupRepository.getConfigHash().ifBlank { null },
)
val fresh = response.config
if (!response.updated || fresh.isNullOrBlank()) return current

val validation = WgConfigValidator.validate(fresh)
if (!validation.ok) {
Timber.w(
"TunnelConnector: server config rejected (%s), keeping stored config",
validation.errors.joinToString(", "),
)
return current
}

setupRepository.saveWireGuardConfig(fresh)
response.hash?.let { setupRepository.saveConfigHash(it) }
Timber.i("TunnelConnector: WireGuard config updated from server")
fresh
} catch (e: Exception) {
Timber.w(e, "TunnelConnector: config check failed, using stored config")
current
}
}

private suspend fun resolveSplitTunnelConfig(serverUrl: String): SplitTunnelConfig {
var splitTunnelConfig = SplitTunnelConfig()
try {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package com.gatecontrol.android.service

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.ConfigCheckResponse
import com.gatecontrol.android.tunnel.SplitTunnelConfig
import com.gatecontrol.android.tunnel.TunnelManager
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test

/**
* Covers the config reload that runs on every connect. Motivating incident:
* after the server moved hosts the stored config still carried the old
* Endpoint, so the tunnel came up but routed nowhere.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class TunnelConnectorTest {

private companion object {
const val SERVER_URL = "https://gate.example.com"
const val PEER_ID = 7
const val STORED_HASH = "hash-old"

fun config(endpoint: String) = buildString {
appendLine("[Interface]")
appendLine("PrivateKey = CyeI87ssPVm18g0yRG9AZV0vdIe9qtkKvFKsOlTCTHI=")
appendLine("Address = 10.8.0.2/32")
appendLine()
appendLine("[Peer]")
appendLine("PublicKey = 86R0I45ZRx/P7WQdj+GkW+q0+MU0cS4Zccy+CVTTvY4=")
appendLine("Endpoint = $endpoint")
appendLine("AllowedIPs = 0.0.0.0/0")
}

val OLD_CONFIG = config("54.36.233.20:51820")
val NEW_CONFIG = config("vpn.example.com:51820")
const val BROKEN_CONFIG = "[Interface]\nPrivateKey = not-a-key\n"
}

private lateinit var setupRepository: SetupRepository
private lateinit var settingsRepository: SettingsRepository
private lateinit var apiClientProvider: ApiClientProvider
private lateinit var apiClient: ApiClient
private lateinit var tunnelManager: TunnelManager
private lateinit var connector: TunnelConnector

@BeforeEach
fun setUp() {
setupRepository = mockk(relaxed = true)
settingsRepository = mockk(relaxed = true)
apiClientProvider = mockk(relaxed = true)
apiClient = mockk(relaxed = true)
tunnelManager = mockk(relaxed = true)

every { setupRepository.getWireGuardConfig() } returns OLD_CONFIG
every { setupRepository.getServerUrl() } returns SERVER_URL
every { setupRepository.getPeerId() } returns PEER_ID
every { setupRepository.getConfigHash() } returns STORED_HASH
every { apiClientProvider.getClient(any()) } returns apiClient
// No admin split-tunnel preset; the connector falls back to user settings.
coEvery { apiClient.getSplitTunnelPreset() } throws IllegalStateException("no preset")
every { settingsRepository.getSplitTunnelMode() } returns flowOf("off")
coEvery { tunnelManager.connect(any(), any<SplitTunnelConfig>()) } returns Unit

connector = TunnelConnector(
setupRepository,
settingsRepository,
apiClientProvider,
tunnelManager,
)
}

@Test
fun `unchanged config connects the stored one`() = runTest {
coEvery { apiClient.checkConfigUpdate(PEER_ID, STORED_HASH) } returns
ConfigCheckResponse(ok = true, updated = false, config = null, hash = STORED_HASH)

assertTrue(connector.connectWithUserSettings())

coVerify { tunnelManager.connect(OLD_CONFIG, any<SplitTunnelConfig>()) }
verify(exactly = 0) { setupRepository.saveWireGuardConfig(any()) }
}

@Test
fun `changed config is validated, stored and connected`() = runTest {
coEvery { apiClient.checkConfigUpdate(PEER_ID, STORED_HASH) } returns
ConfigCheckResponse(ok = true, updated = true, config = NEW_CONFIG, hash = "hash-new")

assertTrue(connector.connectWithUserSettings())

coVerify { tunnelManager.connect(NEW_CONFIG, any<SplitTunnelConfig>()) }
verify { setupRepository.saveWireGuardConfig(NEW_CONFIG) }
verify { setupRepository.saveConfigHash("hash-new") }
}

@Test
fun `invalid server config is discarded and the stored one is used`() = runTest {
coEvery { apiClient.checkConfigUpdate(PEER_ID, STORED_HASH) } returns
ConfigCheckResponse(ok = true, updated = true, config = BROKEN_CONFIG, hash = "hash-bad")

assertTrue(connector.connectWithUserSettings())

coVerify { tunnelManager.connect(OLD_CONFIG, any<SplitTunnelConfig>()) }
verify(exactly = 0) { setupRepository.saveWireGuardConfig(any()) }
verify(exactly = 0) { setupRepository.saveConfigHash(any()) }
}

@Test
fun `unreachable server does not block the connect`() = runTest {
coEvery { apiClient.checkConfigUpdate(any(), any()) } throws
java.io.IOException("connection refused")

assertTrue(connector.connectWithUserSettings())

coVerify { tunnelManager.connect(OLD_CONFIG, any<SplitTunnelConfig>()) }
}

@Test
fun `unregistered client skips the check entirely`() = runTest {
every { setupRepository.getPeerId() } returns -1

assertTrue(connector.connectWithUserSettings())

coVerify(exactly = 0) { apiClient.checkConfigUpdate(any(), any()) }
coVerify { tunnelManager.connect(OLD_CONFIG, any<SplitTunnelConfig>()) }
}
}
Loading