fix(mac): keep remote control tunnel alive

This commit is contained in:
Peter Steinberger
2025-12-12 18:44:44 +00:00
parent 7d37195c1a
commit 8086c66ab8
2 changed files with 135 additions and 3 deletions

View File

@@ -289,9 +289,10 @@ actor PortGuardian {
let expectedCommands = ["node", "clawdis", "tsx", "pnpm", "bun"]
switch mode {
case .remote:
if port == 18788 {
return cmd.contains("ssh") && cmd.contains("18788")
}
// Remote mode expects an SSH tunnel for the gateway WebSocket port.
if port == 18789 { return cmd.contains("ssh") }
// WebChat assets may be served locally (Clawdis) or forwarded via an older SSH tunnel.
if port == 18788 { return cmd.contains("clawdis") || cmd.contains("ssh") }
return false
case .local:
return expectedCommands.contains { cmd.contains($0) }

View File

@@ -0,0 +1,131 @@
import Foundation
import Network
import OSLog
/// Port forwarding tunnel for remote mode.
///
/// Uses `ssh -N -L` to forward the remote gateway ports to localhost.
final class WebChatTunnel {
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "webchat.tunnel")
let process: Process
let localPort: UInt16?
private init(process: Process, localPort: UInt16?) {
self.process = process
self.localPort = localPort
}
deinit {
let pid = self.process.processIdentifier
self.process.terminate()
Task { await PortGuardian.shared.removeRecord(pid: pid) }
}
func terminate() {
let pid = self.process.processIdentifier
if self.process.isRunning {
self.process.terminate()
self.process.waitUntilExit()
}
Task { await PortGuardian.shared.removeRecord(pid: pid) }
}
static func create(remotePort: Int, preferredLocalPort: UInt16? = nil) async throws -> WebChatTunnel {
let settings = CommandResolver.connectionSettings()
guard settings.mode == .remote, let parsed = CommandResolver.parseSSHTarget(settings.target) else {
throw NSError(
domain: "WebChatTunnel",
code: 3,
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not configured"])
}
let localPort = try await Self.findPort(preferred: preferredLocalPort)
var args: [String] = [
"-o", "BatchMode=yes",
"-o", "IdentitiesOnly=yes",
"-o", "ExitOnForwardFailure=yes",
"-o", "ServerAliveInterval=15",
"-o", "ServerAliveCountMax=3",
"-o", "TCPKeepAlive=yes",
"-N",
"-L", "\(localPort):127.0.0.1:\(remotePort)",
]
if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) }
let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines)
if !identity.isEmpty { args.append(contentsOf: ["-i", identity]) }
let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host
args.append(userHost)
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
process.arguments = args
let pipe = Pipe()
process.standardError = pipe
// Consume stderr so ssh cannot block if it logs.
pipe.fileHandleForReading.readabilityHandler = { handle in
let data = handle.availableData
guard !data.isEmpty,
let line = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!line.isEmpty else { return }
Self.logger.error("ssh tunnel stderr: \(line, privacy: .public)")
}
try process.run()
// Track tunnel so we can clean up stale listeners on restart.
Task {
await PortGuardian.shared.record(
port: Int(localPort),
pid: process.processIdentifier,
command: process.executableURL?.path ?? "ssh",
mode: CommandResolver.connectionSettings().mode)
}
return WebChatTunnel(process: process, localPort: localPort)
}
private static func findPort(preferred: UInt16?) async throws -> UInt16 {
if let preferred, self.portIsFree(preferred) { return preferred }
return try await withCheckedThrowingContinuation { cont in
let queue = DispatchQueue(label: "com.steipete.clawdis.webchat.port", qos: .utility)
do {
let listener = try NWListener(using: .tcp, on: .any)
listener.newConnectionHandler = { connection in connection.cancel() }
listener.stateUpdateHandler = { state in
switch state {
case .ready:
if let port = listener.port?.rawValue {
listener.stateUpdateHandler = nil
listener.cancel()
cont.resume(returning: port)
}
case let .failed(error):
listener.stateUpdateHandler = nil
listener.cancel()
cont.resume(throwing: error)
default:
break
}
}
listener.start(queue: queue)
} catch {
cont.resume(throwing: error)
}
}
}
private static func portIsFree(_ port: UInt16) -> Bool {
do {
let listener = try NWListener(using: .tcp, on: NWEndpoint.Port(rawValue: port)!)
listener.cancel()
return true
} catch {
return false
}
}
}