Skip to content
2 changes: 1 addition & 1 deletion .github/workflows/pr-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
38 changes: 28 additions & 10 deletions core/rdp/src/main/java/com/gatecontrol/android/rdp/RdpManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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")
}

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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}",
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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))
}
}
Loading