mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-29 17:25:14 +00:00
139 lines
5.2 KiB
Swift
139 lines
5.2 KiB
Swift
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: "ai.openclaw", category: "remote-tunnel")
|
|
private var controlTunnel: RemotePortTunnel?
|
|
private var createInFlight: (token: UUID, task: Task<RemotePortTunnel, Error>)?
|
|
private var restartInFlight = false
|
|
private var lastRestartAt: Date?
|
|
private let restartBackoffSeconds: TimeInterval = 2.0
|
|
|
|
func controlTunnelPortIfRunning() async -> UInt16? {
|
|
if self.restartInFlight {
|
|
self.logger.info("control tunnel restart in flight; skipping reuse check")
|
|
return nil
|
|
}
|
|
if let tunnel = self.controlTunnel,
|
|
tunnel.process.isRunning,
|
|
let local = tunnel.localPort
|
|
{
|
|
let pid = tunnel.process.processIdentifier
|
|
if await PortGuardian.shared.isListening(port: Int(local), pid: pid) {
|
|
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 not listening; restarting")
|
|
await self.beginRestart()
|
|
tunnel.terminate()
|
|
self.controlTunnel = nil
|
|
}
|
|
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 }
|
|
if let create = self.createInFlight {
|
|
self.logger.info("control tunnel create in flight; joining")
|
|
let tunnel = try await create.task.value
|
|
return try await self.installCreatedTunnel(
|
|
tunnel,
|
|
token: create.token,
|
|
fallbackPort: UInt16(GatewayEnvironment.gatewayPort()))
|
|
}
|
|
await self.waitForRestartBackoffIfNeeded()
|
|
|
|
let desiredPort = UInt16(GatewayEnvironment.gatewayPort())
|
|
let token = UUID()
|
|
let task = Task {
|
|
try await RemotePortTunnel.create(
|
|
remotePort: GatewayEnvironment.gatewayPort(),
|
|
preferredLocalPort: desiredPort,
|
|
allowRandomLocalPort: true)
|
|
}
|
|
self.createInFlight = (token: token, task: task)
|
|
let tunnel: RemotePortTunnel
|
|
do {
|
|
tunnel = try await task.value
|
|
} catch {
|
|
if self.createInFlight?.token == token {
|
|
self.createInFlight = nil
|
|
}
|
|
throw error
|
|
}
|
|
return try await self.installCreatedTunnel(tunnel, token: token, fallbackPort: desiredPort)
|
|
}
|
|
|
|
private func installCreatedTunnel(
|
|
_ tunnel: RemotePortTunnel,
|
|
token: UUID,
|
|
fallbackPort: UInt16) async throws -> UInt16
|
|
{
|
|
if self.createInFlight?.token == token {
|
|
self.createInFlight = nil
|
|
}
|
|
self.controlTunnel = tunnel
|
|
self.endRestart()
|
|
let resolvedPort = tunnel.localPort ?? fallbackPort
|
|
self.logger.info("ssh tunnel ready localPort=\(resolvedPort, privacy: .public)")
|
|
return resolvedPort
|
|
}
|
|
|
|
func stopAll() {
|
|
self.createInFlight?.task.cancel()
|
|
self.createInFlight = nil
|
|
self.controlTunnel?.terminate()
|
|
self.controlTunnel = nil
|
|
}
|
|
|
|
private func beginRestart() async {
|
|
guard !self.restartInFlight else { return }
|
|
self.restartInFlight = true
|
|
self.lastRestartAt = Date()
|
|
self.logger.info("control tunnel restart started")
|
|
Task { [weak self] in
|
|
guard let self else { return }
|
|
try? await Task.sleep(nanoseconds: UInt64(self.restartBackoffSeconds * 1_000_000_000))
|
|
await self.endRestart()
|
|
}
|
|
}
|
|
|
|
private func endRestart() {
|
|
if self.restartInFlight {
|
|
self.restartInFlight = false
|
|
self.logger.info("control tunnel restart finished")
|
|
}
|
|
}
|
|
|
|
private func waitForRestartBackoffIfNeeded() async {
|
|
guard let last = self.lastRestartAt else { return }
|
|
let elapsed = Date().timeIntervalSince(last)
|
|
let remaining = self.restartBackoffSeconds - elapsed
|
|
guard remaining > 0 else { return }
|
|
self.logger.info(
|
|
"control tunnel restart backoff \(remaining, privacy: .public)s")
|
|
try? await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000))
|
|
}
|
|
|
|
// Keep tunnel reuse lightweight; restart only when the listener disappears.
|
|
}
|