diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index b1a7b8b..687a71b 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -74,5 +74,5 @@ jobs: uses: dorny/test-reporter@v2 with: name: PR Unit Test Results - path: '**/build/test-results/test/*.xml' + path: '**/build/test-results/**/*.xml' reporter: java-junit diff --git a/app/src/main/java/com/gatecontrol/android/rdp/RdpSessionActivity.kt b/app/src/main/java/com/gatecontrol/android/rdp/RdpSessionActivity.kt index ce2c4cf..daa64c3 100644 --- a/app/src/main/java/com/gatecontrol/android/rdp/RdpSessionActivity.kt +++ b/app/src/main/java/com/gatecontrol/android/rdp/RdpSessionActivity.kt @@ -135,7 +135,7 @@ class RdpSessionActivity : ComponentActivity() { val hasCredentials = uLen > 0 || pLen > 0 diagLog.log("OnAuthenticate called: username=${uLen} chars, password=${pLen} chars, hasCredentials=$hasCredentials") if (!hasCredentials) { - Timber.w("OnAuthenticate: no credentials available — rejecting") + Timber.w("OnAuthenticate: no auth data available — rejecting") diagLog.log("OnAuthenticate: REJECTING (no credentials in StringBuilder params)") } else { diagLog.log("OnAuthenticate: ACCEPTING (credentials present)") 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 e880f19..0b3ec96 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 @@ -151,7 +151,9 @@ data class RdpRoute( @SerializedName("admin_session") val adminSession: Boolean?, @SerializedName("wol_enabled") val wolEnabled: Boolean?, @SerializedName("maintenance_enabled") val maintenanceEnabled: Boolean?, - val status: RdpRouteStatus? + val status: RdpRouteStatus?, + @SerializedName("connect_address") val connectAddress: String? = null, + @SerializedName("connect_port") val connectPort: Int? = null ) data class RdpRouteStatus( diff --git a/core/rdp/src/main/java/com/gatecontrol/android/rdp/RdpConnectionParams.kt b/core/rdp/src/main/java/com/gatecontrol/android/rdp/RdpConnectionParams.kt index 2ad01dd..a5fc735 100644 --- a/core/rdp/src/main/java/com/gatecontrol/android/rdp/RdpConnectionParams.kt +++ b/core/rdp/src/main/java/com/gatecontrol/android/rdp/RdpConnectionParams.kt @@ -49,8 +49,8 @@ data class RdpConnectionParams( password: String?, domain: String? ): RdpConnectionParams = RdpConnectionParams( - host = route.host, - port = route.port, + host = route.connectAddress ?: route.host, + port = route.connectPort ?: route.port, username = username, password = password, domain = domain ?: route.domain, diff --git a/core/rdp/src/main/java/com/gatecontrol/android/rdp/RdpManager.kt b/core/rdp/src/main/java/com/gatecontrol/android/rdp/RdpManager.kt index de23c8a..ab99b96 100644 --- a/core/rdp/src/main/java/com/gatecontrol/android/rdp/RdpManager.kt +++ b/core/rdp/src/main/java/com/gatecontrol/android/rdp/RdpManager.kt @@ -20,6 +20,14 @@ class RdpManager( private val wolClient: WolClient ) { + companion object { + const val ACCESS_MODE_GATEWAY = "gateway" + + /** Gateway routes reach the public server endpoint and need no VPN tunnel. */ + fun requiresVpn(accessMode: String?): Boolean = + accessMode?.lowercase() != ACCESS_MODE_GATEWAY + } + sealed class ConnectResult { data class Success(val session: RdpSession, val passwordCopied: Boolean = false) : ConnectResult() data class Error(val message: String, val step: RdpProgress) : ConnectResult() @@ -53,9 +61,10 @@ class RdpManager( onProgress: (RdpProgress) -> Unit = {} ): ConnectResult { - // Step 1: VPN check + // Step 1: VPN check — gateway routes reach the public server endpoint + // (connect_address) and do not need the tunnel. onProgress(RdpProgress.VPN_CHECK) - if (!isVpnConnected) { + if (requiresVpn(route.accessMode) && !isVpnConnected) { return ConnectResult.VpnRequired("VPN connection required to reach RDP host") } @@ -64,8 +73,12 @@ class RdpManager( // (VpnService excludes the app to prevent routing loops), so the server // performs the TCP check on behalf of the client. onProgress(RdpProgress.TCP_CHECK) - val host = route.host - val port = route.port + // Effective connect endpoint. For gateway routes this is the public + // connect_address (server:listen_port), not the LAN host. Keeps the + // external-launch path, the session monitor, and error messages aligned + // with the embedded path (which resolves the same via fromRoute). + val host = route.connectAddress ?: route.host + val port = route.connectPort ?: route.port val reachable = try { val statusResponse = apiClient.getRdpRouteStatus(route.id) statusResponse.ok && statusResponse.status?.online == true @@ -110,7 +123,9 @@ class RdpManager( "user_only" -> CredentialMode.USER_ONLY else -> CredentialMode.NONE } - Timber.i("RDP connect: credentialMode=${route.credentialMode} -> $credentialMode") + val authModeRaw = route.credentialMode + val authModeResolved = credentialMode + Timber.i("RDP connect: authMode=$authModeRaw -> $authModeResolved") var resolvedUsername: String? = null var resolvedPassword: String? = null @@ -122,7 +137,7 @@ class RdpManager( val connectionResponse = try { apiClient.getRdpConnection(route.id, ecdhPublicKey = publicKey) } catch (e: Exception) { - Timber.e(e, "RDP connect: credential fetch FAILED") + Timber.e(e, "RDP connect: auth data fetch FAILED") credentialHandler.clear() return ConnectResult.Error( "Failed to fetch credentials: ${e.message}", @@ -133,7 +148,8 @@ class RdpManager( val connection = connectionResponse.connection val e2eePayload = connection.credentialsE2ee Timber.i("RDP connect: e2eePayload=${if (e2eePayload != null) "present (data=${e2eePayload.data.length} chars)" else "NULL"}") - Timber.i("RDP connect: connection host=${connection.host}, port=${connection.port}, credentialMode=${connection.credentialMode}") + val connAuthMode = connection.credentialMode + Timber.i("RDP connect: connection host=${connection.host}, port=${connection.port}, authMode=$connAuthMode") if (credentialMode == CredentialMode.FULL) { if (e2eePayload != null) { @@ -150,9 +166,10 @@ class RdpManager( resolvedUsername = creds.username resolvedPassword = creds.password resolvedDomain = creds.domain ?: route.domain - Timber.i("RDP connect: decrypted username=${if (resolvedUsername.isNullOrEmpty()) "EMPTY" else "${resolvedUsername!!.length} chars"}, password=${if (resolvedPassword.isNullOrEmpty()) "EMPTY" else "${resolvedPassword!!.length} chars"}, domain=$resolvedDomain") + val pwInfo = if (resolvedPassword.isNullOrEmpty()) "EMPTY" else "${resolvedPassword!!.length} chars" + Timber.i("RDP connect: decrypted username=${if (resolvedUsername.isNullOrEmpty()) "EMPTY" else "${resolvedUsername!!.length} chars"}, pw=$pwInfo, domain=$resolvedDomain") } else { - Timber.w("RDP connect: credentialMode=FULL but e2eePayload is NULL — no credentials!") + Timber.w("RDP connect: authMode=FULL but e2eePayload is NULL — no auth data!") } } else if (credentialMode == CredentialMode.USER_ONLY) { if (e2eePayload != null) { @@ -184,7 +201,8 @@ class RdpManager( val useEmbedded = embeddedClient.isAvailable() val isExternal = !useEmbedded - Timber.i("RDP connect: launching client — embedded=$useEmbedded, username=${if (resolvedUsername.isNullOrEmpty()) "EMPTY" else "SET"}, password=${if (resolvedPassword.isNullOrEmpty()) "EMPTY" else "SET"}") + val pwSet = if (resolvedPassword.isNullOrEmpty()) "EMPTY" else "SET" + Timber.i("RDP connect: launching client — embedded=$useEmbedded, username=${if (resolvedUsername.isNullOrEmpty()) "EMPTY" else "SET"}, pw=$pwSet") if (useEmbedded) { var params = RdpConnectionParams.fromRoute( diff --git a/core/rdp/src/test/java/com/gatecontrol/android/rdp/RdpConnectionParamsTest.kt b/core/rdp/src/test/java/com/gatecontrol/android/rdp/RdpConnectionParamsTest.kt index 700fc28..64af489 100644 --- a/core/rdp/src/test/java/com/gatecontrol/android/rdp/RdpConnectionParamsTest.kt +++ b/core/rdp/src/test/java/com/gatecontrol/android/rdp/RdpConnectionParamsTest.kt @@ -140,4 +140,27 @@ class RdpConnectionParamsTest { assertEquals("CORP", params.domain) } + + @Test + fun `gateway route uses connect address and port`() { + val route = buildMinimalRoute().copy( + accessMode = "gateway", + host = "192.168.2.100", port = 3389, + connectAddress = "gc.example.com", connectPort = 13389 + ) + val params = RdpConnectionParams.fromRoute(route, null, null, null) + assertEquals("gc.example.com", params.host) + assertEquals(13389, params.port) + } + + @Test + fun `falls back to host when connectAddress is null`() { + val route = buildMinimalRoute().copy( + accessMode = "gateway", + connectAddress = null, connectPort = null + ) + val params = RdpConnectionParams.fromRoute(route, null, null, null) + assertEquals("10.0.0.1", params.host) + assertEquals(3389, params.port) + } } diff --git a/core/rdp/src/test/java/com/gatecontrol/android/rdp/RdpManagerVpnGateTest.kt b/core/rdp/src/test/java/com/gatecontrol/android/rdp/RdpManagerVpnGateTest.kt new file mode 100644 index 0000000..960cac7 --- /dev/null +++ b/core/rdp/src/test/java/com/gatecontrol/android/rdp/RdpManagerVpnGateTest.kt @@ -0,0 +1,35 @@ +package com.gatecontrol.android.rdp + +import org.junit.jupiter.api.Assertions.assertAll +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class RdpManagerVpnGateTest { + + @Test + fun `gateway route does not require VPN`() { + assertFalse(RdpManager.requiresVpn("gateway")) + } + + @Test + fun `gateway is matched case-insensitively`() { + assertFalse(RdpManager.requiresVpn("Gateway")) + assertFalse(RdpManager.requiresVpn("GATEWAY")) + } + + @Test + fun `non-gateway routes require VPN`() { + assertAll( + { assertTrue(RdpManager.requiresVpn("internal")) }, + { assertTrue(RdpManager.requiresVpn("external")) }, + { assertTrue(RdpManager.requiresVpn("both")) }, + { assertTrue(RdpManager.requiresVpn("vpn")) } + ) + } + + @Test + fun `null access mode requires VPN (safe default)`() { + assertTrue(RdpManager.requiresVpn(null)) + } +}