diff --git a/apps/macos/Sources/OpenClaw/GatewayDiscoveryHelpers.swift b/apps/macos/Sources/OpenClaw/GatewayDiscoveryHelpers.swift index 281dcb9e8bd..3c7b8abdb69 100644 --- a/apps/macos/Sources/OpenClaw/GatewayDiscoveryHelpers.swift +++ b/apps/macos/Sources/OpenClaw/GatewayDiscoveryHelpers.swift @@ -2,11 +2,25 @@ import Foundation import OpenClawDiscovery enum GatewayDiscoveryHelpers { + static func serviceEndpoint( + for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> (host: String, port: Int)? + { + self.serviceEndpoint(serviceHost: gateway.serviceHost, servicePort: gateway.servicePort) + } + + static func serviceEndpoint( + serviceHost: String?, + servicePort: Int?) -> (host: String, port: Int)? + { + guard let host = self.trimmed(serviceHost), !host.isEmpty else { return nil } + guard let port = servicePort, port > 0, port <= 65535 else { return nil } + return (host, port) + } + static func sshTarget(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? { - let host = self.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost - guard let host = self.trimmed(host), !host.isEmpty else { return nil } + guard let endpoint = self.serviceEndpoint(for: gateway) else { return nil } let user = NSUserName() - var target = "\(user)@\(host)" + var target = "\(user)@\(endpoint.host)" if gateway.sshPort != 22 { target += ":\(gateway.sshPort)" } @@ -16,39 +30,21 @@ enum GatewayDiscoveryHelpers { static func directUrl(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? { self.directGatewayUrl( serviceHost: gateway.serviceHost, - servicePort: gateway.servicePort, - lanHost: gateway.lanHost, - gatewayPort: gateway.gatewayPort) + servicePort: gateway.servicePort) } static func directGatewayUrl( serviceHost: String?, - servicePort: Int?, - lanHost: String?, - gatewayPort: Int?) -> String? + servicePort: Int?) -> String? { // Security: do not route using unauthenticated TXT hints (tailnetDns/lanHost/gatewayPort). // Prefer the resolved service endpoint (SRV + A/AAAA). - if let host = self.trimmed(serviceHost), !host.isEmpty, - let port = servicePort, port > 0 - { - let scheme = port == 443 ? "wss" : "ws" - let portSuffix = port == 443 ? "" : ":\(port)" - return "\(scheme)://\(host)\(portSuffix)" - } - - // Legacy fallback (best-effort): keep existing behavior when we couldn't resolve SRV. - guard let lanHost = self.trimmed(lanHost), !lanHost.isEmpty else { return nil } - let port = gatewayPort ?? 18789 - return "ws://\(lanHost):\(port)" - } - - static func sanitizedTailnetHost(_ host: String?) -> String? { - guard let host = self.trimmed(host), !host.isEmpty else { return nil } - if host.hasSuffix(".internal.") || host.hasSuffix(".internal") { + guard let endpoint = self.serviceEndpoint(serviceHost: serviceHost, servicePort: servicePort) else { return nil } - return host + let scheme = endpoint.port == 443 ? "wss" : "ws" + let portSuffix = endpoint.port == 443 ? "" : ":\(endpoint.port)" + return "\(scheme)://\(endpoint.host)\(portSuffix)" } private static func trimmed(_ value: String?) -> String? { diff --git a/apps/macos/Sources/OpenClaw/GeneralSettings.swift b/apps/macos/Sources/OpenClaw/GeneralSettings.swift index d55f7c1b015..b97c5a7a512 100644 --- a/apps/macos/Sources/OpenClaw/GeneralSettings.swift +++ b/apps/macos/Sources/OpenClaw/GeneralSettings.swift @@ -675,22 +675,17 @@ extension GeneralSettings { private func applyDiscoveredGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) { MacNodeModeCoordinator.shared.setPreferredGatewayStableID(gateway.stableID) - let host = gateway.tailnetDns ?? gateway.lanHost - guard let host else { return } - let user = NSUserName() if self.state.remoteTransport == .direct { if let url = GatewayDiscoveryHelpers.directUrl(for: gateway) { self.state.remoteUrl = url } - } else { - self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget( - user: user, - host: host, - port: gateway.sshPort) - self.state.remoteCliPath = gateway.cliPath ?? "" + } else if let target = GatewayDiscoveryHelpers.sshTarget(for: gateway) { + self.state.remoteTarget = target + } + if let endpoint = GatewayDiscoveryHelpers.serviceEndpoint(for: gateway) { OpenClawConfigFile.setRemoteGatewayUrl( - host: gateway.serviceHost ?? host, - port: gateway.servicePort ?? gateway.gatewayPort) + host: endpoint.host, + port: endpoint.port) } } } diff --git a/apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift b/apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift index ee994b38f65..10598d7f4be 100644 --- a/apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift +++ b/apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift @@ -520,11 +520,12 @@ final class NodePairingApprovalPrompter { let preferred = GatewayDiscoveryPreferences.preferredStableID() let gateway = model.gateways.first { $0.stableID == preferred } ?? model.gateways.first guard let gateway else { return nil } - let host = (gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? - gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty) - guard let host, !host.isEmpty else { return nil } - let port = gateway.sshPort > 0 ? gateway.sshPort : 22 - return SSHTarget(host: host, port: port) + guard let target = GatewayDiscoveryHelpers.sshTarget(for: gateway), + let parsed = CommandResolver.parseSSHTarget(target) + else { + return nil + } + return SSHTarget(host: parsed.host, port: parsed.port) } private static func probeSSH(user: String, host: String, port: Int) async -> Bool { diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift index ba43424aa9a..2f822cb39fe 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift @@ -29,17 +29,14 @@ extension OnboardingView { if let url = GatewayDiscoveryHelpers.directUrl(for: gateway) { self.state.remoteUrl = url } - } else if let host = GatewayDiscoveryHelpers.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost { - let user = NSUserName() - self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget( - user: user, - host: host, - port: gateway.sshPort) - OpenClawConfigFile.setRemoteGatewayUrl( - host: gateway.serviceHost ?? host, - port: gateway.servicePort ?? gateway.gatewayPort) + } else if let target = GatewayDiscoveryHelpers.sshTarget(for: gateway) { + self.state.remoteTarget = target + } + if let endpoint = GatewayDiscoveryHelpers.serviceEndpoint(for: gateway) { + OpenClawConfigFile.setRemoteGatewayUrl( + host: endpoint.host, + port: endpoint.port) } - self.state.remoteCliPath = gateway.cliPath ?? "" self.state.connectionMode = .remote MacNodeModeCoordinator.shared.setPreferredGatewayStableID(gateway.stableID) diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift index 5760bfff8c2..5b05ab164c2 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift @@ -265,9 +265,11 @@ extension OnboardingView { if self.state.remoteTransport == .direct { return GatewayDiscoveryHelpers.directUrl(for: gateway) ?? "Gateway pairing only" } - if let host = GatewayDiscoveryHelpers.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost { - let portSuffix = gateway.sshPort != 22 ? " · ssh \(gateway.sshPort)" : "" - return "\(host)\(portSuffix)" + if let target = GatewayDiscoveryHelpers.sshTarget(for: gateway), + let parsed = CommandResolver.parseSSHTarget(target) + { + let portSuffix = parsed.port != 22 ? " · ssh \(parsed.port)" : "" + return "\(parsed.host)\(portSuffix)" } return "Gateway pairing only" } diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryHelpersTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryHelpersTests.swift new file mode 100644 index 00000000000..98c3d7b08ea --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryHelpersTests.swift @@ -0,0 +1,78 @@ +import Foundation +import OpenClawDiscovery +import Testing +@testable import OpenClaw + +@Suite +struct GatewayDiscoveryHelpersTests { + private func makeGateway( + serviceHost: String?, + servicePort: Int?, + lanHost: String? = "txt-host.local", + tailnetDns: String? = "txt-host.ts.net", + sshPort: Int = 22, + gatewayPort: Int? = 18789) -> GatewayDiscoveryModel.DiscoveredGateway + { + GatewayDiscoveryModel.DiscoveredGateway( + displayName: "Gateway", + serviceHost: serviceHost, + servicePort: servicePort, + lanHost: lanHost, + tailnetDns: tailnetDns, + sshPort: sshPort, + gatewayPort: gatewayPort, + cliPath: "/tmp/openclaw", + stableID: UUID().uuidString, + debugID: UUID().uuidString, + isLocal: false) + } + + @Test func sshTargetUsesResolvedServiceHostOnly() { + let gateway = self.makeGateway( + serviceHost: "resolved.example.ts.net", + servicePort: 18789, + sshPort: 2201) + + guard let target = GatewayDiscoveryHelpers.sshTarget(for: gateway) else { + Issue.record("expected ssh target") + return + } + let parsed = CommandResolver.parseSSHTarget(target) + #expect(parsed?.host == "resolved.example.ts.net") + #expect(parsed?.port == 2201) + } + + @Test func sshTargetRejectsTxtOnlyGateways() { + let gateway = self.makeGateway( + serviceHost: nil, + servicePort: nil, + lanHost: "txt-only.local", + tailnetDns: "txt-only.ts.net", + sshPort: 2222) + + #expect(GatewayDiscoveryHelpers.sshTarget(for: gateway) == nil) + } + + @Test func directUrlUsesResolvedServiceEndpointOnly() { + let tlsGateway = self.makeGateway( + serviceHost: "resolved.example.ts.net", + servicePort: 443) + #expect(GatewayDiscoveryHelpers.directUrl(for: tlsGateway) == "wss://resolved.example.ts.net") + + let wsGateway = self.makeGateway( + serviceHost: "resolved.example.ts.net", + servicePort: 18789) + #expect(GatewayDiscoveryHelpers.directUrl(for: wsGateway) == "ws://resolved.example.ts.net:18789") + } + + @Test func directUrlRejectsTxtOnlyFallback() { + let gateway = self.makeGateway( + serviceHost: nil, + servicePort: nil, + lanHost: "txt-only.local", + tailnetDns: "txt-only.ts.net", + gatewayPort: 22222) + + #expect(GatewayDiscoveryHelpers.directUrl(for: gateway) == nil) + } +}