diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bd731d9f..f04b3e33e 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) - AWS IAM authentication for PostgreSQL and MySQL connections to RDS and Aurora. Pick AWS IAM in the connection's Authentication field and use an access key, a named AWS profile, or SSO. TablePro generates a fresh login token on every connect and reconnect, so you never paste an expiring token, and SSL is required automatically. (#1291) 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..ad6c3872d --- /dev/null +++ b/TablePro/Core/Cloudflare/CloudflareTunnelManager.swift @@ -0,0 +1,387 @@ +// +// 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() } + + // 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 { + 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 { + let expandedPath = (config.binaryPath as NSString).expandingTildeInPath + guard FileManager.default.isExecutableFile(atPath: expandedPath) else { + throw CloudflareTunnelError.binaryNotFound + } + return expandedPath + } + 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 } + 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 } + 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 + ) + + return tunneledConnection(from: connection, localPort: tunnelPort) + } + + // 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 { + await recoverDeadTunnel( + connectionId: connectionId, + kind: "Cloudflare", + disconnectedMessage: String(localized: "Cloudflare tunnel disconnected. Click to reconnect.") + ) + } +} diff --git a/TablePro/Core/Database/DatabaseManager+Health.swift b/TablePro/Core/Database/DatabaseManager+Health.swift index 5635e706a..ca192f212 100644 --- a/TablePro/Core/Database/DatabaseManager+Health.swift +++ b/TablePro/Core/Database/DatabaseManager+Health.swift @@ -111,9 +111,9 @@ extension DatabaseManager { // Disconnect existing driver session.driver?.disconnect() - // Rebuild SSH tunnel if needed; otherwise reuse effective connection + // Rebuild the tunnel if needed; otherwise reuse effective connection let connectionForDriver: DatabaseConnection - if session.connection.resolvedSSHConfig.enabled { + if session.connection.resolvedSSHConfig.enabled || session.connection.isCloudflareEnabled { connectionForDriver = try await buildEffectiveConnection(for: session.connection) } else { connectionForDriver = session.effectiveConnection ?? session.connection @@ -136,6 +136,13 @@ extension DatabaseManager { Self.logger.warning("Failed to close SSH tunnel during reconnect: \(error.localizedDescription)") } } + if session.connection.isCloudflareEnabled { + do { + try await CloudflareTunnelManager.shared.closeTunnel(connectionId: session.connection.id) + } catch { + Self.logger.warning("Failed to close Cloudflare tunnel during reconnect: \(error.localizedDescription)") + } + } throw error } diff --git a/TablePro/Core/Database/DatabaseManager+SSH.swift b/TablePro/Core/Database/DatabaseManager+SSH.swift index 8632fc8e1..c805c541e 100644 --- a/TablePro/Core/Database/DatabaseManager+SSH.swift +++ b/TablePro/Core/Database/DatabaseManager+SSH.swift @@ -6,8 +6,6 @@ // import Foundation -import os -import TableProPluginKit // MARK: - SSH Tunnel Helper @@ -24,6 +22,13 @@ extension DatabaseManager { for connection: DatabaseConnection, sshPasswordOverride: String? = nil ) async throws -> 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 } @@ -66,38 +71,7 @@ extension DatabaseManager { totpPeriod: sshConfig.totpPeriod ) - // Adapt SSL config for tunnel: SSH already authenticates the server, - // remote environment and aren't readable locally, so strip them and - // use at least .preferred so libpq negotiates SSL when the server - // requires it (SSH already authenticates the server itself). - 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: - SSH Tunnel Recovery @@ -107,46 +81,10 @@ extension DatabaseManager { /// when both the keepalive death callback and the wake-from-sleep handler fire /// for the same connection. func handleSSHTunnelDied(connectionId: UUID) async { - guard let session = activeSessions[connectionId], - !recoveringConnectionIds.contains(connectionId) else { return } - - recoveringConnectionIds.insert(connectionId) - defer { recoveringConnectionIds.remove(connectionId) } - - Self.logger.warning("SSH tunnel died for connection: \(session.connection.name)") - - // Stop health monitor before retrying to prevent stale pings during reconnect - await stopHealthMonitor(for: connectionId) - - // Disconnect the stale driver and invalidate it so connectToSession - // creates a fresh connection instead of short-circuiting on driver != nil - 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.. 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 cb3ad4024..fcf845315 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 ) } @@ -749,7 +751,8 @@ final class PluginMetadataRegistry: @unchecked Sendable { supportsDropDatabase: false, supportsModifyColumn: false, supportsRenameColumn: true, - supportsModifyPrimaryKey: false + supportsModifyPrimaryKey: false, + supportsCloudflareTunnel: false ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", @@ -934,7 +937,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..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() @@ -380,6 +384,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 +560,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 +641,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 +665,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 +707,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 +775,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 +813,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 +872,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 4aa326918..539c2bdc6 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? @@ -408,6 +409,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, @@ -457,6 +459,7 @@ struct DatabaseConnection: Identifiable, Hashable { } else { self.sshTunnelMode = sshTunnelMode } + self.cloudflareTunnelMode = cloudflareTunnelMode self.aiPolicy = aiPolicy self.aiRules = aiRules self.aiAlwaysAllowedTools = aiAlwaysAllowedTools @@ -512,7 +515,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 } @@ -542,6 +545,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) { @@ -575,6 +579,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/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" : { 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..b77870a7f --- /dev/null +++ b/TablePro/Views/ConnectionForm/Panes/CloudflareTunnelPaneView.swift @@ -0,0 +1,162 @@ +// +// 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) + 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) + 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..1ae574772 --- /dev/null +++ b/TablePro/Views/ConnectionForm/ViewModels/CloudflareTunnelPaneViewModel.swift @@ -0,0 +1,104 @@ +// +// CloudflareTunnelPaneViewModel.swift +// TablePro +// + +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 [] } + 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() { + signInError = nil + guard loginProcess?.isRunning != true else { return } + + let hostname = state.accessHostname.trimmingCharacters(in: .whitespacesAndNewlines) + guard !hostname.isEmpty 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: resolvedPath) + process.arguments = ["access", "login", hostname] + 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/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..2e39c0ac9 --- /dev/null +++ b/TableProTests/Cloudflare/CloudflareTunnelManagerTests.swift @@ -0,0 +1,204 @@ +// +// 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) + + await manager.closeAllTunnels() + #expect(UserDefaults.standard.data(forKey: "cloudflaredStalePids") == nil) + } + + @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": [