import Foundation import OSLog /// Manages the SSH tunnel that forwards the remote gateway/control port to localhost. actor RemoteTunnelManager { static let shared = RemoteTunnelManager() private let logger = Logger(subsystem: "com.clawdbot", category: "remote-tunnel") private var controlTunnel: RemotePortTunnel? func controlTunnelPortIfRunning() async -> UInt16? { if let tunnel = self.controlTunnel, tunnel.process.isRunning, let local = tunnel.localPort { if await self.isTunnelHealthy(port: local) { self.logger.info("reusing active SSH tunnel localPort=\(local, privacy: .public)") return local } self.logger.error("active SSH tunnel on port \(local, privacy: .public) is unhealthy; restarting") tunnel.terminate() self.controlTunnel = nil } // If a previous Clawdbot 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)), self.isSshProcess(desc) { if await self.isTunnelHealthy(port: desiredPort) { self.logger.info( "reusing existing SSH tunnel listener " + "localPort=\(desiredPort, privacy: .public) " + "pid=\(desc.pid, privacy: .public)") return desiredPort } await self.cleanupStaleTunnel(desc: desc, port: desiredPort) } return nil } /// Ensure an SSH tunnel is running for the gateway control port. /// Returns the local forwarded port (usually the configured gateway port). func ensureControlTunnel() async throws -> UInt16 { let settings = CommandResolver.connectionSettings() guard settings.mode == .remote else { throw NSError( domain: "RemoteTunnel", code: 1, userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"]) } let identitySet = !settings.identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty self.logger.info( "ensure SSH tunnel target=\(settings.target, privacy: .public) " + "identitySet=\(identitySet, privacy: .public)") if let local = await self.controlTunnelPortIfRunning() { return local } let desiredPort = UInt16(GatewayEnvironment.gatewayPort()) let tunnel = try await RemotePortTunnel.create( remotePort: GatewayEnvironment.gatewayPort(), preferredLocalPort: desiredPort) self.controlTunnel = tunnel let resolvedPort = tunnel.localPort ?? desiredPort self.logger.info("ssh tunnel ready localPort=\(resolvedPort, privacy: .public)") return tunnel.localPort ?? desiredPort } func stopAll() { self.controlTunnel?.terminate() self.controlTunnel = nil } private func isTunnelHealthy(port: UInt16) async -> Bool { await PortGuardian.shared.probeGatewayHealth(port: Int(port)) } private func isSshProcess(_ desc: PortGuardian.Descriptor) -> Bool { let cmd = desc.command.lowercased() if cmd.contains("ssh") { return true } if let path = desc.executablePath?.lowercased(), path.contains("/ssh") { return true } return false } private func cleanupStaleTunnel(desc: PortGuardian.Descriptor, port: UInt16) async { let pid = desc.pid self.logger.error( "stale SSH tunnel detected on port \(port, privacy: .public) pid \(pid, privacy: .public)") let killed = await self.kill(pid: pid) if !killed { self.logger.error("failed to terminate stale SSH tunnel pid \(pid, privacy: .public)") } await PortGuardian.shared.removeRecord(pid: pid) } private func kill(pid: Int32) async -> Bool { let term = await ShellExecutor.run(command: ["kill", "-TERM", "\(pid)"], cwd: nil, env: nil, timeout: 2) if term.ok { return true } let sigkill = await ShellExecutor.run(command: ["kill", "-KILL", "\(pid)"], cwd: nil, env: nil, timeout: 2) return sigkill.ok } }