fix(apple): accept raw IPv6 gateway setup addresses (#129416)

Bracket IPv6 authorities in the shared endpoint URL owner and derive Watch
HTTPS from its validated WebSocket URL. Preserve stored hosts, credential
namespaces, encoded paths, and TLS policy.

Prove raw/bracketed/scoped hosts through shared setup parsing and Watch
configuration regressions: base failures, then 47 shared and five Watch
test functions passing with the repair.
This commit is contained in:
Peter Steinberger 2026-10-21 11:48:10 -07:00 • committed by GitHub
parent 51ad689168
commit 2fe1956ea1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 72 additions and 13 deletions

View file

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

View file

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

View file

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

View file

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

View file

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