From 9dd613edf7f31fefde596f7bc00662a3cd891096 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 3 Jan 2026 00:02:27 +0100 Subject: [PATCH] fix(mac): harden remote tunnel recovery --- CHANGELOG.md | 1 + .../Sources/Clawdis/ControlChannel.swift | 13 +++++ .../Clawdis/GatewayEndpointStore.swift | 3 ++ .../Sources/Clawdis/MenuContentView.swift | 25 +++++++++ apps/macos/Sources/Clawdis/NodesMenu.swift | 1 + .../Sources/Clawdis/RemotePortTunnel.swift | 51 ++++++++++++++++++- .../Sources/Clawdis/RemoteTunnelManager.swift | 17 ++++++- 7 files changed, 108 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c81deeecf..3870dd581 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -158,6 +158,7 @@ - iOS node: fix ReplayKit screen recording crash caused by queue isolation assertions during capture. - iOS Talk Mode: avoid audio tap queue assertions when starting recognition. - macOS: use $HOME/Library/pnpm for SSH PATH exports (thanks @mbelinky). +- macOS remote: harden SSH tunnel recovery/logging, honor `gateway.remote.url` port when forwarding, clarify gateway disconnect status, and add Debug menu tunnel reset. - iOS/Android nodes: bridge auto-connect refreshes stale tokens and settings now show richer bridge/device details. - macOS: bundle device model resources to prevent Instances crashes (thanks @mbelinky). - iOS/Android nodes: status pill now surfaces camera activity instead of overlay toasts. diff --git a/apps/macos/Sources/Clawdis/ControlChannel.swift b/apps/macos/Sources/Clawdis/ControlChannel.swift index 8a8c31e8e..e01cd2b1b 100644 --- a/apps/macos/Sources/Clawdis/ControlChannel.swift +++ b/apps/macos/Sources/Clawdis/ControlChannel.swift @@ -84,6 +84,7 @@ final class ControlChannel { } func configure() async { + self.logger.info("control channel configure mode=local") self.state = .connecting do { try await GatewayConnection.shared.refresh() @@ -102,6 +103,9 @@ final class ControlChannel { case let .remote(target, identity): do { _ = (target, identity) + let idSet = !identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + self.logger.info( + "control channel configure mode=remote target=\(target, privacy: .public) identitySet=\(idSet, privacy: .public)") _ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel() await self.configure() } catch { @@ -261,6 +265,15 @@ final class ControlChannel { if mode == .local { GatewayProcessManager.shared.setActive(true) } + if mode == .remote { + do { + let port = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel() + self.logger.info("control channel recovery ensured SSH tunnel port=\(port, privacy: .public)") + } catch { + self.logger.error( + "control channel recovery tunnel failed \(error.localizedDescription, privacy: .public)") + } + } do { try await GatewayConnection.shared.refresh() diff --git a/apps/macos/Sources/Clawdis/GatewayEndpointStore.swift b/apps/macos/Sources/Clawdis/GatewayEndpointStore.swift index 1c2aa841d..91f9e15b0 100644 --- a/apps/macos/Sources/Clawdis/GatewayEndpointStore.swift +++ b/apps/macos/Sources/Clawdis/GatewayEndpointStore.swift @@ -165,6 +165,8 @@ actor GatewayEndpointStore { // Auto-recover for remote mode: if the SSH control tunnel died (or hasn't been created yet), // recreate it on demand so callers can recover without a manual reconnect. do { + self.logger.info( + "endpoint unavailable; ensuring remote control tunnel reason=\(reason, privacy: .public)") let forwarded = try await self.deps.ensureRemoteTunnel() let token = self.deps.token() let password = self.deps.password() @@ -174,6 +176,7 @@ actor GatewayEndpointStore { } catch { let msg = "\(reason) (\(error.localizedDescription))" self.setState(.unavailable(mode: .remote, reason: msg)) + self.logger.error("remote control tunnel ensure failed \(msg, privacy: .public)") throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: msg]) } } diff --git a/apps/macos/Sources/Clawdis/MenuContentView.swift b/apps/macos/Sources/Clawdis/MenuContentView.swift index f8e8d5932..837572e13 100644 --- a/apps/macos/Sources/Clawdis/MenuContentView.swift +++ b/apps/macos/Sources/Clawdis/MenuContentView.swift @@ -204,6 +204,16 @@ struct MenuContent: View { } label: { Label("Send Test Heartbeat", systemImage: "waveform.path.ecg") } + if self.state.connectionMode == .remote { + Button { + Task { @MainActor in + let result = await DebugActions.resetGatewayTunnel() + self.presentDebugResult(result, title: "Remote Tunnel") + } + } label: { + Label("Reset Remote Tunnel", systemImage: "arrow.triangle.2.circlepath") + } + } Button { Task { _ = await DebugActions.toggleVerboseLoggingMain() } } label: { @@ -462,6 +472,21 @@ struct MenuContent: View { return "System default" } + @MainActor + private func presentDebugResult(_ result: Result, title: String) { + let alert = NSAlert() + alert.messageText = title + switch result { + case let .success(message): + alert.informativeText = message + alert.alertStyle = .informational + case let .failure(error): + alert.informativeText = error.localizedDescription + alert.alertStyle = .warning + } + alert.runModal() + } + @MainActor private func loadMicrophones(force: Bool = false) async { guard self.showVoiceWakeMicPicker else { diff --git a/apps/macos/Sources/Clawdis/NodesMenu.swift b/apps/macos/Sources/Clawdis/NodesMenu.swift index 70635929b..05fb85f3b 100644 --- a/apps/macos/Sources/Clawdis/NodesMenu.swift +++ b/apps/macos/Sources/Clawdis/NodesMenu.swift @@ -44,6 +44,7 @@ struct NodeMenuEntryFormatter { static func roleText(_ entry: NodeInfo) -> String { if entry.isConnected { return "connected" } + if self.isGateway(entry) { return "disconnected" } if entry.isPaired { return "paired" } return "unpaired" } diff --git a/apps/macos/Sources/Clawdis/RemotePortTunnel.swift b/apps/macos/Sources/Clawdis/RemotePortTunnel.swift index b84d4953c..05bb651df 100644 --- a/apps/macos/Sources/Clawdis/RemotePortTunnel.swift +++ b/apps/macos/Sources/Clawdis/RemotePortTunnel.swift @@ -48,6 +48,16 @@ final class RemotePortTunnel { } let localPort = try await Self.findPort(preferred: preferredLocalPort) + let sshHost = parsed.host.trimmingCharacters(in: .whitespacesAndNewlines) + let remotePortOverride = Self.resolveRemotePortOverride(for: sshHost) + let resolvedRemotePort = remotePortOverride ?? remotePort + if let override = remotePortOverride { + Self.logger.info( + "ssh tunnel remote port override host=\(sshHost, privacy: .public) port=\(override, privacy: .public)") + } else { + Self.logger.debug( + "ssh tunnel using default remote port host=\(sshHost, privacy: .public) port=\(remotePort, privacy: .public)") + } var args: [String] = [ "-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes", @@ -58,7 +68,7 @@ final class RemotePortTunnel { "-o", "ServerAliveCountMax=3", "-o", "TCPKeepAlive=yes", "-N", - "-L", "\(localPort):127.0.0.1:\(remotePort)", + "-L", "\(localPort):127.0.0.1:\(resolvedRemotePort)", ] if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) } let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines) @@ -114,6 +124,45 @@ final class RemotePortTunnel { return RemotePortTunnel(process: process, localPort: localPort, stderrHandle: stderrHandle) } + private static func resolveRemotePortOverride(for sshHost: String) -> Int? { + let root = ClawdisConfigFile.loadDict() + guard let gateway = root["gateway"] as? [String: Any], + let remote = gateway["remote"] as? [String: Any], + let urlRaw = remote["url"] as? String + else { + return nil + } + let trimmed = urlRaw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, let url = URL(string: trimmed), let port = url.port else { + return nil + } + guard let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines), + !host.isEmpty + else { + return nil + } + let sshKey = Self.hostKey(sshHost) + let urlKey = Self.hostKey(host) + guard !sshKey.isEmpty, !urlKey.isEmpty else { return nil } + guard sshKey == urlKey else { + Self.logger.debug( + "remote url host mismatch sshHost=\(sshHost, privacy: .public) urlHost=\(host, privacy: .public)") + return nil + } + return port + } + + private static func hostKey(_ host: String) -> String { + let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !trimmed.isEmpty else { return "" } + if trimmed.contains(":") { return trimmed } + let digits = CharacterSet(charactersIn: "0123456789.") + if trimmed.rangeOfCharacter(from: digits.inverted) == nil { + return trimmed + } + return trimmed.split(separator: ".").first.map(String.init) ?? trimmed + } + private static func findPort(preferred: UInt16?) async throws -> UInt16 { if let preferred, self.portIsFree(preferred) { return preferred } diff --git a/apps/macos/Sources/Clawdis/RemoteTunnelManager.swift b/apps/macos/Sources/Clawdis/RemoteTunnelManager.swift index 137d8e3ca..9351b57a4 100644 --- a/apps/macos/Sources/Clawdis/RemoteTunnelManager.swift +++ b/apps/macos/Sources/Clawdis/RemoteTunnelManager.swift @@ -13,7 +13,10 @@ actor RemoteTunnelManager { tunnel.process.isRunning, let local = tunnel.localPort { - if await self.isTunnelHealthy(port: local) { return local } + 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 @@ -24,7 +27,11 @@ actor RemoteTunnelManager { if let desc = await PortGuardian.shared.describe(port: Int(desiredPort)), self.isSshProcess(desc) { - if await self.isTunnelHealthy(port: desiredPort) { return desiredPort } + 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 @@ -41,6 +48,10 @@ actor RemoteTunnelManager { 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()) @@ -48,6 +59,8 @@ actor RemoteTunnelManager { 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 }