Skip to content

Commit 36df0d9

Browse files
authored
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.
1 parent ae7c13e commit 36df0d9

17 files changed

Lines changed: 277 additions & 98 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ Docs: https://docs.openclaw.ai
102102
### Fixes
103103

104104
- 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`.
105+
- 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.
105106
- 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.
106107
- 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.
107108
- 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.

apps/ios/Sources/Gateway/GatewayConnectionController.swift

Lines changed: 1 addition & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -689,7 +689,7 @@ final class GatewayConnectionController {
689689
}
690690

691691
private func shouldRequireTLS(host: String) -> Bool {
692-
!Self.isLoopbackHost(host)
692+
!LoopbackHost.isLocalNetworkHost(host)
693693
}
694694

695695
private func shouldForceTLS(host: String) -> Bool {
@@ -698,51 +698,6 @@ final class GatewayConnectionController {
698698
return trimmed.hasSuffix(".ts.net") || trimmed.hasSuffix(".ts.net.")
699699
}
700700

701-
private static func isLoopbackHost(_ rawHost: String) -> Bool {
702-
var host = rawHost.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
703-
guard !host.isEmpty else { return false }
704-
705-
if host.hasPrefix("[") && host.hasSuffix("]") {
706-
host.removeFirst()
707-
host.removeLast()
708-
}
709-
if host.hasSuffix(".") {
710-
host.removeLast()
711-
}
712-
if let zoneIndex = host.firstIndex(of: "%") {
713-
host = String(host[..<zoneIndex])
714-
}
715-
if host.isEmpty { return false }
716-
717-
if host == "localhost" || host == "0.0.0.0" || host == "::" {
718-
return true
719-
}
720-
return Self.isLoopbackIPv4(host) || Self.isLoopbackIPv6(host)
721-
}
722-
723-
private static func isLoopbackIPv4(_ host: String) -> Bool {
724-
var addr = in_addr()
725-
let parsed = host.withCString { inet_pton(AF_INET, $0, &addr) == 1 }
726-
guard parsed else { return false }
727-
let value = UInt32(bigEndian: addr.s_addr)
728-
let firstOctet = UInt8((value >> 24) & 0xFF)
729-
return firstOctet == 127
730-
}
731-
732-
private static func isLoopbackIPv6(_ host: String) -> Bool {
733-
var addr = in6_addr()
734-
let parsed = host.withCString { inet_pton(AF_INET6, $0, &addr) == 1 }
735-
guard parsed else { return false }
736-
return withUnsafeBytes(of: &addr) { rawBytes in
737-
let bytes = rawBytes.bindMemory(to: UInt8.self)
738-
let isV6Loopback = bytes[0..<15].allSatisfy { $0 == 0 } && bytes[15] == 1
739-
if isV6Loopback { return true }
740-
741-
let isMappedV4 = bytes[0..<10].allSatisfy { $0 == 0 } && bytes[10] == 0xFF && bytes[11] == 0xFF
742-
return isMappedV4 && bytes[12] == 127
743-
}
744-
}
745-
746701
private func manualStableID(host: String, port: Int) -> String {
747702
"manual|\(host.lowercased())|\(port)"
748703
}

apps/ios/Tests/DeepLinkParserTests.swift

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,20 @@ private func agentAction(
101101
#expect(DeepLinkParser.parse(url) == nil)
102102
}
103103

104+
@Test func parseGatewayLinkAllowsPrivateLanWs() {
105+
let url = URL(
106+
string: "openclaw://gateway?host=openclaw.local&port=18789&tls=0&token=abc")!
107+
#expect(
108+
DeepLinkParser.parse(url) == .gateway(
109+
.init(
110+
host: "openclaw.local",
111+
port: 18789,
112+
tls: false,
113+
bootstrapToken: nil,
114+
token: "abc",
115+
password: nil)))
116+
}
117+
104118
@Test func parseGatewayLinkRejectsInsecurePrefixBypassHost() {
105119
let url = URL(
106120
string: "openclaw://gateway?host=127.attacker.example&port=18789&tls=0&token=abc")!
@@ -162,6 +176,25 @@ private func agentAction(
162176
password: nil))
163177
}
164178

179+
@Test func parseGatewaySetupCodeAllowsPrivateLanWs() {
180+
let payload = #"{"url":"ws://openclaw.local:18789","bootstrapToken":"tok"}"#
181+
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
182+
183+
#expect(link == .init(
184+
host: "openclaw.local",
185+
port: 18789,
186+
tls: false,
187+
bootstrapToken: "tok",
188+
token: nil,
189+
password: nil))
190+
}
191+
192+
@Test func parseGatewaySetupCodeRejectsTailnetPlaintextWs() {
193+
let payload = #"{"url":"ws://gateway.tailnet.ts.net:18789","bootstrapToken":"tok"}"#
194+
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
195+
#expect(link == nil)
196+
}
197+
165198
@Test func parseGatewaySetupInputParsesFullCopiedSetupMessage() {
166199
let payload = #"{"url":"wss://gateway.example.com","bootstrapToken":"tok"}"#
167200
let link = GatewayConnectDeepLink.fromSetupInput("""

apps/ios/Tests/GatewayConnectionSecurityTests.swift

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,9 @@ import Testing
107107
let controller = makeController()
108108

109109
#expect(controller._test_resolveManualUseTLS(host: "gateway.example.com", useTLS: false) == true)
110-
#expect(controller._test_resolveManualUseTLS(host: "openclaw.local", useTLS: false) == true)
111110
#expect(controller._test_resolveManualUseTLS(host: "127.attacker.example", useTLS: false) == true)
111+
#expect(controller._test_resolveManualUseTLS(host: "gateway.ts.net", useTLS: false) == true)
112+
#expect(controller._test_resolveManualUseTLS(host: "100.64.0.9", useTLS: false) == true)
112113

113114
#expect(controller._test_resolveManualUseTLS(host: "localhost", useTLS: false) == false)
114115
#expect(controller._test_resolveManualUseTLS(host: "127.0.0.1", useTLS: false) == false)
@@ -118,6 +119,17 @@ import Testing
118119
#expect(controller._test_resolveManualUseTLS(host: "0.0.0.0", useTLS: false) == false)
119120
}
120121

122+
@Test @MainActor func manualConnectionsAllowPrivateLanPlaintext() async {
123+
let controller = makeController()
124+
125+
#expect(controller._test_resolveManualUseTLS(host: "openclaw.local", useTLS: false) == false)
126+
#expect(controller._test_resolveManualUseTLS(host: "192.168.1.20", useTLS: false) == false)
127+
#expect(controller._test_resolveManualUseTLS(host: "10.0.0.5", useTLS: false) == false)
128+
#expect(controller._test_resolveManualUseTLS(host: "172.16.1.5", useTLS: false) == false)
129+
#expect(controller._test_resolveManualUseTLS(host: "169.254.1.5", useTLS: false) == false)
130+
#expect(controller._test_resolveManualUseTLS(host: "fd00::1", useTLS: false) == false)
131+
}
132+
121133
@Test @MainActor func manualDefaultPortUses443OnlyForTailnetTLSHosts() async {
122134
let controller = makeController()
123135

apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
116116
return nil
117117
}
118118
let tls = payload.tls ?? true
119-
if !tls, !LoopbackHost.isLoopbackHost(host) {
119+
if !tls, !LoopbackHost.isLocalNetworkHost(host) {
120120
return nil
121121
}
122122
return GatewayConnectDeepLink(
@@ -143,7 +143,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
143143
return nil
144144
}
145145
let tls = scheme == "wss" || scheme == "https"
146-
if !tls, !LoopbackHost.isLoopbackHost(hostname) {
146+
if !tls, !LoopbackHost.isLocalNetworkHost(hostname) {
147147
return nil
148148
}
149149
return GatewayConnectDeepLink(
@@ -254,7 +254,7 @@ public enum DeepLinkParser {
254254
}
255255
let port = query["port"].flatMap { Int($0) } ?? 18789
256256
let tls = (query["tls"] as NSString?)?.boolValue ?? false
257-
if !tls, !LoopbackHost.isLoopbackHost(hostParam) {
257+
if !tls, !LoopbackHost.isLocalNetworkHost(hostParam) {
258258
return nil
259259
}
260260
return .gateway(

apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -522,7 +522,8 @@ public actor GatewayChannelActor {
522522
(includeDeviceIdentity && explicitPassword == nil && explicitBootstrapToken == nil
523523
? storedToken
524524
: nil)
525-
let authBootstrapToken = authToken == nil ? explicitBootstrapToken : nil
525+
let authBootstrapToken =
526+
authToken == nil && explicitPassword == nil ? explicitBootstrapToken : nil
526527
let authDeviceToken = shouldUseDeviceRetryToken ? storedToken : nil
527528
let authSource: GatewayAuthSource = if authDeviceToken != nil || (explicitToken == nil && authToken != nil) {
528529
.deviceToken

apps/shared/OpenClawKit/Sources/OpenClawKit/LoopbackHost.swift

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,16 +41,32 @@ public enum LoopbackHost {
4141
}
4242

4343
public static func isLocalNetworkHost(_ rawHost: String) -> Bool {
44-
let host = rawHost.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
44+
let host = self.normalizedHost(rawHost)
4545
guard !host.isEmpty else { return false }
4646
if self.isLoopbackHost(host) { return true }
4747
if host.hasSuffix(".local") { return true }
48-
if host.hasSuffix(".ts.net") { return true }
49-
if host.hasSuffix(".tailscale.net") { return true }
50-
// Allow MagicDNS / LAN hostnames like "peters-mac-studio-1".
51-
if !host.contains("."), !host.contains(":") { return true }
52-
guard let ipv4 = self.parseIPv4(host) else { return false }
53-
return self.isLocalNetworkIPv4(ipv4)
48+
if let ipv4 = self.parseIPv4(host) {
49+
return self.isLocalNetworkIPv4(ipv4)
50+
}
51+
guard let ipv6 = IPv6Address(host) else { return false }
52+
let bytes = Array(ipv6.rawValue)
53+
let isUniqueLocal = (bytes[0] & 0xFE) == 0xFC
54+
let isLinkLocal = bytes[0] == 0xFE && (bytes[1] & 0xC0) == 0x80
55+
return isUniqueLocal || isLinkLocal
56+
}
57+
58+
static func normalizedHost(_ rawHost: String) -> String {
59+
var host = rawHost
60+
.trimmingCharacters(in: .whitespacesAndNewlines)
61+
.lowercased()
62+
.trimmingCharacters(in: CharacterSet(charactersIn: "[]"))
63+
if host.hasSuffix(".") {
64+
host.removeLast()
65+
}
66+
if let zoneIndex = host.firstIndex(of: "%") {
67+
host = String(host[..<zoneIndex])
68+
}
69+
return host
5470
}
5571

5672
static func parseIPv4(_ host: String) -> (UInt8, UInt8, UInt8, UInt8)? {
@@ -73,8 +89,6 @@ public enum LoopbackHost {
7389
if a == 127 { return true }
7490
// 169.254.0.0/16 (link-local)
7591
if a == 169, b == 254 { return true }
76-
// Tailscale: 100.64.0.0/10
77-
if a == 100, (64...127).contains(Int(b)) { return true }
7892
return false
7993
}
8094
}

apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,40 @@ private func setupCode(from payload: String) -> String {
5959
password: nil))
6060
}
6161

62+
@Test func setupCodeAllowsPrivateLanWs() {
63+
let payload = #"{"url":"ws://192.168.1.20:18789","bootstrapToken":"tok"}"#
64+
#expect(
65+
GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) == .init(
66+
host: "192.168.1.20",
67+
port: 18789,
68+
tls: false,
69+
bootstrapToken: "tok",
70+
token: nil,
71+
password: nil))
72+
}
73+
74+
@Test func setupCodeAllowsMDNSWs() {
75+
let payload = #"{"url":"ws://openclaw.local:18789","bootstrapToken":"tok"}"#
76+
#expect(
77+
GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) == .init(
78+
host: "openclaw.local",
79+
port: 18789,
80+
tls: false,
81+
bootstrapToken: "tok",
82+
token: nil,
83+
password: nil))
84+
}
85+
86+
@Test func setupCodeRejectsTailnetPlaintextWs() {
87+
let payload = #"{"url":"ws://gateway.tailnet.ts.net:18789","bootstrapToken":"tok"}"#
88+
#expect(GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) == nil)
89+
}
90+
91+
@Test func setupCodeRejectsCgnatPlaintextWs() {
92+
let payload = #"{"url":"ws://100.64.0.9:18789","bootstrapToken":"tok"}"#
93+
#expect(GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) == nil)
94+
}
95+
6296
@Test func setupCodeParsesHostPayload() {
6397
let payload = #"{"host":"gateway.tailnet.ts.net","port":443,"tls":true,"bootstrapToken":"tok"}"#
6498
#expect(
@@ -88,6 +122,18 @@ private func setupCode(from payload: String) -> String {
88122
#expect(GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) == nil)
89123
}
90124

125+
@Test func setupCodeAllowsPrivateLanHostPayload() {
126+
let payload = #"{"host":"openclaw.local","port":18789,"tls":false,"bootstrapToken":"tok"}"#
127+
#expect(
128+
GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) == .init(
129+
host: "openclaw.local",
130+
port: 18789,
131+
tls: false,
132+
bootstrapToken: "tok",
133+
token: nil,
134+
password: nil))
135+
}
136+
91137
@Test func setupInputParsesFullCopiedSetupMessage() {
92138
let payload = #"{"url":"wss://gateway.tailnet.ts.net","bootstrapToken":"tok"}"#
93139
let message = """

apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,42 @@ struct GatewayNodeSessionTests {
249249
await gateway.disconnect()
250250
}
251251

252+
@Test
253+
func passwordTakesPrecedenceOverBootstrapToken() async throws {
254+
let session = FakeGatewayWebSocketSession()
255+
let gateway = GatewayNodeSession()
256+
let options = GatewayConnectOptions(
257+
role: "operator",
258+
scopes: ["operator.read"],
259+
caps: [],
260+
commands: [],
261+
permissions: [:],
262+
clientId: "openclaw-ios-test",
263+
clientMode: "ui",
264+
clientDisplayName: "iOS Test",
265+
includeDeviceIdentity: false)
266+
267+
try await gateway.connect(
268+
url: URL(string: "ws://example.invalid")!,
269+
token: nil,
270+
bootstrapToken: "stale-bootstrap-token",
271+
password: "shared-password",
272+
connectOptions: options,
273+
sessionBox: WebSocketSessionBox(session: session),
274+
onConnected: {},
275+
onDisconnected: { _ in },
276+
onInvoke: { req in
277+
BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
278+
})
279+
280+
let auth = try #require(session.latestTask()?.latestConnectAuth())
281+
#expect(auth["password"] as? String == "shared-password")
282+
#expect(auth["bootstrapToken"] == nil)
283+
#expect(auth["token"] == nil)
284+
285+
await gateway.disconnect()
286+
}
287+
252288
@Test
253289
func bootstrapHelloStoresAdditionalDeviceTokens() async throws {
254290
let tempDir = FileManager.default.temporaryDirectory

docs/channels/pairing.md

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -134,12 +134,11 @@ That bootstrap token carries the built-in pairing bootstrap profile:
134134

135135
Treat the setup code like a password while it is valid.
136136

137-
For Tailscale, public, or other non-loopback mobile pairing, use Tailscale
138-
Serve/Funnel or another `wss://` Gateway URL. Direct non-loopback `ws://` setup
139-
URLs are rejected before QR/setup-code issuance. Plaintext `ws://` setup codes
140-
are limited to loopback URLs; private-network `ws://` clients still require the explicit
141-
`OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` break-glass described in the remote
142-
Gateway guide.
137+
For Tailscale, public, or other remote mobile pairing, use Tailscale Serve/Funnel
138+
or another `wss://` Gateway URL. Plaintext `ws://` setup codes are accepted only
139+
for loopback, private LAN addresses, `.local` Bonjour hosts, and the Android
140+
emulator host. Tailnet CGNAT addresses, `.ts.net` names, and public hosts still
141+
fail closed before QR/setup-code issuance.
143142

144143
### Approve a node device
145144

0 commit comments

Comments
 (0)