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 {

View File

@@ -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).

View File

@@ -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)

View File

@@ -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://<magicdns>` / `wss://<magicdns>`
- 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 wont 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:

View File

@@ -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: {

View File

@@ -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",
},
});
});

View File

@@ -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 {