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 7f3dd65e998..00794eacd7a 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 @@ -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." } } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayHostSecurity.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayHostSecurity.kt index 3afea447193..c5f0936770d 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayHostSecurity.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayHostSecurity.kt @@ -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() diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt index 4274d26ecfd..23b37eded54 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt @@ -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, 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 b9098e7063e..56f68084e5e 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 @@ -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, ) 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 e8d64939123..24142f8054b 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 @@ -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 = 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 6633ea2f4e9..5735a7fb99c 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,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` 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 629fd287c38..1e5f910c0ab 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 @@ -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, ) 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 bae6953a623..d5a1d800a77 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 @@ -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) diff --git a/apps/android/app/src/test/java/ai/openclaw/app/node/ConnectionManagerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/ConnectionManagerTest.kt index 60c66cf7163..ca09101d136 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/node/ConnectionManagerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/node/ConnectionManagerTest.kt @@ -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 = 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 56f73737f6d..d35aa4316ec 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 @@ -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 { diff --git a/docs/cli/qr.md b/docs/cli/qr.md index 7ee788a2e79..12e7e0317ea 100644 --- a/docs/cli/qr.md +++ b/docs/cli/qr.md @@ -35,7 +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. +- Mobile pairing fails closed for Tailscale/public `ws://` gateway URLs. Private LAN `ws://` remains supported, but Tailscale/public mobile routes should use Tailscale Serve/Funnel or a `wss://` gateway URL. - 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 00dd5e4fd27..25026dccf51 100644 --- a/docs/gateway/discovery.md +++ b/docs/gateway/discovery.md @@ -75,7 +75,7 @@ Security notes: - Bonjour/mDNS TXT records are **unauthenticated**. Clients must treat TXT values as UX hints only. - Routing (host/port) should prefer the **resolved service endpoint** (SRV + A/AAAA) over TXT-provided `lanHost`, `tailnetDns`, or `gatewayPort`. - TLS pinning must never allow an advertised `gatewayTlsSha256` to override a previously stored pin. -- iOS/Android nodes should treat discovery-based direct connects as **TLS-only** and require an explicit “trust this fingerprint” confirmation before storing a first-time pin (out-of-band verification). +- iOS/Android nodes should require an explicit “trust this fingerprint” confirmation before storing a first-time pin (out-of-band verification) whenever the chosen route is secure/TLS-based. Disable/override: @@ -95,10 +95,11 @@ 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: +For mobile node pairing, discovery hints do not relax transport security on tailnet/public routes: -- iOS/Android still require a secure first-time remote connect path (`wss://` or Tailscale Serve/Funnel). +- iOS/Android still require a secure first-time tailnet/public connect path (`wss://` or Tailscale Serve/Funnel). - A discovered raw tailnet IP is a routing hint, not permission to use plaintext remote `ws://`. +- Private LAN direct-connect `ws://` remains supported. - 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 @@ -114,7 +115,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://`. + For mobile nodes on tailnet/public routes, 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 70ac63daad2..99ce7c74f31 100644 --- a/docs/platforms/android.md +++ b/docs/platforms/android.md @@ -29,11 +29,11 @@ Android node app ⇄ (mDNS/NSD + WebSocket) ⇄ **Gateway** Android connects directly to the Gateway WebSocket and uses device pairing (`role: node`). -For remote hosts, Android requires a secure endpoint: +For Tailscale or public 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`) +- Cleartext `ws://` remains supported on private LAN addresses / `.local` hosts, plus `localhost`, `127.0.0.1`, and the Android emulator bridge (`10.0.2.2`) ### Prerequisites @@ -42,7 +42,7 @@ For remote hosts, Android requires a secure endpoint: - 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. +- Tailnet/public 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 @@ -77,7 +77,7 @@ More debugging notes: [Bonjour](/gateway/bonjour). 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): +Discovery alone is not sufficient for tailnet/public 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. @@ -91,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 in **Advanced controls**. For remote hosts, turn on TLS and use a `wss://` / Tailscale Serve endpoint. +- If discovery is blocked, use manual host/port in **Advanced controls**. For private LAN hosts, `ws://` still works. For Tailscale/public 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/qr-cli.test.ts b/src/cli/qr-cli.test.ts index ee0cdc2b499..00fd8af6d9c 100644 --- a/src/cli/qr-cli.test.ts +++ b/src/cli/qr-cli.test.ts @@ -217,7 +217,7 @@ describe("registerQrCli", () => { loadConfig.mockReturnValue({ gateway: { bind: "custom", - customBindHost: "gateway.local", + customBindHost: "gateway.example", auth: { mode: "token", token: "tok" }, }, }); @@ -225,10 +225,24 @@ describe("registerQrCli", () => { 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("Tailscale and public mobile pairing require a secure gateway URL"); expect(output).toContain("gateway.tailscale.mode=serve"); }); + it("allows lan mdns cleartext setup urls", async () => { + loadConfig.mockReturnValue({ + gateway: { + bind: "custom", + customBindHost: "gateway.local", + auth: { mode: "token", token: "tok" }, + }, + }); + + await runQr(["--setup-code-only"]); + + expectLoggedSetupCode("ws://gateway.local:18789"); + }); + it("allows android emulator cleartext override urls", async () => { loadConfig.mockReturnValue({ gateway: { diff --git a/src/pairing/setup-code.test.ts b/src/pairing/setup-code.test.ts index ce59beb660e..87b29bad288 100644 --- a/src/pairing/setup-code.test.ts +++ b/src/pairing/setup-code.test.ts @@ -414,6 +414,36 @@ describe("pairing setup code", () => { urlSource: "gateway.bind=custom", }, }, + { + name: "allows lan ip cleartext setup urls", + config: { + gateway: { + bind: "custom", + customBindHost: "192.168.1.20", + auth: { mode: "token", token: "tok_123" }, + }, + } satisfies ResolveSetupConfig, + expected: { + authLabel: "token", + url: "ws://192.168.1.20:18789", + urlSource: "gateway.bind=custom", + }, + }, + { + name: "allows mdns hostname cleartext setup urls", + config: { + gateway: { + bind: "custom", + customBindHost: "gateway.local", + auth: { mode: "token", token: "tok_123" }, + }, + } satisfies ResolveSetupConfig, + expected: { + authLabel: "token", + url: "ws://gateway.local:18789", + urlSource: "gateway.bind=custom", + }, + }, ] as const)("$name", async ({ config, options, expected }) => { await expectResolvedSetupSuccessCase({ config, @@ -424,15 +454,15 @@ describe("pairing setup code", () => { it.each([ { - name: "rejects custom bind remote ws setup urls for mobile pairing", + name: "rejects custom bind public ws setup urls for mobile pairing", config: { gateway: { bind: "custom", - customBindHost: "gateway.local", + customBindHost: "gateway.example", auth: { mode: "token", token: "tok_123" }, }, } satisfies ResolveSetupConfig, - expectedError: "Mobile pairing requires a secure remote gateway URL", + expectedError: "Tailscale and public mobile pairing require a secure gateway URL", }, { name: "rejects tailnet bind remote ws setup urls for mobile pairing", @@ -447,8 +477,16 @@ describe("pairing setup code", () => { } satisfies ResolveSetupOptions, expectedError: "prefer gateway.tailscale.mode=serve", }, - { - name: "rejects lan bind remote ws setup urls for mobile pairing", + ] as const)("$name", async ({ config, options, expectedError }) => { + await expectResolvedSetupFailureCase({ + config, + options, + expectedError, + }); + }); + + it("allows lan bind cleartext setup urls for mobile pairing", async () => { + await expectResolvedSetupSuccessCase({ config: { gateway: { bind: "lan", @@ -458,13 +496,11 @@ describe("pairing setup code", () => { options: { networkInterfaces: () => createIpv4NetworkInterfaces("192.168.1.20"), } satisfies ResolveSetupOptions, - expectedError: "ws:// is only valid for localhost or the Android emulator", - }, - ] as const)("$name", async ({ config, options, expectedError }) => { - await expectResolvedSetupFailureCase({ - config, - options, - expectedError, + expected: { + authLabel: "password", + url: "ws://192.168.1.20:18789", + urlSource: "gateway.bind=lan", + }, }); }); diff --git a/src/pairing/setup-code.ts b/src/pairing/setup-code.ts index 34bc168c912..f43052a0611 100644 --- a/src/pairing/setup-code.ts +++ b/src/pairing/setup-code.ts @@ -16,7 +16,13 @@ import { } from "../infra/network-interfaces.js"; import { PAIRING_SETUP_BOOTSTRAP_PROFILE } from "../shared/device-bootstrap-profile.js"; import { resolveGatewayBindUrl } from "../shared/gateway-bind-url.js"; -import { isCarrierGradeNatIpv4Address, isRfc1918Ipv4Address } from "../shared/net/ip.js"; +import { + isCarrierGradeNatIpv4Address, + isIpv4Address, + isIpv6Address, + isRfc1918Ipv4Address, + parseCanonicalIpAddress, +} from "../shared/net/ip.js"; import { resolveTailnetHostWithRunner } from "../shared/tailscale-status.js"; export type PairingSetupPayload = { @@ -66,15 +72,50 @@ type ResolveUrlResult = { 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." + + "Tailscale and public mobile pairing require a secure 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 or the Android emulator." + " Fix: use a private LAN host/address, 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, private LAN, or the Android emulator." + ); +} + +function isPrivateLanHostname(host: string): boolean { + const normalized = host.trim().toLowerCase().replace(/\.+$/, ""); + if (!normalized) { + return false; + } + return normalized.endsWith(".local") || (!normalized.includes(".") && !normalized.includes(":")); +} + +function isPrivateLanIpHost(host: string): boolean { + if (isRfc1918Ipv4Address(host)) { + return true; + } + const parsed = parseCanonicalIpAddress(host); + if (!parsed) { + return false; + } + if (isIpv4Address(parsed)) { + const normalized = parsed.toString(); + return normalized.startsWith("169.254.") && !isCarrierGradeNatIpv4Address(normalized); + } + if (!isIpv6Address(parsed)) { + return false; + } + const normalized = parsed.toString().toLowerCase(); + return ( + normalized.startsWith("fe80:") || normalized.startsWith("fc") || normalized.startsWith("fd") ); } function isMobilePairingCleartextAllowedHost(host: string): boolean { - return isLoopbackHost(host) || host === "10.0.2.2"; + return ( + isLoopbackHost(host) || + host === "10.0.2.2" || + isPrivateLanIpHost(host) || + isPrivateLanHostname(host) + ); } function validateMobilePairingUrl(url: string, source?: string): string | null {