From 36df0d93b93a9e734ff0d0c6a82451308a0c6aba Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Tue, 5 May 2026 21:07:19 -0500 Subject: [PATCH] fix: repair iOS LAN pairing Fix iOS LAN/setup-code pairing policy for #47887. - Allow explicit private LAN and .local plaintext ws:// setup/manual connects where policy allows it. - Keep public hosts, .ts.net, and Tailscale CGNAT plaintext fail-closed. - Prefer explicit passwords over stale bootstrap tokens in Swift and TypeScript gateway clients. - Update setup-code/device-pair coverage, docs, and changelog with source credit for #65185. Verification: - pnpm install - git diff --check origin/main..HEAD - pnpm exec oxfmt --check --threads=1 src/gateway/client.ts src/gateway/client.test.ts src/pairing/setup-code.ts src/pairing/setup-code.test.ts extensions/device-pair/index.ts extensions/device-pair/index.test.ts - pnpm format:docs:check - pnpm test src/gateway/client.test.ts src/pairing/setup-code.test.ts extensions/device-pair/index.test.ts - cd apps/shared/OpenClawKit && swift test --filter 'DeepLinksSecurityTests|GatewayNodeSessionTests' - pnpm lint:swift passes with the existing TalkModeRuntime.swift type-body-length warning Blocked locally: - iOS app-target xcodebuild tests require unavailable watchOS 26.4 runtime here. - Testbox check:changed previously failed because the image lacks swiftlint; local swiftlint passes. --- CHANGELOG.md | 1 + .../Gateway/GatewayConnectionController.swift | 47 +------------------ apps/ios/Tests/DeepLinkParserTests.swift | 33 +++++++++++++ .../GatewayConnectionSecurityTests.swift | 14 +++++- .../Sources/OpenClawKit/DeepLinks.swift | 6 +-- .../Sources/OpenClawKit/GatewayChannel.swift | 3 +- .../Sources/OpenClawKit/LoopbackHost.swift | 32 +++++++++---- .../DeepLinksSecurityTests.swift | 46 ++++++++++++++++++ .../GatewayNodeSessionTests.swift | 36 ++++++++++++++ docs/channels/pairing.md | 11 ++--- docs/cli/qr.md | 2 +- extensions/device-pair/index.test.ts | 27 +++++++++-- extensions/device-pair/index.ts | 25 ++++++++-- src/gateway/client.test.ts | 20 ++++++++ src/gateway/client.ts | 4 +- src/pairing/setup-code.test.ts | 26 +++++----- src/pairing/setup-code.ts | 42 +++++++++++++---- 17 files changed, 277 insertions(+), 98 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53185e317c8..ecf4124c65f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,6 +102,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Slack: preserve Socket Mode SDK error context and structured Slack API fields in reconnect logs, so startup failures no longer collapse to a bare `unknown error`. +- iOS pairing: allow setup-code and manual `ws://` connects for private LAN and `.local` gateways while keeping Tailscale/public routes on `wss://`, and prefer explicit gateway passwords over stale bootstrap tokens in mixed-auth reconnects. Fixes #47887; carries forward #65185. Thanks @draix and @BunsDev. - Plugins/diagnostics: make source-only TypeScript package warnings actionable by explaining that missing compiled runtime output is a publisher packaging issue and pointing users to update/reinstall or disable/uninstall the plugin. Fixes #77835. Thanks @googlerest. - TUI: skip the generic CLI respawn wrapper for interactive launches, exit cleanly on terminal loss, and refuse to restore heartbeat sessions as the remembered chat session, preventing stale heartbeat history and orphaned `openclaw-tui` processes on first boot. Thanks @vincentkoc. - Doctor/sessions: move heartbeat-poisoned default main session store entries to recovery keys and clear stale TUI restore pointers, so `doctor --fix` can repair instances already stuck on `agent:main:main` heartbeat history. Thanks @vincentkoc. diff --git a/apps/ios/Sources/Gateway/GatewayConnectionController.swift b/apps/ios/Sources/Gateway/GatewayConnectionController.swift index 95d0af2e2bd..f82b0d40f76 100644 --- a/apps/ios/Sources/Gateway/GatewayConnectionController.swift +++ b/apps/ios/Sources/Gateway/GatewayConnectionController.swift @@ -689,7 +689,7 @@ final class GatewayConnectionController { } private func shouldRequireTLS(host: String) -> Bool { - !Self.isLoopbackHost(host) + !LoopbackHost.isLocalNetworkHost(host) } private func shouldForceTLS(host: String) -> Bool { @@ -698,51 +698,6 @@ final class GatewayConnectionController { return trimmed.hasSuffix(".ts.net") || trimmed.hasSuffix(".ts.net.") } - private static func isLoopbackHost(_ rawHost: String) -> Bool { - var host = rawHost.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !host.isEmpty else { return false } - - if host.hasPrefix("[") && host.hasSuffix("]") { - host.removeFirst() - host.removeLast() - } - if host.hasSuffix(".") { - host.removeLast() - } - if let zoneIndex = host.firstIndex(of: "%") { - host = String(host[.. Bool { - var addr = in_addr() - let parsed = host.withCString { inet_pton(AF_INET, $0, &addr) == 1 } - guard parsed else { return false } - let value = UInt32(bigEndian: addr.s_addr) - let firstOctet = UInt8((value >> 24) & 0xFF) - return firstOctet == 127 - } - - private static func isLoopbackIPv6(_ host: String) -> Bool { - var addr = in6_addr() - let parsed = host.withCString { inet_pton(AF_INET6, $0, &addr) == 1 } - guard parsed else { return false } - return withUnsafeBytes(of: &addr) { rawBytes in - let bytes = rawBytes.bindMemory(to: UInt8.self) - let isV6Loopback = bytes[0..<15].allSatisfy { $0 == 0 } && bytes[15] == 1 - if isV6Loopback { return true } - - let isMappedV4 = bytes[0..<10].allSatisfy { $0 == 0 } && bytes[10] == 0xFF && bytes[11] == 0xFF - return isMappedV4 && bytes[12] == 127 - } - } - private func manualStableID(host: String, port: Int) -> String { "manual|\(host.lowercased())|\(port)" } diff --git a/apps/ios/Tests/DeepLinkParserTests.swift b/apps/ios/Tests/DeepLinkParserTests.swift index d76bc8c84ee..0c1c03f86bb 100644 --- a/apps/ios/Tests/DeepLinkParserTests.swift +++ b/apps/ios/Tests/DeepLinkParserTests.swift @@ -101,6 +101,20 @@ private func agentAction( #expect(DeepLinkParser.parse(url) == nil) } + @Test func parseGatewayLinkAllowsPrivateLanWs() { + let url = URL( + string: "openclaw://gateway?host=openclaw.local&port=18789&tls=0&token=abc")! + #expect( + DeepLinkParser.parse(url) == .gateway( + .init( + host: "openclaw.local", + port: 18789, + tls: false, + bootstrapToken: nil, + token: "abc", + password: nil))) + } + @Test func parseGatewayLinkRejectsInsecurePrefixBypassHost() { let url = URL( string: "openclaw://gateway?host=127.attacker.example&port=18789&tls=0&token=abc")! @@ -162,6 +176,25 @@ private func agentAction( password: nil)) } + @Test func parseGatewaySetupCodeAllowsPrivateLanWs() { + let payload = #"{"url":"ws://openclaw.local:18789","bootstrapToken":"tok"}"# + let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) + + #expect(link == .init( + host: "openclaw.local", + port: 18789, + tls: false, + bootstrapToken: "tok", + token: nil, + password: nil)) + } + + @Test func parseGatewaySetupCodeRejectsTailnetPlaintextWs() { + let payload = #"{"url":"ws://gateway.tailnet.ts.net:18789","bootstrapToken":"tok"}"# + let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) + #expect(link == nil) + } + @Test func parseGatewaySetupInputParsesFullCopiedSetupMessage() { let payload = #"{"url":"wss://gateway.example.com","bootstrapToken":"tok"}"# let link = GatewayConnectDeepLink.fromSetupInput(""" diff --git a/apps/ios/Tests/GatewayConnectionSecurityTests.swift b/apps/ios/Tests/GatewayConnectionSecurityTests.swift index 1cddd7c73f6..06b63d84628 100644 --- a/apps/ios/Tests/GatewayConnectionSecurityTests.swift +++ b/apps/ios/Tests/GatewayConnectionSecurityTests.swift @@ -107,8 +107,9 @@ import Testing let controller = makeController() #expect(controller._test_resolveManualUseTLS(host: "gateway.example.com", useTLS: false) == true) - #expect(controller._test_resolveManualUseTLS(host: "openclaw.local", useTLS: false) == true) #expect(controller._test_resolveManualUseTLS(host: "127.attacker.example", useTLS: false) == true) + #expect(controller._test_resolveManualUseTLS(host: "gateway.ts.net", useTLS: false) == true) + #expect(controller._test_resolveManualUseTLS(host: "100.64.0.9", useTLS: false) == true) #expect(controller._test_resolveManualUseTLS(host: "localhost", useTLS: false) == false) #expect(controller._test_resolveManualUseTLS(host: "127.0.0.1", useTLS: false) == false) @@ -118,6 +119,17 @@ import Testing #expect(controller._test_resolveManualUseTLS(host: "0.0.0.0", useTLS: false) == false) } + @Test @MainActor func manualConnectionsAllowPrivateLanPlaintext() async { + let controller = makeController() + + #expect(controller._test_resolveManualUseTLS(host: "openclaw.local", useTLS: false) == false) + #expect(controller._test_resolveManualUseTLS(host: "192.168.1.20", useTLS: false) == false) + #expect(controller._test_resolveManualUseTLS(host: "10.0.0.5", useTLS: false) == false) + #expect(controller._test_resolveManualUseTLS(host: "172.16.1.5", useTLS: false) == false) + #expect(controller._test_resolveManualUseTLS(host: "169.254.1.5", useTLS: false) == false) + #expect(controller._test_resolveManualUseTLS(host: "fd00::1", useTLS: false) == false) + } + @Test @MainActor func manualDefaultPortUses443OnlyForTailnetTLSHosts() async { let controller = makeController() diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift index 22c099ad8a5..5bf818220f7 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift @@ -116,7 +116,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable { return nil } let tls = payload.tls ?? true - if !tls, !LoopbackHost.isLoopbackHost(host) { + if !tls, !LoopbackHost.isLocalNetworkHost(host) { return nil } return GatewayConnectDeepLink( @@ -143,7 +143,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable { return nil } let tls = scheme == "wss" || scheme == "https" - if !tls, !LoopbackHost.isLoopbackHost(hostname) { + if !tls, !LoopbackHost.isLocalNetworkHost(hostname) { return nil } return GatewayConnectDeepLink( @@ -254,7 +254,7 @@ public enum DeepLinkParser { } let port = query["port"].flatMap { Int($0) } ?? 18789 let tls = (query["tls"] as NSString?)?.boolValue ?? false - if !tls, !LoopbackHost.isLoopbackHost(hostParam) { + if !tls, !LoopbackHost.isLocalNetworkHost(hostParam) { return nil } return .gateway( diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift index 5ec99f5d799..340f69c160f 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift @@ -522,7 +522,8 @@ public actor GatewayChannelActor { (includeDeviceIdentity && explicitPassword == nil && explicitBootstrapToken == nil ? storedToken : nil) - let authBootstrapToken = authToken == nil ? explicitBootstrapToken : nil + let authBootstrapToken = + authToken == nil && explicitPassword == nil ? explicitBootstrapToken : nil let authDeviceToken = shouldUseDeviceRetryToken ? storedToken : nil let authSource: GatewayAuthSource = if authDeviceToken != nil || (explicitToken == nil && authToken != nil) { .deviceToken diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/LoopbackHost.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/LoopbackHost.swift index b090549800a..d23f0a63880 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/LoopbackHost.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/LoopbackHost.swift @@ -41,16 +41,32 @@ public enum LoopbackHost { } public static func isLocalNetworkHost(_ rawHost: String) -> Bool { - let host = rawHost.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let host = self.normalizedHost(rawHost) guard !host.isEmpty else { return false } if self.isLoopbackHost(host) { return true } if host.hasSuffix(".local") { return true } - if host.hasSuffix(".ts.net") { return true } - if host.hasSuffix(".tailscale.net") { return true } - // Allow MagicDNS / LAN hostnames like "peters-mac-studio-1". - if !host.contains("."), !host.contains(":") { return true } - guard let ipv4 = self.parseIPv4(host) else { return false } - return self.isLocalNetworkIPv4(ipv4) + if let ipv4 = self.parseIPv4(host) { + return self.isLocalNetworkIPv4(ipv4) + } + guard let ipv6 = IPv6Address(host) else { return false } + let bytes = Array(ipv6.rawValue) + let isUniqueLocal = (bytes[0] & 0xFE) == 0xFC + let isLinkLocal = bytes[0] == 0xFE && (bytes[1] & 0xC0) == 0x80 + return isUniqueLocal || isLinkLocal + } + + static func normalizedHost(_ rawHost: String) -> String { + var host = rawHost + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .trimmingCharacters(in: CharacterSet(charactersIn: "[]")) + if host.hasSuffix(".") { + host.removeLast() + } + if let zoneIndex = host.firstIndex(of: "%") { + host = String(host[.. (UInt8, UInt8, UInt8, UInt8)? { @@ -73,8 +89,6 @@ public enum LoopbackHost { if a == 127 { return true } // 169.254.0.0/16 (link-local) if a == 169, b == 254 { return true } - // Tailscale: 100.64.0.0/10 - if a == 100, (64...127).contains(Int(b)) { return true } return false } } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift index b58b051c3bd..e9e4a699a6f 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift @@ -59,6 +59,40 @@ private func setupCode(from payload: String) -> String { password: nil)) } + @Test func setupCodeAllowsPrivateLanWs() { + let payload = #"{"url":"ws://192.168.1.20:18789","bootstrapToken":"tok"}"# + #expect( + GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) == .init( + host: "192.168.1.20", + port: 18789, + tls: false, + bootstrapToken: "tok", + token: nil, + password: nil)) + } + + @Test func setupCodeAllowsMDNSWs() { + let payload = #"{"url":"ws://openclaw.local:18789","bootstrapToken":"tok"}"# + #expect( + GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) == .init( + host: "openclaw.local", + port: 18789, + tls: false, + bootstrapToken: "tok", + token: nil, + password: nil)) + } + + @Test func setupCodeRejectsTailnetPlaintextWs() { + let payload = #"{"url":"ws://gateway.tailnet.ts.net:18789","bootstrapToken":"tok"}"# + #expect(GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) == nil) + } + + @Test func setupCodeRejectsCgnatPlaintextWs() { + let payload = #"{"url":"ws://100.64.0.9:18789","bootstrapToken":"tok"}"# + #expect(GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) == nil) + } + @Test func setupCodeParsesHostPayload() { let payload = #"{"host":"gateway.tailnet.ts.net","port":443,"tls":true,"bootstrapToken":"tok"}"# #expect( @@ -88,6 +122,18 @@ private func setupCode(from payload: String) -> String { #expect(GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) == nil) } + @Test func setupCodeAllowsPrivateLanHostPayload() { + let payload = #"{"host":"openclaw.local","port":18789,"tls":false,"bootstrapToken":"tok"}"# + #expect( + GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) == .init( + host: "openclaw.local", + port: 18789, + tls: false, + bootstrapToken: "tok", + token: nil, + password: nil)) + } + @Test func setupInputParsesFullCopiedSetupMessage() { let payload = #"{"url":"wss://gateway.tailnet.ts.net","bootstrapToken":"tok"}"# let message = """ diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift index 0f0b2191d77..bb7f074b076 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift @@ -249,6 +249,42 @@ struct GatewayNodeSessionTests { await gateway.disconnect() } + @Test + func passwordTakesPrecedenceOverBootstrapToken() async throws { + let session = FakeGatewayWebSocketSession() + let gateway = GatewayNodeSession() + let options = GatewayConnectOptions( + role: "operator", + scopes: ["operator.read"], + caps: [], + commands: [], + permissions: [:], + clientId: "openclaw-ios-test", + clientMode: "ui", + clientDisplayName: "iOS Test", + includeDeviceIdentity: false) + + try await gateway.connect( + url: URL(string: "ws://example.invalid")!, + token: nil, + bootstrapToken: "stale-bootstrap-token", + password: "shared-password", + connectOptions: options, + sessionBox: WebSocketSessionBox(session: session), + onConnected: {}, + onDisconnected: { _ in }, + onInvoke: { req in + BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) + }) + + let auth = try #require(session.latestTask()?.latestConnectAuth()) + #expect(auth["password"] as? String == "shared-password") + #expect(auth["bootstrapToken"] == nil) + #expect(auth["token"] == nil) + + await gateway.disconnect() + } + @Test func bootstrapHelloStoresAdditionalDeviceTokens() async throws { let tempDir = FileManager.default.temporaryDirectory diff --git a/docs/channels/pairing.md b/docs/channels/pairing.md index fbf3d75e2d2..a7dc23ecacd 100644 --- a/docs/channels/pairing.md +++ b/docs/channels/pairing.md @@ -134,12 +134,11 @@ That bootstrap token carries the built-in pairing bootstrap profile: Treat the setup code like a password while it is valid. -For Tailscale, public, or other non-loopback mobile pairing, use Tailscale -Serve/Funnel or another `wss://` Gateway URL. Direct non-loopback `ws://` setup -URLs are rejected before QR/setup-code issuance. Plaintext `ws://` setup codes -are limited to loopback URLs; private-network `ws://` clients still require the explicit -`OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` break-glass described in the remote -Gateway guide. +For Tailscale, public, or other remote mobile pairing, use Tailscale Serve/Funnel +or another `wss://` Gateway URL. Plaintext `ws://` setup codes are accepted only +for loopback, private LAN addresses, `.local` Bonjour hosts, and the Android +emulator host. Tailnet CGNAT addresses, `.ts.net` names, and public hosts still +fail closed before QR/setup-code issuance. ### Approve a node device diff --git a/docs/cli/qr.md b/docs/cli/qr.md index c043b01ee6e..25742309116 100644 --- a/docs/cli/qr.md +++ b/docs/cli/qr.md @@ -38,7 +38,7 @@ openclaw qr --url wss://gateway.example/ws - In the built-in node/operator bootstrap flow, the primary node token still lands with `scopes: []`. - If bootstrap handoff also issues an operator token, it stays bounded to the bootstrap allowlist: `operator.approvals`, `operator.read`, `operator.talk.secrets`, `operator.write`. - Bootstrap scope checks are role-prefixed. That operator allowlist only satisfies operator requests; non-operator roles still need scopes under their own role prefix. -- 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. +- Mobile pairing fails closed for Tailscale/public `ws://` gateway URLs. Private LAN addresses and `.local` Bonjour hosts remain supported over `ws://`, but Tailscale/public mobile routes should use Tailscale Serve/Funnel or a `wss://` gateway URL. - With `--remote`, OpenClaw requires either `gateway.remote.url` or `gateway.tailscale.mode=serve|funnel`. - 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. diff --git a/extensions/device-pair/index.test.ts b/extensions/device-pair/index.test.ts index 008b47a103b..73c427b26f8 100644 --- a/extensions/device-pair/index.test.ts +++ b/extensions/device-pair/index.test.ts @@ -742,7 +742,7 @@ describe("device-pair /pair default setup code", () => { expect(text).toContain("Gateway: ws://127.0.0.1:18789"); }); - it("rejects private LAN cleartext setup urls before issuing setup codes", async () => { + it("allows private LAN cleartext setup urls", async () => { const command = registerPairCommand({ pluginConfig: { publicUrl: "ws://192.168.1.20:18789", @@ -757,10 +757,27 @@ describe("device-pair /pair default setup code", () => { }), ); - expect(pluginApiMocks.issueDeviceBootstrapToken).not.toHaveBeenCalled(); - expect(requireText(result)).toContain( - "Mobile pairing over non-loopback networks requires a secure gateway URL", + expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledTimes(1); + expect(requireText(result)).toContain("Gateway: ws://192.168.1.20:18789"); + }); + + it("allows mdns cleartext setup urls", async () => { + const command = registerPairCommand({ + pluginConfig: { + publicUrl: "ws://openclaw.local:18789", + }, + }); + const result = await command.handler( + createCommandContext({ + channel: "webchat", + args: "", + commandBody: "/pair", + gatewayClientScopes: ["operator.write", "operator.pairing"], + }), ); + + expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledTimes(1); + expect(requireText(result)).toContain("Gateway: ws://openclaw.local:18789"); }); it("rejects public cleartext setup urls before issuing setup codes", async () => { @@ -780,7 +797,7 @@ describe("device-pair /pair default setup code", () => { expect(pluginApiMocks.issueDeviceBootstrapToken).not.toHaveBeenCalled(); expect(requireText(result)).toContain( - "Mobile pairing over non-loopback networks requires a secure gateway URL", + "Tailscale and public mobile pairing require a secure gateway URL", ); }); diff --git a/extensions/device-pair/index.ts b/extensions/device-pair/index.ts index 967d8d27b5e..685322be50d 100644 --- a/extensions/device-pair/index.ts +++ b/extensions/device-pair/index.ts @@ -175,11 +175,11 @@ function parseNormalizedGatewayUrl(raw: string): string | null { function describeSecureMobilePairingFix(source?: string): string { const sourceNote = source ? ` Resolved source: ${source}.` : ""; return ( - "Mobile pairing over non-loopback networks requires a secure 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 " + + " Fix: use a private LAN address, prefer gateway.tailscale.mode=serve, or set " + "gateway.remote.url / plugins.entries.device-pair.config.publicUrl to a wss:// URL. " + - "ws:// setup codes are only valid for localhost/loopback or the Android emulator." + "ws:// setup codes are only valid for localhost/loopback, private LAN addresses, .local hosts, or the Android emulator." ); } @@ -256,6 +256,21 @@ function isPrivateIPv4(address: string): boolean { return false; } +function isPrivateLanCleartextHost(host: string): boolean { + const normalized = normalizeHostForIpCheck(host); + if (normalized.endsWith(".local")) { + return true; + } + if (isPrivateIPv4(normalized)) { + return true; + } + const octets = parseIPv4Octets(normalized); + if (!octets) { + return false; + } + return octets[0] === 169 && octets[1] === 254; +} + function isTailnetIPv4(address: string): boolean { const octets = parseIPv4Octets(address); if (!octets) { @@ -267,7 +282,9 @@ function isTailnetIPv4(address: string): boolean { function isMobilePairingCleartextAllowedHost(host: string): boolean { const normalized = normalizeHostForIpCheck(host); - return isLoopbackHost(normalized) || normalized === "10.0.2.2"; + return ( + isLoopbackHost(normalized) || normalized === "10.0.2.2" || isPrivateLanCleartextHost(normalized) + ); } function validateMobilePairingUrl(url: string, source?: string): string | null { diff --git a/src/gateway/client.test.ts b/src/gateway/client.test.ts index cf1fd9d4b78..98c96246f78 100644 --- a/src/gateway/client.test.ts +++ b/src/gateway/client.test.ts @@ -800,6 +800,26 @@ describe("GatewayClient connect auth payload", () => { client.stop(); }); + it("prefers explicit shared password over bootstrap token", () => { + const client = new GatewayClient({ + url: "ws://127.0.0.1:18789", + bootstrapToken: "stale-bootstrap-token", + password: "shared-password", // pragma: allowlist secret + }); + + client.start(); + const ws = getLatestWs(); + ws.emitOpen(); + emitConnectChallenge(ws); + + expect(connectFrameFrom(ws)).toMatchObject({ + password: "shared-password", // pragma: allowlist secret + }); + expect(connectFrameFrom(ws).bootstrapToken).toBeUndefined(); + expect(connectFrameFrom(ws).token).toBeUndefined(); + client.stop(); + }); + it("uses stored device token scopes when shared token is not provided", () => { loadDeviceAuthTokenMock.mockReturnValue({ token: "stored-device-token", diff --git a/src/gateway/client.ts b/src/gateway/client.ts index daa7b6421ac..f83291bff35 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -799,7 +799,9 @@ export class GatewayClient { // no explicit shared token is present. const authToken = explicitGatewayToken ?? resolvedDeviceToken; const authBootstrapToken = - !explicitGatewayToken && !resolvedDeviceToken ? explicitBootstrapToken : undefined; + !explicitGatewayToken && !resolvedDeviceToken && !authPassword + ? explicitBootstrapToken + : undefined; return { authToken, authBootstrapToken, diff --git a/src/pairing/setup-code.test.ts b/src/pairing/setup-code.test.ts index 50899103e60..c91ba350ea2 100644 --- a/src/pairing/setup-code.test.ts +++ b/src/pairing/setup-code.test.ts @@ -467,6 +467,21 @@ describe("pairing setup code", () => { urlSource: "gateway.bind=custom", }, }, + { + name: "allows mdns 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", + }, + }, { name: "allows lan ip cleartext setup urls", config: { @@ -502,17 +517,6 @@ describe("pairing setup code", () => { } satisfies ResolveSetupConfig, expectedError: "Tailscale and public mobile pairing require a secure gateway URL", }, - { - name: "rejects mdns hostname cleartext setup urls", - config: { - gateway: { - bind: "custom", - customBindHost: "gateway.local", - auth: { mode: "token", token: "tok_123" }, - }, - } satisfies ResolveSetupConfig, - expectedError: "private LAN IP address", - }, { name: "rejects tailnet bind remote ws setup urls for mobile pairing", config: { diff --git a/src/pairing/setup-code.ts b/src/pairing/setup-code.ts index 49d9d8101f2..44d90d71cd9 100644 --- a/src/pairing/setup-code.ts +++ b/src/pairing/setup-code.ts @@ -74,35 +74,57 @@ function describeSecureMobilePairingFix(source?: string): string { return ( "Tailscale and public mobile pairing require a secure gateway URL (wss://) or Tailscale Serve/Funnel." + sourceNote + - " Fix: use a private LAN IP address, prefer gateway.tailscale.mode=serve, or set " + + " Fix: use a private LAN 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 IP addresses, or the Android emulator." + "ws:// is only valid for localhost, private LAN addresses, .local hosts, or the Android emulator." ); } -function isPrivateLanIpHost(host: string): boolean { - if (isRfc1918Ipv4Address(host)) { +function normalizeMobilePairingHost(host: string): string { + let normalized = normalizeLowercaseStringOrEmpty(host); + if (normalized.startsWith("[") && normalized.endsWith("]")) { + normalized = normalized.slice(1, -1); + } + if (normalized.endsWith(".")) { + normalized = normalized.slice(0, -1); + } + const zoneIndex = normalized.indexOf("%"); + if (zoneIndex >= 0) { + normalized = normalized.slice(0, zoneIndex); + } + return normalized; +} + +function isPrivateLanHost(host: string): boolean { + const normalized = normalizeMobilePairingHost(host); + if (normalized.endsWith(".local")) { return true; } - const parsed = parseCanonicalIpAddress(host); + if (isRfc1918Ipv4Address(normalized)) { + return true; + } + const parsed = parseCanonicalIpAddress(normalized); if (!parsed) { return false; } if (isIpv4Address(parsed)) { - const normalized = parsed.toString(); - return normalized.startsWith("169.254.") && !isCarrierGradeNatIpv4Address(normalized); + const normalizedIp = parsed.toString(); + return normalizedIp.startsWith("169.254.") && !isCarrierGradeNatIpv4Address(normalizedIp); } if (!isIpv6Address(parsed)) { return false; } - const normalized = normalizeLowercaseStringOrEmpty(parsed.toString()); + const normalizedIp = normalizeLowercaseStringOrEmpty(parsed.toString()); return ( - normalized.startsWith("fe80:") || normalized.startsWith("fc") || normalized.startsWith("fd") + normalizedIp.startsWith("fe80:") || + normalizedIp.startsWith("fc") || + normalizedIp.startsWith("fd") ); } function isMobilePairingCleartextAllowedHost(host: string): boolean { - return isLoopbackHost(host) || host === "10.0.2.2" || isPrivateLanIpHost(host); + const normalized = normalizeMobilePairingHost(host); + return isLoopbackHost(normalized) || normalized === "10.0.2.2" || isPrivateLanHost(normalized); } function validateMobilePairingUrl(url: string, source?: string): string | null {