fix(macos): harden remote ssh tunnel
This commit is contained in:
@@ -6,13 +6,21 @@ actor RemoteTunnelManager {
|
|||||||
|
|
||||||
private var controlTunnel: WebChatTunnel?
|
private var controlTunnel: WebChatTunnel?
|
||||||
|
|
||||||
func controlTunnelPortIfRunning() -> UInt16? {
|
func controlTunnelPortIfRunning() async -> UInt16? {
|
||||||
if let tunnel = self.controlTunnel,
|
if let tunnel = self.controlTunnel,
|
||||||
tunnel.process.isRunning,
|
tunnel.process.isRunning,
|
||||||
let local = tunnel.localPort
|
let local = tunnel.localPort
|
||||||
{
|
{
|
||||||
return local
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,7 +35,7 @@ actor RemoteTunnelManager {
|
|||||||
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
|
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 desiredPort = UInt16(GatewayEnvironment.gatewayPort())
|
||||||
let tunnel = try await WebChatTunnel.create(
|
let tunnel = try await WebChatTunnel.create(
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Network
|
import Network
|
||||||
import OSLog
|
import OSLog
|
||||||
|
#if canImport(Darwin)
|
||||||
|
import Darwin
|
||||||
|
#endif
|
||||||
|
|
||||||
/// Port forwarding tunnel for remote mode.
|
/// Port forwarding tunnel for remote mode.
|
||||||
///
|
///
|
||||||
@@ -10,19 +13,23 @@ final class WebChatTunnel {
|
|||||||
|
|
||||||
let process: Process
|
let process: Process
|
||||||
let localPort: UInt16?
|
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.process = process
|
||||||
self.localPort = localPort
|
self.localPort = localPort
|
||||||
|
self.stderrHandle = stderrHandle
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
|
Self.cleanupStderr(self.stderrHandle)
|
||||||
let pid = self.process.processIdentifier
|
let pid = self.process.processIdentifier
|
||||||
self.process.terminate()
|
self.process.terminate()
|
||||||
Task { await PortGuardian.shared.removeRecord(pid: pid) }
|
Task { await PortGuardian.shared.removeRecord(pid: pid) }
|
||||||
}
|
}
|
||||||
|
|
||||||
func terminate() {
|
func terminate() {
|
||||||
|
Self.cleanupStderr(self.stderrHandle)
|
||||||
let pid = self.process.processIdentifier
|
let pid = self.process.processIdentifier
|
||||||
if self.process.isRunning {
|
if self.process.isRunning {
|
||||||
self.process.terminate()
|
self.process.terminate()
|
||||||
@@ -63,19 +70,36 @@ final class WebChatTunnel {
|
|||||||
|
|
||||||
let pipe = Pipe()
|
let pipe = Pipe()
|
||||||
process.standardError = pipe
|
process.standardError = pipe
|
||||||
|
let stderrHandle = pipe.fileHandleForReading
|
||||||
|
|
||||||
// Consume stderr so ssh cannot block if it logs.
|
// Consume stderr so ssh cannot block if it logs.
|
||||||
pipe.fileHandleForReading.readabilityHandler = { handle in
|
stderrHandle.readabilityHandler = { handle in
|
||||||
let data = handle.availableData
|
let data = handle.availableData
|
||||||
guard !data.isEmpty,
|
guard !data.isEmpty else {
|
||||||
let line = String(data: data, encoding: .utf8)?
|
// EOF: stop monitoring to avoid spinning on a closed pipe.
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
Self.cleanupStderr(handle)
|
||||||
!line.isEmpty else { return }
|
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)")
|
Self.logger.error("ssh tunnel stderr: \(line, privacy: .public)")
|
||||||
}
|
}
|
||||||
|
process.terminationHandler = { _ in
|
||||||
|
Self.cleanupStderr(stderrHandle)
|
||||||
|
}
|
||||||
|
|
||||||
try process.run()
|
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.
|
// Track tunnel so we can clean up stale listeners on restart.
|
||||||
Task {
|
Task {
|
||||||
await PortGuardian.shared.record(
|
await PortGuardian.shared.record(
|
||||||
@@ -85,7 +109,7 @@ final class WebChatTunnel {
|
|||||||
mode: CommandResolver.connectionSettings().mode)
|
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 {
|
private static func findPort(preferred: UInt16?) async throws -> UInt16 {
|
||||||
@@ -120,6 +144,11 @@ final class WebChatTunnel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func portIsFree(_ port: UInt16) -> Bool {
|
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 {
|
do {
|
||||||
let listener = try NWListener(using: .tcp, on: NWEndpoint.Port(rawValue: port)!)
|
let listener = try NWListener(using: .tcp, on: NWEndpoint.Port(rawValue: port)!)
|
||||||
listener.cancel()
|
listener.cancel()
|
||||||
@@ -127,5 +156,82 @@ final class WebChatTunnel {
|
|||||||
} catch {
|
} catch {
|
||||||
return false
|
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<sockaddr_in>.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<sockaddr_in>.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<sockaddr_in6>.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<sockaddr_in6>.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
|
||||||
}
|
}
|
||||||
|
|||||||
52
apps/macos/Tests/ClawdisIPCTests/WebChatTunnelTests.swift
Normal file
52
apps/macos/Tests/ClawdisIPCTests/WebChatTunnelTests.swift
Normal file
@@ -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<sockaddr_in>.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<sockaddr_in>.size))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#expect(bound == 0)
|
||||||
|
guard bound == 0 else { return }
|
||||||
|
#expect(Darwin.listen(fd, 1) == 0)
|
||||||
|
|
||||||
|
var name = sockaddr_in()
|
||||||
|
var nameLen = socklen_t(MemoryLayout<sockaddr_in>.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
|
||||||
Reference in New Issue
Block a user