diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt index 271bf27b424..7f3dd65e998 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt @@ -16,6 +16,8 @@ import ai.openclaw.app.gateway.DeviceIdentityStore import ai.openclaw.app.gateway.GatewayDiscovery import ai.openclaw.app.gateway.GatewayEndpoint import ai.openclaw.app.gateway.GatewaySession +import ai.openclaw.app.gateway.GatewayTlsProbeFailure +import ai.openclaw.app.gateway.GatewayTlsProbeResult import ai.openclaw.app.gateway.probeGatewayTlsFingerprint import ai.openclaw.app.node.* import ai.openclaw.app.protocol.OpenClawCanvasA2UIAction @@ -44,7 +46,7 @@ import java.util.concurrent.atomic.AtomicLong class NodeRuntime( context: Context, val prefs: SecurePrefs = SecurePrefs(context.applicationContext), - private val tlsFingerprintProbe: suspend (String, Int) -> String? = ::probeGatewayTlsFingerprint, + private val tlsFingerprintProbe: suspend (String, Int) -> GatewayTlsProbeResult = ::probeGatewayTlsFingerprint, ) { data class GatewayConnectAuth( val token: String?, @@ -839,8 +841,9 @@ class NodeRuntime( // First-time TLS: capture fingerprint, ask user to verify out-of-band, then store and connect. _statusText.value = "Verify gateway TLS fingerprint…" scope.launch { - val fp = tlsFingerprintProbe(endpoint.host, endpoint.port) ?: run { - _statusText.value = "Failed: can't read TLS fingerprint" + val tlsProbe = tlsFingerprintProbe(endpoint.host, endpoint.port) + val fp = tlsProbe.fingerprintSha256 ?: run { + _statusText.value = gatewayTlsProbeFailureMessage(tlsProbe.failure) return@launch } _pendingGatewayTrust.value = @@ -888,6 +891,15 @@ class NodeRuntime( _statusText.value = "Offline" } + private fun gatewayTlsProbeFailureMessage(failure: GatewayTlsProbeFailure?): String { + return when (failure) { + GatewayTlsProbeFailure.TLS_UNAVAILABLE -> + "Failed: remote mobile nodes require wss:// or Tailscale Serve. No TLS endpoint detected for this host." + GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE, null -> + "Failed: couldn't reach a secure gateway endpoint. Remote mobile nodes require wss:// or Tailscale Serve." + } + } + private fun hasRecordAudioPermission(): Boolean { return ( ContextCompat.checkSelfPermission(appContext, Manifest.permission.RECORD_AUDIO) == diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayTls.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayTls.kt index 20e71cc364a..910b2a24e45 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayTls.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayTls.kt @@ -3,7 +3,11 @@ package ai.openclaw.app.gateway import android.annotation.SuppressLint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import java.io.EOFException +import java.net.ConnectException import java.net.InetSocketAddress +import java.net.SocketTimeoutException +import java.net.UnknownHostException import java.security.MessageDigest import java.security.SecureRandom import java.security.cert.CertificateException @@ -12,6 +16,7 @@ import java.util.Locale import javax.net.ssl.HttpsURLConnection import javax.net.ssl.HostnameVerifier import javax.net.ssl.SSLContext +import javax.net.ssl.SSLException import javax.net.ssl.SSLParameters import javax.net.ssl.SSLSocketFactory import javax.net.ssl.SNIHostName @@ -32,6 +37,16 @@ data class GatewayTlsConfig( val hostnameVerifier: HostnameVerifier, ) +enum class GatewayTlsProbeFailure { + TLS_UNAVAILABLE, + ENDPOINT_UNREACHABLE, +} + +data class GatewayTlsProbeResult( + val fingerprintSha256: String? = null, + val failure: GatewayTlsProbeFailure? = null, +) + fun buildGatewayTlsConfig( params: GatewayTlsParams?, onStore: ((String) -> Unit)? = null, @@ -85,10 +100,10 @@ suspend fun probeGatewayTlsFingerprint( host: String, port: Int, timeoutMs: Int = 3_000, -): String? { +): GatewayTlsProbeResult { val trimmedHost = host.trim() - if (trimmedHost.isEmpty()) return null - if (port !in 1..65535) return null + if (trimmedHost.isEmpty()) return GatewayTlsProbeResult(failure = GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE) + if (port !in 1..65535) return GatewayTlsProbeResult(failure = GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE) return withContext(Dispatchers.IO) { val trustAll = @@ -121,10 +136,21 @@ suspend fun probeGatewayTlsFingerprint( } socket.startHandshake() - val cert = socket.session.peerCertificates.firstOrNull() as? X509Certificate ?: return@withContext null - sha256Hex(cert.encoded) - } catch (_: Throwable) { - null + val cert = + socket.session.peerCertificates.firstOrNull() as? X509Certificate + ?: return@withContext GatewayTlsProbeResult(failure = GatewayTlsProbeFailure.TLS_UNAVAILABLE) + GatewayTlsProbeResult(fingerprintSha256 = sha256Hex(cert.encoded)) + } catch (err: Throwable) { + val failure = + when (err) { + is SSLException, + is EOFException -> GatewayTlsProbeFailure.TLS_UNAVAILABLE + is ConnectException, + is SocketTimeoutException, + is UnknownHostException -> GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE + else -> GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE + } + GatewayTlsProbeResult(failure = failure) } finally { try { socket.close() diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/ConnectTabScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/ConnectTabScreen.kt index 0f554a0a651..b9098e7063e 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/ConnectTabScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/ConnectTabScreen.kt @@ -256,9 +256,23 @@ fun ConnectTabScreen(viewModel: MainViewModel) { if (config == null) { validationText = if (inputMode == ConnectInputMode.SetupCode) { - "Paste a valid setup code to connect." + val parsedSetup = decodeGatewaySetupCode(setupCode) + if (parsedSetup == null) { + "Paste a valid setup code to connect." + } else { + val parsedGateway = parseGatewayEndpointResult(parsedSetup.url) + gatewayEndpointValidationMessage( + parsedGateway.error ?: GatewayEndpointValidationError.INVALID_URL, + GatewayEndpointInputSource.SETUP_CODE, + ) + } } else { - "Enter a valid manual host and port to connect." + val manualUrl = composeGatewayManualUrl(manualHostInput, manualPortInput, manualTlsInput) + val parsedGateway = manualUrl?.let(::parseGatewayEndpointResult) + gatewayEndpointValidationMessage( + parsedGateway?.error ?: GatewayEndpointValidationError.INVALID_URL, + GatewayEndpointInputSource.MANUAL, + ) } return@Button } @@ -386,6 +400,11 @@ fun ConnectTabScreen(viewModel: MainViewModel) { Text("Run these on the gateway host:", style = mobileCallout, color = mobileTextSecondary) CommandBlock("openclaw qr --setup-code-only") CommandBlock("openclaw qr --json") + Text( + "Remote mobile nodes require wss:// or Tailscale Serve. ws:// is only for localhost or the Android emulator.", + style = mobileCaption1, + color = mobileTextSecondary, + ) if (inputMode == ConnectInputMode.SetupCode) { Text("Setup Code", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) @@ -468,7 +487,11 @@ fun ConnectTabScreen(viewModel: MainViewModel) { ) { Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { Text("Use TLS", style = mobileHeadline, color = mobileText) - Text("Switch to secure websocket (`wss`).", style = mobileCallout, color = mobileTextSecondary) + Text( + "Required for remote hosts. Use Tailscale Serve or a wss:// gateway URL.", + style = mobileCallout, + color = mobileTextSecondary, + ) } Switch( checked = manualTlsInput, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt index 169223a34da..e8d64939123 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt @@ -33,7 +33,32 @@ internal data class GatewayConnectConfig( val password: String, ) +internal enum class GatewayEndpointValidationError { + INVALID_URL, + INSECURE_REMOTE_URL, +} + +internal enum class GatewayEndpointInputSource { + SETUP_CODE, + MANUAL, + QR_SCAN, +} + +internal data class GatewayEndpointParseResult( + val config: GatewayEndpointConfig? = null, + val error: GatewayEndpointValidationError? = null, +) + +internal data class GatewayScannedSetupCodeResult( + val setupCode: String? = null, + val error: GatewayEndpointValidationError? = null, +) + private val gatewaySetupJson = Json { ignoreUnknownKeys = true } +private const val remoteGatewaySecurityRule = + "Remote mobile nodes require wss:// or Tailscale Serve. ws:// is only for localhost or the Android emulator." +private const val remoteGatewaySecurityFix = + "Enable Tailscale Serve or expose a wss:// gateway URL." internal fun resolveGatewayConnectConfig( useSetupCode: Boolean, @@ -50,7 +75,7 @@ internal fun resolveGatewayConnectConfig( ): GatewayConnectConfig? { if (useSetupCode) { val setup = decodeGatewaySetupCode(setupCode) ?: return null - val parsed = parseGatewayEndpoint(setup.url) ?: return null + val parsed = parseGatewayEndpointResult(setup.url).config ?: return null val setupBootstrapToken = setup.bootstrapToken?.trim().orEmpty() val sharedToken = when { @@ -75,10 +100,10 @@ internal fun resolveGatewayConnectConfig( } val manualUrl = composeGatewayManualUrl(manualHostInput, manualPortInput, manualTlsInput) ?: return null - val parsed = parseGatewayEndpoint(manualUrl) ?: return null + val parsed = parseGatewayEndpointResult(manualUrl).config ?: return null val savedManualEndpoint = composeGatewayManualUrl(savedManualHost, savedManualPort, savedManualTls) - ?.let(::parseGatewayEndpoint) + ?.let { parseGatewayEndpointResult(it).config } val preserveBootstrapToken = savedManualEndpoint != null && savedManualEndpoint.host == parsed.host && @@ -97,13 +122,19 @@ internal fun resolveGatewayConnectConfig( } internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? { + return parseGatewayEndpointResult(rawInput).config +} + +internal fun parseGatewayEndpointResult(rawInput: String): GatewayEndpointParseResult { val raw = rawInput.trim() - if (raw.isEmpty()) return null + if (raw.isEmpty()) return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INVALID_URL) val normalized = if (raw.contains("://")) raw else "https://$raw" - val uri = runCatching { URI(normalized) }.getOrNull() ?: return null + val uri = + runCatching { URI(normalized) }.getOrNull() + ?: return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INVALID_URL) val host = uri.host?.trim()?.trim('[', ']').orEmpty() - if (host.isEmpty()) return null + if (host.isEmpty()) return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INVALID_URL) val scheme = uri.scheme?.trim()?.lowercase(Locale.US).orEmpty() val tls = @@ -112,7 +143,9 @@ internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? { "wss", "https" -> true else -> true } - if (!tls && !isLoopbackGatewayHost(host)) return null + if (!tls && !isLoopbackGatewayHost(host)) { + return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INSECURE_REMOTE_URL) + } val defaultPort = when (scheme) { "wss", "https" -> 443 @@ -134,7 +167,9 @@ internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? { "${if (tls) "https" else "http"}://$displayHost:$port" } - return GatewayEndpointConfig(host = host, port = port, tls = tls, displayUrl = displayUrl) + return GatewayEndpointParseResult( + config = GatewayEndpointConfig(host = host, port = port, tls = tls, displayUrl = displayUrl), + ) } internal fun decodeGatewaySetupCode(rawInput: String): GatewaySetupCode? { @@ -165,9 +200,44 @@ internal fun decodeGatewaySetupCode(rawInput: String): GatewaySetupCode? { } internal fun resolveScannedSetupCode(rawInput: String): String? { - val setupCode = resolveSetupCodeCandidate(rawInput) ?: return null - val decoded = decodeGatewaySetupCode(setupCode) ?: return null - return setupCode.takeIf { parseGatewayEndpoint(decoded.url) != null } + return resolveScannedSetupCodeResult(rawInput).setupCode +} + +internal fun resolveScannedSetupCodeResult(rawInput: String): GatewayScannedSetupCodeResult { + val setupCode = + resolveSetupCodeCandidate(rawInput) + ?: return GatewayScannedSetupCodeResult(error = GatewayEndpointValidationError.INVALID_URL) + val decoded = + decodeGatewaySetupCode(setupCode) + ?: return GatewayScannedSetupCodeResult(error = GatewayEndpointValidationError.INVALID_URL) + val parsed = parseGatewayEndpointResult(decoded.url) + if (parsed.config == null) { + return GatewayScannedSetupCodeResult(error = parsed.error) + } + return GatewayScannedSetupCodeResult(setupCode = setupCode) +} + +internal fun gatewayEndpointValidationMessage( + error: GatewayEndpointValidationError, + source: GatewayEndpointInputSource, +): String { + return when (error) { + GatewayEndpointValidationError.INSECURE_REMOTE_URL -> + when (source) { + GatewayEndpointInputSource.SETUP_CODE -> + "Setup code points to an insecure remote gateway. $remoteGatewaySecurityRule $remoteGatewaySecurityFix" + GatewayEndpointInputSource.QR_SCAN -> + "QR code points to an insecure remote gateway. $remoteGatewaySecurityRule $remoteGatewaySecurityFix" + GatewayEndpointInputSource.MANUAL -> + "$remoteGatewaySecurityRule $remoteGatewaySecurityFix" + } + GatewayEndpointValidationError.INVALID_URL -> + when (source) { + GatewayEndpointInputSource.SETUP_CODE -> "Setup code has invalid gateway URL." + GatewayEndpointInputSource.QR_SCAN -> "QR code did not contain a valid setup code." + GatewayEndpointInputSource.MANUAL -> "Enter a valid manual host and port to connect." + } + } } internal fun composeGatewayManualUrl(hostInput: String, portInput: String, tls: Boolean): String? { diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayDiagnostics.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayDiagnostics.kt index 90737e51bc1..6633ea2f4e9 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayDiagnostics.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayDiagnostics.kt @@ -49,6 +49,7 @@ internal fun buildGatewayDiagnosticsReport( Please: - pick one route only: same machine, same LAN, Tailscale, or public URL - classify this as pairing/auth, TLS trust, wrong advertised route, wrong address/port, or gateway down + - remember: remote mobile nodes require wss:// or Tailscale Serve; ws:// is only for localhost or the Android emulator - quote the exact app status/error below - tell me whether `openclaw devices list` should show a pending pairing request - if more signal is needed, ask for `openclaw qr --json`, `openclaw devices list`, and `openclaw nodes status` diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt index 80912d978b7..629fd287c38 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt @@ -566,12 +566,16 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { if (contents.isEmpty()) { return@addOnSuccessListener } - val scannedSetupCode = resolveScannedSetupCode(contents) - if (scannedSetupCode == null) { - gatewayError = "QR code did not contain a valid setup code." + val scannedSetupCode = resolveScannedSetupCodeResult(contents) + if (scannedSetupCode.setupCode == null) { + gatewayError = + gatewayEndpointValidationMessage( + scannedSetupCode.error ?: GatewayEndpointValidationError.INVALID_URL, + GatewayEndpointInputSource.QR_SCAN, + ) return@addOnSuccessListener } - setupCode = scannedSetupCode + setupCode = scannedSetupCode.setupCode gatewayInputMode = GatewayInputMode.SetupCode gatewayError = null attemptedConnect = false @@ -799,9 +803,13 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { gatewayError = "Scan QR code first, or use Advanced setup." return@Button } - val parsedGateway = parseGatewayEndpoint(parsedSetup.url) - if (parsedGateway == null) { - gatewayError = "Setup code has invalid gateway URL." + val parsedGateway = parseGatewayEndpointResult(parsedSetup.url) + if (parsedGateway.config == null) { + gatewayError = + gatewayEndpointValidationMessage( + parsedGateway.error ?: GatewayEndpointValidationError.INVALID_URL, + GatewayEndpointInputSource.SETUP_CODE, + ) return@Button } gatewayUrl = parsedSetup.url @@ -819,12 +827,16 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { } } else { val manualUrl = composeGatewayManualUrl(manualHost, manualPort, manualTls) - val parsedGateway = manualUrl?.let(::parseGatewayEndpoint) - if (parsedGateway == null) { - gatewayError = "Manual endpoint is invalid." + val parsedGateway = manualUrl?.let(::parseGatewayEndpointResult) + if (parsedGateway?.config == null) { + gatewayError = + gatewayEndpointValidationMessage( + parsedGateway?.error ?: GatewayEndpointValidationError.INVALID_URL, + GatewayEndpointInputSource.MANUAL, + ) return@Button } - gatewayUrl = parsedGateway.displayUrl + gatewayUrl = parsedGateway.config.displayUrl viewModel.setGatewayBootstrapToken("") } step = OnboardingStep.Permissions @@ -863,19 +875,23 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { } else { Button( onClick = { - val parsed = parseGatewayEndpoint(gatewayUrl) - if (parsed == null) { + val parsed = parseGatewayEndpointResult(gatewayUrl) + if (parsed.config == null) { step = OnboardingStep.Gateway - gatewayError = "Invalid gateway URL." + gatewayError = + gatewayEndpointValidationMessage( + parsed.error ?: GatewayEndpointValidationError.INVALID_URL, + GatewayEndpointInputSource.MANUAL, + ) return@Button } val token = persistedGatewayToken.trim() val password = gatewayPassword.trim() attemptedConnect = true viewModel.setManualEnabled(true) - viewModel.setManualHost(parsed.host) - viewModel.setManualPort(parsed.port) - viewModel.setManualTls(parsed.tls) + viewModel.setManualHost(parsed.config.host) + viewModel.setManualPort(parsed.config.port) + viewModel.setManualTls(parsed.config.tls) if (gatewayInputMode == GatewayInputMode.Manual) { viewModel.setGatewayBootstrapToken("") } @@ -886,7 +902,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { } viewModel.setGatewayPassword(password) viewModel.connect( - GatewayEndpoint.manual(host = parsed.host, port = parsed.port), + GatewayEndpoint.manual(host = parsed.config.host, port = parsed.config.port), token = token.ifEmpty { null }, bootstrapToken = if (gatewayInputMode == GatewayInputMode.SetupCode) { @@ -1040,7 +1056,7 @@ private fun GatewayStep( StepShell(title = "Gateway Connection") { Text( - "Run `openclaw qr` on your gateway host, then scan the code with this device.", + "Run `openclaw qr` on your gateway host, then scan the code with this device. Remote mobile nodes require wss:// or Tailscale Serve.", style = onboardingCalloutStyle, color = onboardingTextSecondary, ) @@ -1072,7 +1088,7 @@ private fun GatewayStep( ) { Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { Text("Advanced setup", style = onboardingHeadlineStyle, color = onboardingText) - Text("Paste setup code or enter host/port manually.", style = onboardingCaption1Style, color = onboardingTextSecondary) + Text("Paste setup code or enter host/port manually. ws:// is only for localhost or the Android emulator.", style = onboardingCaption1Style, color = onboardingTextSecondary) } Icon( imageVector = if (advancedOpen) Icons.Default.ExpandLess else Icons.Default.ExpandMore, @@ -1153,7 +1169,11 @@ private fun GatewayStep( ) { Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { Text("Use TLS", style = onboardingHeadlineStyle, color = onboardingText) - Text("Switch to secure websocket (`wss`).", style = onboardingCalloutStyle.copy(lineHeight = 18.sp), color = onboardingTextSecondary) + Text( + "Required for remote hosts. Use Tailscale Serve or a wss:// gateway URL.", + style = onboardingCalloutStyle.copy(lineHeight = 18.sp), + color = onboardingTextSecondary, + ) } Switch( checked = manualTls, diff --git a/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt index 5547254b446..bae6953a623 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt @@ -2,6 +2,8 @@ package ai.openclaw.app import ai.openclaw.app.gateway.GatewayEndpoint import ai.openclaw.app.gateway.GatewaySession +import ai.openclaw.app.gateway.GatewayTlsProbeFailure +import ai.openclaw.app.gateway.GatewayTlsProbeResult import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -77,7 +79,7 @@ class GatewayBootstrapAuthTest { NodeRuntime( app, prefs, - tlsFingerprintProbe = { _, _ -> "fp-1" }, + tlsFingerprintProbe = { _, _ -> GatewayTlsProbeResult(fingerprintSha256 = "fp-1") }, ) val endpoint = GatewayEndpoint.manual(host = "gateway.example", port = 18789) val explicitAuth = @@ -98,6 +100,29 @@ class GatewayBootstrapAuthTest { assertEquals("setup-bootstrap-token", desiredBootstrapToken(runtime, "operatorSession")) } + @Test + fun connect_showsSecureEndpointGuidanceWhenTlsProbeFails() { + val app = RuntimeEnvironment.getApplication() + val runtime = + NodeRuntime( + app, + tlsFingerprintProbe = { _, _ -> + GatewayTlsProbeResult(failure = GatewayTlsProbeFailure.TLS_UNAVAILABLE) + }, + ) + + runtime.connect( + GatewayEndpoint.manual(host = "gateway.example", port = 18789), + NodeRuntime.GatewayConnectAuth(token = "shared-token", bootstrapToken = null, password = null), + ) + + assertEquals( + "Failed: remote mobile nodes require wss:// or Tailscale Serve. No TLS endpoint detected for this host.", + waitForStatusText(runtime), + ) + assertNull(runtime.pendingGatewayTrust.value) + } + private fun waitForGatewayTrustPrompt(runtime: NodeRuntime): NodeRuntime.GatewayTrustPrompt { repeat(50) { runtime.pendingGatewayTrust.value?.let { return it } @@ -106,6 +131,17 @@ class GatewayBootstrapAuthTest { error("Expected pending gateway trust prompt") } + private fun waitForStatusText(runtime: NodeRuntime): String { + repeat(50) { + val status = runtime.statusText.value + if (status != "Verify gateway TLS fingerprint…") { + return status + } + Thread.sleep(10) + } + error("Expected status text update") + } + private fun desiredBootstrapToken(runtime: NodeRuntime, sessionFieldName: String): String? { val session = readField(runtime, sessionFieldName) val desired = readField(session, "desired") ?: return null diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/GatewayConfigResolverTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/GatewayConfigResolverTest.kt index 9f58b2c57aa..56f73737f6d 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/GatewayConfigResolverTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/GatewayConfigResolverTest.kt @@ -220,6 +220,25 @@ class GatewayConfigResolverTest { assertNull(resolved) } + @Test + fun resolveScannedSetupCodeResultFlagsInsecureRemoteGateway() { + val setupCode = + encodeSetupCode("""{"url":"ws://attacker.example:18789","bootstrapToken":"bootstrap-1"}""") + + val resolved = resolveScannedSetupCodeResult(setupCode) + + assertNull(resolved.setupCode) + assertEquals(GatewayEndpointValidationError.INSECURE_REMOTE_URL, resolved.error) + } + + @Test + fun parseGatewayEndpointResultFlagsInsecureRemoteGateway() { + val parsed = parseGatewayEndpointResult("ws://gateway.example:18789") + + assertNull(parsed.config) + assertEquals(GatewayEndpointValidationError.INSECURE_REMOTE_URL, parsed.error) + } + @Test fun decodeGatewaySetupCodeParsesBootstrapToken() { val setupCode = diff --git a/docs/cli/qr.md b/docs/cli/qr.md index 1575b16d029..7ee788a2e79 100644 --- a/docs/cli/qr.md +++ b/docs/cli/qr.md @@ -1,14 +1,14 @@ --- -summary: "CLI reference for `openclaw qr` (generate iOS pairing QR + setup code)" +summary: "CLI reference for `openclaw qr` (generate mobile pairing QR + setup code)" read_when: - - You want to pair the iOS app with a gateway quickly + - You want to pair a mobile node app with a gateway quickly - You need setup-code output for remote/manual sharing title: "qr" --- # `openclaw qr` -Generate an iOS pairing QR and setup code from your current Gateway configuration. +Generate a mobile pairing QR and setup code from your current Gateway configuration. ## Usage @@ -35,6 +35,7 @@ openclaw qr --url wss://gateway.example/ws - `--token` and `--password` are mutually exclusive. - The setup code itself now carries an opaque short-lived `bootstrapToken`, not the shared gateway token/password. +- Mobile pairing fails closed for insecure remote `ws://` gateway URLs. For remote/mobile use, prefer Tailscale Serve/Funnel or a `wss://` gateway URL. Plain `ws://` is only valid for localhost/debugging. - With `--remote`, if effectively active remote credentials are configured as SecretRefs and you do not pass `--token` or `--password`, the command resolves them from the active gateway snapshot. If gateway is unavailable, the command fails fast. - Without `--remote`, local gateway auth SecretRefs are resolved when no CLI auth override is passed: - `gateway.auth.token` resolves when token auth can win (explicit `gateway.auth.mode="token"` or inferred mode where no password source wins). diff --git a/docs/gateway/discovery.md b/docs/gateway/discovery.md index b1020e063d3..00dd5e4fd27 100644 --- a/docs/gateway/discovery.md +++ b/docs/gateway/discovery.md @@ -95,6 +95,12 @@ If the gateway can detect it is running under Tailscale, it publishes `tailnetDn The macOS app now prefers MagicDNS names over raw Tailscale IPs for gateway discovery. This improves reliability when tailnet IPs change (for example after node restarts or CGNAT reassignment), because MagicDNS names resolve to the current IP automatically. +For mobile node pairing, discovery hints do not relax transport security: + +- iOS/Android still require a secure first-time remote connect path (`wss://` or Tailscale Serve/Funnel). +- A discovered raw tailnet IP is a routing hint, not permission to use plaintext remote `ws://`. +- If you want the simplest Tailscale path for mobile nodes, use Tailscale Serve so discovery and the setup code both resolve to the same secure MagicDNS endpoint. + ### 3) Manual / SSH target When there is no direct route (or direct is disabled), clients can always connect via SSH by forwarding the loopback gateway port. @@ -108,6 +114,7 @@ Recommended client behavior: 1. If a paired direct endpoint is configured and reachable, use it. 2. Else, if Bonjour finds a gateway on LAN, offer a one-tap “Use this gateway” choice and save it as the direct endpoint. 3. Else, if a tailnet DNS/IP is configured, try direct. + For mobile nodes, direct means a secure endpoint, not plaintext remote `ws://`. 4. Else, fall back to SSH. ## Pairing + auth (direct transport) diff --git a/docs/platforms/android.md b/docs/platforms/android.md index 7d1bd68ebc3..70ac63daad2 100644 --- a/docs/platforms/android.md +++ b/docs/platforms/android.md @@ -27,7 +27,13 @@ System control (launchd/systemd) lives on the Gateway host. See [Gateway](/gatew Android node app ⇄ (mDNS/NSD + WebSocket) ⇄ **Gateway** -Android connects directly to the Gateway WebSocket (default `ws://:18789`) and uses device pairing (`role: node`). +Android connects directly to the Gateway WebSocket and uses device pairing (`role: node`). + +For remote hosts, Android requires a secure endpoint: + +- Preferred: Tailscale Serve / Funnel with `https://` / `wss://` +- Also supported: any other `wss://` Gateway URL with a real TLS endpoint +- Local debugging only: `ws://` on `localhost`, `127.0.0.1`, or the Android emulator bridge (`10.0.2.2`) ### Prerequisites @@ -36,6 +42,7 @@ Android connects directly to the Gateway WebSocket (default `ws://:18789`) - Same LAN with mDNS/NSD, **or** - Same Tailscale tailnet using Wide-Area Bonjour / unicast DNS-SD (see below), **or** - Manual gateway host/port (fallback) +- Remote mobile pairing does **not** use raw tailnet IP `ws://` endpoints. Use Tailscale Serve or another `wss://` URL instead. - You can run the CLI (`openclaw`) on the gateway machine (or via SSH). ### 1) Start the Gateway @@ -48,10 +55,13 @@ Confirm in logs you see something like: - `listening on ws://0.0.0.0:18789` -For tailnet-only setups (recommended for Vienna ⇄ London), bind the gateway to the tailnet IP: +For remote Android access over Tailscale, prefer Serve/Funnel instead of a raw tailnet bind: -- Set `gateway.bind: "tailnet"` in `~/.openclaw/openclaw.json` on the gateway host. -- Restart the Gateway / macOS menubar app. +```bash +openclaw gateway --tailscale serve +``` + +This gives Android a secure `wss://` / `https://` endpoint. A plain `gateway.bind: "tailnet"` setup is not enough for first-time remote Android pairing unless you also terminate TLS separately. ### 2) Verify discovery (optional) @@ -65,7 +75,9 @@ More debugging notes: [Bonjour](/gateway/bonjour). #### Tailnet (Vienna ⇄ London) discovery via unicast DNS-SD -Android NSD/mDNS discovery won’t cross networks. If your Android node and the gateway are on different networks but connected via Tailscale, use Wide-Area Bonjour / unicast DNS-SD instead: +Android NSD/mDNS discovery won’t cross networks. If your Android node and the gateway are on different networks but connected via Tailscale, use Wide-Area Bonjour / unicast DNS-SD instead. + +Discovery alone is not sufficient for remote Android pairing. The discovered route still needs a secure endpoint (`wss://` or Tailscale Serve): 1. Set up a DNS-SD zone (example `openclaw.internal.`) on the gateway host and publish `_openclaw-gw._tcp` records. 2. Configure Tailscale split DNS for your chosen domain pointing at that DNS server. @@ -79,7 +91,7 @@ In the Android app: - The app keeps its gateway connection alive via a **foreground service** (persistent notification). - Open the **Connect** tab. - Use **Setup Code** or **Manual** mode. -- If discovery is blocked, use manual host/port (and TLS/token/password when required) in **Advanced controls**. +- If discovery is blocked, use manual host/port in **Advanced controls**. For remote hosts, turn on TLS and use a `wss://` / Tailscale Serve endpoint. After the first successful pairing, Android auto-reconnects on launch: diff --git a/src/cli/program/register.subclis.ts b/src/cli/program/register.subclis.ts index 8692b2307e0..654e7e46551 100644 --- a/src/cli/program/register.subclis.ts +++ b/src/cli/program/register.subclis.ts @@ -201,7 +201,7 @@ const entries: SubCliEntry[] = [ }, { name: "qr", - description: "Generate iOS pairing QR/setup code", + description: "Generate mobile pairing QR/setup code", hasSubcommands: false, register: async (program) => { const mod = await import("../qr-cli.js"); diff --git a/src/cli/program/subcli-descriptors.ts b/src/cli/program/subcli-descriptors.ts index 4011e706b2b..e05e90971bb 100644 --- a/src/cli/program/subcli-descriptors.ts +++ b/src/cli/program/subcli-descriptors.ts @@ -80,7 +80,7 @@ export const SUB_CLI_DESCRIPTORS = [ }, { name: "qr", - description: "Generate iOS pairing QR/setup code", + description: "Generate mobile pairing QR/setup code", hasSubcommands: false, }, { diff --git a/src/cli/qr-cli.test.ts b/src/cli/qr-cli.test.ts index 674ba4a2afb..0cceca131b0 100644 --- a/src/cli/qr-cli.test.ts +++ b/src/cli/qr-cli.test.ts @@ -87,7 +87,7 @@ function createLocalGatewayConfigWithAuth(auth: Record) { secrets: createDefaultSecretProvider(), gateway: { bind: "custom", - customBindHost: "gateway.local", + customBindHost: "127.0.0.1", auth, }, }; @@ -149,7 +149,7 @@ describe("registerQrCli", () => { } function expectLoggedLocalSetupCode() { - expectLoggedSetupCode("ws://gateway.local:18789"); + expectLoggedSetupCode("ws://127.0.0.1:18789"); } function mockTailscaleStatusLookup() { @@ -178,7 +178,7 @@ describe("registerQrCli", () => { loadConfig.mockReturnValue({ gateway: { bind: "custom", - customBindHost: "gateway.local", + customBindHost: "127.0.0.1", auth: { mode: "token", token: "tok" }, }, }); @@ -186,7 +186,7 @@ describe("registerQrCli", () => { await runQr(["--setup-code-only"]); const expected = encodePairingSetupCode({ - url: "ws://gateway.local:18789", + url: "ws://127.0.0.1:18789", bootstrapToken: "bootstrap-123", }); expect(runtime.log).toHaveBeenCalledWith(expected); @@ -198,7 +198,7 @@ describe("registerQrCli", () => { loadConfig.mockReturnValue({ gateway: { bind: "custom", - customBindHost: "gateway.local", + customBindHost: "127.0.0.1", auth: { mode: "token", token: "tok" }, }, }); @@ -213,11 +213,27 @@ describe("registerQrCli", () => { expect(output).toContain("openclaw devices approve "); }); - it("accepts --token override when config has no auth", async () => { + it("fails fast for insecure remote mobile pairing setup urls", async () => { loadConfig.mockReturnValue({ gateway: { bind: "custom", customBindHost: "gateway.local", + auth: { mode: "token", token: "tok" }, + }, + }); + + await expectQrExit(["--setup-code-only"]); + + const output = runtime.error.mock.calls.map((call) => readRuntimeCallText(call)).join("\n"); + expect(output).toContain("Mobile pairing requires a secure remote gateway URL"); + expect(output).toContain("gateway.tailscale.mode=serve"); + }); + + it("accepts --token override when config has no auth", async () => { + loadConfig.mockReturnValue({ + gateway: { + bind: "custom", + customBindHost: "127.0.0.1", }, }); diff --git a/src/cli/qr-cli.ts b/src/cli/qr-cli.ts index f543033c4d7..cacd62ed102 100644 --- a/src/cli/qr-cli.ts +++ b/src/cli/qr-cli.ts @@ -98,7 +98,7 @@ function emitQrSecretResolveDiagnostics(diagnostics: string[], opts: QrCliOption export function registerQrCli(program: Command) { program .command("qr") - .description("Generate an iOS pairing QR code and setup code") + .description("Generate a mobile pairing QR code and setup code") .addHelpText( "after", () => `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/qr", "docs.openclaw.ai/cli/qr")}\n`, @@ -236,7 +236,7 @@ export function registerQrCli(program: Command) { const lines: string[] = [ theme.heading("Pairing QR"), - "Scan this with the OpenClaw iOS app (Onboarding -> Scan QR).", + "Scan this with the OpenClaw mobile app (Onboarding -> Scan QR).", "", ]; diff --git a/src/cli/qr-dashboard.integration.test.ts b/src/cli/qr-dashboard.integration.test.ts index 45276df551a..16616cac184 100644 --- a/src/cli/qr-dashboard.integration.test.ts +++ b/src/cli/qr-dashboard.integration.test.ts @@ -50,7 +50,7 @@ function createGatewayTokenRefFixture() { }, gateway: { bind: "custom", - customBindHost: "gateway.local", + customBindHost: "127.0.0.1", port: 18789, auth: { mode: "token", @@ -157,7 +157,7 @@ describe("cli integration: qr + dashboard token SecretRef", () => { const setupCode = runtimeLogs.at(-1); expect(setupCode).toBeTruthy(); const payload = decodeSetupCode(setupCode ?? ""); - expect(payload.url).toBe("ws://gateway.local:18789"); + expect(payload.url).toBe("ws://127.0.0.1:18789"); expect(payload.bootstrapToken).toBeTruthy(); expect(runtimeErrors).toEqual([]); diff --git a/src/pairing/setup-code.test.ts b/src/pairing/setup-code.test.ts index 99847cada12..84610554d47 100644 --- a/src/pairing/setup-code.test.ts +++ b/src/pairing/setup-code.test.ts @@ -43,7 +43,7 @@ describe("pairing setup code", () => { ...config, gateway: { bind: "custom", - customBindHost: "gateway.local", + customBindHost: "127.0.0.1", auth, }, }; @@ -57,6 +57,23 @@ describe("pairing setup code", () => { })); } + function createIpv4NetworkInterfaces( + address: string, + ): ReturnType["networkInterfaces"]>> { + return { + en0: [ + { + address, + family: "IPv4", + internal: false, + netmask: "255.255.255.0", + mac: "00:00:00:00:00:00", + cidr: `${address}/24`, + }, + ], + }; + } + function expectResolvedSetupOk( resolved: ResolvedSetup, params: { @@ -279,7 +296,7 @@ describe("pairing setup code", () => { { gateway: { bind: "custom", - customBindHost: "gateway.local", + customBindHost: "127.0.0.1", auth: { token }, }, ...defaultEnvSecretProviderConfig, @@ -351,14 +368,14 @@ describe("pairing setup code", () => { config: { gateway: { bind: "custom", - customBindHost: "gateway.local", + customBindHost: "127.0.0.1", port: 19001, auth: { mode: "token", token: "tok_123" }, }, } satisfies ResolveSetupConfig, expected: { authLabel: "token", - url: "ws://gateway.local:19001", + url: "ws://127.0.0.1:19001", urlSource: "gateway.bind=custom", }, }, @@ -367,7 +384,7 @@ describe("pairing setup code", () => { config: { gateway: { bind: "custom", - customBindHost: "gateway.local", + customBindHost: "127.0.0.1", auth: { mode: "token", token: "old" }, }, } satisfies ResolveSetupConfig, @@ -378,7 +395,7 @@ describe("pairing setup code", () => { } satisfies ResolveSetupOptions, expected: { authLabel: "token", - url: "ws://gateway.local:18789", + url: "ws://127.0.0.1:18789", urlSource: "gateway.bind=custom", }, }, @@ -390,6 +407,52 @@ describe("pairing setup code", () => { }); }); + it.each([ + { + name: "rejects custom bind remote ws setup urls for mobile pairing", + config: { + gateway: { + bind: "custom", + customBindHost: "gateway.local", + auth: { mode: "token", token: "tok_123" }, + }, + } satisfies ResolveSetupConfig, + expectedError: "Mobile pairing requires a secure remote gateway URL", + }, + { + name: "rejects tailnet bind remote ws setup urls for mobile pairing", + config: { + gateway: { + bind: "tailnet", + auth: { mode: "token", token: "tok_123" }, + }, + } satisfies ResolveSetupConfig, + options: { + networkInterfaces: () => createIpv4NetworkInterfaces("100.64.0.9"), + } satisfies ResolveSetupOptions, + expectedError: "prefer gateway.tailscale.mode=serve", + }, + { + name: "rejects lan bind remote ws setup urls for mobile pairing", + config: { + gateway: { + bind: "lan", + auth: { mode: "password", password: "secret" }, + }, + } satisfies ResolveSetupConfig, + options: { + networkInterfaces: () => createIpv4NetworkInterfaces("192.168.1.20"), + } satisfies ResolveSetupOptions, + expectedError: "ws:// is only valid for localhost", + }, + ] as const)("$name", async ({ config, options, expectedError }) => { + await expectResolvedSetupFailureCase({ + config, + options, + expectedError, + }); + }); + it.each([ { name: "errors when gateway is loopback only", diff --git a/src/pairing/setup-code.ts b/src/pairing/setup-code.ts index fb22d3bcd0f..5d08d6dcde3 100644 --- a/src/pairing/setup-code.ts +++ b/src/pairing/setup-code.ts @@ -7,6 +7,7 @@ import { resolveSecretInputRef, } from "../config/types.secrets.js"; import { assertExplicitGatewayAuthModeWhenBothConfigured } from "../gateway/auth-mode-policy.js"; +import { isLoopbackHost, isSecureWebSocketUrl } from "../gateway/net.js"; import { resolveRequiredConfiguredSecretRefInputString } from "../gateway/resolve-configured-secret-input-string.js"; import { issueDeviceBootstrapToken } from "../infra/device-bootstrap.js"; import { @@ -62,6 +63,34 @@ type ResolveUrlResult = { error?: string; }; +function describeSecureMobilePairingFix(source?: string): string { + const sourceNote = source ? ` Resolved source: ${source}.` : ""; + return ( + "Mobile pairing requires a secure remote gateway URL (wss://) or Tailscale Serve/Funnel." + + sourceNote + + " Fix: prefer gateway.tailscale.mode=serve, or set gateway.remote.url / " + + "plugins.entries.device-pair.config.publicUrl to a wss:// URL. ws:// is only valid for localhost." + ); +} + +function validateMobilePairingUrl(url: string, source?: string): string | null { + if (isSecureWebSocketUrl(url)) { + return null; + } + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return "Resolved mobile pairing URL is invalid."; + } + const protocol = + parsed.protocol === "https:" ? "wss:" : parsed.protocol === "http:" ? "ws:" : parsed.protocol; + if (protocol !== "ws:" || isLoopbackHost(parsed.hostname)) { + return null; + } + return describeSecureMobilePairingFix(source); +} + type ResolveAuthLabelResult = { label?: "token" | "password"; error?: string; @@ -373,6 +402,10 @@ export async function resolvePairingSetupFromConfig( if (!urlResult.url) { return { ok: false, error: urlResult.error ?? "Gateway URL unavailable." }; } + const mobilePairingUrlError = validateMobilePairingUrl(urlResult.url, urlResult.source); + if (mobilePairingUrlError) { + return { ok: false, error: mobilePairingUrlError }; + } if (!authLabel.label) { return { ok: false, error: "Gateway auth is not configured (no token or password)." };