Skip to content

Commit 21d1e1f

Browse files
authored
Fix Android TLS fingerprint timeout handling (#98366)
1 parent a1cddbd commit 21d1e1f

6 files changed

Lines changed: 188 additions & 4 deletions

File tree

apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1676,6 +1676,8 @@ class NodeRuntime(
16761676
when (failure) {
16771677
GatewayTlsProbeFailure.TLS_UNAVAILABLE ->
16781678
"Failed: this host requires wss:// or Tailscale Serve. No TLS endpoint detected."
1679+
GatewayTlsProbeFailure.TLS_HANDSHAKE_TIMEOUT ->
1680+
"Failed: secure endpoint reached, but TLS fingerprint verification timed out. Check Tailscale Serve or gateway TLS and retry."
16791681
GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE, null ->
16801682
"Failed: couldn't reach the secure gateway endpoint for this host."
16811683
}

apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayTls.kt

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import kotlinx.coroutines.withContext
66
import java.io.EOFException
77
import java.net.ConnectException
88
import java.net.InetSocketAddress
9+
import java.net.SocketException
910
import java.net.SocketTimeoutException
1011
import java.net.UnknownHostException
1112
import java.security.MessageDigest
@@ -43,6 +44,7 @@ data class GatewayTlsConfig(
4344
/** Distinguishes non-TLS endpoints from unreachable endpoints during probing. */
4445
enum class GatewayTlsProbeFailure {
4546
TLS_UNAVAILABLE,
47+
TLS_HANDSHAKE_TIMEOUT,
4648
ENDPOINT_UNREACHABLE,
4749
}
4850

@@ -52,6 +54,9 @@ data class GatewayTlsProbeResult(
5254
val failure: GatewayTlsProbeFailure? = null,
5355
)
5456

57+
internal const val GATEWAY_TLS_PROBE_CONNECT_TIMEOUT_MS = 3_000
58+
internal const val GATEWAY_TLS_PROBE_HANDSHAKE_TIMEOUT_MS = 10_000
59+
5560
/** Builds a TLS config that supports pinned fingerprints and trust-on-first-use. */
5661
fun buildGatewayTlsConfig(
5762
params: GatewayTlsParams?,
@@ -119,11 +124,24 @@ fun buildGatewayTlsConfig(
119124
suspend fun probeGatewayTlsFingerprint(
120125
host: String,
121126
port: Int,
122-
timeoutMs: Int = 3_000,
127+
): GatewayTlsProbeResult =
128+
probeGatewayTlsFingerprint(
129+
host = host,
130+
port = port,
131+
connectTimeoutMs = GATEWAY_TLS_PROBE_CONNECT_TIMEOUT_MS,
132+
handshakeTimeoutMs = GATEWAY_TLS_PROBE_HANDSHAKE_TIMEOUT_MS,
133+
)
134+
135+
internal suspend fun probeGatewayTlsFingerprint(
136+
host: String,
137+
port: Int,
138+
connectTimeoutMs: Int,
139+
handshakeTimeoutMs: Int,
123140
): GatewayTlsProbeResult {
124141
val trimmedHost = host.trim()
125142
if (trimmedHost.isEmpty()) return GatewayTlsProbeResult(failure = GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE)
126143
if (port !in 1..65535) return GatewayTlsProbeResult(failure = GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE)
144+
if (connectTimeoutMs <= 0 || handshakeTimeoutMs <= 0) return GatewayTlsProbeResult(failure = GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE)
127145

128146
return withContext(Dispatchers.IO) {
129147
val fingerprintRef = AtomicReference<String?>(null)
@@ -152,9 +170,14 @@ suspend fun probeGatewayTlsFingerprint(
152170
context.init(null, arrayOf(probeTrustManager), SecureRandom())
153171

154172
val socket = (context.socketFactory.createSocket() as SSLSocket)
173+
var connected = false
155174
try {
156-
socket.soTimeout = timeoutMs
157-
socket.connect(InetSocketAddress(trimmedHost, port), timeoutMs)
175+
// TCP reachability and TLS handshake progress fail differently on mobile
176+
// tailnets; keep the budgets separate so a reachable-but-slow secure
177+
// endpoint does not collapse into generic gateway unreachable guidance.
178+
socket.soTimeout = handshakeTimeoutMs
179+
socket.connect(InetSocketAddress(trimmedHost, port), connectTimeoutMs)
180+
connected = true
158181

159182
// Best-effort SNI for hostnames (avoid crashing on IP literals).
160183
try {
@@ -180,10 +203,21 @@ suspend fun probeGatewayTlsFingerprint(
180203
is SSLException,
181204
is EOFException,
182205
-> GatewayTlsProbeFailure.TLS_UNAVAILABLE
206+
is SocketTimeoutException ->
207+
if (connected) {
208+
GatewayTlsProbeFailure.TLS_HANDSHAKE_TIMEOUT
209+
} else {
210+
GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE
211+
}
183212
is ConnectException,
184-
is SocketTimeoutException,
185213
is UnknownHostException,
186214
-> GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE
215+
is SocketException ->
216+
if (connected) {
217+
GatewayTlsProbeFailure.TLS_UNAVAILABLE
218+
} else {
219+
GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE
220+
}
187221
else -> GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE
188222
}
189223
GatewayTlsProbeResult(failure = failure)

apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1007,6 +1007,8 @@ private fun gatewayStatusLabel(
10071007
status.contains("connecting") || status.contains("reconnecting") -> "Connecting..."
10081008
status.contains("pair") -> "Pairing needed"
10091009
status.contains("auth") -> "Authentication needed"
1010+
status.contains("fingerprint verification timed out") -> "TLS timed out"
1011+
status.contains("no tls endpoint") -> "No TLS endpoint"
10101012
status.contains("certificate") || status.contains("tls") -> "Certificate review needed"
10111013
status.contains("failed") || status.contains("error") || status.contains("offline") || status.contains("not connected") -> "Cannot reach gateway"
10121014
status.isBlank() -> "Not connected"

apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1801,6 +1801,8 @@ private fun gatewaySummary(
18011801
status.contains("connecting") || status.contains("reconnecting") -> "Connecting..."
18021802
status.contains("pairing") -> "Waiting for pairing"
18031803
status.contains("auth") -> "Authentication needed"
1804+
status.contains("fingerprint verification timed out") -> "TLS timed out"
1805+
status.contains("no tls endpoint") -> "No TLS endpoint"
18041806
status.contains("certificate") || status.contains("tls") -> "Certificate review needed"
18051807
else -> "Not connected"
18061808
}

apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,29 @@ class GatewayBootstrapAuthTest {
349349
assertNull(runtime.pendingGatewayTrust.value)
350350
}
351351

352+
@Test
353+
fun connect_showsTlsTimeoutGuidanceWhenFingerprintProbeTimesOut() {
354+
val app = RuntimeEnvironment.getApplication()
355+
val runtime =
356+
NodeRuntime(
357+
app,
358+
tlsFingerprintProbe = { _, _ ->
359+
GatewayTlsProbeResult(failure = GatewayTlsProbeFailure.TLS_HANDSHAKE_TIMEOUT)
360+
},
361+
)
362+
363+
runtime.connect(
364+
GatewayEndpoint.manual(host = "gateway.example", port = 18789),
365+
NodeRuntime.GatewayConnectAuth(token = "shared-token", bootstrapToken = null, password = null),
366+
)
367+
368+
assertEquals(
369+
"Failed: secure endpoint reached, but TLS fingerprint verification timed out. Check Tailscale Serve or gateway TLS and retry.",
370+
waitForStatusText(runtime),
371+
)
372+
assertNull(runtime.pendingGatewayTrust.value)
373+
}
374+
352375
@Test
353376
fun resetGatewaySetupAuth_clearsStoredGatewayAndDeviceTokens() {
354377
val app = RuntimeEnvironment.getApplication()
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package ai.openclaw.app.gateway
2+
3+
import kotlinx.coroutines.runBlocking
4+
import org.junit.Assert.assertEquals
5+
import org.junit.Test
6+
import java.net.InetAddress
7+
import java.net.ServerSocket
8+
import java.net.Socket
9+
import java.net.SocketException
10+
import kotlin.concurrent.thread
11+
12+
class GatewayTlsTest {
13+
@Test
14+
fun probeGatewayTlsFingerprint_reportsHandshakeTimeoutAfterTcpConnect() =
15+
runBlocking {
16+
TcpTestServer { socket ->
17+
socket.soTimeout = 1_000
18+
runCatching { socket.getInputStream().read(ByteArray(512)) }
19+
Thread.sleep(700)
20+
}.use { server ->
21+
val result =
22+
probeGatewayTlsFingerprint(
23+
host = LOOPBACK_HOST,
24+
port = server.port,
25+
connectTimeoutMs = 250,
26+
handshakeTimeoutMs = 250,
27+
)
28+
29+
assertEquals(GatewayTlsProbeFailure.TLS_HANDSHAKE_TIMEOUT, result.failure)
30+
}
31+
}
32+
33+
@Test
34+
fun probeGatewayTlsFingerprint_reportsTlsUnavailableForPlainHttpEndpoint() =
35+
runBlocking {
36+
TcpTestServer { socket ->
37+
socket.soTimeout = 1_000
38+
runCatching { socket.getInputStream().read(ByteArray(512)) }
39+
socket.getOutputStream().write("HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n".toByteArray())
40+
socket.getOutputStream().flush()
41+
}.use { server ->
42+
val result =
43+
probeGatewayTlsFingerprint(
44+
host = LOOPBACK_HOST,
45+
port = server.port,
46+
connectTimeoutMs = 250,
47+
handshakeTimeoutMs = 1_000,
48+
)
49+
50+
assertEquals(GatewayTlsProbeFailure.TLS_UNAVAILABLE, result.failure)
51+
}
52+
}
53+
54+
@Test
55+
fun probeGatewayTlsFingerprint_reportsTlsUnavailableForConnectedReset() =
56+
runBlocking {
57+
TcpTestServer { socket ->
58+
socket.close()
59+
}.use { server ->
60+
val result =
61+
probeGatewayTlsFingerprint(
62+
host = LOOPBACK_HOST,
63+
port = server.port,
64+
connectTimeoutMs = 250,
65+
handshakeTimeoutMs = 1_000,
66+
)
67+
68+
assertEquals(GatewayTlsProbeFailure.TLS_UNAVAILABLE, result.failure)
69+
}
70+
}
71+
72+
@Test
73+
fun probeGatewayTlsFingerprint_reportsUnreachableWhenTcpConnectFails() =
74+
runBlocking {
75+
val result =
76+
probeGatewayTlsFingerprint(
77+
host = LOOPBACK_HOST,
78+
port = unusedLoopbackPort(),
79+
connectTimeoutMs = 250,
80+
handshakeTimeoutMs = 250,
81+
)
82+
83+
assertEquals(GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE, result.failure)
84+
}
85+
86+
private class TcpTestServer(
87+
private val handler: (Socket) -> Unit,
88+
) : AutoCloseable {
89+
private val serverSocket = ServerSocket(0, 50, LOOPBACK_ADDRESS)
90+
private var acceptedSocket: Socket? = null
91+
private val worker =
92+
thread(start = true, isDaemon = true, name = "openclaw-tls-probe-test-server") {
93+
try {
94+
serverSocket.accept().use { socket ->
95+
acceptedSocket = socket
96+
handler(socket)
97+
}
98+
} catch (_: SocketException) {
99+
// Closing the server during test cleanup interrupts accept/read.
100+
}
101+
}
102+
103+
val port: Int = serverSocket.localPort
104+
105+
override fun close() {
106+
runCatching { acceptedSocket?.close() }
107+
runCatching { serverSocket.close() }
108+
worker.join(1_000)
109+
}
110+
}
111+
112+
private companion object {
113+
const val LOOPBACK_HOST = "127.0.0.1"
114+
val LOOPBACK_ADDRESS: InetAddress = InetAddress.getByName(LOOPBACK_HOST)
115+
116+
fun unusedLoopbackPort(): Int =
117+
ServerSocket(0, 50, LOOPBACK_ADDRESS).use { server ->
118+
server.localPort
119+
}
120+
}
121+
}

0 commit comments

Comments
 (0)