mac: route remote mode through SSH

This commit is contained in:
Peter Steinberger
2025-12-10 01:43:59 +01:00
parent 5bbc7c8ba2
commit 27f9cd591d
5 changed files with 120 additions and 16 deletions

View File

@@ -0,0 +1,48 @@
import Foundation
import OSLog
@MainActor
final class ConnectionModeCoordinator {
static let shared = ConnectionModeCoordinator()
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "connection")
/// Apply the requested connection mode by starting/stopping local gateway,
/// managing the control-channel SSH tunnel, and cleaning up WebChat tunnels.
func apply(mode: AppState.ConnectionMode, paused: Bool) async {
switch mode {
case .local:
await RemoteTunnelManager.shared.stopAll()
WebChatManager.shared.resetTunnels()
do {
try await ControlChannel.shared.configure(mode: .local)
} catch {
// Control channel will mark itself degraded; nothing else to do here.
self.logger.error("control channel local configure failed: \(error.localizedDescription, privacy: .public)")
}
if paused {
GatewayProcessManager.shared.stop()
} else {
GatewayProcessManager.shared.setActive(true)
}
Task.detached { await PortGuardian.shared.sweep(mode: .local) }
case .remote:
// Never run a local gateway in remote mode.
GatewayProcessManager.shared.stop()
WebChatManager.shared.resetTunnels()
do {
_ = try await RemoteTunnelManager.shared.ensureControlTunnel()
let settings = CommandResolver.connectionSettings()
try await ControlChannel.shared.configure(mode: .remote(
target: settings.target,
identity: settings.identity))
} catch {
self.logger.error("remote tunnel/configure failed: \(error.localizedDescription, privacy: .public)")
}
Task.detached { await PortGuardian.shared.sweep(mode: .remote) }
}
}
}

View File

@@ -56,10 +56,8 @@ final class ControlChannel: ObservableObject {
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "control")
private let gateway = GatewayChannel()
private var gatewayURL: URL {
let port = GatewayEnvironment.gatewayPort()
return URL(string: "ws://127.0.0.1:\(port)")!
}
private var gatewayPort: Int = GatewayEnvironment.gatewayPort()
private var gatewayURL: URL { URL(string: "ws://127.0.0.1:\(self.gatewayPort)")! }
private var gatewayToken: String? {
ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_TOKEN"]
@@ -78,11 +76,19 @@ final class ControlChannel: ObservableObject {
func configure(mode: Mode = .local) async throws {
switch mode {
case .local:
self.gatewayPort = GatewayEnvironment.gatewayPort()
await self.configure()
case let .remote(target, identity):
// Remote mode assumed to have an existing tunnel; placeholders retained for future use.
// Create/ensure SSH tunnel, then talk to the forwarded local port.
_ = (target, identity)
await self.configure()
do {
let forwarded = try await RemoteTunnelManager.shared.ensureControlTunnel()
self.gatewayPort = Int(forwarded)
await self.configure()
} catch {
self.state = .degraded(error.localizedDescription)
throw error
}
}
}

View File

@@ -45,7 +45,14 @@ struct ClawdisApp: App {
}
.onChange(of: self.state.isPaused) { _, paused in
self.applyStatusItemAppearance(paused: paused)
self.gatewayManager.setActive(!paused)
if self.state.connectionMode == .local {
self.gatewayManager.setActive(!paused)
} else {
self.gatewayManager.stop()
}
}
.onChange(of: self.state.connectionMode) { _, mode in
Task { await ConnectionModeCoordinator.shared.apply(mode: mode, paused: self.state.isPaused) }
}
Settings {
@@ -161,17 +168,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate
self.state = AppStateStore.shared
AppActivationPolicy.apply(showDockIcon: self.state?.showDockIcon ?? false)
if let state {
GatewayProcessManager.shared.setActive(!state.isPaused)
}
Task {
await ControlChannel.shared.configure()
PresenceReporter.shared.start()
Task { await ConnectionModeCoordinator.shared.apply(mode: state.connectionMode, paused: state.isPaused) }
}
Task { PresenceReporter.shared.start() }
Task { await HealthStore.shared.refresh(onDemand: true) }
Task {
let mode = AppStateStore.shared.connectionMode
await PortGuardian.shared.sweep(mode: mode)
}
Task { await PortGuardian.shared.sweep(mode: AppStateStore.shared.connectionMode) }
self.startListener()
self.scheduleFirstRunOnboardingIfNeeded()
@@ -186,6 +187,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate
GatewayProcessManager.shared.stop()
PresenceReporter.shared.stop()
WebChatManager.shared.close()
WebChatManager.shared.resetTunnels()
Task { await RemoteTunnelManager.shared.stopAll() }
Task { await AgentRPC.shared.shutdown() }
}

View File

@@ -0,0 +1,35 @@
import Foundation
/// Manages the SSH tunnel that forwards the remote gateway/control port to localhost.
actor RemoteTunnelManager {
static let shared = RemoteTunnelManager()
private var controlTunnel: WebChatTunnel?
/// Ensure an SSH tunnel is running for the gateway control port.
/// Returns the local forwarded port (usually 18789).
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"])
}
if let tunnel = self.controlTunnel,
tunnel.process.isRunning,
let local = tunnel.localPort {
return local
}
let desiredPort = UInt16(GatewayEnvironment.gatewayPort())
let tunnel = try await WebChatTunnel.create(
remotePort: GatewayEnvironment.gatewayPort(),
preferredLocalPort: desiredPort)
self.controlTunnel = tunnel
return tunnel.localPort ?? desiredPort
}
func stopAll() {
self.controlTunnel?.terminate()
self.controlTunnel = nil
}
}

View File

@@ -514,6 +514,18 @@ final class WebChatManager {
return "+1003"
}
@MainActor
func resetTunnels() {
self.browserTunnel?.terminate()
self.browserTunnel = nil
self.windowController?.shutdown()
self.windowController?.close()
self.windowController = nil
self.panelController?.shutdown()
self.panelController?.close()
self.panelController = nil
}
@MainActor
func openInBrowser(sessionKey: String) async {
let port = AppStateStore.webChatPort