From ef83a07066d3a408ee1d13ae92d5f0dd396012e3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 13 Dec 2025 01:43:23 +0000 Subject: [PATCH] fix(macos): harden remote ssh tunnel --- .../Sources/Clawdis/RemoteTunnelManager.swift | 12 +- .../macos/Sources/Clawdis/WebChatTunnel.swift | 120 +++++++++++++++++- .../ClawdisIPCTests/WebChatTunnelTests.swift | 52 ++++++++ 3 files changed, 175 insertions(+), 9 deletions(-) create mode 100644 apps/macos/Tests/ClawdisIPCTests/WebChatTunnelTests.swift diff --git a/apps/macos/Sources/Clawdis/RemoteTunnelManager.swift b/apps/macos/Sources/Clawdis/RemoteTunnelManager.swift index 98e10d41b..4121ac009 100644 --- a/apps/macos/Sources/Clawdis/RemoteTunnelManager.swift +++ b/apps/macos/Sources/Clawdis/RemoteTunnelManager.swift @@ -6,13 +6,21 @@ actor RemoteTunnelManager { private var controlTunnel: WebChatTunnel? - func controlTunnelPortIfRunning() -> UInt16? { + func controlTunnelPortIfRunning() async -> UInt16? { if let tunnel = self.controlTunnel, tunnel.process.isRunning, let local = tunnel.localPort { return local } + // If a previous Clawdis run already has an SSH listener on the expected port (common after restarts), + // reuse it instead of spawning new ssh processes that immediately fail with "Address already in use". + let desiredPort = UInt16(GatewayEnvironment.gatewayPort()) + if let desc = await PortGuardian.shared.describe(port: Int(desiredPort)), + desc.command.lowercased().contains("ssh") + { + return desiredPort + } return nil } @@ -27,7 +35,7 @@ actor RemoteTunnelManager { userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"]) } - if let local = self.controlTunnelPortIfRunning() { return local } + if let local = await self.controlTunnelPortIfRunning() { return local } let desiredPort = UInt16(GatewayEnvironment.gatewayPort()) let tunnel = try await WebChatTunnel.create( diff --git a/apps/macos/Sources/Clawdis/WebChatTunnel.swift b/apps/macos/Sources/Clawdis/WebChatTunnel.swift index 1a48313ae..b675b69eb 100644 --- a/apps/macos/Sources/Clawdis/WebChatTunnel.swift +++ b/apps/macos/Sources/Clawdis/WebChatTunnel.swift @@ -1,6 +1,9 @@ import Foundation import Network import OSLog +#if canImport(Darwin) +import Darwin +#endif /// Port forwarding tunnel for remote mode. /// @@ -10,19 +13,23 @@ final class WebChatTunnel { let process: Process let localPort: UInt16? + private let stderrHandle: FileHandle? - private init(process: Process, localPort: UInt16?) { + private init(process: Process, localPort: UInt16?, stderrHandle: FileHandle?) { self.process = process self.localPort = localPort + self.stderrHandle = stderrHandle } deinit { + Self.cleanupStderr(self.stderrHandle) let pid = self.process.processIdentifier self.process.terminate() Task { await PortGuardian.shared.removeRecord(pid: pid) } } func terminate() { + Self.cleanupStderr(self.stderrHandle) let pid = self.process.processIdentifier if self.process.isRunning { self.process.terminate() @@ -63,19 +70,36 @@ final class WebChatTunnel { let pipe = Pipe() process.standardError = pipe + let stderrHandle = pipe.fileHandleForReading // Consume stderr so ssh cannot block if it logs. - pipe.fileHandleForReading.readabilityHandler = { handle in + stderrHandle.readabilityHandler = { handle in let data = handle.availableData - guard !data.isEmpty, - let line = String(data: data, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines), - !line.isEmpty else { return } + guard !data.isEmpty else { + // EOF: stop monitoring to avoid spinning on a closed pipe. + Self.cleanupStderr(handle) + return + } + guard let line = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !line.isEmpty + else { return } Self.logger.error("ssh tunnel stderr: \(line, privacy: .public)") } + process.terminationHandler = { _ in + Self.cleanupStderr(stderrHandle) + } try process.run() + // If ssh exits immediately (e.g. local port already in use), surface stderr and ensure we stop monitoring. + try? await Task.sleep(nanoseconds: 150_000_000) // 150ms + if !process.isRunning { + let stderr = Self.drainStderr(stderrHandle) + let msg = stderr.isEmpty ? "ssh tunnel exited immediately" : "ssh tunnel failed: \(stderr)" + throw NSError(domain: "WebChatTunnel", code: 4, userInfo: [NSLocalizedDescriptionKey: msg]) + } + // Track tunnel so we can clean up stale listeners on restart. Task { await PortGuardian.shared.record( @@ -85,7 +109,7 @@ final class WebChatTunnel { mode: CommandResolver.connectionSettings().mode) } - return WebChatTunnel(process: process, localPort: localPort) + return WebChatTunnel(process: process, localPort: localPort, stderrHandle: stderrHandle) } private static func findPort(preferred: UInt16?) async throws -> UInt16 { @@ -120,6 +144,11 @@ final class WebChatTunnel { } private static func portIsFree(_ port: UInt16) -> Bool { + #if canImport(Darwin) + // NWListener can succeed even when only one address family is held. Mirror what ssh needs by checking + // both 127.0.0.1 and ::1 for availability. + return self.canBindIPv4(port) && self.canBindIPv6(port) + #else do { let listener = try NWListener(using: .tcp, on: NWEndpoint.Port(rawValue: port)!) listener.cancel() @@ -127,5 +156,82 @@ final class WebChatTunnel { } catch { return false } + #endif } + + #if canImport(Darwin) + private static func canBindIPv4(_ port: UInt16) -> Bool { + let fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { return false } + defer { _ = Darwin.close(fd) } + + var one: Int32 = 1 + _ = setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, socklen_t(MemoryLayout.size(ofValue: one))) + + var addr = sockaddr_in() + addr.sin_len = UInt8(MemoryLayout.size) + addr.sin_family = sa_family_t(AF_INET) + addr.sin_port = port.bigEndian + addr.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) + + let result = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in + Darwin.bind(fd, sa, socklen_t(MemoryLayout.size)) + } + } + return result == 0 + } + + private static func canBindIPv6(_ port: UInt16) -> Bool { + let fd = socket(AF_INET6, SOCK_STREAM, 0) + guard fd >= 0 else { return false } + defer { _ = Darwin.close(fd) } + + var one: Int32 = 1 + _ = setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, socklen_t(MemoryLayout.size(ofValue: one))) + + var addr = sockaddr_in6() + addr.sin6_len = UInt8(MemoryLayout.size) + addr.sin6_family = sa_family_t(AF_INET6) + addr.sin6_port = port.bigEndian + var loopback = in6_addr() + _ = withUnsafeMutablePointer(to: &loopback) { ptr in + inet_pton(AF_INET6, "::1", ptr) + } + addr.sin6_addr = loopback + + let result = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in + Darwin.bind(fd, sa, socklen_t(MemoryLayout.size)) + } + } + return result == 0 + } + #endif + + private static func cleanupStderr(_ handle: FileHandle?) { + guard let handle else { return } + Self.cleanupStderr(handle) + } + + private static func cleanupStderr(_ handle: FileHandle) { + if handle.readabilityHandler != nil { + handle.readabilityHandler = nil + } + try? handle.close() + } + + private static func drainStderr(_ handle: FileHandle) -> String { + handle.readabilityHandler = nil + let data = handle.readDataToEndOfFile() + try? handle.close() + return String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } + + #if SWIFT_PACKAGE + static func _testPortIsFree(_ port: UInt16) -> Bool { + self.portIsFree(port) + } + #endif } diff --git a/apps/macos/Tests/ClawdisIPCTests/WebChatTunnelTests.swift b/apps/macos/Tests/ClawdisIPCTests/WebChatTunnelTests.swift new file mode 100644 index 000000000..1fd5592a0 --- /dev/null +++ b/apps/macos/Tests/ClawdisIPCTests/WebChatTunnelTests.swift @@ -0,0 +1,52 @@ +import Testing +@testable import Clawdis + +#if canImport(Darwin) +import Darwin + +@Suite struct WebChatTunnelTests { + @Test func portIsFreeDetectsIPv4Listener() { + var fd = socket(AF_INET, SOCK_STREAM, 0) + #expect(fd >= 0) + guard fd >= 0 else { return } + defer { + if fd >= 0 { _ = Darwin.close(fd) } + } + + var one: Int32 = 1 + _ = setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, socklen_t(MemoryLayout.size(ofValue: one))) + + var addr = sockaddr_in() + addr.sin_len = UInt8(MemoryLayout.size) + addr.sin_family = sa_family_t(AF_INET) + addr.sin_port = 0 + addr.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) + + let bound = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in + Darwin.bind(fd, sa, socklen_t(MemoryLayout.size)) + } + } + #expect(bound == 0) + guard bound == 0 else { return } + #expect(Darwin.listen(fd, 1) == 0) + + var name = sockaddr_in() + var nameLen = socklen_t(MemoryLayout.size) + let got = withUnsafeMutablePointer(to: &name) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in + getsockname(fd, sa, &nameLen) + } + } + #expect(got == 0) + guard got == 0 else { return } + + let port = UInt16(bigEndian: name.sin_port) + #expect(WebChatTunnel._testPortIsFree(port) == false) + + _ = Darwin.close(fd) + fd = -1 + #expect(WebChatTunnel._testPortIsFree(port) == true) + } +} +#endif