From 9fbd8ab1ae837c0df393a7a66d897612ef141c76 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 24 May 2026 13:58:27 +0700 Subject: [PATCH 1/5] feat(connections): manage cloudflared access tcp tunnels per connection (#1285) --- CHANGELOG.md | 1 + TablePro/AppDelegate.swift | 3 + .../Cloudflare/CloudflareTunnelError.swift | 40 ++ .../Cloudflare/CloudflareTunnelManager.swift | 382 ++++++++++++++++++ .../Core/Cloudflare/CloudflaredProcess.swift | 147 +++++++ .../Database/DatabaseManager+Cloudflare.swift | 114 ++++++ .../Database/DatabaseManager+Health.swift | 11 +- .../Core/Database/DatabaseManager+SSH.swift | 7 + .../Database/DatabaseManager+Sessions.swift | 25 ++ .../DatabaseManager+SystemEvents.swift | 29 +- .../Plugins/PluginManager+Registration.swift | 5 + .../Core/Plugins/PluginMetadataRegistry.swift | 10 +- TablePro/Core/Storage/ConnectionStorage.swift | 52 +++ TablePro/Core/Sync/SyncRecordMapper.swift | 2 + .../Connection/CloudflareConfiguration.swift | 51 +++ .../CloudflareTunnelFormState.swift | 61 +++ .../Connection/CloudflareTunnelMode.swift | 49 +++ .../DatabaseConnection+Cloudflare.swift | 18 + .../Connection/DatabaseConnection.swift | 9 +- .../ConnectionFormCoordinator.swift | 22 + .../ConnectionForm/ConnectionFormPane.swift | 5 + .../ConnectionForm/ConnectionFormView.swift | 2 + .../Panes/CloudflareTunnelPaneView.swift | 156 +++++++ .../CloudflareTunnelPaneViewModel.swift | 83 ++++ .../Cloudflare/CloudflareModelTests.swift | 82 ++++ .../CloudflareTunnelManagerTests.swift | 201 +++++++++ .../CloudflareTunnelPaneViewModelTests.swift | 56 +++ docs/databases/cloudflare-tunnel.mdx | 97 +++++ docs/docs.json | 1 + 29 files changed, 1703 insertions(+), 18 deletions(-) create mode 100644 TablePro/Core/Cloudflare/CloudflareTunnelError.swift create mode 100644 TablePro/Core/Cloudflare/CloudflareTunnelManager.swift create mode 100644 TablePro/Core/Cloudflare/CloudflaredProcess.swift create mode 100644 TablePro/Core/Database/DatabaseManager+Cloudflare.swift create mode 100644 TablePro/Models/Connection/CloudflareConfiguration.swift create mode 100644 TablePro/Models/Connection/CloudflareTunnelFormState.swift create mode 100644 TablePro/Models/Connection/CloudflareTunnelMode.swift create mode 100644 TablePro/Models/Connection/DatabaseConnection+Cloudflare.swift create mode 100644 TablePro/Views/ConnectionForm/Panes/CloudflareTunnelPaneView.swift create mode 100644 TablePro/Views/ConnectionForm/ViewModels/CloudflareTunnelPaneViewModel.swift create mode 100644 TableProTests/Cloudflare/CloudflareModelTests.swift create mode 100644 TableProTests/Cloudflare/CloudflareTunnelManagerTests.swift create mode 100644 TableProTests/Cloudflare/CloudflareTunnelPaneViewModelTests.swift create mode 100644 docs/databases/cloudflare-tunnel.mdx diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c0a5d639..56bc60ba3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Cloudflare Tunnel: connect to a database behind Cloudflare Access by letting TablePro start and stop `cloudflared access tcp` for you, the same way it manages SSH tunnels. Configure it per connection with browser sign-in or a service token. Needs cloudflared installed (`brew install cloudflared`). (#1285) - Fill Column: right-click a column header and choose Fill Column to set one value across all loaded rows. The change is staged like a normal edit, so you review it and Save before it applies, and one undo reverts the whole fill. Not available on primary key columns. (#1304) ## [0.44.0] - 2026-05-23 diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index 7dacdc12e..e76603182 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -59,6 +59,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { UserDefaults.standard.set(passwordSyncExpected, forKey: KeychainHelper.passwordSyncEnabledKey) DatabaseManager.shared.startObservingSystemEvents() + Task { await CloudflareTunnelManager.shared.sweepStalePidsIfNeeded() } + MemoryPressureAdvisor.startMonitoring() PluginManager.shared.loadPlugins() UNUserNotificationCenter.current().delegate = self @@ -136,6 +138,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { LinkedFolderWatcher.shared.stop() SQLFolderWatcher.shared.stop() SSHTunnelManager.shared.terminateAllProcessesSync() + CloudflareTunnelManager.shared.terminateAllProcessesSync() } @objc func handleSystemDidWake(_ notification: Notification) { diff --git a/TablePro/Core/Cloudflare/CloudflareTunnelError.swift b/TablePro/Core/Cloudflare/CloudflareTunnelError.swift new file mode 100644 index 000000000..9e94122e5 --- /dev/null +++ b/TablePro/Core/Cloudflare/CloudflareTunnelError.swift @@ -0,0 +1,40 @@ +// +// CloudflareTunnelError.swift +// TablePro +// + +import Foundation + +/// Errors raised while starting or supervising a cloudflared Access TCP tunnel. +enum CloudflareTunnelError: Error, LocalizedError, Equatable { + case binaryNotFound + case noAvailablePort + case startupFailed(stderrTail: String) + case readinessTimeout(stderrTail: String) + case browserAuthRequired(url: String) + case mutualExclusivityViolation + case tunnelAlreadyExists(UUID) + + var errorDescription: String? { + switch self { + case .binaryNotFound: + return String(localized: "cloudflared was not found. Install it with `brew install cloudflared`, or set its path in the connection's Cloudflare Tunnel settings.") + case .noAvailablePort: + return String(localized: "No available local port for the Cloudflare tunnel.") + case .startupFailed(let stderrTail): + return stderrTail.isEmpty + ? String(localized: "cloudflared failed to start.") + : String(format: String(localized: "cloudflared failed to start: %@"), stderrTail) + case .readinessTimeout(let stderrTail): + return stderrTail.isEmpty + ? String(localized: "The Cloudflare tunnel did not become ready in time.") + : String(format: String(localized: "The Cloudflare tunnel did not become ready in time: %@"), stderrTail) + case .browserAuthRequired(let url): + return String(format: String(localized: "Cloudflare Access needs a browser sign-in. Sign in at %@, then reconnect."), url) + case .mutualExclusivityViolation: + return String(localized: "A connection cannot use SSH and Cloudflare tunnels at the same time.") + case .tunnelAlreadyExists(let id): + return String(format: String(localized: "A Cloudflare tunnel already exists for connection: %@"), id.uuidString) + } + } +} diff --git a/TablePro/Core/Cloudflare/CloudflareTunnelManager.swift b/TablePro/Core/Cloudflare/CloudflareTunnelManager.swift new file mode 100644 index 000000000..ab756b889 --- /dev/null +++ b/TablePro/Core/Cloudflare/CloudflareTunnelManager.swift @@ -0,0 +1,382 @@ +// +// CloudflareTunnelManager.swift +// TablePro +// +// Manages cloudflared Access TCP tunnel lifecycle for database connections. +// + +import Darwin +import Foundation +import Network +import os + +actor CloudflareTunnelManager { + static let shared = CloudflareTunnelManager() + private static let logger = Logger(subsystem: "com.TablePro", category: "CloudflareTunnelManager") + + private static let readinessTimeout: TimeInterval = 30 + private static let readinessPollInterval: UInt64 = 250_000_000 + private static let portRetryCount = 5 + private static let stalePidsDefaultsKey = "cloudflaredStalePids" + + private struct TunnelState { + let runner: any CloudflaredRunner + let localPort: Int + } + + private var tunnels: [UUID: TunnelState] = [:] + private var pidRecords: [UUID: CloudflaredPidRecord] = [:] + private let runnerFactory: () -> any CloudflaredRunner + + /// Static registry for synchronous termination during app shutdown. + private static let runnerRegistry = OSAllocatedUnfairLock(initialState: [UUID: any CloudflaredRunner]()) + + /// Prevents App Nap from throttling the supervised process while tunnels are active. + private var appNapActivity: NSObjectProtocol? + + init(runnerFactory: @escaping () -> any CloudflaredRunner = { ProcessCloudflaredRunner() }) { + self.runnerFactory = runnerFactory + } + + /// Create a Cloudflare Access TCP tunnel for a database connection. + /// Returns the local loopback port the database driver should connect to. + func createTunnel( + connectionId: UUID, + config: CloudflareConfiguration, + tokenId: String? = nil, + tokenSecret: String? = nil + ) async throws -> Int { + if tunnels[connectionId] != nil { + try await closeTunnel(connectionId: connectionId) + } + + let binaryPath = try resolveBinaryPath(config: config) + let environment = Self.buildEnvironment(config: config, tokenId: tokenId, tokenSecret: tokenSecret) + let listenHost = config.exposeToLAN ? "0.0.0.0" : "127.0.0.1" + let attempts = config.localPort != nil ? 1 : Self.portRetryCount + + var lastError: Error = CloudflareTunnelError.noAvailablePort + for _ in 0.. [any CloudflaredRunner] in + let values = Array(dict.values) + dict.removeAll() + return values + } + for runner in runners { + runner.stop() + } + } + + func hasTunnel(connectionId: UUID) -> Bool { + tunnels[connectionId] != nil + } + + func getLocalPort(connectionId: UUID) -> Int? { + tunnels[connectionId]?.localPort + } + + /// Reap cloudflared processes left running by a previous session that crashed + /// or was force-quit. Verifies each recorded PID still points at cloudflared + /// before signalling it, so a recycled PID is never killed. + func sweepStalePidsIfNeeded() { + defer { UserDefaults.standard.removeObject(forKey: Self.stalePidsDefaultsKey) } + guard let data = UserDefaults.standard.data(forKey: Self.stalePidsDefaultsKey), + let records = try? JSONDecoder().decode([CloudflaredPidRecord].self, from: data) else { + return + } + for record in records where Self.isLiveCloudflared(record) { + kill(record.pid, SIGTERM) + Self.logger.notice("Reaped stale cloudflared pid \(record.pid)") + } + } + + // MARK: - Private: lifecycle + + private func register(connectionId: UUID, runner: any CloudflaredRunner, port: Int, binaryPath: String) { + tunnels[connectionId] = TunnelState(runner: runner, localPort: port) + Self.runnerRegistry.withLock { $0[connectionId] = runner } + if let pid = runner.processIdentifier { + pidRecords[connectionId] = CloudflaredPidRecord(pid: pid, binaryPath: binaryPath) + persistPidRecords() + } + updateAppNapState() + startDeathWatch(connectionId: connectionId, runner: runner) + } + + private func startDeathWatch(connectionId: UUID, runner: any CloudflaredRunner) { + Task { [weak self] in + let result = await runner.termination + await self?.handleTermination(connectionId: connectionId, result: result) + } + } + + private func handleTermination(connectionId: UUID, result: CloudflaredTermination) async { + guard tunnels.removeValue(forKey: connectionId) != nil else { return } + Self.runnerRegistry.withLock { $0[connectionId] = nil } + pidRecords.removeValue(forKey: connectionId) + persistPidRecords() + updateAppNapState() + guard !result.wasRequested else { return } + Self.logger.warning("Cloudflare tunnel died for connection \(connectionId.uuidString, privacy: .public)") + await DatabaseManager.shared.handleCloudflareTunnelDied(connectionId: connectionId) + } + + // MARK: - Private: readiness + + private func awaitReadiness(runner: any CloudflaredRunner, port: Int) async throws { + let monitor = CloudflaredStartupMonitor() + let stderrTask = Task { + for await line in runner.stderrLines { + await monitor.append(line) + } + await monitor.markStreamEnded() + } + defer { stderrTask.cancel() } + + let deadline = Date().addingTimeInterval(Self.readinessTimeout) + while Date() < deadline { + if let url = await monitor.browserAuthURL { + throw CloudflareTunnelError.browserAuthRequired(url: url) + } + if await monitor.streamEnded { + throw CloudflareTunnelError.startupFailed(stderrTail: await monitor.tail) + } + if await Self.canConnect(host: "127.0.0.1", port: port) { + if let url = await monitor.browserAuthURL { + throw CloudflareTunnelError.browserAuthRequired(url: url) + } + return + } + try await Task.sleep(nanoseconds: Self.readinessPollInterval) + } + throw CloudflareTunnelError.readinessTimeout(stderrTail: await monitor.tail) + } + + private static func canConnect(host: String, port: Int) async -> Bool { + guard let nwPort = NWEndpoint.Port(rawValue: UInt16(port)) else { return false } + return await withCheckedContinuation { continuation in + let connection = NWConnection(host: NWEndpoint.Host(host), port: nwPort, using: .tcp) + let resumed = OSAllocatedUnfairLock(initialState: false) + let complete: (Bool) -> Void = { value in + let shouldResume = resumed.withLock { done -> Bool in + guard !done else { return false } + done = true + return true + } + guard shouldResume else { return } + connection.cancel() + continuation.resume(returning: value) + } + connection.stateUpdateHandler = { state in + switch state { + case .ready: + complete(true) + case .failed, .cancelled, .waiting: + complete(false) + default: + break + } + } + connection.start(queue: .global(qos: .utility)) + } + } + + // MARK: - Private: binary, environment, port + + private func resolveBinaryPath(config: CloudflareConfiguration) throws -> String { + if !config.binaryPath.isEmpty { + guard FileManager.default.isExecutableFile(atPath: config.binaryPath) else { + throw CloudflareTunnelError.binaryNotFound + } + return config.binaryPath + } + guard let resolved = CLIExecutableFinder.findExecutable("cloudflared") else { + throw CloudflareTunnelError.binaryNotFound + } + return resolved + } + + private static func buildEnvironment( + config: CloudflareConfiguration, + tokenId: String?, + tokenSecret: String? + ) -> [String: String] { + var environment = ProcessInfo.processInfo.environment + guard config.authMethod == .serviceToken else { return environment } + if let tokenId, !tokenId.isEmpty { + environment["TUNNEL_SERVICE_TOKEN_ID"] = tokenId + } + if let tokenSecret, !tokenSecret.isEmpty { + environment["TUNNEL_SERVICE_TOKEN_SECRET"] = tokenSecret + } + return environment + } + + private func allocateFreePort() throws -> Int { + let descriptor = socket(AF_INET, SOCK_STREAM, 0) + guard descriptor >= 0 else { throw CloudflareTunnelError.noAvailablePort } + defer { close(descriptor) } + + var address = sockaddr_in() + address.sin_family = sa_family_t(AF_INET) + address.sin_port = 0 + address.sin_addr.s_addr = inet_addr("127.0.0.1") + + let bound = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(descriptor, $0, socklen_t(MemoryLayout.size)) + } + } + guard bound == 0 else { throw CloudflareTunnelError.noAvailablePort } + + var boundAddress = sockaddr_in() + var length = socklen_t(MemoryLayout.size) + let named = withUnsafeMutablePointer(to: &boundAddress) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + getsockname(descriptor, $0, &length) + } + } + guard named == 0 else { throw CloudflareTunnelError.noAvailablePort } + return Int(UInt16(bigEndian: boundAddress.sin_port)) + } + + // MARK: - Private: stale PID persistence + + private func persistPidRecords() { + let records = Array(pidRecords.values) + guard !records.isEmpty else { + UserDefaults.standard.removeObject(forKey: Self.stalePidsDefaultsKey) + return + } + guard let data = try? JSONEncoder().encode(records) else { return } + UserDefaults.standard.set(data, forKey: Self.stalePidsDefaultsKey) + } + + private static func isLiveCloudflared(_ record: CloudflaredPidRecord) -> Bool { + guard record.pid > 0 else { return false } + var buffer = [CChar](repeating: 0, count: Int(PROC_PIDPATHINFO_MAXSIZE)) + let length = proc_pidpath(record.pid, &buffer, UInt32(buffer.count)) + guard length > 0 else { return false } + let path = String(cString: buffer) + if !record.binaryPath.isEmpty, path == record.binaryPath { return true } + return (path as NSString).lastPathComponent == "cloudflared" + } + + private static func isPortInUse(_ stderrTail: String) -> Bool { + stderrTail.lowercased().contains("address already in use") + } + + // MARK: - Private: App Nap + + private func updateAppNapState() { + if !tunnels.isEmpty, appNapActivity == nil { + appNapActivity = ProcessInfo.processInfo.beginActivity( + options: .userInitiatedAllowingIdleSystemSleep, + reason: "Cloudflare tunnel process requires timely execution" + ) + } else if tunnels.isEmpty, let activity = appNapActivity { + ProcessInfo.processInfo.endActivity(activity) + appNapActivity = nil + } + } +} + +// MARK: - PID record + +struct CloudflaredPidRecord: Codable, Sendable, Equatable { + let pid: Int32 + let binaryPath: String +} + +// MARK: - Startup monitor + +/// Accumulates cloudflared stderr during startup so the manager can detect a +/// browser sign-in prompt, surface an error tail, and notice an early exit. +private actor CloudflaredStartupMonitor { + private(set) var tail = "" + private(set) var browserAuthURL: String? + private(set) var streamEnded = false + private let tailCap = 2_000 + + func append(_ line: String) { + if browserAuthURL == nil, let url = Self.extractBrowserAuthURL(from: line) { + browserAuthURL = url + } + tail += line + "\n" + if tail.count > tailCap { + tail = String(tail.suffix(tailCap)) + } + } + + func markStreamEnded() { + streamEnded = true + } + + private static func extractBrowserAuthURL(from line: String) -> String? { + let lowercased = line.lowercased() + guard lowercased.contains("/cdn-cgi/access/") || lowercased.contains("browser window should have opened") else { + return nil + } + guard let range = line.range(of: "https://") else { return nil } + let token = line[range.lowerBound...].split { $0 == " " || $0 == "\"" || $0 == "\t" }.first + return token.map(String.init) + } +} diff --git a/TablePro/Core/Cloudflare/CloudflaredProcess.swift b/TablePro/Core/Cloudflare/CloudflaredProcess.swift new file mode 100644 index 000000000..4585310c0 --- /dev/null +++ b/TablePro/Core/Cloudflare/CloudflaredProcess.swift @@ -0,0 +1,147 @@ +// +// CloudflaredProcess.swift +// TablePro +// +// Spawns and supervises a single long-lived `cloudflared access tcp` process. +// The Process work is fronted by a protocol so CloudflareTunnelManager can be +// tested with a fake runner. +// + +import Foundation + +/// Terminal state of a cloudflared process. +struct CloudflaredTermination: Sendable, Equatable { + let exitCode: Int32 + let wasRequested: Bool +} + +/// Launches and supervises one cloudflared subprocess. Abstracted so the tunnel +/// manager can be exercised in tests without spawning a real process. +protocol CloudflaredRunner: AnyObject { + /// Launches cloudflared. Throws synchronously if the binary can't be spawned. + func start(binaryPath: String, arguments: [String], environment: [String: String]) throws + /// Sends SIGTERM. Safe to call multiple times and from any thread. + func stop() + /// PID of the running child, or nil before launch / after exit. + var processIdentifier: Int32? { get } + /// Stderr emitted by cloudflared, split into lines. Finishes when the process exits. + var stderrLines: AsyncStream { get } + /// Resolves once the process has terminated (normally or via stop()). + var termination: CloudflaredTermination { get async } +} + +// MARK: - Process-backed runner + +final class ProcessCloudflaredRunner: CloudflaredRunner { + private let process = Process() + private let stdoutPipe = Pipe() + private let stderrPipe = Pipe() + private let stateLock = NSLock() + + private var partialLine = "" + private var wasRequested = false + private var terminationResult: CloudflaredTermination? + private var terminationContinuation: CheckedContinuation? + + let stderrLines: AsyncStream + private let stderrContinuation: AsyncStream.Continuation + + init() { + var continuation: AsyncStream.Continuation! + // Bound the buffer: once the tunnel is ready nobody drains this stream, + // but cloudflared keeps logging for the life of the connection. + stderrLines = AsyncStream(bufferingPolicy: .bufferingNewest(100)) { continuation = $0 } + stderrContinuation = continuation + } + + var processIdentifier: Int32? { + let pid = process.processIdentifier + return pid > 0 ? pid : nil + } + + func start(binaryPath: String, arguments: [String], environment: [String: String]) throws { + process.executableURL = URL(fileURLWithPath: binaryPath) + process.arguments = arguments + process.environment = environment + process.standardInput = FileHandle.nullDevice + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + stdoutPipe.fileHandleForReading.readabilityHandler = { handle in + _ = handle.availableData + } + + stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let chunk = handle.availableData + guard !chunk.isEmpty, let self else { return } + self.ingestStderr(chunk) + } + + process.terminationHandler = { [weak self] proc in + self?.finish(exitCode: proc.terminationStatus) + } + + try process.run() + } + + func stop() { + stateLock.lock() + wasRequested = true + stateLock.unlock() + if process.isRunning { + process.terminate() + } + } + + var termination: CloudflaredTermination { + get async { + await withCheckedContinuation { continuation in + stateLock.lock() + if let cached = terminationResult { + stateLock.unlock() + continuation.resume(returning: cached) + return + } + terminationContinuation = continuation + stateLock.unlock() + } + } + } + + // MARK: - Private + + private func ingestStderr(_ chunk: Data) { + guard let text = String(data: chunk, encoding: .utf8) else { return } + stateLock.lock() + partialLine += text + var lines: [String] = [] + while let newlineIndex = partialLine.firstIndex(of: "\n") { + lines.append(String(partialLine[.. DatabaseConnection { + guard let config = connection.resolvedCloudflareConfig else { return connection } + + let tokenId: String? + let tokenSecret: String? + if config.authMethod == .serviceToken { + tokenId = connectionStorage.loadCloudflareTokenId(for: connection.id) + tokenSecret = connectionStorage.loadCloudflareTokenSecret(for: connection.id) + } else { + tokenId = nil + tokenSecret = nil + } + + let tunnelPort = try await CloudflareTunnelManager.shared.createTunnel( + connectionId: connection.id, + config: config, + tokenId: tokenId, + tokenSecret: tokenSecret + ) + + // The driver connects to 127.0.0.1, so hostname-based certificate + // verification can't match the origin. Keep transport encryption but + // drop verification and local-only cert paths, mirroring SSH tunneling. + var tunnelSSL = connection.sslConfig + if tunnelSSL.isEnabled { + if tunnelSSL.verifiesCertificate { + tunnelSSL.mode = .required + } + tunnelSSL.caCertificatePath = "" + tunnelSSL.clientCertificatePath = "" + tunnelSSL.clientKeyPath = "" + } + + var effectiveFields = connection.additionalFields + if connection.usePgpass { + effectiveFields["pgpassOriginalHost"] = connection.host + effectiveFields["pgpassOriginalPort"] = String(connection.port) + } + + return DatabaseConnection( + id: connection.id, + name: connection.name, + host: "127.0.0.1", + port: tunnelPort, + database: connection.database, + username: connection.username, + type: connection.type, + sshConfig: SSHConfiguration(), + sslConfig: tunnelSSL, + additionalFields: effectiveFields + ) + } + + // MARK: - Cloudflare Tunnel Recovery + + /// Handle Cloudflare tunnel death by reconnecting with exponential backoff. + /// Guarded by `recoveringConnectionIds` to prevent duplicate concurrent recovery. + func handleCloudflareTunnelDied(connectionId: UUID) async { + guard let session = activeSessions[connectionId], + !recoveringConnectionIds.contains(connectionId) else { return } + + recoveringConnectionIds.insert(connectionId) + defer { recoveringConnectionIds.remove(connectionId) } + + Self.logger.warning("Cloudflare tunnel died for connection: \(session.connection.name)") + + await stopHealthMonitor(for: connectionId) + + activeSessions[connectionId]?.driver?.disconnect() + updateSession(connectionId) { session in + session.driver = nil + session.status = .connecting + } + + let maxRetries = 10 + for retryCount in 0.. DatabaseConnection { + if connection.isCloudflareEnabled { + guard !connection.resolvedSSHConfig.enabled else { + throw CloudflareTunnelError.mutualExclusivityViolation + } + return try await buildCloudflareEffectiveConnection(for: connection) + } + let sshConfig = connection.resolvedSSHConfig guard sshConfig.enabled else { return connection } diff --git a/TablePro/Core/Database/DatabaseManager+Sessions.swift b/TablePro/Core/Database/DatabaseManager+Sessions.swift index 2b05f879e..b2e21af59 100644 --- a/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -90,6 +90,15 @@ extension DatabaseManager { } } } + if !Task.isCancelled, connection.isCloudflareEnabled { + Task { + do { + try await CloudflareTunnelManager.shared.closeTunnel(connectionId: connection.id) + } catch { + Self.logger.warning("Cloudflare tunnel cleanup failed for \(connection.name): \(error.localizedDescription)") + } + } + } finalizeConnectionFailure(for: connection.id, cancelled: Task.isCancelled) throw error } @@ -154,6 +163,14 @@ extension DatabaseManager { Self.logger.warning("SSH tunnel cleanup failed for \(connection.name): \(error.localizedDescription)") } } + } else if connection.isCloudflareEnabled { + Task { + do { + try await CloudflareTunnelManager.shared.closeTunnel(connectionId: connection.id) + } catch { + Self.logger.warning("Cloudflare tunnel cleanup failed for \(connection.name): \(error.localizedDescription)") + } + } } finalizeConnectionFailure(for: connection.id, cancelled: cancelled) @@ -308,6 +325,14 @@ extension DatabaseManager { ) } + if session.connection.isCloudflareEnabled { + do { + try await CloudflareTunnelManager.shared.closeTunnel(connectionId: session.connection.id) + } catch { + Self.logger.warning("Cloudflare tunnel cleanup failed for \(session.connection.name): \(error.localizedDescription)") + } + } + let hmStart = Date() await stopHealthMonitor(for: sessionId) lifecycleLogger.info( diff --git a/TablePro/Core/Database/DatabaseManager+SystemEvents.swift b/TablePro/Core/Database/DatabaseManager+SystemEvents.swift index caf5a199f..94ef952b9 100644 --- a/TablePro/Core/Database/DatabaseManager+SystemEvents.swift +++ b/TablePro/Core/Database/DatabaseManager+SystemEvents.swift @@ -25,26 +25,31 @@ extension DatabaseManager { } @objc private func handleSystemDidWake(_ notification: Notification) { - Self.logger.info("System woke from sleep — validating SSH-tunneled sessions") + Self.logger.info("System woke from sleep, validating tunneled sessions") Task { @MainActor [weak self] in guard let self else { return } - await self.validateSSHTunneledSessions() + await self.validateTunneledSessions() } } - /// After waking from sleep, proactively check all SSH-tunneled sessions. + /// After waking from sleep, proactively check all tunneled sessions. /// If the tunnel is dead, trigger an immediate reconnect rather than waiting /// for the next 30-second health monitor ping. - private func validateSSHTunneledSessions() async { - for (connectionId, session) in activeSessions { - guard session.connection.resolvedSSHConfig.enabled, - session.isConnected else { continue } - - let tunnelAlive = await SSHTunnelManager.shared.hasTunnel(connectionId: connectionId) - if !tunnelAlive { - Self.logger.warning("SSH tunnel missing after wake for: \(session.connection.name)") - await handleSSHTunnelDied(connectionId: connectionId) + private func validateTunneledSessions() async { + for (connectionId, session) in activeSessions where session.isConnected { + if session.connection.resolvedSSHConfig.enabled { + let tunnelAlive = await SSHTunnelManager.shared.hasTunnel(connectionId: connectionId) + if !tunnelAlive { + Self.logger.warning("SSH tunnel missing after wake for: \(session.connection.name)") + await handleSSHTunnelDied(connectionId: connectionId) + } + } else if session.connection.isCloudflareEnabled { + let tunnelAlive = await CloudflareTunnelManager.shared.hasTunnel(connectionId: connectionId) + if !tunnelAlive { + Self.logger.warning("Cloudflare tunnel missing after wake for: \(session.connection.name)") + await handleCloudflareTunnelDied(connectionId: connectionId) + } } } } diff --git a/TablePro/Core/Plugins/PluginManager+Registration.swift b/TablePro/Core/Plugins/PluginManager+Registration.swift index 4b2cdc740..888a93439 100644 --- a/TablePro/Core/Plugins/PluginManager+Registration.swift +++ b/TablePro/Core/Plugins/PluginManager+Registration.swift @@ -406,6 +406,11 @@ extension PluginManager { .capabilities.supportsSSL ?? true } + func supportsCloudflareTunnel(for databaseType: DatabaseType) -> Bool { + PluginMetadataRegistry.shared.snapshot(forTypeId: databaseType.pluginTypeId)? + .capabilities.supportsCloudflareTunnel ?? true + } + func supportsColumnReorder(for databaseType: DatabaseType) -> Bool { PluginMetadataRegistry.shared.snapshot(forTypeId: databaseType.pluginTypeId)? .supportsColumnReorder ?? false diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/TablePro/Core/Plugins/PluginMetadataRegistry.swift index 224e7e52a..d74719aa8 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry.swift @@ -56,6 +56,7 @@ struct PluginMetadataSnapshot: Sendable { var supportsModifyPrimaryKey: Bool = true var defaultSSLMode: SSLMode = .disabled var supportsOpportunisticTLS: Bool = true + var supportsCloudflareTunnel: Bool = true static let defaults = CapabilityFlags( supportsSchemaSwitching: false, @@ -77,7 +78,8 @@ struct PluginMetadataSnapshot: Sendable { supportsDropIndex: true, supportsModifyPrimaryKey: true, defaultSSLMode: .disabled, - supportsOpportunisticTLS: true + supportsOpportunisticTLS: true, + supportsCloudflareTunnel: true ) } @@ -694,7 +696,8 @@ final class PluginMetadataRegistry: @unchecked Sendable { supportsDropDatabase: false, supportsModifyColumn: false, supportsRenameColumn: true, - supportsModifyPrimaryKey: false + supportsModifyPrimaryKey: false, + supportsCloudflareTunnel: false ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", @@ -879,7 +882,8 @@ final class PluginMetadataRegistry: @unchecked Sendable { supportsDropIndex: driverType.supportsDropIndex, supportsModifyPrimaryKey: driverType.supportsModifyPrimaryKey, defaultSSLMode: existingSnapshot?.capabilities.defaultSSLMode ?? .disabled, - supportsOpportunisticTLS: existingSnapshot?.capabilities.supportsOpportunisticTLS ?? true + supportsOpportunisticTLS: existingSnapshot?.capabilities.supportsOpportunisticTLS ?? true, + supportsCloudflareTunnel: driverType.supportsSSH ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: driverType.defaultSchemaName, diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index 3c3c63a21..63700a27f 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -380,6 +380,38 @@ final class ConnectionStorage { keychain.delete(forKey: key) } + // MARK: - Cloudflare Service Token Storage + + func saveCloudflareTokenId(_ tokenId: String, for connectionId: UUID) { + let key = "com.TablePro.cloudflaretokenid.\(connectionId.uuidString)" + keychain.writeString(tokenId, forKey: key) + } + + func loadCloudflareTokenId(for connectionId: UUID) -> String? { + let key = "com.TablePro.cloudflaretokenid.\(connectionId.uuidString)" + return resolveString(.init(label: "Cloudflare token ID", connectionId: connectionId), forKey: key) + } + + func deleteCloudflareTokenId(for connectionId: UUID) { + let key = "com.TablePro.cloudflaretokenid.\(connectionId.uuidString)" + keychain.delete(forKey: key) + } + + func saveCloudflareTokenSecret(_ tokenSecret: String, for connectionId: UUID) { + let key = "com.TablePro.cloudflaretokensecret.\(connectionId.uuidString)" + keychain.writeString(tokenSecret, forKey: key) + } + + func loadCloudflareTokenSecret(for connectionId: UUID) -> String? { + let key = "com.TablePro.cloudflaretokensecret.\(connectionId.uuidString)" + return resolveString(.init(label: "Cloudflare token secret", connectionId: connectionId), forKey: key) + } + + func deleteCloudflareTokenSecret(for connectionId: UUID) { + let key = "com.TablePro.cloudflaretokensecret.\(connectionId.uuidString)" + keychain.delete(forKey: key) + } + private struct SecretContext { let label: String let connectionId: UUID @@ -524,6 +556,9 @@ private struct StoredConnection: Codable { // SSH tunnel mode (v2 JSON blob preserving jump hosts + profile links) let sshTunnelModeJson: Data? + // Cloudflare Access TCP tunnel mode (JSON blob) + let cloudflareTunnelModeJson: Data? + // Plugin-driven additional fields let additionalFields: [String: String]? @@ -602,6 +637,11 @@ private struct StoredConnection: Codable { // SSH tunnel mode (v2 format preserving jump hosts, profiles, etc.) self.sshTunnelModeJson = try? JSONEncoder().encode(connection.sshTunnelMode) + // Cloudflare tunnel mode (only persisted when enabled) + self.cloudflareTunnelModeJson = connection.isCloudflareEnabled + ? (try? JSONEncoder().encode(connection.cloudflareTunnelMode)) + : nil + // Plugin-driven additional fields self.additionalFields = connection.additionalFields.isEmpty ? nil : connection.additionalFields } @@ -621,6 +661,7 @@ private struct StoredConnection: Codable { case mongoAuthSource, mongoReadPreference, mongoWriteConcern, redisDatabase case mssqlSchema, oracleServiceName, startupCommands, sortOrder case sshTunnelModeJson + case cloudflareTunnelModeJson case additionalFields case localOnly case isSample @@ -662,6 +703,7 @@ private struct StoredConnection: Codable { try container.encodeIfPresent(startupCommands, forKey: .startupCommands) try container.encode(sortOrder, forKey: .sortOrder) try container.encodeIfPresent(sshTunnelModeJson, forKey: .sshTunnelModeJson) + try container.encodeIfPresent(cloudflareTunnelModeJson, forKey: .cloudflareTunnelModeJson) try container.encodeIfPresent(additionalFields, forKey: .additionalFields) try container.encode(localOnly, forKey: .localOnly) try container.encode(isSample, forKey: .isSample) @@ -729,6 +771,7 @@ private struct StoredConnection: Codable { startupCommands = try container.decodeIfPresent(String.self, forKey: .startupCommands) sortOrder = try container.decodeIfPresent(Int.self, forKey: .sortOrder) ?? 0 sshTunnelModeJson = try container.decodeIfPresent(Data.self, forKey: .sshTunnelModeJson) + cloudflareTunnelModeJson = try container.decodeIfPresent(Data.self, forKey: .cloudflareTunnelModeJson) additionalFields = try container.decodeIfPresent([String: String].self, forKey: .additionalFields) localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly) ?? false isSample = try container.decodeIfPresent(Bool.self, forKey: .isSample) ?? false @@ -766,6 +809,14 @@ private struct StoredConnection: Codable { resolvedTunnelMode = .disabled } + let resolvedCloudflareMode: CloudflareTunnelMode + if let json = cloudflareTunnelModeJson, + let decoded = try? JSONDecoder().decode(CloudflareTunnelMode.self, from: json) { + resolvedCloudflareMode = decoded + } else { + resolvedCloudflareMode = .disabled + } + var resolvedSSLCaPath = sslCaCertificatePath if type == "Cassandra", resolvedSSLCaPath.isEmpty, let legacy = additionalFields?["sslCaCertPath"], !legacy.isEmpty { @@ -817,6 +868,7 @@ private struct StoredConnection: Codable { groupId: parsedGroupId, sshProfileId: parsedSSHProfileId, sshTunnelMode: resolvedTunnelMode, + cloudflareTunnelMode: resolvedCloudflareMode, safeModeLevel: SafeModeLevel(rawValue: safeModeLevel) ?? .silent, aiPolicy: parsedAIPolicy, aiRules: aiRules, diff --git a/TablePro/Core/Sync/SyncRecordMapper.swift b/TablePro/Core/Sync/SyncRecordMapper.swift index 0d14e43a2..b0e236b1c 100644 --- a/TablePro/Core/Sync/SyncRecordMapper.swift +++ b/TablePro/Core/Sync/SyncRecordMapper.swift @@ -110,6 +110,8 @@ struct SyncRecordMapper { // Note: sshTunnelMode is intentionally NOT synced — it is re-derived // on decode from sshConfig + sshProfileId. If adding sshTunnelMode to // the sync schema in the future, apply path contraction to its snapshot. + // cloudflareTunnelMode is also NOT synced: it is device-local runtime + // config and its service-token secrets live in the Keychain. do { let sshData = try encoder.encode(Self.makePortable(connection.sshConfig)) record["sshConfigJson"] = sshData as CKRecordValue diff --git a/TablePro/Models/Connection/CloudflareConfiguration.swift b/TablePro/Models/Connection/CloudflareConfiguration.swift new file mode 100644 index 000000000..87566058e --- /dev/null +++ b/TablePro/Models/Connection/CloudflareConfiguration.swift @@ -0,0 +1,51 @@ +// +// CloudflareConfiguration.swift +// TablePro +// + +import Foundation + +/// How TablePro authenticates the cloudflared subprocess to Cloudflare Access. +enum CloudflareAuthMethod: String, CaseIterable, Identifiable, Codable, Sendable { + case browserSSO + case serviceToken + + var id: String { rawValue } + + var displayName: String { + switch self { + case .browserSSO: return String(localized: "Browser Sign-In") + case .serviceToken: return String(localized: "Service Token") + } + } +} + +/// Cloudflare Access TCP tunnel configuration for a database connection. +/// cloudflared opens the local listener itself via `--url`, so TablePro only +/// chooses the loopback port and supervises the process. +struct CloudflareConfiguration: Codable, Hashable, Sendable { + var accessHostname: String = "" + var localPort: Int? + var authMethod: CloudflareAuthMethod = .browserSSO + var exposeToLAN: Bool = false + var binaryPath: String = "" + + var isValid: Bool { + !accessHostname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } +} + +extension CloudflareConfiguration { + private enum CodingKeys: String, CodingKey { + case accessHostname, localPort, authMethod, exposeToLAN, binaryPath + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + accessHostname = try container.decodeIfPresent(String.self, forKey: .accessHostname) ?? "" + localPort = try container.decodeIfPresent(Int.self, forKey: .localPort) + authMethod = try container.decodeIfPresent(CloudflareAuthMethod.self, forKey: .authMethod) ?? .browserSSO + exposeToLAN = try container.decodeIfPresent(Bool.self, forKey: .exposeToLAN) ?? false + binaryPath = try container.decodeIfPresent(String.self, forKey: .binaryPath) ?? "" + } +} diff --git a/TablePro/Models/Connection/CloudflareTunnelFormState.swift b/TablePro/Models/Connection/CloudflareTunnelFormState.swift new file mode 100644 index 000000000..75045ab3a --- /dev/null +++ b/TablePro/Models/Connection/CloudflareTunnelFormState.swift @@ -0,0 +1,61 @@ +// +// CloudflareTunnelFormState.swift +// TablePro +// + +import Foundation + +/// Encapsulates all Cloudflare tunnel UI state for the connection form. +struct CloudflareTunnelFormState { + var enabled: Bool = false + var accessHostname: String = "" + var authMethod: CloudflareAuthMethod = .browserSSO + var serviceTokenId: String = "" + var serviceTokenSecret: String = "" + var automaticPort: Bool = true + var localPort: String = "" + var exposeToLAN: Bool = false + var binaryPath: String = "" + + // MARK: - Build Methods + + func buildConfig() -> CloudflareConfiguration { + CloudflareConfiguration( + accessHostname: accessHostname.trimmingCharacters(in: .whitespacesAndNewlines), + localPort: automaticPort ? nil : Int(localPort), + authMethod: authMethod, + exposeToLAN: exposeToLAN, + binaryPath: binaryPath.trimmingCharacters(in: .whitespacesAndNewlines) + ) + } + + func buildTunnelMode() -> CloudflareTunnelMode { + enabled ? .inline(buildConfig()) : .disabled + } + + // MARK: - Load Methods + + mutating func load(from connection: DatabaseConnection) { + switch connection.cloudflareTunnelMode { + case .disabled: + enabled = false + case .inline(let config): + enabled = true + populateFields(from: config) + } + } + + mutating func populateFields(from config: CloudflareConfiguration) { + accessHostname = config.accessHostname + authMethod = config.authMethod + exposeToLAN = config.exposeToLAN + binaryPath = config.binaryPath + if let port = config.localPort { + automaticPort = false + localPort = String(port) + } else { + automaticPort = true + localPort = "" + } + } +} diff --git a/TablePro/Models/Connection/CloudflareTunnelMode.swift b/TablePro/Models/Connection/CloudflareTunnelMode.swift new file mode 100644 index 000000000..c66d4776e --- /dev/null +++ b/TablePro/Models/Connection/CloudflareTunnelMode.swift @@ -0,0 +1,49 @@ +// +// CloudflareTunnelMode.swift +// TablePro +// + +import Foundation + +/// Single source of truth for how a connection handles Cloudflare Access TCP tunneling. +enum CloudflareTunnelMode: Hashable, Sendable { + case disabled + case inline(CloudflareConfiguration) +} + +// MARK: - Codable + +extension CloudflareTunnelMode: Codable { + private enum CodingKeys: String, CodingKey { + case mode + case config + } + + private enum Mode: String, Codable { + case disabled + case inline + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let mode = try container.decode(Mode.self, forKey: .mode) + switch mode { + case .disabled: + self = .disabled + case .inline: + let config = try container.decode(CloudflareConfiguration.self, forKey: .config) + self = .inline(config) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .disabled: + try container.encode(Mode.disabled, forKey: .mode) + case .inline(let config): + try container.encode(Mode.inline, forKey: .mode) + try container.encode(config, forKey: .config) + } + } +} diff --git a/TablePro/Models/Connection/DatabaseConnection+Cloudflare.swift b/TablePro/Models/Connection/DatabaseConnection+Cloudflare.swift new file mode 100644 index 000000000..cd67e4236 --- /dev/null +++ b/TablePro/Models/Connection/DatabaseConnection+Cloudflare.swift @@ -0,0 +1,18 @@ +// +// DatabaseConnection+Cloudflare.swift +// TablePro +// + +extension DatabaseConnection { + /// Whether this connection routes through a Cloudflare Access TCP tunnel. + var isCloudflareEnabled: Bool { + if case .inline = cloudflareTunnelMode { return true } + return false + } + + /// The resolved Cloudflare configuration, or nil when tunneling is disabled. + var resolvedCloudflareConfig: CloudflareConfiguration? { + if case .inline(let config) = cloudflareTunnelMode { return config } + return nil + } +} diff --git a/TablePro/Models/Connection/DatabaseConnection.swift b/TablePro/Models/Connection/DatabaseConnection.swift index 45ee4a1c0..cbb039778 100644 --- a/TablePro/Models/Connection/DatabaseConnection.swift +++ b/TablePro/Models/Connection/DatabaseConnection.swift @@ -321,6 +321,7 @@ struct DatabaseConnection: Identifiable, Hashable { var groupId: UUID? var sshProfileId: UUID? var sshTunnelMode: SSHTunnelMode + var cloudflareTunnelMode: CloudflareTunnelMode = .disabled var safeModeLevel: SafeModeLevel var aiPolicy: AIConnectionPolicy? var aiRules: String? @@ -403,6 +404,7 @@ struct DatabaseConnection: Identifiable, Hashable { groupId: UUID? = nil, sshProfileId: UUID? = nil, sshTunnelMode: SSHTunnelMode = .disabled, + cloudflareTunnelMode: CloudflareTunnelMode = .disabled, safeModeLevel: SafeModeLevel = .silent, aiPolicy: AIConnectionPolicy? = nil, aiRules: String? = nil, @@ -452,6 +454,7 @@ struct DatabaseConnection: Identifiable, Hashable { } else { self.sshTunnelMode = sshTunnelMode } + self.cloudflareTunnelMode = cloudflareTunnelMode self.aiPolicy = aiPolicy self.aiRules = aiRules self.aiAlwaysAllowedTools = aiAlwaysAllowedTools @@ -507,7 +510,7 @@ extension DatabaseConnection: Codable { private enum CodingKeys: String, CodingKey { case id, name, host, port, database, username, type case sshConfig, sslConfig, color, tagId, groupId, sshProfileId - case sshTunnelMode, safeModeLevel, aiPolicy, aiRules, aiAlwaysAllowedTools, externalAccess, additionalFields + case sshTunnelMode, cloudflareTunnelMode, safeModeLevel, aiPolicy, aiRules, aiAlwaysAllowedTools, externalAccess, additionalFields case redisDatabase, startupCommands, sortOrder, localOnly, isSample } @@ -537,6 +540,7 @@ extension DatabaseConnection: Codable { sortOrder = try container.decodeIfPresent(Int.self, forKey: .sortOrder) ?? 0 localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly) ?? false isSample = try container.decodeIfPresent(Bool.self, forKey: .isSample) ?? false + cloudflareTunnelMode = try container.decodeIfPresent(CloudflareTunnelMode.self, forKey: .cloudflareTunnelMode) ?? .disabled // Migrate from legacy fields if sshTunnelMode is not present if let tunnelMode = try container.decodeIfPresent(SSHTunnelMode.self, forKey: .sshTunnelMode) { @@ -570,6 +574,9 @@ extension DatabaseConnection: Codable { try container.encodeIfPresent(groupId, forKey: .groupId) try container.encodeIfPresent(sshProfileId, forKey: .sshProfileId) try container.encode(sshTunnelMode, forKey: .sshTunnelMode) + if case .inline = cloudflareTunnelMode { + try container.encode(cloudflareTunnelMode, forKey: .cloudflareTunnelMode) + } try container.encode(safeModeLevel, forKey: .safeModeLevel) try container.encodeIfPresent(aiPolicy, forKey: .aiPolicy) try container.encodeIfPresent(aiRules, forKey: .aiRules) diff --git a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift index 23b9e6f74..95c45755d 100644 --- a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift +++ b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift @@ -29,6 +29,7 @@ final class ConnectionFormCoordinator { var network: NetworkPaneViewModel var auth: AuthPaneViewModel var ssh: SSHPaneViewModel + var cloudflareTunnel: CloudflareTunnelPaneViewModel var ssl: SSLPaneViewModel var customization: CustomizationPaneViewModel var advanced: AdvancedPaneViewModel @@ -65,6 +66,9 @@ final class ConnectionFormCoordinator { if services.pluginManager.supportsSSH(for: network.type) { panes.append(.ssh) } + if services.pluginManager.supportsCloudflareTunnel(for: network.type) { + panes.append(.cloudflareTunnel) + } if services.pluginManager.supportsSSL(for: network.type) { panes.append(.ssl) } @@ -78,6 +82,7 @@ final class ConnectionFormCoordinator { network.validationIssues.isEmpty && auth.validationIssues.isEmpty && ssh.validationIssues.isEmpty + && cloudflareTunnel.validationIssues.isEmpty && ssl.validationIssues.isEmpty && customization.validationIssues.isEmpty && advanced.validationIssues.isEmpty @@ -99,6 +104,7 @@ final class ConnectionFormCoordinator { self.network = NetworkPaneViewModel() self.auth = AuthPaneViewModel() self.ssh = SSHPaneViewModel() + self.cloudflareTunnel = CloudflareTunnelPaneViewModel() self.ssl = SSLPaneViewModel() self.customization = CustomizationPaneViewModel() self.advanced = AdvancedPaneViewModel() @@ -108,6 +114,7 @@ final class ConnectionFormCoordinator { network.coordinator = ref auth.coordinator = ref ssh.coordinator = ref + cloudflareTunnel.coordinator = ref ssl.coordinator = ref customization.coordinator = ref advanced.coordinator = ref @@ -152,6 +159,7 @@ final class ConnectionFormCoordinator { network.load(from: existing) auth.load(from: existing, storage: storage) ssh.load(from: existing, storage: storage) + cloudflareTunnel.load(from: existing, storage: storage) ssl.load(from: existing) customization.load(from: existing) advanced.load(from: existing) @@ -255,6 +263,7 @@ final class ConnectionFormCoordinator { } let sshTunnelMode = ssh.state.buildTunnelMode() + let cloudflareTunnelMode = cloudflareTunnel.state.buildTunnelMode() let connectionToSave = DatabaseConnection( id: finalId, name: network.name, @@ -270,6 +279,7 @@ final class ConnectionFormCoordinator { groupId: customization.groupId, sshProfileId: ssh.state.enabled ? ssh.state.profileId : nil, sshTunnelMode: sshTunnelMode, + cloudflareTunnelMode: cloudflareTunnelMode, safeModeLevel: customization.safeModeLevel, aiPolicy: advanced.aiPolicy, aiRules: aiRules.trimmedRules, @@ -307,6 +317,8 @@ final class ConnectionFormCoordinator { storage.deleteTOTPSecret(for: connectionToSave.id) } + cloudflareTunnel.save(to: connectionToSave.id, storage: storage) + var savedConnections = storage.loadConnections() if isNew { savedConnections.append(connectionToSave) @@ -428,6 +440,7 @@ final class ConnectionFormCoordinator { } let testTunnelMode = ssh.state.buildTunnelMode() + let testCloudflareMode = cloudflareTunnel.state.buildTunnelMode() let testConn = DatabaseConnection( name: network.name, host: testHost, @@ -442,6 +455,7 @@ final class ConnectionFormCoordinator { groupId: customization.groupId, sshProfileId: ssh.state.enabled ? ssh.state.profileId : nil, sshTunnelMode: testTunnelMode, + cloudflareTunnelMode: testCloudflareMode, redisDatabase: advanced.additionalFieldValues["redisDatabase"].map { Int($0) ?? 0 }, startupCommands: advanced.startupCommands.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : advanced.startupCommands, @@ -454,6 +468,7 @@ final class ConnectionFormCoordinator { let connectionType = network.type let displayName = network.name.isEmpty ? network.host : network.name let sshState = ssh.state + let cloudflareState = cloudflareTunnel.state let additionalFieldValues = finalAdditionalFields testTask = Task { [weak self] in @@ -475,6 +490,11 @@ final class ConnectionFormCoordinator { } } + if cloudflareState.enabled && cloudflareState.authMethod == .serviceToken { + services.connectionStorage.saveCloudflareTokenId(cloudflareState.serviceTokenId, for: testConn.id) + services.connectionStorage.saveCloudflareTokenSecret(cloudflareState.serviceTokenSecret, for: testConn.id) + } + for field in services.pluginManager.additionalConnectionFields(for: connectionType) where field.isSecure { @@ -554,6 +574,8 @@ final class ConnectionFormCoordinator { services.connectionStorage.deleteSSHPassword(for: testId) services.connectionStorage.deleteKeyPassphrase(for: testId) services.connectionStorage.deleteTOTPSecret(for: testId) + services.connectionStorage.deleteCloudflareTokenId(for: testId) + services.connectionStorage.deleteCloudflareTokenSecret(for: testId) let secureFieldIds = services.pluginManager.additionalConnectionFields(for: network.type) .filter(\.isSecure).map(\.id) services.connectionStorage.deleteAllPluginSecureFields(for: testId, fieldIds: secureFieldIds) diff --git a/TablePro/Views/ConnectionForm/ConnectionFormPane.swift b/TablePro/Views/ConnectionForm/ConnectionFormPane.swift index 7a2d0e37d..e65d255c4 100644 --- a/TablePro/Views/ConnectionForm/ConnectionFormPane.swift +++ b/TablePro/Views/ConnectionForm/ConnectionFormPane.swift @@ -8,6 +8,7 @@ import Foundation enum ConnectionFormPane: String, CaseIterable, Identifiable, Hashable { case general case ssh + case cloudflareTunnel case ssl case customization case advanced @@ -19,6 +20,7 @@ enum ConnectionFormPane: String, CaseIterable, Identifiable, Hashable { switch self { case .general: return String(localized: "General") case .ssh: return String(localized: "SSH Tunnel") + case .cloudflareTunnel: return String(localized: "Cloudflare Tunnel") case .ssl: return String(localized: "SSL/TLS") case .customization: return String(localized: "Customization") case .advanced: return String(localized: "Advanced") @@ -30,6 +32,7 @@ enum ConnectionFormPane: String, CaseIterable, Identifiable, Hashable { switch self { case .general: return "network" case .ssh: return "lock.shield" + case .cloudflareTunnel: return "cloud" case .ssl: return "lock.fill" case .customization: return "paintbrush" case .advanced: return "gearshape.2" @@ -45,6 +48,8 @@ enum ConnectionFormPane: String, CaseIterable, Identifiable, Hashable { issues = coordinator.network.validationIssues + coordinator.auth.validationIssues case .ssh: issues = coordinator.ssh.validationIssues + case .cloudflareTunnel: + issues = coordinator.cloudflareTunnel.validationIssues case .ssl: issues = coordinator.ssl.validationIssues case .customization: diff --git a/TablePro/Views/ConnectionForm/ConnectionFormView.swift b/TablePro/Views/ConnectionForm/ConnectionFormView.swift index 76b101f30..f95693818 100644 --- a/TablePro/Views/ConnectionForm/ConnectionFormView.swift +++ b/TablePro/Views/ConnectionForm/ConnectionFormView.swift @@ -97,6 +97,8 @@ private struct ConnectionFormDetail: View { GeneralPaneView(coordinator: coordinator) case .ssh: SSHPaneView(coordinator: coordinator) + case .cloudflareTunnel: + CloudflareTunnelPaneView(coordinator: coordinator) case .ssl: SSLPaneView(coordinator: coordinator) case .customization: diff --git a/TablePro/Views/ConnectionForm/Panes/CloudflareTunnelPaneView.swift b/TablePro/Views/ConnectionForm/Panes/CloudflareTunnelPaneView.swift new file mode 100644 index 000000000..52c8ff309 --- /dev/null +++ b/TablePro/Views/ConnectionForm/Panes/CloudflareTunnelPaneView.swift @@ -0,0 +1,156 @@ +// +// CloudflareTunnelPaneView.swift +// TablePro +// + +import AppKit +import SwiftUI + +struct CloudflareTunnelPaneView: View { + @Bindable var coordinator: ConnectionFormCoordinator + + private var viewModel: CloudflareTunnelPaneViewModel { coordinator.cloudflareTunnel } + + var body: some View { + Form { + Section { + Toggle(String(localized: "Enable Cloudflare Tunnel"), isOn: $coordinator.cloudflareTunnel.state.enabled) + } footer: { + Text("Starts and stops `cloudflared access tcp` with this connection and routes it through a local port.") + } + + if coordinator.cloudflareTunnel.state.enabled { + if coordinator.ssh.state.enabled { + mutualExclusivitySection + } + hostnameSection + authenticationSection + listenerSection + binarySection + } + } + .formStyle(.grouped) + .scrollContentBackground(.hidden) + } + + // MARK: - Sections + + private var mutualExclusivitySection: some View { + Section { + Label( + String(localized: "A connection can use one tunnel at a time. Disable the SSH Tunnel to use a Cloudflare Tunnel."), + systemImage: "exclamationmark.triangle.fill" + ) + .foregroundStyle(.orange) + Button("Disable SSH Tunnel") { + coordinator.ssh.state.disable() + } + } + } + + private var hostnameSection: some View { + Section(String(localized: "Access Application")) { + TextField( + String(localized: "Hostname"), + text: $coordinator.cloudflareTunnel.state.accessHostname, + prompt: Text(verbatim: "db.example.com") + ) + .autocorrectionDisabled() + } + } + + @ViewBuilder + private var authenticationSection: some View { + Section(String(localized: "Authentication")) { + Picker(String(localized: "Method"), selection: $coordinator.cloudflareTunnel.state.authMethod) { + ForEach(CloudflareAuthMethod.allCases) { method in + Text(method.displayName).tag(method) + } + } + + switch coordinator.cloudflareTunnel.state.authMethod { + case .browserSSO: + Button("Sign In with Browser...") { + viewModel.signInWithBrowser() + } + .disabled(coordinator.cloudflareTunnel.state.accessHostname.trimmingCharacters(in: .whitespaces).isEmpty) + Text("Signs in to Cloudflare Access once and caches the token, so connecting doesn't open a browser.") + .font(.caption) + .foregroundStyle(.secondary) + case .serviceToken: + SecureField(String(localized: "Client ID"), text: $coordinator.cloudflareTunnel.state.serviceTokenId) + SecureField(String(localized: "Client Secret"), text: $coordinator.cloudflareTunnel.state.serviceTokenSecret) + Text("The Access application policy must use a Service Auth rule, or Cloudflare still prompts for browser sign-in.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + @ViewBuilder + private var listenerSection: some View { + Section { + Toggle(String(localized: "Choose port automatically"), isOn: $coordinator.cloudflareTunnel.state.automaticPort) + if !coordinator.cloudflareTunnel.state.automaticPort { + TextField( + String(localized: "Local port"), + text: $coordinator.cloudflareTunnel.state.localPort, + prompt: Text(verbatim: "5432") + ) + } + Toggle(String(localized: "Expose to local network"), isOn: $coordinator.cloudflareTunnel.state.exposeToLAN) + } header: { + Text("Local Listener") + } footer: { + if coordinator.cloudflareTunnel.state.exposeToLAN { + Text("Listens on all interfaces (0.0.0.0), reachable from your local network.") + } else { + Text("Listens only on 127.0.0.1.") + } + } + } + + @ViewBuilder + private var binarySection: some View { + Section { + TextField( + String(localized: "Path"), + text: $coordinator.cloudflareTunnel.state.binaryPath, + prompt: Text("Automatic") + ) + Button("Choose...") { + chooseBinary() + } + .controlSize(.small) + + if coordinator.cloudflareTunnel.state.binaryPath.isEmpty { + if let resolved = viewModel.resolvedBinaryPath { + LabeledContent(String(localized: "Detected"), value: resolved) + .foregroundStyle(.secondary) + } else if viewModel.didResolveBinary { + Label( + String(localized: "cloudflared not found. Install it with `brew install cloudflared`, or choose the binary above."), + systemImage: "exclamationmark.triangle.fill" + ) + .foregroundStyle(.orange) + .textSelection(.enabled) + } + } + } header: { + Text(verbatim: "cloudflared") + } + } + + // MARK: - Actions + + private func chooseBinary() { + let panel = NSOpenPanel() + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = false + panel.directoryURL = URL(fileURLWithPath: "/usr/local/bin") + if panel.runModal() == .OK, let url = panel.url { + coordinator.cloudflareTunnel.state.binaryPath = url.path + } + } +} diff --git a/TablePro/Views/ConnectionForm/ViewModels/CloudflareTunnelPaneViewModel.swift b/TablePro/Views/ConnectionForm/ViewModels/CloudflareTunnelPaneViewModel.swift new file mode 100644 index 000000000..55b6f2e0d --- /dev/null +++ b/TablePro/Views/ConnectionForm/ViewModels/CloudflareTunnelPaneViewModel.swift @@ -0,0 +1,83 @@ +// +// CloudflareTunnelPaneViewModel.swift +// TablePro +// + +import Foundation + +@Observable +@MainActor +final class CloudflareTunnelPaneViewModel { + var state = CloudflareTunnelFormState() + + var coordinator: WeakCoordinatorRef? + + var resolvedBinaryPath: String? + var didResolveBinary: Bool = false + + var validationIssues: [String] { + guard state.enabled else { return [] } + var issues: [String] = [] + + if state.accessHostname.trimmingCharacters(in: .whitespaces).isEmpty { + issues.append(String(localized: "Cloudflare hostname is required")) + } + + if !state.automaticPort { + let portIsValid = Int(state.localPort).map { (1...65_535).contains($0) } ?? false + if !portIsValid { + issues.append(String(localized: "Local port must be between 1 and 65535")) + } + } + + if state.authMethod == .serviceToken { + if state.serviceTokenId.trimmingCharacters(in: .whitespaces).isEmpty + || state.serviceTokenSecret.trimmingCharacters(in: .whitespaces).isEmpty { + issues.append(String(localized: "Service token ID and secret are required")) + } + } + + if coordinator?.value?.ssh.state.enabled == true { + issues.append(String(localized: "Cannot use SSH Tunnel and Cloudflare Tunnel at the same time")) + } + + return issues + } + + func load(from connection: DatabaseConnection, storage: ConnectionStorage) { + state.load(from: connection) + state.serviceTokenId = storage.loadCloudflareTokenId(for: connection.id) ?? "" + state.serviceTokenSecret = storage.loadCloudflareTokenSecret(for: connection.id) ?? "" + resolveBinary() + } + + func save(to connectionId: UUID, storage: ConnectionStorage) { + guard state.enabled, state.authMethod == .serviceToken else { + storage.deleteCloudflareTokenId(for: connectionId) + storage.deleteCloudflareTokenSecret(for: connectionId) + return + } + storage.saveCloudflareTokenId(state.serviceTokenId, for: connectionId) + storage.saveCloudflareTokenSecret(state.serviceTokenSecret, for: connectionId) + } + + func resolveBinary() { + Task { + let path = await Task.detached { CLIExecutableFinder.findExecutable("cloudflared") }.value + resolvedBinaryPath = path + didResolveBinary = true + } + } + + func signInWithBrowser() { + let hostname = state.accessHostname.trimmingCharacters(in: .whitespacesAndNewlines) + guard !hostname.isEmpty else { return } + let binaryPath = state.binaryPath.isEmpty ? resolvedBinaryPath : state.binaryPath + guard let binaryPath, FileManager.default.isExecutableFile(atPath: binaryPath) else { return } + + let process = Process() + process.executableURL = URL(fileURLWithPath: binaryPath) + process.arguments = ["access", "login", hostname] + try? process.run() + } +} diff --git a/TableProTests/Cloudflare/CloudflareModelTests.swift b/TableProTests/Cloudflare/CloudflareModelTests.swift new file mode 100644 index 000000000..9807ea169 --- /dev/null +++ b/TableProTests/Cloudflare/CloudflareModelTests.swift @@ -0,0 +1,82 @@ +// +// CloudflareModelTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("Cloudflare tunnel model") +struct CloudflareModelTests { + @Test("CloudflareConfiguration round-trips through Codable") + func configurationRoundTrip() throws { + let config = CloudflareConfiguration( + accessHostname: "db.example.com", + localPort: 6543, + authMethod: .serviceToken, + exposeToLAN: true, + binaryPath: "/opt/homebrew/bin/cloudflared" + ) + + let data = try JSONEncoder().encode(config) + let decoded = try JSONDecoder().decode(CloudflareConfiguration.self, from: data) + + #expect(decoded == config) + } + + @Test("CloudflareTunnelMode encodes inline config and decodes back") + func tunnelModeRoundTrip() throws { + let mode = CloudflareTunnelMode.inline(CloudflareConfiguration(accessHostname: "tcp.example.com")) + + let data = try JSONEncoder().encode(mode) + let decoded = try JSONDecoder().decode(CloudflareTunnelMode.self, from: data) + + #expect(decoded == mode) + } + + @Test("CloudflareTunnelMode disabled round-trips") + func disabledRoundTrip() throws { + let data = try JSONEncoder().encode(CloudflareTunnelMode.disabled) + let decoded = try JSONDecoder().decode(CloudflareTunnelMode.self, from: data) + #expect(decoded == .disabled) + } + + @Test("DatabaseConnection preserves cloudflareTunnelMode through Codable") + func connectionRoundTrip() throws { + let connection = DatabaseConnection( + name: "CF", + host: "db.internal", + port: 5432, + type: .postgresql, + cloudflareTunnelMode: .inline(CloudflareConfiguration(accessHostname: "db.example.com", authMethod: .browserSSO)) + ) + + let data = try JSONEncoder().encode(connection) + let decoded = try JSONDecoder().decode(DatabaseConnection.self, from: data) + + #expect(decoded.cloudflareTunnelMode == connection.cloudflareTunnelMode) + #expect(decoded.isCloudflareEnabled) + #expect(decoded.resolvedCloudflareConfig?.accessHostname == "db.example.com") + } + + @Test("DatabaseConnection without cloudflare defaults to disabled") + func connectionDefaultsDisabled() throws { + let connection = DatabaseConnection(name: "Plain", type: .mysql) + let data = try JSONEncoder().encode(connection) + let decoded = try JSONDecoder().decode(DatabaseConnection.self, from: data) + + #expect(decoded.cloudflareTunnelMode == .disabled) + #expect(!decoded.isCloudflareEnabled) + #expect(decoded.resolvedCloudflareConfig == nil) + } + + @Test("CloudflaredPidRecord round-trips for the stale-PID sweep") + func pidRecordRoundTrip() throws { + let records = [CloudflaredPidRecord(pid: 4242, binaryPath: "/opt/homebrew/bin/cloudflared")] + let data = try JSONEncoder().encode(records) + let decoded = try JSONDecoder().decode([CloudflaredPidRecord].self, from: data) + #expect(decoded == records) + } +} diff --git a/TableProTests/Cloudflare/CloudflareTunnelManagerTests.swift b/TableProTests/Cloudflare/CloudflareTunnelManagerTests.swift new file mode 100644 index 000000000..0e581e130 --- /dev/null +++ b/TableProTests/Cloudflare/CloudflareTunnelManagerTests.swift @@ -0,0 +1,201 @@ +// +// CloudflareTunnelManagerTests.swift +// TableProTests +// + +import Darwin +import Foundation +import Testing + +@testable import TablePro + +/// Fake cloudflared process. Depending on `behavior` it either opens a real +/// loopback listener (so the manager's readiness probe succeeds), prints a +/// browser sign-in line, or exits during startup. +final class FakeCloudflaredRunner: CloudflaredRunner, @unchecked Sendable { + enum Behavior { + case ready + case browserAuth + case startupFailure + } + + let behavior: Behavior + private(set) var stopCallCount = 0 + private var listenerFd: Int32? + + let stderrLines: AsyncStream + private let stderrContinuation: AsyncStream.Continuation + + private let lock = NSLock() + private var requested = false + private var terminationResult: CloudflaredTermination? + private var terminationContinuation: CheckedContinuation? + + init(behavior: Behavior) { + self.behavior = behavior + var continuation: AsyncStream.Continuation! + stderrLines = AsyncStream { continuation = $0 } + stderrContinuation = continuation + } + + var processIdentifier: Int32? { 4_242 } + + func start(binaryPath: String, arguments: [String], environment: [String: String]) throws { + switch behavior { + case .ready: + if let port = Self.parsePort(arguments) { + listenerFd = Self.openListener(port: port) + } + case .browserAuth: + stderrContinuation.yield( + "INF A browser window should have opened at the following URL: https://team.cloudflareaccess.com/cdn-cgi/access/cli?redirect_url=tcp" + ) + case .startupFailure: + stderrContinuation.yield("ERR failed to dial origin: connection refused") + finish(exitCode: 1) + } + } + + func stop() { + lock.lock() + requested = true + stopCallCount += 1 + lock.unlock() + if let fd = listenerFd { + close(fd) + listenerFd = nil + } + finish(exitCode: 0) + } + + var termination: CloudflaredTermination { + get async { + await withCheckedContinuation { continuation in + lock.lock() + if let cached = terminationResult { + lock.unlock() + continuation.resume(returning: cached) + return + } + terminationContinuation = continuation + lock.unlock() + } + } + } + + private func finish(exitCode: Int32) { + lock.lock() + if terminationResult != nil { + lock.unlock() + return + } + let result = CloudflaredTermination(exitCode: exitCode, wasRequested: requested) + terminationResult = result + let pending = terminationContinuation + terminationContinuation = nil + lock.unlock() + stderrContinuation.finish() + pending?.resume(returning: result) + } + + private static func parsePort(_ arguments: [String]) -> Int? { + guard let index = arguments.firstIndex(of: "--url"), index + 1 < arguments.count else { return nil } + return arguments[index + 1].split(separator: ":").last.flatMap { Int($0) } + } + + private static func openListener(port: Int) -> Int32? { + let descriptor = socket(AF_INET, SOCK_STREAM, 0) + guard descriptor >= 0 else { return nil } + var reuse: Int32 = 1 + setsockopt(descriptor, SOL_SOCKET, SO_REUSEADDR, &reuse, socklen_t(MemoryLayout.size)) + var address = sockaddr_in() + address.sin_family = sa_family_t(AF_INET) + address.sin_port = in_port_t(port).bigEndian + address.sin_addr.s_addr = inet_addr("127.0.0.1") + let bound = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(descriptor, $0, socklen_t(MemoryLayout.size)) + } + } + guard bound == 0, listen(descriptor, 4) == 0 else { + close(descriptor) + return nil + } + return descriptor + } +} + +@Suite("Cloudflare tunnel manager", .serialized) +struct CloudflareTunnelManagerTests { + private func config(hostname: String = "db.example.com", localPort: Int? = nil) -> CloudflareConfiguration { + CloudflareConfiguration(accessHostname: hostname, localPort: localPort, binaryPath: "/bin/echo") + } + + @Test("createTunnel returns the allocated port once cloudflared is listening") + func readinessSucceeds() async throws { + let fake = FakeCloudflaredRunner(behavior: .ready) + let manager = CloudflareTunnelManager(runnerFactory: { fake }) + let id = UUID() + + let port = try await manager.createTunnel(connectionId: id, config: config()) + + #expect(port > 0) + #expect(await manager.hasTunnel(connectionId: id)) + #expect(await manager.getLocalPort(connectionId: id) == port) + + try await manager.closeTunnel(connectionId: id) + #expect(fake.stopCallCount >= 1) + #expect(!(await manager.hasTunnel(connectionId: id))) + } + + @Test("createTunnel surfaces a browser sign-in prompt") + func browserAuthDetected() async { + let fake = FakeCloudflaredRunner(behavior: .browserAuth) + let manager = CloudflareTunnelManager(runnerFactory: { fake }) + + await #expect(throws: CloudflareTunnelError.self) { + _ = try await manager.createTunnel(connectionId: UUID(), config: self.config()) + } + } + + @Test("createTunnel fails when cloudflared exits during startup") + func startupFailure() async { + let fake = FakeCloudflaredRunner(behavior: .startupFailure) + let manager = CloudflareTunnelManager(runnerFactory: { fake }) + + await #expect(throws: CloudflareTunnelError.self) { + _ = try await manager.createTunnel(connectionId: UUID(), config: self.config(localPort: 59_998)) + } + } + + @Test("missing binary throws binaryNotFound") + func missingBinary() async { + let manager = CloudflareTunnelManager(runnerFactory: { FakeCloudflaredRunner(behavior: .ready) }) + let badConfig = CloudflareConfiguration(accessHostname: "db.example.com", binaryPath: "/nonexistent/cloudflared") + + await #expect(throws: CloudflareTunnelError.binaryNotFound) { + _ = try await manager.createTunnel(connectionId: UUID(), config: badConfig) + } + } + + @Test("terminateAllProcessesSync stops the running tunnel") + func terminateAllStops() async throws { + let fake = FakeCloudflaredRunner(behavior: .ready) + let manager = CloudflareTunnelManager(runnerFactory: { fake }) + _ = try await manager.createTunnel(connectionId: UUID(), config: config()) + + manager.terminateAllProcessesSync() + #expect(fake.stopCallCount >= 1) + } + + @Test("sweepStalePidsIfNeeded clears the persisted records") + func sweepClearsRecords() async { + let records = [CloudflaredPidRecord(pid: -1, binaryPath: "/nonexistent")] + UserDefaults.standard.set(try? JSONEncoder().encode(records), forKey: "cloudflaredStalePids") + + let manager = CloudflareTunnelManager(runnerFactory: { FakeCloudflaredRunner(behavior: .ready) }) + await manager.sweepStalePidsIfNeeded() + + #expect(UserDefaults.standard.data(forKey: "cloudflaredStalePids") == nil) + } +} diff --git a/TableProTests/Cloudflare/CloudflareTunnelPaneViewModelTests.swift b/TableProTests/Cloudflare/CloudflareTunnelPaneViewModelTests.swift new file mode 100644 index 000000000..ad266ca7d --- /dev/null +++ b/TableProTests/Cloudflare/CloudflareTunnelPaneViewModelTests.swift @@ -0,0 +1,56 @@ +// +// CloudflareTunnelPaneViewModelTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("Cloudflare tunnel pane validation") +@MainActor +struct CloudflareTunnelPaneViewModelTests { + @Test("disabled tunnel reports no validation issues") + func disabledNoIssues() { + let viewModel = CloudflareTunnelPaneViewModel() + viewModel.state.enabled = false + #expect(viewModel.validationIssues.isEmpty) + } + + @Test("enabled tunnel requires a hostname") + func requiresHostname() { + let viewModel = CloudflareTunnelPaneViewModel() + viewModel.state.enabled = true + viewModel.state.accessHostname = " " + #expect(viewModel.validationIssues.contains { $0.localizedCaseInsensitiveContains("hostname") }) + } + + @Test("manual port must be within range") + func portRange() { + let viewModel = CloudflareTunnelPaneViewModel() + viewModel.state.enabled = true + viewModel.state.accessHostname = "db.example.com" + viewModel.state.automaticPort = false + viewModel.state.localPort = "70000" + #expect(viewModel.validationIssues.contains { $0.localizedCaseInsensitiveContains("port") }) + } + + @Test("service token mode requires id and secret") + func serviceTokenRequired() { + let viewModel = CloudflareTunnelPaneViewModel() + viewModel.state.enabled = true + viewModel.state.accessHostname = "db.example.com" + viewModel.state.authMethod = .serviceToken + #expect(!viewModel.validationIssues.isEmpty) + } + + @Test("valid browser sign-in config has no issues") + func validBrowserConfig() { + let viewModel = CloudflareTunnelPaneViewModel() + viewModel.state.enabled = true + viewModel.state.accessHostname = "db.example.com" + viewModel.state.authMethod = .browserSSO + #expect(viewModel.validationIssues.isEmpty) + } +} diff --git a/docs/databases/cloudflare-tunnel.mdx b/docs/databases/cloudflare-tunnel.mdx new file mode 100644 index 000000000..1bedefcc5 --- /dev/null +++ b/docs/databases/cloudflare-tunnel.mdx @@ -0,0 +1,97 @@ +--- +title: Cloudflare Tunnel +description: Reach a database behind Cloudflare Access by letting TablePro manage the cloudflared process +--- + +# Cloudflare Tunnel + +Cloudflare Tunnel routes your database connection through `cloudflared access tcp` so you can reach a database published behind [Cloudflare Access](https://developers.cloudflare.com/cloudflare-one/). Instead of running cloudflared by hand in a terminal before every session, TablePro starts it when you connect and stops it when you disconnect, the same way it manages [SSH tunnels](/databases/ssh-tunneling). + +## How it works + +```mermaid +flowchart LR + subgraph mac ["Your Mac"] + TablePro["TablePro
127.0.0.1:auto"] + CFD["cloudflared"] + end + + subgraph edge ["Cloudflare"] + Access["Cloudflare
Access"] + end + + subgraph db ["Database Server"] + Database["PostgreSQL
MySQL
db:5432"] + end + + TablePro -->|"loopback"| CFD -->|"Access tunnel"| Access -->|"origin"| Database +``` + +TablePro picks a free loopback port, runs `cloudflared access tcp --hostname --url 127.0.0.1:`, waits until the local port accepts connections, then points the database driver at it. When you disconnect, quit the app, or the process exits, the tunnel is torn down. + +## Prerequisites + +Install cloudflared: + +```bash +brew install cloudflared +``` + +TablePro looks for cloudflared on your `PATH` and in the common Homebrew locations (`/opt/homebrew/bin`, `/usr/local/bin`). If it lives somewhere else, set the path in the pane. + +## Setting up + +Open the connection form, switch to the **Cloudflare Tunnel** pane, toggle **Enable Cloudflare Tunnel** on, enter the Access **hostname**, choose how to authenticate, then go back to **General** and click **Test Connection**. + +A connection uses one tunnel at a time. If the SSH Tunnel is enabled, the pane offers to turn it off. + +## Options + +### Access application + +| Field | Description | +|-------|-------------| +| **Hostname** | The Access application hostname, for example `db.example.com`. This is the `--hostname` cloudflared connects to. | + +### Authentication + + + + cloudflared signs in through your browser and caches a token under `~/.cloudflared`. Click **Sign In with Browser** once so the login happens up front; after that, connecting uses the cached token without opening a browser. + + If you connect without a cached token, TablePro detects the sign-in prompt and asks you to sign in, rather than appearing to hang. + + + For unattended connections, enter a Cloudflare Access **service token** (Client ID and Client Secret). TablePro stores them in the macOS Keychain and passes them to cloudflared as environment variables, never on the command line. + + + The Access application policy must use a **Service Auth** rule. If the policy only allows an identity provider, Cloudflare still prompts for a browser sign-in even when a service token is set. This is configured in your Cloudflare Zero Trust dashboard, not in TablePro. + + + + +### Local listener + +| Option | Description | Default | +|--------|-------------|---------| +| **Choose port automatically** | TablePro picks a free loopback port. Avoids collisions between connections. | On | +| **Local port** | Set a fixed port instead. | - | +| **Expose to local network** | Bind `0.0.0.0` instead of `127.0.0.1`, so other machines on your network can reach the listener. Leave off unless you need it. | Off | + +### cloudflared binary + +Leave the path blank to auto-detect. The pane shows the detected path, or a hint to install cloudflared if it isn't found. Use **Choose** to point at a specific binary. + +## Troubleshooting + +### cloudflared not found + +Install it with `brew install cloudflared`, or set the binary path in the pane. A GUI app doesn't see your shell's `PATH`, so a custom install location may need to be set explicitly. + +### A browser keeps opening on connect + +The cached Access token expired (Access sessions are time-limited), or you're using a service token against a policy that isn't set to **Service Auth**. Sign in again, or fix the policy in your Cloudflare Zero Trust dashboard. + +### Tunnel didn't become ready + +TablePro waits up to 30 seconds for the local port to accept connections. If it times out, the last lines of cloudflared's output are shown. Check the hostname and that the Access application is reachable. diff --git a/docs/docs.json b/docs/docs.json index ec267928c..e1a0367c9 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -32,6 +32,7 @@ "databases/overview", "databases/connection-urls", "databases/ssh-tunneling", + "databases/cloudflare-tunnel", { "group": "SQL", "pages": [ From bb64058e4d8ed2e1f62546228a74554040ea9529 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 24 May 2026 14:25:23 +0700 Subject: [PATCH 2/5] fix(connections): expand ~ in cloudflared path and fix pid-path buffer size (#1285) --- TablePro/Core/Cloudflare/CloudflareTunnelManager.swift | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/TablePro/Core/Cloudflare/CloudflareTunnelManager.swift b/TablePro/Core/Cloudflare/CloudflareTunnelManager.swift index ab756b889..d0e55b272 100644 --- a/TablePro/Core/Cloudflare/CloudflareTunnelManager.swift +++ b/TablePro/Core/Cloudflare/CloudflareTunnelManager.swift @@ -243,10 +243,11 @@ actor CloudflareTunnelManager { private func resolveBinaryPath(config: CloudflareConfiguration) throws -> String { if !config.binaryPath.isEmpty { - guard FileManager.default.isExecutableFile(atPath: config.binaryPath) else { + let expandedPath = (config.binaryPath as NSString).expandingTildeInPath + guard FileManager.default.isExecutableFile(atPath: expandedPath) else { throw CloudflareTunnelError.binaryNotFound } - return config.binaryPath + return expandedPath } guard let resolved = CLIExecutableFinder.findExecutable("cloudflared") else { throw CloudflareTunnelError.binaryNotFound @@ -312,8 +313,9 @@ actor CloudflareTunnelManager { private static func isLiveCloudflared(_ record: CloudflaredPidRecord) -> Bool { guard record.pid > 0 else { return false } - var buffer = [CChar](repeating: 0, count: Int(PROC_PIDPATHINFO_MAXSIZE)) - let length = proc_pidpath(record.pid, &buffer, UInt32(buffer.count)) + let pathBufferSize = 4 * Int(PATH_MAX) + var buffer = [CChar](repeating: 0, count: pathBufferSize) + let length = proc_pidpath(record.pid, &buffer, UInt32(pathBufferSize)) guard length > 0 else { return false } let path = String(cString: buffer) if !record.binaryPath.isEmpty, path == record.binaryPath { return true } From bf4f25fe1330ca081d1df1133d6f647af2e8fa9a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 24 May 2026 14:25:34 +0700 Subject: [PATCH 3/5] chore(connections): add localized strings for Cloudflare tunnel (#1285) --- TablePro/Resources/Localizable.xcstrings | 263 +++++++++++++++++++++++ 1 file changed, 263 insertions(+) diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 76bee811a..28500ba9d 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -3551,9 +3551,18 @@ } } } + }, + "A Cloudflare tunnel already exists for connection: %@" : { + }, "A command named \"/%@\" already exists." : { + }, + "A connection can use one tunnel at a time. Disable the SSH Tunnel to use a Cloudflare Tunnel." : { + + }, + "A connection cannot use SSH and Cloudflare tunnels at the same time." : { + }, "A connection with this name, host, and type already exists." : { "localizations" : { @@ -3690,6 +3699,9 @@ } } } + }, + "Access Application" : { + }, "Access Key ID" : { "localizations" : { @@ -3718,6 +3730,9 @@ } } } + }, + "Access Key ID and Secret Access Key are required for AWS IAM authentication." : { + }, "Account" : { "localizations" : { @@ -7325,6 +7340,9 @@ } } } + }, + "Automatic" : { + }, "Automatically check for updates" : { "localizations" : { @@ -7369,6 +7387,15 @@ } } } + }, + "AWS IAM (Access Key)" : { + + }, + "AWS IAM (Profile)" : { + + }, + "AWS IAM (SSO)" : { + }, "AWS managed key-value/document store" : { @@ -7916,6 +7943,9 @@ } } } + }, + "Browser Sign-In" : { + }, "Built-in" : { "localizations" : { @@ -8371,6 +8401,12 @@ } } } + }, + "Cannot read ~/.aws/config." : { + + }, + "Cannot read ~/.aws/credentials." : { + }, "Cannot save changes: connection is read only" : { @@ -8429,6 +8465,9 @@ }, "Cannot Show DDL" : { + }, + "Cannot use SSH Tunnel and Cloudflare Tunnel at the same time" : { + }, "Cap user query results at the configured row count" : { "localizations" : { @@ -9116,12 +9155,18 @@ }, "Choose Dump File" : { + }, + "Choose port automatically" : { + }, "Choose where to save the dump of “%@”." : { }, "Choose your client and follow the steps to connect it to TablePro." : { + }, + "Choose..." : { + }, "Claude Code" : { "localizations" : { @@ -9806,6 +9851,9 @@ } } } + }, + "Client ID" : { + }, "Client Key" : { "localizations" : { @@ -9831,6 +9879,9 @@ }, "Client key is required when client certificate is set" : { + }, + "Client Secret" : { + }, "Client:" : { "extractionState" : "stale", @@ -10089,6 +10140,30 @@ }, "Cloud Native" : { + }, + "Cloudflare Access needs a browser sign-in. Sign in at %@, then reconnect." : { + + }, + "Cloudflare hostname is required" : { + + }, + "Cloudflare Tunnel" : { + + }, + "Cloudflare tunnel disconnected. Click to reconnect." : { + + }, + "cloudflared failed to start: %@" : { + + }, + "cloudflared failed to start." : { + + }, + "cloudflared not found. Install it with `brew install cloudflared`, or choose the binary above." : { + + }, + "cloudflared was not found. Install it with `brew install cloudflared`, or set its path in the connection's Cloudflare Tunnel settings." : { + }, "CMD" : { "extractionState" : "stale", @@ -12765,6 +12840,9 @@ }, "Could not decode image" : { + }, + "Could not determine an AWS region for \"%@\". Set the AWS Region field." : { + }, "Could not encode image" : { @@ -16001,6 +16079,9 @@ }, "Details: %@" : { + }, + "Detected" : { + }, "Diagnostic Info" : { @@ -16074,6 +16155,9 @@ } } } + }, + "Disable SSH Tunnel" : { + }, "disabled" : { @@ -17757,6 +17841,9 @@ } } } + }, + "Enable Cloudflare Tunnel" : { + }, "Enable inline suggestions" : { "extractionState" : "stale", @@ -19995,6 +20082,9 @@ } } } + }, + "Expose to local network" : { + }, "Expression (e.g., age >= 0)" : { "extractionState" : "stale", @@ -20176,6 +20266,9 @@ } } } + }, + "Failed to build the SSO portal URL for profile \"%@\"." : { + }, "Failed to compress data" : { "localizations" : { @@ -20221,6 +20314,9 @@ } } } + }, + "Failed to decode the SSO portal response for profile \"%@\"." : { + }, "Failed to decompress .gz file" : { "localizations" : { @@ -20977,6 +21073,16 @@ } } }, + "Failed to reach the SSO portal for profile \"%@\": %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Failed to reach the SSO portal for profile \"%1$@\": %2$@" + } + } + } + }, "Failed to read file: %@" : { "localizations" : { "tr" : { @@ -21590,6 +21696,18 @@ } } } + }, + "Fill" : { + + }, + "Fill Column" : { + + }, + "Fill column \"%@\"" : { + + }, + "Fill Column…" : { + }, "Filter" : { "localizations" : { @@ -23442,6 +23560,9 @@ } } } + }, + "Hostname" : { + }, "hostname:%lld" : { "extractionState" : "stale", @@ -27299,6 +27420,12 @@ }, "List tables and views in the active database of a connection." : { + }, + "Listens on all interfaces (0.0.0.0), reachable from your local network." : { + + }, + "Listens only on 127.0.0.1." : { + }, "Load" : { "extractionState" : "stale", @@ -27665,6 +27792,9 @@ } } } + }, + "Local Listener" : { + }, "Local only" : { "localizations" : { @@ -27713,6 +27843,12 @@ }, "Local only, not synced to iCloud" : { + }, + "Local port" : { + + }, + "Local port must be between 1 and 65535" : { + }, "localhost" : { "localizations" : { @@ -30252,6 +30388,9 @@ } } } + }, + "No available local port for the Cloudflare tunnel." : { + }, "No changes to preview" : { "extractionState" : "stale", @@ -33343,6 +33482,9 @@ }, "Optional one-line description" : { + }, + "Optional, for temporary credentials" : { + }, "Options" : { "localizations" : { @@ -36080,6 +36222,18 @@ } } } + }, + "Profile \"%@\" in ~/.aws/config is missing sso_account_id or sso_role_name." : { + + }, + "Profile \"%@\" in ~/.aws/config is missing sso_start_url or sso_region." : { + + }, + "Profile \"%@\" not found in ~/.aws/config." : { + + }, + "Profile \"%@\" was not found or is missing keys in ~/.aws/credentials." : { + }, "Profile Details" : { @@ -39324,6 +39478,16 @@ } } }, + "Role \"%@\" in account \"%@\" is not accessible via SSO. Check role permissions in IAM Identity Center." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Role \"%1$@\" in account \"%2$@\" is not accessible via SSO. Check role permissions in IAM Identity Center." + } + } + } + }, "root" : { "localizations" : { "tr" : { @@ -41981,6 +42145,12 @@ } } } + }, + "Service Token" : { + + }, + "Service token ID and secret are required" : { + }, "Session Token" : { "localizations" : { @@ -42120,6 +42290,9 @@ } } } + }, + "Set to NULL" : { + }, "Set Up AI Provider" : { "extractionState" : "stale", @@ -42863,6 +43036,9 @@ } } } + }, + "Sign In with Browser..." : { + }, "Sign in with GitHub" : { "localizations" : { @@ -42995,6 +43171,9 @@ } } } + }, + "Signs in to Cloudflare Access once and caches the token, so connecting doesn't open a browser." : { + }, "Silent" : { "localizations" : { @@ -44537,6 +44716,69 @@ } } }, + "SSO portal returned HTTP %lld for profile \"%@\"." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "SSO portal returned HTTP %1$lld for profile \"%2$@\"." + } + } + } + }, + "SSO role credentials for profile \"%@\" were already expired. Run 'aws sso login --profile %@' to refresh." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "SSO role credentials for profile \"%1$@\" were already expired. Run 'aws sso login --profile %2$@' to refresh." + } + } + } + }, + "SSO session \"%@\" in ~/.aws/config is missing sso_start_url or sso_region." : { + + }, + "SSO session \"%@\" referenced by profile \"%@\" was not found in ~/.aws/config." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "SSO session \"%1$@\" referenced by profile \"%2$@\" was not found in ~/.aws/config." + } + } + } + }, + "SSO session for profile \"%@\" has expired. Run 'aws sso login --profile %@' to refresh." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "SSO session for profile \"%1$@\" has expired. Run 'aws sso login --profile %2$@' to refresh." + } + } + } + }, + "SSO token cache for profile \"%@\" is malformed. Run 'aws sso login --profile %@' to refresh." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "SSO token cache for profile \"%1$@\" is malformed. Run 'aws sso login --profile %2$@' to refresh." + } + } + } + }, + "SSO token cache not found for profile \"%@\". Run 'aws sso login --profile %@' first." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "SSO token cache not found for profile \"%1$@\". Run 'aws sso login --profile %2$@' first." + } + } + } + }, "Starting service…" : { "localizations" : { "tr" : { @@ -44580,6 +44822,9 @@ } } } + }, + "Starts and stops `cloudflared access tcp` with this connection and routes it through a local port." : { + }, "starts with" : { "localizations" : { @@ -46908,6 +47153,9 @@ } } } + }, + "The Access application policy must use a Service Auth rule, or Cloudflare still prompts for browser sign-in." : { + }, "The API key will be permanently deleted." : { "localizations" : { @@ -46961,6 +47209,12 @@ }, "The bundled sample database is missing from the app." : { + }, + "The Cloudflare tunnel did not become ready in time: %@" : { + + }, + "The Cloudflare tunnel did not become ready in time." : { + }, "The code expires in 15 minutes." : { "localizations" : { @@ -48024,6 +48278,12 @@ } } } + }, + "This sets %lld loaded rows. Review and Save to apply." : { + + }, + "This sets 1 loaded row. Review and Save to apply." : { + }, "This shortcut is reserved by macOS and cannot be assigned." : { "localizations" : { @@ -49878,6 +50138,9 @@ } } } + }, + "Unexpected response from the SSO portal for profile \"%@\"." : { + }, "Uninstall" : { "localizations" : { From 7eb812d49d283c0e58a3af9cb9fabd26d601ec68 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 24 May 2026 14:40:11 +0700 Subject: [PATCH 4/5] fix(connections): clean up Cloudflare tokens on delete and surface sign-in errors (#1285) --- .../Cloudflare/CloudflareTunnelManager.swift | 3 ++ TablePro/Core/Storage/ConnectionStorage.swift | 4 +++ .../Panes/CloudflareTunnelPaneView.swift | 12 ++++++-- .../CloudflareTunnelPaneViewModel.swift | 29 ++++++++++++++++--- .../CloudflareTunnelManagerTests.swift | 3 ++ 5 files changed, 44 insertions(+), 7 deletions(-) diff --git a/TablePro/Core/Cloudflare/CloudflareTunnelManager.swift b/TablePro/Core/Cloudflare/CloudflareTunnelManager.swift index d0e55b272..ad6c3872d 100644 --- a/TablePro/Core/Cloudflare/CloudflareTunnelManager.swift +++ b/TablePro/Core/Cloudflare/CloudflareTunnelManager.swift @@ -191,6 +191,9 @@ actor CloudflareTunnelManager { } defer { stderrTask.cancel() } + // The stderr scan is load-bearing: cloudflared may accept the local port + // before it has authenticated, so a passing TCP probe alone can't tell a + // ready tunnel from one waiting on browser sign-in. Keep checking both. let deadline = Date().addingTimeInterval(Self.readinessTimeout) while Date() < deadline { if let url = await monitor.browserAuthURL { diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index 63700a27f..d212298ef 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -188,6 +188,8 @@ final class ConnectionStorage { deleteSSHPassword(for: connection.id) deleteKeyPassphrase(for: connection.id) deleteTOTPSecret(for: connection.id) + deleteCloudflareTokenId(for: connection.id) + deleteCloudflareTokenSecret(for: connection.id) let secureFieldIds = Self.secureFieldIds(for: connection.type) deleteAllPluginSecureFields(for: connection.id, fieldIds: secureFieldIds) @@ -214,6 +216,8 @@ final class ConnectionStorage { deleteSSHPassword(for: conn.id) deleteKeyPassphrase(for: conn.id) deleteTOTPSecret(for: conn.id) + deleteCloudflareTokenId(for: conn.id) + deleteCloudflareTokenSecret(for: conn.id) let fields = Self.secureFieldIds(for: conn.type) deleteAllPluginSecureFields(for: conn.id, fieldIds: fields) let appSettings = appSettingsProvider() diff --git a/TablePro/Views/ConnectionForm/Panes/CloudflareTunnelPaneView.swift b/TablePro/Views/ConnectionForm/Panes/CloudflareTunnelPaneView.swift index 52c8ff309..b77870a7f 100644 --- a/TablePro/Views/ConnectionForm/Panes/CloudflareTunnelPaneView.swift +++ b/TablePro/Views/ConnectionForm/Panes/CloudflareTunnelPaneView.swift @@ -74,9 +74,15 @@ struct CloudflareTunnelPaneView: View { viewModel.signInWithBrowser() } .disabled(coordinator.cloudflareTunnel.state.accessHostname.trimmingCharacters(in: .whitespaces).isEmpty) - Text("Signs in to Cloudflare Access once and caches the token, so connecting doesn't open a browser.") - .font(.caption) - .foregroundStyle(.secondary) + if let signInError = viewModel.signInError { + Label(signInError, systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(.orange) + } else { + Text("Signs in to Cloudflare Access once and caches the token, so connecting doesn't open a browser.") + .font(.caption) + .foregroundStyle(.secondary) + } case .serviceToken: SecureField(String(localized: "Client ID"), text: $coordinator.cloudflareTunnel.state.serviceTokenId) SecureField(String(localized: "Client Secret"), text: $coordinator.cloudflareTunnel.state.serviceTokenSecret) diff --git a/TablePro/Views/ConnectionForm/ViewModels/CloudflareTunnelPaneViewModel.swift b/TablePro/Views/ConnectionForm/ViewModels/CloudflareTunnelPaneViewModel.swift index 55b6f2e0d..1ae574772 100644 --- a/TablePro/Views/ConnectionForm/ViewModels/CloudflareTunnelPaneViewModel.swift +++ b/TablePro/Views/ConnectionForm/ViewModels/CloudflareTunnelPaneViewModel.swift @@ -4,16 +4,22 @@ // import Foundation +import os @Observable @MainActor final class CloudflareTunnelPaneViewModel { + private static let logger = Logger(subsystem: "com.TablePro", category: "CloudflareTunnelPane") + var state = CloudflareTunnelFormState() var coordinator: WeakCoordinatorRef? var resolvedBinaryPath: String? var didResolveBinary: Bool = false + var signInError: String? + + @ObservationIgnored private var loginProcess: Process? var validationIssues: [String] { guard state.enabled else { return [] } @@ -70,14 +76,29 @@ final class CloudflareTunnelPaneViewModel { } func signInWithBrowser() { + signInError = nil + guard loginProcess?.isRunning != true else { return } + let hostname = state.accessHostname.trimmingCharacters(in: .whitespacesAndNewlines) guard !hostname.isEmpty else { return } - let binaryPath = state.binaryPath.isEmpty ? resolvedBinaryPath : state.binaryPath - guard let binaryPath, FileManager.default.isExecutableFile(atPath: binaryPath) else { return } + + let rawPath = state.binaryPath.isEmpty ? resolvedBinaryPath : state.binaryPath + guard let resolvedPath = rawPath.map({ ($0 as NSString).expandingTildeInPath }), + FileManager.default.isExecutableFile(atPath: resolvedPath) else { + signInError = String(localized: "cloudflared was not found. Set its path below first.") + return + } let process = Process() - process.executableURL = URL(fileURLWithPath: binaryPath) + process.executableURL = URL(fileURLWithPath: resolvedPath) process.arguments = ["access", "login", hostname] - try? process.run() + do { + try process.run() + loginProcess = process + Self.logger.info("Started cloudflared access login for \(hostname, privacy: .public)") + } catch { + signInError = error.localizedDescription + Self.logger.error("cloudflared access login failed to start: \(error.localizedDescription, privacy: .public)") + } } } diff --git a/TableProTests/Cloudflare/CloudflareTunnelManagerTests.swift b/TableProTests/Cloudflare/CloudflareTunnelManagerTests.swift index 0e581e130..2e39c0ac9 100644 --- a/TableProTests/Cloudflare/CloudflareTunnelManagerTests.swift +++ b/TableProTests/Cloudflare/CloudflareTunnelManagerTests.swift @@ -186,6 +186,9 @@ struct CloudflareTunnelManagerTests { manager.terminateAllProcessesSync() #expect(fake.stopCallCount >= 1) + + await manager.closeAllTunnels() + #expect(UserDefaults.standard.data(forKey: "cloudflaredStalePids") == nil) } @Test("sweepStalePidsIfNeeded clears the persisted records") From 6c8f2eba6729e5feeda575046719c93f468acee7 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 24 May 2026 14:45:05 +0700 Subject: [PATCH 5/5] refactor(connections): share tunnel rewrite and reconnect logic between SSH and Cloudflare (#1285) --- .../Database/DatabaseManager+Cloudflare.swift | 76 ++-------------- .../Core/Database/DatabaseManager+SSH.swift | 81 ++--------------- .../Database/DatabaseManager+Tunnel.swift | 89 +++++++++++++++++++ 3 files changed, 101 insertions(+), 145 deletions(-) create mode 100644 TablePro/Core/Database/DatabaseManager+Tunnel.swift diff --git a/TablePro/Core/Database/DatabaseManager+Cloudflare.swift b/TablePro/Core/Database/DatabaseManager+Cloudflare.swift index 696eb2af3..2d83b2a68 100644 --- a/TablePro/Core/Database/DatabaseManager+Cloudflare.swift +++ b/TablePro/Core/Database/DatabaseManager+Cloudflare.swift @@ -4,8 +4,6 @@ // import Foundation -import os -import TableProPluginKit // MARK: - Cloudflare Tunnel Helper @@ -35,37 +33,7 @@ extension DatabaseManager { tokenSecret: tokenSecret ) - // The driver connects to 127.0.0.1, so hostname-based certificate - // verification can't match the origin. Keep transport encryption but - // drop verification and local-only cert paths, mirroring SSH tunneling. - var tunnelSSL = connection.sslConfig - if tunnelSSL.isEnabled { - if tunnelSSL.verifiesCertificate { - tunnelSSL.mode = .required - } - tunnelSSL.caCertificatePath = "" - tunnelSSL.clientCertificatePath = "" - tunnelSSL.clientKeyPath = "" - } - - var effectiveFields = connection.additionalFields - if connection.usePgpass { - effectiveFields["pgpassOriginalHost"] = connection.host - effectiveFields["pgpassOriginalPort"] = String(connection.port) - } - - return DatabaseConnection( - id: connection.id, - name: connection.name, - host: "127.0.0.1", - port: tunnelPort, - database: connection.database, - username: connection.username, - type: connection.type, - sshConfig: SSHConfiguration(), - sslConfig: tunnelSSL, - additionalFields: effectiveFields - ) + return tunneledConnection(from: connection, localPort: tunnelPort) } // MARK: - Cloudflare Tunnel Recovery @@ -73,42 +41,10 @@ extension DatabaseManager { /// Handle Cloudflare tunnel death by reconnecting with exponential backoff. /// Guarded by `recoveringConnectionIds` to prevent duplicate concurrent recovery. func handleCloudflareTunnelDied(connectionId: UUID) async { - guard let session = activeSessions[connectionId], - !recoveringConnectionIds.contains(connectionId) else { return } - - recoveringConnectionIds.insert(connectionId) - defer { recoveringConnectionIds.remove(connectionId) } - - Self.logger.warning("Cloudflare tunnel died for connection: \(session.connection.name)") - - await stopHealthMonitor(for: connectionId) - - activeSessions[connectionId]?.driver?.disconnect() - updateSession(connectionId) { session in - session.driver = nil - session.status = .connecting - } - - let maxRetries = 10 - for retryCount in 0.. DatabaseConnection { + var tunnelSSL = connection.sslConfig + if tunnelSSL.isEnabled { + if tunnelSSL.verifiesCertificate { + tunnelSSL.mode = .required + } + tunnelSSL.caCertificatePath = "" + tunnelSSL.clientCertificatePath = "" + tunnelSSL.clientKeyPath = "" + } + + var effectiveFields = connection.additionalFields + if connection.usePgpass { + effectiveFields["pgpassOriginalHost"] = connection.host + effectiveFields["pgpassOriginalPort"] = String(connection.port) + } + + return DatabaseConnection( + id: connection.id, + name: connection.name, + host: "127.0.0.1", + port: localPort, + database: connection.database, + username: connection.username, + type: connection.type, + sshConfig: SSHConfiguration(), + sslConfig: tunnelSSL, + additionalFields: effectiveFields + ) + } + + /// Reconnect a session whose tunnel died, with exponential backoff. Guarded by + /// `recoveringConnectionIds` so the keepalive death callback and the wake-from-sleep + /// handler don't recover the same connection twice. Shared by both tunnel types. + func recoverDeadTunnel(connectionId: UUID, kind: String, disconnectedMessage: String) async { + guard let session = activeSessions[connectionId], + !recoveringConnectionIds.contains(connectionId) else { return } + + recoveringConnectionIds.insert(connectionId) + defer { recoveringConnectionIds.remove(connectionId) } + + Self.logger.warning("\(kind, privacy: .public) tunnel died for connection: \(session.connection.name)") + + await stopHealthMonitor(for: connectionId) + + activeSessions[connectionId]?.driver?.disconnect() + updateSession(connectionId) { session in + session.driver = nil + session.status = .connecting + } + + let maxRetries = 10 + for retryCount in 0..