fix(pairing): allow private lan mobile ws

This commit is contained in:
Ayaan Zaidi
2026-04-03 13:21:39 +05:30
parent 84add47525
commit a2b53522eb
16 changed files with 274 additions and 52 deletions

View File

@@ -894,9 +894,9 @@ class NodeRuntime(
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."
"Failed: this host requires wss:// or Tailscale Serve. No TLS endpoint detected."
GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE, null ->
"Failed: couldn't reach a secure gateway endpoint. Remote mobile nodes require wss:// or Tailscale Serve."
"Failed: couldn't reach the secure gateway endpoint for this host."
}
}

View File

@@ -18,9 +18,7 @@ internal fun isLoopbackGatewayHost(
host = host.dropLast(1)
}
val zoneIndex = host.indexOf('%')
if (zoneIndex >= 0) {
host = host.substring(0, zoneIndex)
}
if (zoneIndex >= 0) return false
if (host.isEmpty()) return false
if (host == "localhost") return true
if (allowEmulatorBridgeAlias && host == "10.0.2.2") return true
@@ -46,6 +44,52 @@ internal fun isLoopbackGatewayHost(
return isMappedIpv4 && address[12] == 127.toByte()
}
internal fun isPrivateLanGatewayHost(
rawHost: String?,
allowEmulatorBridgeAlias: Boolean = isAndroidEmulatorRuntime(),
): Boolean {
var host =
rawHost
?.trim()
?.lowercase(Locale.US)
?.trim('[', ']')
.orEmpty()
if (host.endsWith(".")) {
host = host.dropLast(1)
}
val zoneIndex = host.indexOf('%')
if (zoneIndex >= 0) {
host = host.substring(0, zoneIndex)
}
if (host.isEmpty()) return false
if (isLoopbackGatewayHost(host, allowEmulatorBridgeAlias = allowEmulatorBridgeAlias)) return true
if (host.endsWith(".local")) return true
if (!host.contains('.') && !host.contains(':')) return true
parseIpv4Address(host)?.let { ipv4 ->
val first = ipv4[0].toInt() and 0xff
val second = ipv4[1].toInt() and 0xff
return when {
first == 10 -> true
first == 172 && second in 16..31 -> true
first == 192 && second == 168 -> true
first == 169 && second == 254 -> true
else -> false
}
}
if (!host.contains(':') || !host.all(::isIpv6LiteralChar)) return false
val address = runCatching { InetAddress.getByName(host) }.getOrNull() ?: return false
return when {
address.isLinkLocalAddress -> true
address.isSiteLocalAddress -> true
else -> {
val bytes = address.address
bytes.size == 16 && (bytes[0].toInt() and 0xfe) == 0xfc
}
}
}
private fun isAndroidEmulatorRuntime(): Boolean {
val fingerprint = Build.FINGERPRINT?.lowercase(Locale.US).orEmpty()
val model = Build.MODEL?.lowercase(Locale.US).orEmpty()

View File

@@ -8,6 +8,7 @@ import ai.openclaw.app.gateway.GatewayConnectOptions
import ai.openclaw.app.gateway.GatewayEndpoint
import ai.openclaw.app.gateway.GatewayTlsParams
import ai.openclaw.app.gateway.isLoopbackGatewayHost
import ai.openclaw.app.gateway.isPrivateLanGatewayHost
import ai.openclaw.app.LocationMode
import ai.openclaw.app.VoiceWakeMode
@@ -34,10 +35,10 @@ class ConnectionManager(
val stableId = endpoint.stableId
val stored = storedFingerprint?.trim().takeIf { !it.isNullOrEmpty() }
val isManual = stableId.startsWith("manual|")
val isLoopback = isLoopbackGatewayHost(endpoint.host)
val cleartextAllowedHost = isPrivateLanGatewayHost(endpoint.host)
if (isManual) {
if (!manualTlsEnabled && isLoopback) return null
if (!manualTlsEnabled && cleartextAllowedHost) return null
if (!stored.isNullOrBlank()) {
return GatewayTlsParams(
required = true,
@@ -75,7 +76,7 @@ class ConnectionManager(
)
}
if (!isLoopback) {
if (!cleartextAllowedHost) {
return GatewayTlsParams(
required = true,
expectedFingerprint = null,

View File

@@ -401,7 +401,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
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.",
"For Tailscale or public hosts, use wss:// or Tailscale Serve. Private LAN ws:// remains supported.",
style = mobileCaption1,
color = mobileTextSecondary,
)
@@ -488,7 +488,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("Use TLS", style = mobileHeadline, color = mobileText)
Text(
"Required for remote hosts. Use Tailscale Serve or a wss:// gateway URL.",
"Turn this on for Tailscale or public hosts. Private LAN ws:// remains supported.",
style = mobileCallout,
color = mobileTextSecondary,
)

View File

@@ -1,6 +1,6 @@
package ai.openclaw.app.ui
import ai.openclaw.app.gateway.isLoopbackGatewayHost
import ai.openclaw.app.gateway.isPrivateLanGatewayHost
import java.util.Base64
import java.util.Locale
import java.net.URI
@@ -56,9 +56,9 @@ internal data class GatewayScannedSetupCodeResult(
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."
"Tailscale and public mobile nodes require wss:// or Tailscale Serve. ws:// is allowed for private LAN, localhost, and the Android emulator."
private const val remoteGatewaySecurityFix =
"Enable Tailscale Serve or expose a wss:// gateway URL."
"Use a private LAN host/address, or enable Tailscale Serve / expose a wss:// gateway URL."
internal fun resolveGatewayConnectConfig(
useSetupCode: Boolean,
@@ -128,6 +128,7 @@ internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? {
internal fun parseGatewayEndpointResult(rawInput: String): GatewayEndpointParseResult {
val raw = rawInput.trim()
if (raw.isEmpty()) return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INVALID_URL)
if (raw.contains('%')) return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INVALID_URL)
val normalized = if (raw.contains("://")) raw else "https://$raw"
val uri =
@@ -143,7 +144,7 @@ internal fun parseGatewayEndpointResult(rawInput: String): GatewayEndpointParseR
"wss", "https" -> true
else -> true
}
if (!tls && !isLoopbackGatewayHost(host)) {
if (!tls && !isPrivateLanGatewayHost(host)) {
return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INSECURE_REMOTE_URL)
}
val defaultPort =

View File

@@ -49,7 +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
- remember: Tailscale/public mobile routes require wss:// or Tailscale Serve; private LAN ws:// is still allowed
- 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`

View File

@@ -1056,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. Remote mobile nodes require wss:// or Tailscale Serve.",
"Run `openclaw qr` on your gateway host, then scan the code with this device. For Tailscale or public hosts, use wss:// or Tailscale Serve.",
style = onboardingCalloutStyle,
color = onboardingTextSecondary,
)
@@ -1088,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. ws:// is only for localhost or the Android emulator.", style = onboardingCaption1Style, color = onboardingTextSecondary)
Text("Paste setup code or enter host/port manually. Private LAN ws:// is supported; Tailscale/public hosts need wss://.", style = onboardingCaption1Style, color = onboardingTextSecondary)
}
Icon(
imageVector = if (advancedOpen) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
@@ -1170,7 +1170,7 @@ private fun GatewayStep(
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("Use TLS", style = onboardingHeadlineStyle, color = onboardingText)
Text(
"Required for remote hosts. Use Tailscale Serve or a wss:// gateway URL.",
"Turn this on for Tailscale or public hosts. Private LAN ws:// remains supported.",
style = onboardingCalloutStyle.copy(lineHeight = 18.sp),
color = onboardingTextSecondary,
)

View File

@@ -117,7 +117,7 @@ class GatewayBootstrapAuthTest {
)
assertEquals(
"Failed: remote mobile nodes require wss:// or Tailscale Serve. No TLS endpoint detected for this host.",
"Failed: this host requires wss:// or Tailscale Serve. No TLS endpoint detected.",
waitForStatusText(runtime),
)
assertNull(runtime.pendingGatewayTrust.value)

View File

@@ -11,6 +11,7 @@ import ai.openclaw.app.protocol.OpenClawMotionCommand
import ai.openclaw.app.protocol.OpenClawSmsCommand
import ai.openclaw.app.gateway.GatewayEndpoint
import ai.openclaw.app.gateway.isLoopbackGatewayHost
import ai.openclaw.app.gateway.isPrivateLanGatewayHost
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
@@ -107,12 +108,26 @@ class ConnectionManagerTest {
}
@Test
fun resolveTlsParamsForEndpoint_discoveryNonLoopbackWithoutHintsStillRequiresTls() {
fun resolveTlsParamsForEndpoint_manualPrivateLanCanStayCleartextWhenToggleIsOff() {
val endpoint = GatewayEndpoint.manual(host = "192.168.1.20", port = 18789)
val params =
ConnectionManager.resolveTlsParamsForEndpoint(
endpoint,
storedFingerprint = null,
manualTlsEnabled = false,
)
assertNull(params)
}
@Test
fun resolveTlsParamsForEndpoint_discoveryTailnetWithoutHintsStillRequiresTls() {
val endpoint =
GatewayEndpoint(
stableId = "_openclaw-gw._tcp.|local.|Test",
name = "Test",
host = "10.0.0.2",
host = "100.64.0.9",
port = 18789,
tlsEnabled = false,
tlsFingerprintSha256 = null,
@@ -130,6 +145,28 @@ class ConnectionManagerTest {
assertEquals(false, params?.allowTOFU)
}
@Test
fun resolveTlsParamsForEndpoint_discoveryPrivateLanWithoutHintsCanStayCleartext() {
val endpoint =
GatewayEndpoint(
stableId = "_openclaw-gw._tcp.|local.|Test",
name = "Test",
host = "192.168.1.20",
port = 18789,
tlsEnabled = false,
tlsFingerprintSha256 = null,
)
val params =
ConnectionManager.resolveTlsParamsForEndpoint(
endpoint,
storedFingerprint = null,
manualTlsEnabled = false,
)
assertNull(params)
}
@Test
fun resolveTlsParamsForEndpoint_discoveryLoopbackWithoutHintsCanStayCleartext() {
val endpoint =
@@ -202,6 +239,14 @@ class ConnectionManagerTest {
assertFalse(isLoopbackGatewayHost("10.0.2.2", allowEmulatorBridgeAlias = false))
}
@Test
fun isPrivateLanGatewayHost_acceptsLanHostsButRejectsTailnetHosts() {
assertTrue(isPrivateLanGatewayHost("192.168.1.20"))
assertTrue(isPrivateLanGatewayHost("gateway.local"))
assertFalse(isPrivateLanGatewayHost("100.64.0.9"))
assertFalse(isPrivateLanGatewayHost("gateway.tailnet.ts.net"))
}
@Test
fun resolveTlsParamsForEndpoint_discoveryIpv6LoopbackWithoutHintsCanStayCleartext() {
val endpoint =

View File

@@ -31,6 +31,13 @@ class GatewayConfigResolverTest {
assertNull(parsed)
}
@Test
fun parseGatewayEndpointRejectsTailnetCleartextWsUrls() {
val parsed = parseGatewayEndpoint("ws://100.64.0.9:18789")
assertNull(parsed)
}
@Test
fun parseGatewayEndpointOmitsExplicitDefaultTlsPortFromDisplayUrl() {
val parsed = parseGatewayEndpoint("https://gateway.example:443")
@@ -91,6 +98,36 @@ class GatewayConfigResolverTest {
)
}
@Test
fun parseGatewayEndpointAllowsPrivateLanCleartextWsUrls() {
val parsed = parseGatewayEndpoint("ws://192.168.1.20:18789")
assertEquals(
GatewayEndpointConfig(
host = "192.168.1.20",
port = 18789,
tls = false,
displayUrl = "http://192.168.1.20:18789",
),
parsed,
)
}
@Test
fun parseGatewayEndpointAllowsMdnsCleartextWsUrls() {
val parsed = parseGatewayEndpoint("ws://gateway.local:18789")
assertEquals(
GatewayEndpointConfig(
host = "gateway.local",
port = 18789,
tls = false,
displayUrl = "http://gateway.local:18789",
),
parsed,
)
}
@Test
fun parseGatewayEndpointAllowsIpv6LoopbackCleartextWsUrls() {
val parsed = parseGatewayEndpoint("ws://[::1]")
@@ -377,7 +414,7 @@ class GatewayConfigResolverTest {
}
@Test
fun resolveGatewayConnectConfigRejectsNonLoopbackManualCleartextEndpoint() {
fun resolveGatewayConnectConfigAllowsPrivateLanManualCleartextEndpoint() {
val resolved =
resolveGatewayConnectConfig(
useSetupCode = false,
@@ -393,7 +430,9 @@ class GatewayConfigResolverTest {
fallbackPassword = "",
)
assertNull(resolved)
assertEquals("192.168.31.100", resolved?.host)
assertEquals(18789, resolved?.port)
assertEquals(false, resolved?.tls)
}
private fun encodeSetupCode(payloadJson: String): String {