mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 16:30:57 +00:00
fix(macos): repair stale gateway tls pins (#75038)
Merged via squash.
Prepared head SHA: 35196f8f71
Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>
Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>
Reviewed-by: @ngutman
This commit is contained in:
@@ -2,6 +2,7 @@ import AppKit
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import Observation
|
||||
import OpenClawKit
|
||||
import SwiftUI
|
||||
|
||||
/// Menu contents for the OpenClaw menu bar extra.
|
||||
@@ -14,6 +15,7 @@ struct MenuContent: View {
|
||||
private let heartbeatStore = HeartbeatStore.shared
|
||||
private let controlChannel = ControlChannel.shared
|
||||
private let activityStore = WorkActivityStore.shared
|
||||
private let nodesStore = NodesStore.shared
|
||||
@Bindable private var pairingPrompter = NodePairingApprovalPrompter.shared
|
||||
@Bindable private var devicePairingPrompter = DevicePairingApprovalPrompter.shared
|
||||
@Environment(\.openSettings) private var openSettings
|
||||
@@ -44,6 +46,9 @@ struct MenuContent: View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(self.connectionLabel)
|
||||
self.statusLine(label: self.healthStatus.label, color: self.healthStatus.color)
|
||||
if let macNodeStatus = self.macNodeStatus {
|
||||
self.statusLine(label: macNodeStatus.label, color: macNodeStatus.color)
|
||||
}
|
||||
if self.pairingPrompter.pendingCount > 0 {
|
||||
let repairCount = self.pairingPrompter.pendingRepairCount
|
||||
let repairSuffix = repairCount > 0 ? " · \(repairCount) repair" : ""
|
||||
@@ -351,6 +356,31 @@ struct MenuContent: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var macNodeStatus: (label: String, color: Color)? {
|
||||
guard self.state.connectionMode != .unconfigured else { return nil }
|
||||
guard case .connected = self.controlChannel.state else { return nil }
|
||||
|
||||
let deviceId = DeviceIdentityStore.loadOrCreate().deviceId
|
||||
if let entry = self.nodesStore.nodes.first(where: { $0.nodeId == deviceId }) {
|
||||
guard entry.isConnected else {
|
||||
return ("Mac capabilities offline", .orange)
|
||||
}
|
||||
let commands = Set(entry.commands ?? [])
|
||||
let missingRequiredCommands = [
|
||||
OpenClawSystemCommand.notify.rawValue,
|
||||
OpenClawSystemCommand.run.rawValue,
|
||||
OpenClawSystemCommand.which.rawValue,
|
||||
].filter { !commands.contains($0) }
|
||||
if !missingRequiredCommands.isEmpty {
|
||||
return ("Mac capabilities incomplete", .orange)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
guard !self.nodesStore.isLoading, !self.nodesStore.nodes.isEmpty else { return nil }
|
||||
return ("Mac capabilities offline", .orange)
|
||||
}
|
||||
|
||||
private var healthStatus: (label: String, color: Color) {
|
||||
if let activity = self.activityStore.current {
|
||||
let color: Color = activity.role == .main ? .accentColor : .gray
|
||||
|
||||
@@ -1156,7 +1156,7 @@ extension MenuSessionsInjector {
|
||||
}
|
||||
|
||||
private func sortedNodeEntries() -> [NodeInfo] {
|
||||
let entries = self.nodesStore.nodes.filter(\.isConnected)
|
||||
let entries = self.nodesStore.nodes.filter { $0.isConnected || $0.isPaired }
|
||||
return entries.sorted { lhs, rhs in
|
||||
if lhs.isConnected != rhs.isConnected { return lhs.isConnected }
|
||||
if lhs.isPaired != rhs.isPaired { return lhs.isPaired }
|
||||
@@ -1239,5 +1239,9 @@ extension MenuSessionsInjector {
|
||||
func testingFindNodesInsertIndex(in menu: NSMenu) -> Int? {
|
||||
self.findNodesInsertIndex(in: menu)
|
||||
}
|
||||
|
||||
func testingSortedNodeEntries() -> [NodeInfo] {
|
||||
self.sortedNodeEntries()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -10,6 +10,7 @@ final class MacNodeModeCoordinator {
|
||||
private var task: Task<Void, Never>?
|
||||
private let runtime = MacNodeRuntime()
|
||||
private let session = GatewayNodeSession()
|
||||
private var autoRepairedTLSFingerprintsByStoreKey: [String: String] = [:]
|
||||
|
||||
func start() {
|
||||
guard self.task == nil else { return }
|
||||
@@ -58,8 +59,10 @@ final class MacNodeModeCoordinator {
|
||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||
}
|
||||
|
||||
var attemptedURL: URL?
|
||||
do {
|
||||
let config = try await GatewayEndpointStore.shared.requireConfig()
|
||||
attemptedURL = config.url
|
||||
let caps = self.currentCaps()
|
||||
let commands = self.currentCommands(caps: caps)
|
||||
let permissions = await self.currentPermissions()
|
||||
@@ -109,6 +112,10 @@ final class MacNodeModeCoordinator {
|
||||
retryDelay = 1_000_000_000
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
} catch {
|
||||
if await self.autoRepairStaleTLSPinIfNeeded(error: error, url: attemptedURL) {
|
||||
retryDelay = 1_000_000_000
|
||||
continue
|
||||
}
|
||||
self.logger.error("mac node gateway connect failed: \(error.localizedDescription, privacy: .public)")
|
||||
try? await Task.sleep(nanoseconds: min(retryDelay, 10_000_000_000))
|
||||
retryDelay = min(retryDelay * 2, 10_000_000_000)
|
||||
@@ -188,11 +195,49 @@ final class MacNodeModeCoordinator {
|
||||
Self.resolvedCommands(caps: caps)
|
||||
}
|
||||
|
||||
nonisolated static func tlsPinStoreKey(for url: URL) -> String {
|
||||
let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "gateway"
|
||||
let port = url.port ?? 443
|
||||
return "\(host):\(port)"
|
||||
}
|
||||
|
||||
nonisolated static func shouldAutoRepairStaleTLSPin(url: URL, failure: GatewayTLSValidationFailure) -> Bool {
|
||||
guard failure.kind == .pinMismatch else { return false }
|
||||
guard url.scheme?.lowercased() == "wss" else { return false }
|
||||
guard failure.storeKey == nil || failure.storeKey == self.tlsPinStoreKey(for: url) else { return false }
|
||||
guard let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), !host.isEmpty
|
||||
else { return false }
|
||||
|
||||
if LoopbackHost.isLoopback(host) {
|
||||
return failure.systemTrustOk
|
||||
}
|
||||
|
||||
// Tailscale Serve uses publicly trusted, rotating certificates for *.ts.net names.
|
||||
// A stale legacy leaf pin should not leave the companion app half-connected forever.
|
||||
if host == "ts.net" || host.hasSuffix(".ts.net") {
|
||||
return failure.systemTrustOk
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private func autoRepairStaleTLSPinIfNeeded(error: Error, url: URL?) async -> Bool {
|
||||
guard let tlsError = error as? GatewayTLSValidationError, let url else { return false }
|
||||
guard Self.shouldAutoRepairStaleTLSPin(url: url, failure: tlsError.failure) else { return false }
|
||||
let storeKey = tlsError.failure.storeKey ?? Self.tlsPinStoreKey(for: url)
|
||||
guard let observedFingerprint = tlsError.failure.observedFingerprint else { return false }
|
||||
guard self.autoRepairedTLSFingerprintsByStoreKey[storeKey] != observedFingerprint else { return false }
|
||||
|
||||
guard GatewayTLSStore.replaceFingerprint(observedFingerprint, stableID: storeKey) else { return false }
|
||||
self.autoRepairedTLSFingerprintsByStoreKey[storeKey] = observedFingerprint
|
||||
self.logger.info("replaced stale gateway TLS pin storeKey=\(storeKey, privacy: .public)")
|
||||
await self.session.disconnect()
|
||||
return true
|
||||
}
|
||||
|
||||
private func buildSessionBox(url: URL) -> WebSocketSessionBox? {
|
||||
guard url.scheme?.lowercased() == "wss" else { return nil }
|
||||
let host = url.host ?? "gateway"
|
||||
let port = url.port ?? 443
|
||||
let stableID = "\(host):\(port)"
|
||||
let stableID = Self.tlsPinStoreKey(for: url)
|
||||
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
||||
let params = GatewayTLSParams(
|
||||
required: true,
|
||||
|
||||
@@ -44,10 +44,12 @@ 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"
|
||||
if self.isGateway(entry) {
|
||||
return entry.isConnected ? "connected" : "disconnected"
|
||||
}
|
||||
let pairing = entry.isPaired ? "paired" : "unpaired"
|
||||
let connection = entry.isConnected ? "connected" : "disconnected"
|
||||
return "\(pairing) · \(connection)"
|
||||
}
|
||||
|
||||
static func detailLeft(_ entry: NodeInfo) -> String {
|
||||
|
||||
Reference in New Issue
Block a user