diff --git a/apps/ios/WatchApp/Sources/WatchGatewayConfiguration.swift b/apps/ios/WatchApp/Sources/WatchGatewayConfiguration.swift index 6d859272902b..5ecb7230ffbe 100644 --- a/apps/ios/WatchApp/Sources/WatchGatewayConfiguration.swift +++ b/apps/ios/WatchApp/Sources/WatchGatewayConfiguration.swift @@ -67,12 +67,10 @@ struct WatchGatewayConfiguration: Codable { } static func httpBaseURL(for link: GatewayConnectDeepLink) -> URL? { - guard link.tls else { return nil } - var components = URLComponents() + guard link.tls, let websocketURL = link.websocketURL, + var components = URLComponents(url: websocketURL, resolvingAgainstBaseURL: false) + else { return nil } components.scheme = "https" - components.host = link.host - components.port = link.port - components.percentEncodedPath = link.contextPath ?? "" return components.url } } diff --git a/apps/ios/WatchTests/WatchGatewayConfigurationTests.swift b/apps/ios/WatchTests/WatchGatewayConfigurationTests.swift index f8187660b57f..d04b807b4e74 100644 --- a/apps/ios/WatchTests/WatchGatewayConfigurationTests.swift +++ b/apps/ios/WatchTests/WatchGatewayConfigurationTests.swift @@ -4,9 +4,22 @@ import Testing @testable import OpenClawWatchApp struct WatchGatewayConfigurationTests { - @Test func `direct Watch setup retains the endpoint prefix across credential handoff`() throws { + @Test(arguments: [ + ("watch.example.invalid", "watch.example.invalid"), + ("127.0.0.1", "127.0.0.1"), + ("::1", "[::1]"), + ("[::1]", "[::1]"), + ("fd00::1", "[fd00::1]"), + ("[fd00::1]", "[fd00::1]"), + ("fe80::1%en0", "[fe80::1%25en0]"), + ("[fe80::1%en0]", "[fe80::1%25en0]"), + ]) + func `direct Watch setup retains the endpoint prefix across credential handoff`( + host: String, + authority: String) throws + { let link = GatewayConnectDeepLink( - host: "watch.example.invalid", + host: host, port: 443, tls: true, contextPath: "/gateways/team%2Fwatch", @@ -16,12 +29,13 @@ struct WatchGatewayConfigurationTests { let configuration = try #require(WatchGatewayConfiguration(setupLink: link, sentAtMs: 1)) let paired = configuration.withoutBootstrapToken() + #expect(configuration.link.host == host) #expect(configuration.link.contextPath == "/gateways/team%2Fwatch") - #expect(configuration.gatewayID == "watch-direct:https://watch.example.invalid:443/gateways/team%2Fwatch") + #expect(configuration.gatewayID == "watch-direct:https://" + host.lowercased() + ":443/gateways/team%2Fwatch") #expect(WatchGatewayConfiguration.httpBaseURL(for: paired.link)?.absoluteString - == "https://watch.example.invalid:443/gateways/team%2Fwatch") + == "https://" + authority + ":443/gateways/team%2Fwatch") #expect(paired.voiceConnection.websocketURLs.first?.absoluteString - == "wss://watch.example.invalid:443/gateways/team%2Fwatch") + == "wss://" + authority + ":443/gateways/team%2Fwatch") #expect(paired.link.bootstrapToken == nil) let restored = try JSONDecoder().decode( @@ -33,6 +47,7 @@ struct WatchGatewayConfigurationTests { @Test func `direct Watch setup selects a secure endpoint without changing its namespace`() throws { let link = try #require(GatewayConnectDeepLink.fromSetupCode( #"{"url":"ws://192.168.1.2:18789","urls":["wss://watch.example.invalid/team-a","wss://backup.example.invalid/team-b"],"bootstrapToken":"one-time-setup"}"#)) + #expect(WatchGatewayConfiguration.httpBaseURL(for: link) == nil) let configuration = try #require(WatchGatewayConfiguration(setupLink: link, sentAtMs: 2)) #expect(configuration.link.contextPath == "/team-a") #expect(configuration.voiceConnection.websocketURLs.map(\.absoluteString) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift index 7e4b37eeaeae..9bdd3f6fc653 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift @@ -476,7 +476,7 @@ public struct GatewayConnectEndpoint: Codable, Sendable, Equatable { guard (1...65535).contains(self.port) else { return nil } var components = URLComponents() components.scheme = self.tls ? "wss" : "ws" - components.host = self.host + components.host = self.host.contains(":") && !self.host.hasPrefix("[") ? "[" + self.host + "]" : self.host components.port = self.port components.percentEncodedPath = self.contextPath ?? "" return components.url diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift index ec61d1da5275..b9a2571d4855 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift @@ -19,6 +19,27 @@ private func gatewayLink(from raw: String) -> GatewayConnectDeepLink? { } @Suite struct DeepLinksSecurityTests { + @Test(arguments: [ + ("::1", false, "ws://[::1]:18789"), + ("fd00::1", false, "ws://[fd00::1]:18789"), + ("fd00::1", true, "wss://[fd00::1]:18789"), + ("[::1]", false, "ws://[::1]:18789"), + ("[fd00::1]", true, "wss://[fd00::1]:18789"), + ("fe80::1%en0", false, "ws://[fe80::1%25en0]:18789"), + ("[fe80::1%en0]", false, "ws://[fe80::1%25en0]:18789"), + ("127.0.0.1", false, "ws://127.0.0.1:18789"), + ("gateway.example", true, "wss://gateway.example:18789"), + ]) + func gatewayEndpointFormatsRawAndBracketedHosts(host: String, tls: Bool, expectedURL: String) { + let contextPaths: [String?] = [nil, "/gateways/team%2Fwatch"] + for contextPath in contextPaths { + let endpoint = GatewayConnectEndpoint(host: host, port: 18789, tls: tls, contextPath: contextPath) + + #expect(endpoint.host == host) + #expect(endpoint.websocketURL?.absoluteString == expectedURL + (contextPath ?? "")) + } + } + @Test func setupResultInitializerDefaultsOptionalFields() { let result = DevicePairSetupCodeResult( setupid: "setup-1", @@ -412,6 +433,26 @@ private func gatewayLink(from raw: String) -> GatewayConnectDeepLink? { password: nil)) } + @Test(arguments: [ + ("::1", false, "ws://[::1]:18789"), + ("fd00::1", false, "ws://[fd00::1]:18789"), + ("fd00::1", true, "wss://[fd00::1]:18789"), + ("[fd00::1]", false, "ws://[fd00::1]:18789"), + ("fe80::1%en0", false, "ws://[fe80::1%25en0]:18789"), + ("2001:db8::1", true, "wss://[2001:db8::1]:18789"), + ]) + func setupCodeParsesIPv6HostPayload(host: String, tls: Bool, expectedURL: String) { + let payload = #"{"host":"\#(host)","port":18789,"tls":\#(tls),"bootstrapToken":"tok"}"# + + for input in [payload, setupCode(from: payload)] { + let link = GatewayConnectDeepLink.fromSetupInput(input) + + #expect(link?.host == host) + #expect(link?.bootstrapToken == "tok") + #expect(link?.websocketURL?.absoluteString == expectedURL) + } + } + @Test func setupCodeParsesHostPayloadWithTLSDefaultPort() { let payload = #"{"host":"gateway.tailnet.ts.net","tls":true,"bootstrapToken":"tok"}"# #expect( @@ -424,8 +465,9 @@ private func gatewayLink(from raw: String) -> GatewayConnectDeepLink? { password: nil)) } - @Test func setupCodeRejectsInsecureHostPayload() { - let payload = #"{"host":"gateway.tailnet.ts.net","port":18789,"tls":false,"bootstrapToken":"tok"}"# + @Test(arguments: ["gateway.tailnet.ts.net", "2001:db8::1", "[2001:db8::1]"]) + func setupCodeRejectsInsecureHostPayload(host: String) { + let payload = #"{"host":"\#(host)","port":18789,"tls":false,"bootstrapToken":"tok"}"# #expect(GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) == nil) } diff --git a/docs/platforms/ios.md b/docs/platforms/ios.md index a71dbc6d596a..2b82355d7b80 100644 --- a/docs/platforms/ios.md +++ b/docs/platforms/ios.md @@ -727,6 +727,10 @@ If mDNS is blocked, use a unicast DNS-SD zone (choose a domain; example: `opencl Open **Settings → Gateway**, enable **Use Manual Gateway**, and enter the Gateway host + port (default `18789`). +IPv6 host fields accept raw addresses such as `fd00::1` and bracketed addresses +such as `[fd00::1]`. Full Gateway URLs require brackets around the address, +for example `wss://[fd00::1]:18789`. + ## Multiple Gateways The app keeps a registry of every Gateway it has paired with, so you can switch between them without pairing again: