From 8981aaf8ffcd274a8a7b83f8c2854c4b2fae7929 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 4 Sep 2026 19:54:51 +0700 Subject: [PATCH] feat(connections): reach a database through a tunnel command Claude-Session: https://claude.ai/code/session_016cKgYoccE88XFeVPuBLRak --- CHANGELOG.md | 4 + .../ConnectionExportEnvelope.swift | 70 +++- TablePro/AppDelegate.swift | 2 + .../Core/Database/CLIExecutableFinder.swift | 1 + .../Core/Database/DatabaseManager+SSH.swift | 2 + .../DatabaseManager+SystemEvents.swift | 2 + .../Database/DatabaseManager+Tunnel.swift | 1 + .../DatabaseManager+TunnelCommand.swift | 40 ++ .../Plugins/PluginManager+Registration.swift | 5 + .../Core/Plugins/PluginMetadataRegistry.swift | 5 + .../Core/Process/CLIToolEnvironment.swift | 26 ++ .../Process/SupervisedProcessRunner.swift | 40 +- .../Export/ConnectionExportService.swift | 27 +- .../Infrastructure/DeeplinkParser.swift | 2 +- TablePro/Core/Storage/ConnectionStorage.swift | 1 + TablePro/Core/Storage/StoredConnection.swift | 20 + TablePro/Core/Sync/SyncRecordMapper.swift | 7 +- .../TunnelCommand/TunnelCommandError.swift | 68 ++++ .../TunnelCommand/TunnelCommandManager.swift | 357 ++++++++++++++++++ .../Connection/PasswordSourceResolver.swift | 13 +- .../ConnectionStageLabelFormatter.swift | 2 + .../Connection/ConnectionTunnelKind.swift | 5 +- .../DatabaseConnection+TunnelCommand.swift | 16 + .../Connection/DatabaseConnection.swift | 10 +- .../Connection/TunnelCommandBuilder.swift | 231 ++++++++++++ .../TunnelCommandConfiguration.swift | 72 ++++ .../TunnelCommandExportBridge.swift | 40 ++ .../Connection/TunnelCommandFormState.swift | 38 ++ .../Models/Connection/TunnelCommandLine.swift | 98 +++++ .../Models/Connection/TunnelCommandMode.swift | 46 +++ .../TeamLibrary/TeamLibraryModels.swift | 2 +- .../Connection/ConnectionImportSheet.swift | 67 +++- ...ionFormCoordinator+TunnelExclusivity.swift | 5 + .../ConnectionFormCoordinator.swift | 13 +- .../ConnectionForm/ConnectionFormPane.swift | 5 + .../ConnectionForm/ConnectionFormView.swift | 2 + .../Panes/TunnelCommandPaneView.swift | 198 ++++++++++ .../Support/ConnectionFormEdits.swift | 2 + .../TunnelCommandPaneViewModel.swift | 41 ++ .../PluginManagerVariantAccessorTests.swift | 1 + .../SupervisedProcessRunnerTests.swift | 44 +++ .../ConnectionStoragePersistenceTests.swift | 7 +- .../Models/ConnectionTunnelKindTests.swift | 15 +- .../TunnelCommandBuilderTests.swift | 187 +++++++++ .../TunnelCommandImportTests.swift | 143 +++++++ .../TunnelCommandLineTests.swift | 80 ++++ .../TunnelCommandManagerTests.swift | 286 ++++++++++++++ .../TunnelCommandModelTests.swift | 106 ++++++ .../ConnectionFormEditsCoverageTests.swift | 2 + .../ViewModels/ConnectionFormEditsTests.swift | 1 + ...ConnectionFormTunnelExclusivityTests.swift | 20 +- docs/connections/connection-form.mdx | 59 +-- docs/connections/tunnel-command.mdx | 107 ++++++ docs/docs.json | 1 + docs/images/tunnel-command-pane-dark.png | Bin 0 -> 5899 bytes docs/images/tunnel-command-pane.png | Bin 0 -> 5899 bytes 56 files changed, 2568 insertions(+), 77 deletions(-) create mode 100644 TablePro/Core/Database/DatabaseManager+TunnelCommand.swift create mode 100644 TablePro/Core/Process/CLIToolEnvironment.swift create mode 100644 TablePro/Core/TunnelCommand/TunnelCommandError.swift create mode 100644 TablePro/Core/TunnelCommand/TunnelCommandManager.swift create mode 100644 TablePro/Models/Connection/DatabaseConnection+TunnelCommand.swift create mode 100644 TablePro/Models/Connection/TunnelCommandBuilder.swift create mode 100644 TablePro/Models/Connection/TunnelCommandConfiguration.swift create mode 100644 TablePro/Models/Connection/TunnelCommandExportBridge.swift create mode 100644 TablePro/Models/Connection/TunnelCommandFormState.swift create mode 100644 TablePro/Models/Connection/TunnelCommandLine.swift create mode 100644 TablePro/Models/Connection/TunnelCommandMode.swift create mode 100644 TablePro/Views/ConnectionForm/Panes/TunnelCommandPaneView.swift create mode 100644 TablePro/Views/ConnectionForm/ViewModels/TunnelCommandPaneViewModel.swift create mode 100644 TableProTests/TunnelCommand/TunnelCommandBuilderTests.swift create mode 100644 TableProTests/TunnelCommand/TunnelCommandImportTests.swift create mode 100644 TableProTests/TunnelCommand/TunnelCommandLineTests.swift create mode 100644 TableProTests/TunnelCommand/TunnelCommandManagerTests.swift create mode 100644 TableProTests/TunnelCommand/TunnelCommandModelTests.swift create mode 100644 docs/connections/tunnel-command.mdx create mode 100644 docs/images/tunnel-command-pane-dark.png create mode 100644 docs/images/tunnel-command-pane.png diff --git a/CHANGELOG.md b/CHANGELOG.md index e2080ad681..8e34157441 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Tunnel Command pane, with presets for `kubectl port-forward` and `aws ssm start-session` and a custom command line. (#2520) + ### Fixed - SQL `IN`, `AND`, `OR`, `NOT`, `BY` and `ON` in the editor's plain text colour. (#2634) diff --git a/Packages/TableProCore/Sources/TableProImport/ConnectionExportEnvelope.swift b/Packages/TableProCore/Sources/TableProImport/ConnectionExportEnvelope.swift index f4736b4d62..2315eb01cb 100644 --- a/Packages/TableProCore/Sources/TableProImport/ConnectionExportEnvelope.swift +++ b/Packages/TableProCore/Sources/TableProImport/ConnectionExportEnvelope.swift @@ -93,6 +93,7 @@ public struct ExportableConnection: Codable, Sendable { public let redisDatabase: Int? public let startupCommands: String? public let localOnly: Bool? + public let tunnelCommand: ExportableTunnelCommand? public init( name: String, @@ -113,7 +114,8 @@ public struct ExportableConnection: Codable, Sendable { additionalFields: [String: String]?, redisDatabase: Int?, startupCommands: String?, - localOnly: Bool? + localOnly: Bool?, + tunnelCommand: ExportableTunnelCommand? = nil ) { self.name = name self.host = host @@ -134,6 +136,7 @@ public struct ExportableConnection: Codable, Sendable { self.redisDatabase = redisDatabase self.startupCommands = startupCommands self.localOnly = localOnly + self.tunnelCommand = tunnelCommand } public func renamed(to newName: String) -> ExportableConnection { @@ -144,11 +147,52 @@ public struct ExportableConnection: Codable, Sendable { groupName: groupName, sshProfileId: sshProfileId, safeModeLevel: safeModeLevel, aiPolicy: aiPolicy, additionalFields: additionalFields, redisDatabase: redisDatabase, - startupCommands: startupCommands, localOnly: localOnly + startupCommands: startupCommands, localOnly: localOnly, + tunnelCommand: tunnelCommand ) } } +/// A forwarding command carried by an exported connection. +/// +/// It holds no secret, which is why it can travel at all, and it is the only exported field that +/// describes a process TablePro would start. Import keeps it only behind an explicit confirmation, +/// and the routes that are a click rather than a decision, a deeplink and the team library, drop it +/// before anyone is asked. +public struct ExportableTunnelCommand: Codable, Sendable, Equatable { + public let method: String + public let command: String? + public let executablePath: String? + public let kubernetesNamespace: String? + public let kubernetesResource: String? + public let kubernetesContext: String? + public let awsTarget: String? + public let awsProfile: String? + public let awsRegion: String? + + public init( + method: String, + command: String?, + executablePath: String?, + kubernetesNamespace: String?, + kubernetesResource: String?, + kubernetesContext: String?, + awsTarget: String?, + awsProfile: String?, + awsRegion: String? + ) { + self.method = method + self.command = command + self.executablePath = executablePath + self.kubernetesNamespace = kubernetesNamespace + self.kubernetesResource = kubernetesResource + self.kubernetesContext = kubernetesContext + self.awsTarget = awsTarget + self.awsProfile = awsProfile + self.awsRegion = awsRegion + } +} + public extension ExportableConnection { static let importBlockedAdditionalFieldKeys: Set = [ "preconnectscript", @@ -176,7 +220,24 @@ public extension ExportableConnection { groupName: groupName, sshProfileId: sshProfileId, safeModeLevel: safeModeLevel, aiPolicy: aiPolicy, additionalFields: additionalFields, redisDatabase: redisDatabase, - startupCommands: nil, localOnly: localOnly + startupCommands: nil, localOnly: localOnly, + tunnelCommand: tunnelCommand + ) + } + + var carriesTunnelCommand: Bool { tunnelCommand != nil } + + func withoutTunnelCommand() -> ExportableConnection { + guard tunnelCommand != nil else { return self } + return ExportableConnection( + name: name, host: host, port: port, database: database, + username: username, type: type, sshConfig: sshConfig, + sslConfig: sslConfig, color: color, tagName: tagName, tagNames: tagNames, + groupName: groupName, sshProfileId: sshProfileId, + safeModeLevel: safeModeLevel, aiPolicy: aiPolicy, + additionalFields: additionalFields, redisDatabase: redisDatabase, + startupCommands: startupCommands, localOnly: localOnly, + tunnelCommand: nil ) } @@ -191,7 +252,8 @@ public extension ExportableConnection { groupName: groupName, sshProfileId: sshProfileId, safeModeLevel: safeModeLevel, aiPolicy: aiPolicy, additionalFields: allowed.isEmpty ? nil : allowed, redisDatabase: redisDatabase, - startupCommands: startupCommands, localOnly: localOnly + startupCommands: startupCommands, localOnly: localOnly, + tunnelCommand: tunnelCommand ) } } diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index 45d08fda63..eb95a0af74 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -100,6 +100,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { /// the main actor, so starting them here costs the first frame nothing. Task { await CloudflareTunnelManager.shared.sweepStalePidsIfNeeded() } Task { await CloudSQLProxyManager.shared.sweepStalePidsIfNeeded() } + Task { await TunnelCommandManager.shared.sweepStalePidsIfNeeded() } NSWorkspace.shared.notificationCenter.addObserver( self, selector: #selector(handleSystemDidWake), @@ -193,6 +194,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { SSHTunnelManager.shared.terminateAllProcessesSync() CloudflareTunnelManager.shared.terminateAllProcessesSync() CloudSQLProxyManager.shared.terminateAllProcessesSync() + TunnelCommandManager.shared.terminateAllProcessesSync() } private func persistOpenConnectionsForRecovery() { diff --git a/TablePro/Core/Database/CLIExecutableFinder.swift b/TablePro/Core/Database/CLIExecutableFinder.swift index 0951faf4f0..8b6572285f 100644 --- a/TablePro/Core/Database/CLIExecutableFinder.swift +++ b/TablePro/Core/Database/CLIExecutableFinder.swift @@ -30,6 +30,7 @@ enum CLIExecutableFinder { let process = Process() process.executableURL = URL(fileURLWithPath: path) process.arguments = arguments + process.environment = CLIToolEnvironment.augmented() let pipe = Pipe() process.standardOutput = pipe diff --git a/TablePro/Core/Database/DatabaseManager+SSH.swift b/TablePro/Core/Database/DatabaseManager+SSH.swift index ad2992a67d..ee3e11d445 100644 --- a/TablePro/Core/Database/DatabaseManager+SSH.swift +++ b/TablePro/Core/Database/DatabaseManager+SSH.swift @@ -33,6 +33,8 @@ extension DatabaseManager { return try await buildCloudSQLProxyEffectiveConnection(for: connection) case .socksProxy: return try await buildSOCKSProxyEffectiveConnection(for: connection) + case .tunnelCommand: + return try await buildTunnelCommandEffectiveConnection(for: connection) case .remoteFile: return try await buildRemoteFileEffectiveConnection( for: connection, diff --git a/TablePro/Core/Database/DatabaseManager+SystemEvents.swift b/TablePro/Core/Database/DatabaseManager+SystemEvents.swift index e4c41671a9..4059332eee 100644 --- a/TablePro/Core/Database/DatabaseManager+SystemEvents.swift +++ b/TablePro/Core/Database/DatabaseManager+SystemEvents.swift @@ -52,6 +52,8 @@ extension DatabaseManager { await handleCloudSQLProxyTunnelDied(connectionId: connectionId) case .socksProxy: await handleSOCKSProxyTunnelDied(connectionId: connectionId) + case .tunnelCommand: + await handleTunnelCommandDied(connectionId: connectionId) case .remoteFile: break } diff --git a/TablePro/Core/Database/DatabaseManager+Tunnel.swift b/TablePro/Core/Database/DatabaseManager+Tunnel.swift index 50168a0c0b..12f4256480 100644 --- a/TablePro/Core/Database/DatabaseManager+Tunnel.swift +++ b/TablePro/Core/Database/DatabaseManager+Tunnel.swift @@ -99,6 +99,7 @@ extension DatabaseManager { case .cloudflare: return CloudflareTunnelManager.shared case .cloudSQLProxy: return CloudSQLProxyManager.shared case .socksProxy: return SOCKSProxyManager.shared + case .tunnelCommand: return TunnelCommandManager.shared case .remoteFile: return RemoteFileTransportManager.shared case .none: return nil } diff --git a/TablePro/Core/Database/DatabaseManager+TunnelCommand.swift b/TablePro/Core/Database/DatabaseManager+TunnelCommand.swift new file mode 100644 index 0000000000..a295929b34 --- /dev/null +++ b/TablePro/Core/Database/DatabaseManager+TunnelCommand.swift @@ -0,0 +1,40 @@ +// +// DatabaseManager+TunnelCommand.swift +// TablePro +// + +import Foundation + +extension DatabaseManager { + func buildTunnelCommandEffectiveConnection( + for connection: DatabaseConnection + ) async throws -> DatabaseConnection { + guard let config = connection.resolvedTunnelCommandConfig else { return connection } + + /// The command is the one part of a connection that runs code, and `connections.json` is + /// ordinary user-writable storage. `ConnectionStoreIntegrity` already answers whether the + /// file is the one TablePro last wrote, and re-saving the connection in the app is the + /// confirmation that clears it, exactly as it is for a password source. + guard await ConnectionStorage.shared.storeIsTrusted else { + throw TunnelCommandError.storeNotTrusted + } + + let endpoint = connection.tunnelForwardEndpoint + let tunnelPort = try await TunnelCommandManager.shared.createTunnel( + connectionId: connection.id, + config: config, + remoteHost: endpoint.host, + remotePort: endpoint.port + ) + + return tunneledConnection(from: connection, localPort: tunnelPort) + } + + func handleTunnelCommandDied(connectionId: UUID) async { + await recoverDeadTunnel( + connectionId: connectionId, + kind: "Tunnel command", + disconnectedMessage: String(localized: "The tunnel command stopped. Click to reconnect.") + ) + } +} diff --git a/TablePro/Core/Plugins/PluginManager+Registration.swift b/TablePro/Core/Plugins/PluginManager+Registration.swift index 57ae4bc953..c27d0e2827 100644 --- a/TablePro/Core/Plugins/PluginManager+Registration.swift +++ b/TablePro/Core/Plugins/PluginManager+Registration.swift @@ -531,6 +531,11 @@ extension PluginManager { .capabilities.supportsSOCKSProxy ?? true } + func supportsTunnelCommand(for databaseType: DatabaseType) -> Bool { + PluginMetadataRegistry.shared.snapshot(for: databaseType)? + .capabilities.supportsTunnelCommand ?? true + } + func columnReorderSupport(for databaseType: DatabaseType) -> ColumnReorderSupport { PluginMetadataRegistry.shared.snapshot(for: databaseType)? .columnReorder ?? .unsupported diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/TablePro/Core/Plugins/PluginMetadataRegistry.swift index c1ece16d7b..e15a9cd4eb 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry.swift @@ -82,6 +82,11 @@ struct PluginMetadataSnapshot: Sendable { var supportsSOCKSProxy: Bool { supportsSSH } + /// A tunnel command forwards a loopback port to the server the connection names, so it + /// applies wherever an SSH tunnel would. Computed for the same reason `supportsSOCKSProxy` + /// is: a stored flag would need an opt-out line in every hand-written snapshot. + var supportsTunnelCommand: Bool { supportsSSH } + /// Whether this type may point at a file on an SSH server instead of a local one. /// /// Deliberately not derived from `localFilePathField`. Beancount opens a local file and must diff --git a/TablePro/Core/Process/CLIToolEnvironment.swift b/TablePro/Core/Process/CLIToolEnvironment.swift new file mode 100644 index 0000000000..98bd5c2231 --- /dev/null +++ b/TablePro/Core/Process/CLIToolEnvironment.swift @@ -0,0 +1,26 @@ +// +// CLIToolEnvironment.swift +// TablePro +// + +import Foundation + +/// The environment a helper process is launched with. +/// +/// An app started from the Dock inherits `launchd`'s minimal PATH, not a login shell's, so a tool +/// installed by Homebrew or the AWS installer is absent from it. The AWS CLI in particular looks +/// its own `session-manager-plugin` up on PATH, so this is not only about finding the tool named +/// in the connection. +enum CLIToolEnvironment { + static let toolPaths = ["/usr/local/bin", "/opt/homebrew/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"] + + static func augmented(_ base: [String: String] = ProcessInfo.processInfo.environment) -> [String: String] { + var environment = base + var components = (environment["PATH"] ?? "").split(separator: ":").map(String.init) + for toolPath in toolPaths where !components.contains(toolPath) { + components.append(toolPath) + } + environment["PATH"] = components.joined(separator: ":") + return environment + } +} diff --git a/TablePro/Core/Process/SupervisedProcessRunner.swift b/TablePro/Core/Process/SupervisedProcessRunner.swift index fc6dcac5b5..c172ebacb9 100644 --- a/TablePro/Core/Process/SupervisedProcessRunner.swift +++ b/TablePro/Core/Process/SupervisedProcessRunner.swift @@ -3,6 +3,7 @@ // TablePro // +import Darwin import Foundation struct SubprocessTermination: Sendable, Equatable { @@ -31,6 +32,8 @@ final class ProcessSupervisedRunner: SupervisedProcessRunner, @unchecked Sendabl /// `finish` drained an already empty pipe and closed the stream under it. private let ingestLock = NSLock() + private static let forcedTerminationGrace = Duration.seconds(2) + private var partialLine = "" private var wasRequested = false private var terminationResult: SubprocessTermination? @@ -78,13 +81,48 @@ final class ProcessSupervisedRunner: SupervisedProcessRunner, @unchecked Sendabl try process.run() } + /// Signals the whole process group rather than the child alone, then forces what is left. + /// + /// Foundation gives every child its own process group and descendants inherit it, so the group + /// is the only handle that reaches a helper the command spawned for itself. `aws ssm + /// start-session` runs `session-manager-plugin` that way, and the plugin is what actually holds + /// the forwarded port, so signalling the pid alone leaves the port held by an orphan. A + /// command that then ignores `SIGTERM` would hold it for the life of the app, which is what the + /// escalation is for. func stop() { stateLock.lock() + let alreadyRequested = wasRequested wasRequested = true stateLock.unlock() - if process.isRunning { + guard !alreadyRequested, process.isRunning else { return } + + let pid = process.processIdentifier + guard pid > 1 else { + process.terminate() + return + } + if kill(-pid, SIGTERM) != 0 { process.terminate() } + scheduleForcedTermination(pid: pid) + } + + private func scheduleForcedTermination(pid: pid_t) { + Task.detached { [weak self] in + try? await Task.sleep(for: Self.forcedTerminationGrace) + guard let self, self.isUnterminated else { return } + kill(-pid, SIGKILL) + } + } + + /// Both halves matter. `terminationResult` is written by the termination handler, which + /// Foundation runs after it has reaped the child, and `isRunning` goes false at the same + /// point; checking them together is what keeps a forced kill from ever reaching a process + /// group that inherited a recycled pid. + private var isUnterminated: Bool { + stateLock.lock() + defer { stateLock.unlock() } + return terminationResult == nil && process.isRunning } var termination: SubprocessTermination { diff --git a/TablePro/Core/Services/Export/ConnectionExportService.swift b/TablePro/Core/Services/Export/ConnectionExportService.swift index 6de73f80c0..d54c7ba5ed 100644 --- a/TablePro/Core/Services/Export/ConnectionExportService.swift +++ b/TablePro/Core/Services/Export/ConnectionExportService.swift @@ -140,7 +140,8 @@ enum ConnectionExportService { additionalFields: additionalFields, redisDatabase: connection.redisDatabase, startupCommands: connection.startupCommands, - localOnly: connection.localOnly ? true : nil + localOnly: connection.localOnly ? true : nil, + tunnelCommand: connection.resolvedTunnelCommandConfig.map(ExportableTunnelCommand.init) ) exportableConnections.append(exportable) @@ -366,7 +367,8 @@ enum ConnectionExportService { @discardableResult static func performImport( _ preview: ConnectionImportPreview, - resolutions: [UUID: ImportResolution] + resolutions: [UUID: ImportResolution], + keepTunnelCommands: Bool = false ) -> ImportResult { if let envelopeGroups = preview.envelope.groups { for exportGroup in envelopeGroups { @@ -413,18 +415,23 @@ enum ConnectionExportService { resolutions: resolutions, existingNames: ConnectionStorage.shared.loadConnections().map(\.name), tagIdsByName: tagIdsByName(), - groupIdsByName: groupIdsByName() + groupIdsByName: groupIdsByName(), + keepTunnelCommands: keepTunnelCommands ) return performPreparedImport(prepared) } + /// `keepTunnelCommands` defaults to false so a route that has not asked the user cannot carry + /// one in by omission. Only the file import sheet, which shows the command and takes an answer, + /// passes true. static func prepareImport( _ preview: ConnectionImportPreview, resolutions: [UUID: ImportResolution], existingNames: [String] = [], tagIdsByName: [String: UUID], - groupIdsByName: [String: UUID] + groupIdsByName: [String: UUID], + keepTunnelCommands: Bool = false ) -> PreparedConnectionImport { var operations: [PreparedImportOperation] = [] var connectionIdMap: [Int: UUID] = [:] @@ -438,6 +445,7 @@ enum ConnectionExportService { for item in preview.items { let resolution = resolutions[item.id] ?? .skip guard let envelopeIndex = itemIndexMap[item.id] else { continue } + let exportable = keepTunnelCommands ? item.connection : item.connection.withoutTunnelCommand() switch resolution { case .skip: @@ -447,14 +455,14 @@ enum ConnectionExportService { let connectionId = UUID() let name: String if resolution == .importAsCopy { - name = uniqueCopyName(for: item.connection.name, taken: takenNames) + name = uniqueCopyName(for: exportable.name, taken: takenNames) } else { - name = item.connection.name + name = exportable.name } takenNames.insert(normalizedLookupKey(name)) let connection = buildDatabaseConnection( id: connectionId, - from: item.connection, + from: exportable, name: name, tagIdsByName: tagIdsByName, groupIdsByName: groupIdsByName @@ -466,8 +474,8 @@ enum ConnectionExportService { case .replace(let existingId): let connection = buildDatabaseConnection( id: existingId, - from: item.connection, - name: item.connection.name, + from: exportable, + name: exportable.name, tagIdsByName: tagIdsByName, groupIdsByName: groupIdsByName ) @@ -712,6 +720,7 @@ enum ConnectionExportService { tagIds: tagIds, groupId: groupId, sshProfileId: parsedSSHProfileId, + tunnelCommandMode: exportable.tunnelCommand.map { .inline(TunnelCommandConfiguration($0)) } ?? .disabled, safeModeLevel: exportable.safeModeLevel.flatMap { SafeModeLevel(rawValue: $0) } ?? .silent, aiPolicy: exportable.aiPolicy.flatMap { AIConnectionPolicy(rawValue: $0) }, redisDatabase: exportable.redisDatabase, diff --git a/TablePro/Core/Services/Infrastructure/DeeplinkParser.swift b/TablePro/Core/Services/Infrastructure/DeeplinkParser.swift index 2fd1a252d1..c4c3d5b02f 100644 --- a/TablePro/Core/Services/Infrastructure/DeeplinkParser.swift +++ b/TablePro/Core/Services/Infrastructure/DeeplinkParser.swift @@ -337,7 +337,7 @@ internal enum DeeplinkParser { localOnly: value("localOnly") == "1" ? true : nil ) - return .success(.importConnection(exportable.sanitizedForImport())) + return .success(.importConnection(exportable.sanitizedForImport().withoutTunnelCommand())) } private static func pathSegments(_ url: URL) -> [String] { diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index fe42e0068b..192715162a 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -366,6 +366,7 @@ final class ConnectionStorage { cloudflareTunnelMode: connection.cloudflareTunnelMode, cloudSQLProxyMode: connection.cloudSQLProxyMode, socksProxyMode: connection.socksProxyMode, + tunnelCommandMode: connection.tunnelCommandMode, safeModeLevel: connection.safeModeLevel, aiPolicy: connection.aiPolicy, aiRules: connection.aiRules, diff --git a/TablePro/Core/Storage/StoredConnection.swift b/TablePro/Core/Storage/StoredConnection.swift index b656897e27..df4b5928a3 100644 --- a/TablePro/Core/Storage/StoredConnection.swift +++ b/TablePro/Core/Storage/StoredConnection.swift @@ -84,6 +84,9 @@ struct StoredConnection: Codable { // SOCKS proxy mode (JSON blob) let socksProxyModeJson: Data? + // Tunnel command mode (JSON blob) + let tunnelCommandModeJson: Data? + // Plugin-driven additional fields let additionalFields: [String: String]? @@ -171,6 +174,11 @@ struct StoredConnection: Codable { ? (try? JSONEncoder().encode(connection.socksProxyMode)) : nil + // Tunnel command mode (only persisted when enabled) + self.tunnelCommandModeJson = connection.isTunnelCommandEnabled + ? (try? JSONEncoder().encode(connection.tunnelCommandMode)) + : nil + self.additionalFields = connection.additionalFields.isEmpty ? nil : connection.additionalFields // Password source (not synced to iCloud; see SyncRecordMapper) @@ -196,6 +204,7 @@ struct StoredConnection: Codable { case cloudflareTunnelModeJson case cloudSQLProxyModeJson case socksProxyModeJson + case tunnelCommandModeJson case additionalFields case localOnly case isSample @@ -244,6 +253,7 @@ struct StoredConnection: Codable { try container.encodeIfPresent(cloudflareTunnelModeJson, forKey: .cloudflareTunnelModeJson) try container.encodeIfPresent(cloudSQLProxyModeJson, forKey: .cloudSQLProxyModeJson) try container.encodeIfPresent(socksProxyModeJson, forKey: .socksProxyModeJson) + try container.encodeIfPresent(tunnelCommandModeJson, forKey: .tunnelCommandModeJson) try container.encodeIfPresent(additionalFields, forKey: .additionalFields) try container.encode(localOnly, forKey: .localOnly) try container.encode(isSample, forKey: .isSample) @@ -320,6 +330,7 @@ struct StoredConnection: Codable { cloudflareTunnelModeJson = try container.decodeIfPresent(Data.self, forKey: .cloudflareTunnelModeJson) cloudSQLProxyModeJson = try container.decodeIfPresent(Data.self, forKey: .cloudSQLProxyModeJson) socksProxyModeJson = try container.decodeIfPresent(Data.self, forKey: .socksProxyModeJson) + tunnelCommandModeJson = try container.decodeIfPresent(Data.self, forKey: .tunnelCommandModeJson) additionalFields = try container.decodeIfPresent([String: String].self, forKey: .additionalFields) passwordSource = PasswordSource.resilientlyDecoded(from: container, forKey: .passwordSource) localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly) ?? false @@ -383,6 +394,14 @@ struct StoredConnection: Codable { resolvedSOCKSProxyMode = .disabled } + let resolvedTunnelCommandMode: TunnelCommandMode + if let json = tunnelCommandModeJson, + let decoded = try? JSONDecoder().decode(TunnelCommandMode.self, from: json) { + resolvedTunnelCommandMode = decoded + } else { + resolvedTunnelCommandMode = .disabled + } + var resolvedSSLCaPath = sslCaCertificatePath if type == "Cassandra", resolvedSSLCaPath.isEmpty, let legacy = additionalFields?["sslCaCertPath"], !legacy.isEmpty { @@ -444,6 +463,7 @@ struct StoredConnection: Codable { cloudflareTunnelMode: resolvedCloudflareMode, cloudSQLProxyMode: resolvedCloudSQLProxyMode, socksProxyMode: resolvedSOCKSProxyMode, + tunnelCommandMode: resolvedTunnelCommandMode, safeModeLevel: SafeModeLevel(rawValue: safeModeLevel) ?? .silent, aiPolicy: parsedAIPolicy, aiRules: aiRules, diff --git a/TablePro/Core/Sync/SyncRecordMapper.swift b/TablePro/Core/Sync/SyncRecordMapper.swift index a63c2c2e9d..baf014a0d5 100644 --- a/TablePro/Core/Sync/SyncRecordMapper.swift +++ b/TablePro/Core/Sync/SyncRecordMapper.swift @@ -124,9 +124,10 @@ 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, cloudSQLProxyMode, and socksProxyMode are also NOT - // synced: they are device-local runtime config and their secrets live in - // the Keychain. + // cloudflareTunnelMode, cloudSQLProxyMode, socksProxyMode and + // tunnelCommandMode are also NOT synced: they are device-local runtime + // config, their secrets live in the Keychain, and a tunnel command names a + // process that only exists on the Mac it was written on. // passwordSource is also NOT synced: its file path, env var, or command // is device-local and may not exist or resolve on another Mac. do { diff --git a/TablePro/Core/TunnelCommand/TunnelCommandError.swift b/TablePro/Core/TunnelCommand/TunnelCommandError.swift new file mode 100644 index 0000000000..c07599bfa7 --- /dev/null +++ b/TablePro/Core/TunnelCommand/TunnelCommandError.swift @@ -0,0 +1,68 @@ +// +// TunnelCommandError.swift +// TablePro +// + +import Foundation + +enum TunnelCommandError: Error, LocalizedError, Equatable { + case commandEmpty + case missingLocalPortPlaceholder + case unbalancedQuote + case executableNotFound(String) + case noAvailablePort + case startupFailed(stderrTail: String) + case readinessTimeout(stderrTail: String) + case storeNotTrusted + + var errorDescription: String? { + switch self { + case .commandEmpty: + return String(localized: "This connection has no tunnel command to run.") + case .missingLocalPortPlaceholder: + return String( + format: String(localized: "The tunnel command must contain %@, where the local port goes."), + TunnelCommandLine.localPortPlaceholder + ) + case .unbalancedQuote: + return String(localized: "The tunnel command has an unclosed quote.") + case .executableNotFound(let name): + return String(format: String(localized: "%@ was not found."), name) + case .noAvailablePort: + return String(localized: "No local port was free for the tunnel.") + case .startupFailed(let tail): + return tail.isEmpty + ? String(localized: "The tunnel command exited before the port was open.") + : String( + format: String(localized: "The tunnel command exited before the port was open:\n\n%@"), + tail + ) + case .readinessTimeout(let tail): + return tail.isEmpty + ? String(localized: "The tunnel command did not open its local port in time.") + : String( + format: String(localized: "The tunnel command did not open its local port in time:\n\n%@"), + tail + ) + case .storeNotTrusted: + return String(localized: """ + Your connections file was changed outside TablePro, so this connection's tunnel \ + command was not run. Open the connection and save it again to confirm the change. + """) + } + } + + var recoverySuggestion: String? { + switch self { + case .executableNotFound: + return String(localized: """ + Give its full path in the connection. Apps launched from the Dock do not inherit \ + your shell's PATH. + """) + case .startupFailed, .readinessTimeout: + return String(localized: "Run the command in Terminal to see what it reports.") + default: + return nil + } + } +} diff --git a/TablePro/Core/TunnelCommand/TunnelCommandManager.swift b/TablePro/Core/TunnelCommand/TunnelCommandManager.swift new file mode 100644 index 0000000000..0a1880d70b --- /dev/null +++ b/TablePro/Core/TunnelCommand/TunnelCommandManager.swift @@ -0,0 +1,357 @@ +// +// TunnelCommandManager.swift +// TablePro +// + +import Darwin +import Foundation +import os + +/// Runs the process that forwards a local port to the database, and holds it for the life of the +/// connection. +/// +/// The subprocess shape is the one `CloudSQLProxyManager` established: allocate a loopback port, +/// start the process, wait for the port to answer or for the process to give up, then watch it so +/// a death becomes a reconnect rather than a session that quietly stops working. +actor TunnelCommandManager: TunnelManaging { + static let shared = TunnelCommandManager() + private static let logger = Logger(subsystem: "com.TablePro", category: "TunnelCommandManager") + + private static let readinessTimeout: TimeInterval = 30 + private static let readinessPollInterval: UInt64 = 250_000_000 + private static let portRetryCount = 5 + private static let stalePidsDefaultsKey = "tunnelCommandStalePids" + + private struct TunnelState { + let runner: any SupervisedProcessRunner + let localPort: Int + } + + private var tunnels: [UUID: TunnelState] = [:] + private var pidRecords: [UUID: TunnelCommandPidRecord] = [:] + private let runnerFactory: () -> any SupervisedProcessRunner + private let executableLookup: (String) -> String? + private var staleSweep: Task? + private let reaperTimings: StaleProcessReaper.Timings + + private static let runnerRegistry = OSAllocatedUnfairLock(initialState: [UUID: any SupervisedProcessRunner]()) + + private var appNapActivity: NSObjectProtocol? + + init( + runnerFactory: @escaping () -> any SupervisedProcessRunner = { ProcessSupervisedRunner() }, + executableLookup: @escaping (String) -> String? = { CLIExecutableFinder.findExecutable($0) }, + reaperTimings: StaleProcessReaper.Timings = .production + ) { + self.runnerFactory = runnerFactory + self.executableLookup = executableLookup + self.reaperTimings = reaperTimings + } + + func createTunnel( + connectionId: UUID, + config: TunnelCommandConfiguration, + remoteHost: String, + remotePort: Int + ) async throws -> Int { + await sweepStalePidsIfNeeded() + + if tunnels[connectionId] != nil { + try await closeTunnel(connectionId: connectionId) + } + + let environment = CLIToolEnvironment.augmented() + var lastError: Error = TunnelCommandError.noAvailablePort + + for _ in 0.. [any SupervisedProcessRunner] 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 + } + + /// Runs at most once per process, and `createTunnel` awaits it so a forward a crashed session + /// left behind has released its port before a replacement asks for one. + func sweepStalePidsIfNeeded() async { + if let staleSweep { + await staleSweep.value + return + } + let task = Task { await self.performStaleSweep() } + staleSweep = task + await task.value + } + + private func performStaleSweep() async { + let defaults = AppStorageEnvironment.shared.defaults + guard let data = defaults.data(forKey: Self.stalePidsDefaultsKey), + let records = try? JSONDecoder().decode([TunnelCommandPidRecord].self, from: data) else { + defaults.removeObject(forKey: Self.stalePidsDefaultsKey) + return + } + + let survivors = await StaleProcessReaper.reap( + records.map { $0.reaperTarget }, + timings: reaperTimings, + signal: Self.signalProcessGroup + ) + + guard !survivors.isEmpty else { + defaults.removeObject(forKey: Self.stalePidsDefaultsKey) + return + } + let surviving = Set(survivors.map(\.pid)) + let kept = records.filter { surviving.contains($0.pid) } + if let data = try? JSONEncoder().encode(kept) { + defaults.set(data, forKey: Self.stalePidsDefaultsKey) + } + } + + /// A forwarding command can outlive its own process: `aws ssm start-session` runs + /// `session-manager-plugin` beside it, and that is what actually holds the port. Every child + /// inherits the process group Foundation gives the command, so the group is what has to go. + nonisolated private static func signalProcessGroup(_ pid: pid_t, _ signalNumber: Int32) { + guard pid > 1 else { return } + if kill(-pid, signalNumber) != 0 { + kill(pid, signalNumber) + } + } + + // MARK: - Private: lifecycle + + private func register( + connectionId: UUID, + runner: any SupervisedProcessRunner, + port: Int, + binaryPath: String, + executableName: String + ) { + tunnels[connectionId] = TunnelState(runner: runner, localPort: port) + Self.runnerRegistry.withLock { $0[connectionId] = runner } + if let pid = runner.processIdentifier { + pidRecords[connectionId] = TunnelCommandPidRecord( + pid: pid, + binaryPath: binaryPath, + executableName: executableName + ) + persistPidRecords() + } + updateAppNapState() + startDeathWatch(connectionId: connectionId, runner: runner) + } + + private func startDeathWatch(connectionId: UUID, runner: any SupervisedProcessRunner) { + Task { [weak self] in + let result = await runner.termination + await self?.handleTermination(connectionId: connectionId, result: result) + } + } + + private func handleTermination(connectionId: UUID, result: SubprocessTermination) 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("Tunnel command died for connection \(connectionId.uuidString, privacy: .public)") + await DatabaseManager.shared.handleTunnelCommandDied(connectionId: connectionId) + } + + // MARK: - Private: readiness + + private func awaitReadiness(runner: any SupervisedProcessRunner, port: Int) async throws { + let monitor = TunnelCommandStartupMonitor() + 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 await LoopbackPort.isReachable(host: "127.0.0.1", port: port) { + return + } + if await monitor.streamEnded { + throw TunnelCommandError.startupFailed(stderrTail: await monitor.tail) + } + try await Task.sleep(nanoseconds: Self.readinessPollInterval) + } + throw TunnelCommandError.readinessTimeout(stderrTail: await monitor.tail) + } + + // MARK: - Private: executable + + private func resolveExecutablePath(_ executable: String) throws -> String { + guard !executable.isEmpty else { throw TunnelCommandError.commandEmpty } + if executable.contains("/") { + let expanded = (executable as NSString).expandingTildeInPath + guard FileManager.default.isExecutableFile(atPath: expanded) else { + throw TunnelCommandError.executableNotFound(executable) + } + return expanded + } + guard let resolved = executableLookup(executable) else { + throw TunnelCommandError.executableNotFound(executable) + } + return resolved + } + + // MARK: - Private: stale PID persistence + + private func persistPidRecords() { + let records = Array(pidRecords.values) + guard !records.isEmpty else { + AppStorageEnvironment.shared.defaults.removeObject(forKey: Self.stalePidsDefaultsKey) + return + } + do { + let data = try JSONEncoder().encode(records) + AppStorageEnvironment.shared.defaults.set(data, forKey: Self.stalePidsDefaultsKey) + } catch { + Self.logger.error("Failed to persist tunnel command PID records: \(error.localizedDescription, privacy: .public)") + } + } + + private static func isPortInUse(_ stderrTail: String) -> Bool { + let lowered = stderrTail.lowercased() + return lowered.contains("address already in use") || lowered.contains("bind: address already in use") + } + + // MARK: - Private: App Nap + + private func updateAppNapState() { + if !tunnels.isEmpty, appNapActivity == nil { + appNapActivity = ProcessInfo.processInfo.beginActivity( + options: .userInitiatedAllowingIdleSystemSleep, + reason: "Tunnel command process requires timely execution" + ) + } else if tunnels.isEmpty, let activity = appNapActivity { + ProcessInfo.processInfo.endActivity(activity) + appNapActivity = nil + } + } +} + +// MARK: - PID record + +struct TunnelCommandPidRecord: Codable, Sendable, Equatable { + let pid: Int32 + let binaryPath: String + let executableName: String + + var reaperTarget: StaleProcessReaper.Target { + StaleProcessReaper.Target(pid: pid, binaryPath: binaryPath, executableName: executableName) + } +} + +// MARK: - Startup monitor + +private actor TunnelCommandStartupMonitor { + private(set) var tail = "" + private(set) var streamEnded = false + private let tailCap = 2_000 + + func append(_ line: String) { + tail += line + "\n" + if tail.count > tailCap { + tail = String(tail.suffix(tailCap)) + } + } + + func markStreamEnded() { + streamEnded = true + } +} diff --git a/TablePro/Core/Utilities/Connection/PasswordSourceResolver.swift b/TablePro/Core/Utilities/Connection/PasswordSourceResolver.swift index 0143bc1d93..ee49e19589 100644 --- a/TablePro/Core/Utilities/Connection/PasswordSourceResolver.swift +++ b/TablePro/Core/Utilities/Connection/PasswordSourceResolver.swift @@ -153,7 +153,7 @@ enum PasswordSourceResolver { let process = Process() process.executableURL = URL(fileURLWithPath: "/bin/bash") process.arguments = ["-c", shell] - process.environment = augmentedEnvironment() + process.environment = CLIToolEnvironment.augmented() process.standardInput = FileHandle.nullDevice let stdoutPipe = Pipe() @@ -241,17 +241,6 @@ enum PasswordSourceResolver { } } - private static func augmentedEnvironment() -> [String: String] { - var environment = ProcessInfo.processInfo.environment - let toolPaths = ["/usr/local/bin", "/opt/homebrew/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"] - var pathComponents = (environment["PATH"] ?? "").split(separator: ":").map(String.init) - for toolPath in toolPaths where !pathComponents.contains(toolPath) { - pathComponents.append(toolPath) - } - environment["PATH"] = pathComponents.joined(separator: ":") - return environment - } - private static func warnIfPermissionsInsecure(path: String) { guard let attributes = try? FileManager.default.attributesOfItem(atPath: path), let permissions = attributes[.posixPermissions] as? Int else { diff --git a/TablePro/Models/Connection/ConnectionStageLabelFormatter.swift b/TablePro/Models/Connection/ConnectionStageLabelFormatter.swift index ce93041782..c1b8adef58 100644 --- a/TablePro/Models/Connection/ConnectionStageLabelFormatter.swift +++ b/TablePro/Models/Connection/ConnectionStageLabelFormatter.swift @@ -65,6 +65,8 @@ internal enum ConnectionStageLabelFormatter { return "Cloud SQL Auth Proxy" case .socksProxy: return String(localized: "the SOCKS proxy") + case .tunnelCommand: + return String(localized: "the tunnel command") case .remoteFile: let host = connection.resolvedSSHConfig.host.trimmingCharacters(in: .whitespaces) return host.isEmpty ? nil : host diff --git a/TablePro/Models/Connection/ConnectionTunnelKind.swift b/TablePro/Models/Connection/ConnectionTunnelKind.swift index 90da371e59..fc378c8006 100644 --- a/TablePro/Models/Connection/ConnectionTunnelKind.swift +++ b/TablePro/Models/Connection/ConnectionTunnelKind.swift @@ -10,6 +10,7 @@ enum ConnectionTunnelKind: String, CaseIterable, Sendable { case cloudflare case cloudSQLProxy case socksProxy + case tunnelCommand /// A database file fetched from an SSH server over SFTP rather than a port forwarded from it. /// @@ -25,7 +26,7 @@ enum ConnectionTunnelKind: String, CaseIterable, Sendable { /// `.ssh` and can never be turned on without it. Anything reasoning about which controls /// conflict wants this list; anything reasoning about which transport will run wants /// `allCases`. - static let formToggleable: [ConnectionTunnelKind] = [.ssh, .cloudflare, .cloudSQLProxy, .socksProxy] + static let formToggleable: [ConnectionTunnelKind] = [.ssh, .cloudflare, .cloudSQLProxy, .socksProxy, .tunnelCommand] var displayName: String { switch self { @@ -33,6 +34,7 @@ enum ConnectionTunnelKind: String, CaseIterable, Sendable { case .cloudflare: return String(localized: "Cloudflare Tunnel") case .cloudSQLProxy: return String(localized: "Cloud SQL Auth Proxy") case .socksProxy: return String(localized: "SOCKS Proxy") + case .tunnelCommand: return String(localized: "Tunnel Command") case .remoteFile: return String(localized: "Remote Database File") } } @@ -67,6 +69,7 @@ extension DatabaseConnection { if isCloudflareEnabled { kinds.append(.cloudflare) } if isCloudSQLProxyEnabled { kinds.append(.cloudSQLProxy) } if isSOCKSProxyEnabled { kinds.append(.socksProxy) } + if isTunnelCommandEnabled { kinds.append(.tunnelCommand) } return kinds } diff --git a/TablePro/Models/Connection/DatabaseConnection+TunnelCommand.swift b/TablePro/Models/Connection/DatabaseConnection+TunnelCommand.swift new file mode 100644 index 0000000000..ed15d057ce --- /dev/null +++ b/TablePro/Models/Connection/DatabaseConnection+TunnelCommand.swift @@ -0,0 +1,16 @@ +// +// DatabaseConnection+TunnelCommand.swift +// TablePro +// + +extension DatabaseConnection { + var isTunnelCommandEnabled: Bool { + if case .inline = tunnelCommandMode { return true } + return false + } + + var resolvedTunnelCommandConfig: TunnelCommandConfiguration? { + if case .inline(let config) = tunnelCommandMode { return config } + return nil + } +} diff --git a/TablePro/Models/Connection/DatabaseConnection.swift b/TablePro/Models/Connection/DatabaseConnection.swift index fc3bc4fe9f..36be17ee53 100644 --- a/TablePro/Models/Connection/DatabaseConnection.swift +++ b/TablePro/Models/Connection/DatabaseConnection.swift @@ -413,6 +413,7 @@ struct DatabaseConnection: Identifiable, Hashable { var cloudflareTunnelMode: CloudflareTunnelMode = .disabled var cloudSQLProxyMode: CloudSQLProxyMode = .disabled var socksProxyMode: SOCKSProxyMode = .disabled + var tunnelCommandMode: TunnelCommandMode = .disabled var safeModeLevel: SafeModeLevel var aiPolicy: AIConnectionPolicy? var aiRules: String? @@ -518,6 +519,7 @@ struct DatabaseConnection: Identifiable, Hashable { cloudflareTunnelMode: CloudflareTunnelMode = .disabled, cloudSQLProxyMode: CloudSQLProxyMode = .disabled, socksProxyMode: SOCKSProxyMode = .disabled, + tunnelCommandMode: TunnelCommandMode = .disabled, safeModeLevel: SafeModeLevel = .silent, aiPolicy: AIConnectionPolicy? = nil, aiRules: String? = nil, @@ -572,6 +574,7 @@ struct DatabaseConnection: Identifiable, Hashable { self.cloudflareTunnelMode = cloudflareTunnelMode self.cloudSQLProxyMode = cloudSQLProxyMode self.socksProxyMode = socksProxyMode + self.tunnelCommandMode = tunnelCommandMode self.aiPolicy = aiPolicy self.aiRules = aiRules self.aiAlwaysAllowedTools = aiAlwaysAllowedTools @@ -641,7 +644,8 @@ extension DatabaseConnection: Codable { private enum CodingKeys: String, CodingKey { case id, name, host, port, database, username, type case sshConfig, sslConfig, color, tagId, tagIds, groupId, sshProfileId - case sshTunnelMode, cloudflareTunnelMode, cloudSQLProxyMode, socksProxyMode, safeModeLevel, aiPolicy, aiRules, aiAlwaysAllowedTools, externalAccess, additionalFields + case sshTunnelMode, cloudflareTunnelMode, cloudSQLProxyMode, socksProxyMode, tunnelCommandMode + case safeModeLevel, aiPolicy, aiRules, aiAlwaysAllowedTools, externalAccess, additionalFields case redisDatabase, startupCommands, sortOrder, localOnly, isSample, isFavorite case passwordSource } @@ -682,6 +686,7 @@ extension DatabaseConnection: Codable { cloudflareTunnelMode = try container.decodeIfPresent(CloudflareTunnelMode.self, forKey: .cloudflareTunnelMode) ?? .disabled cloudSQLProxyMode = try container.decodeIfPresent(CloudSQLProxyMode.self, forKey: .cloudSQLProxyMode) ?? .disabled socksProxyMode = try container.decodeIfPresent(SOCKSProxyMode.self, forKey: .socksProxyMode) ?? .disabled + tunnelCommandMode = try container.decodeIfPresent(TunnelCommandMode.self, forKey: .tunnelCommandMode) ?? .disabled // Migrate from legacy fields if sshTunnelMode is not present if let tunnelMode = try container.decodeIfPresent(SSHTunnelMode.self, forKey: .sshTunnelMode) { @@ -727,6 +732,9 @@ extension DatabaseConnection: Codable { if case .inline = socksProxyMode { try container.encode(socksProxyMode, forKey: .socksProxyMode) } + if case .inline = tunnelCommandMode { + try container.encode(tunnelCommandMode, forKey: .tunnelCommandMode) + } try container.encode(safeModeLevel, forKey: .safeModeLevel) try container.encodeIfPresent(aiPolicy, forKey: .aiPolicy) try container.encodeIfPresent(aiRules, forKey: .aiRules) diff --git a/TablePro/Models/Connection/TunnelCommandBuilder.swift b/TablePro/Models/Connection/TunnelCommandBuilder.swift new file mode 100644 index 0000000000..fdd813fcd3 --- /dev/null +++ b/TablePro/Models/Connection/TunnelCommandBuilder.swift @@ -0,0 +1,231 @@ +// +// TunnelCommandBuilder.swift +// TablePro +// + +import Foundation + +/// Turns a tunnel command configuration into the exact argument vector TablePro will run. +/// +/// Preset flags are written in `--flag=value` form so a value can never be read as a flag of its +/// own, and the two positional arguments a preset still needs are validated instead. That is why +/// a preset carries parameters rather than code: nothing a preset field holds can add an argument. +enum TunnelCommandBuilder { + struct Invocation: Equatable, Sendable { + let executable: String + let arguments: [String] + } + + private static let sessionDocument = "AWS-StartPortForwardingSessionToRemoteHost" + + static func invocation( + for config: TunnelCommandConfiguration, + localPort: Int, + remoteHost: String, + remotePort: Int + ) throws -> Invocation { + try invocation( + for: config, + localPortToken: String(localPort), + remoteHost: remoteHost, + remotePort: remotePort + ) + } + + /// The command as the connection form shows it, with the local port left as its placeholder + /// because it is allocated at connect time and naming a number here would be a lie. + static func previewCommand( + for config: TunnelCommandConfiguration, + remoteHost: String, + remotePort: Int + ) -> String? { + guard let invocation = try? invocation( + for: config, + localPortToken: TunnelCommandLine.localPortPlaceholder, + remoteHost: remoteHost, + remotePort: remotePort + ) else { return nil } + return ([invocation.executable] + invocation.arguments).map(displayQuoted).joined(separator: " ") + } + + static func validationIssues(for config: TunnelCommandConfiguration) -> [String] { + switch config.method { + case .custom: + return customValidationIssues(for: config) + case .kubectl: + return kubectlValidationIssues(for: config) + case .awsSSM: + return awsValidationIssues(for: config) + } + } + + // MARK: - Private: invocation + + private static func invocation( + for config: TunnelCommandConfiguration, + localPortToken: String, + remoteHost: String, + remotePort: Int + ) throws -> Invocation { + switch config.method { + case .custom: + /// `ParseError` carries no message of its own, so letting it escape would put + /// "The operation couldn't be completed" in front of the user instead of the two + /// `TunnelCommandError` cases written for exactly these failures. + let tokens: [String] + do { + tokens = try TunnelCommandLine.tokenize(config.command) + } catch TunnelCommandLine.ParseError.empty { + throw TunnelCommandError.commandEmpty + } catch { + throw TunnelCommandError.unbalancedQuote + } + guard TunnelCommandLine.containsLocalPortPlaceholder(config.command) else { + throw TunnelCommandError.missingLocalPortPlaceholder + } + let substituted = tokens.map { token in + token + .replacingOccurrences(of: TunnelCommandLine.localPortPlaceholder, with: localPortToken) + .replacingOccurrences(of: TunnelCommandLine.hostPlaceholder, with: remoteHost) + .replacingOccurrences(of: TunnelCommandLine.remotePortPlaceholder, with: String(remotePort)) + } + return Invocation(executable: substituted[0], arguments: Array(substituted.dropFirst())) + case .kubectl: + return Invocation( + executable: executable(for: config), + arguments: kubectlArguments(for: config, localPortToken: localPortToken, remotePort: remotePort) + ) + case .awsSSM: + return Invocation( + executable: executable(for: config), + arguments: awsArguments( + for: config, + localPortToken: localPortToken, + remoteHost: remoteHost, + remotePort: remotePort + ) + ) + } + } + + private static func executable(for config: TunnelCommandConfiguration) -> String { + let path = config.executablePath.trimmingCharacters(in: .whitespacesAndNewlines) + return path.isEmpty ? config.method.executableName : (path as NSString).expandingTildeInPath + } + + private static func kubectlArguments( + for config: TunnelCommandConfiguration, + localPortToken: String, + remotePort: Int + ) -> [String] { + var arguments: [String] = [] + let context = trimmed(config.kubernetesContext) + let namespace = trimmed(config.kubernetesNamespace) + if !context.isEmpty { arguments.append("--context=\(context)") } + if !namespace.isEmpty { arguments.append("--namespace=\(namespace)") } + arguments.append("port-forward") + arguments.append("--address=127.0.0.1") + arguments.append(trimmed(config.kubernetesResource)) + arguments.append("\(localPortToken):\(remotePort)") + return arguments + } + + private static func awsArguments( + for config: TunnelCommandConfiguration, + localPortToken: String, + remoteHost: String, + remotePort: Int + ) -> [String] { + var arguments = [ + "ssm", + "start-session", + "--target=\(trimmed(config.awsTarget))", + "--document-name=\(sessionDocument)", + "--parameters=host=\(remoteHost),portNumber=\(remotePort),localPortNumber=\(localPortToken)" + ] + let profile = trimmed(config.awsProfile) + let region = trimmed(config.awsRegion) + if !profile.isEmpty { arguments.append("--profile=\(profile)") } + if !region.isEmpty { arguments.append("--region=\(region)") } + return arguments + } + + // MARK: - Private: validation + + private static func customValidationIssues(for config: TunnelCommandConfiguration) -> [String] { + var issues: [String] = [] + let command = trimmed(config.command) + if command.isEmpty { + issues.append(String(localized: "A command is required")) + return issues + } + do { + _ = try TunnelCommandLine.tokenize(command) + } catch { + issues.append(String(localized: "The command has an unclosed quote")) + } + if !TunnelCommandLine.containsLocalPortPlaceholder(command) { + issues.append(String( + format: String(localized: "The command must contain %@, where the local port goes"), + TunnelCommandLine.localPortPlaceholder + )) + } + return issues + } + + private static func kubectlValidationIssues(for config: TunnelCommandConfiguration) -> [String] { + var issues: [String] = [] + let resource = trimmed(config.kubernetesResource) + if resource.isEmpty { + issues.append(String(localized: "A Kubernetes resource is required, such as service/postgres")) + } + for (value, label) in [ + (resource, String(localized: "Resource")), + (trimmed(config.kubernetesNamespace), String(localized: "Namespace")), + (trimmed(config.kubernetesContext), String(localized: "Context")) + ] where isFlagLike(value) { + issues.append(flagLikeIssue(label)) + } + return issues + } + + private static func awsValidationIssues(for config: TunnelCommandConfiguration) -> [String] { + var issues: [String] = [] + let target = trimmed(config.awsTarget) + if target.isEmpty { + issues.append(String(localized: "An SSM target is required, such as i-0123456789abcdef0")) + } + for (value, label) in [ + (target, String(localized: "Target")), + (trimmed(config.awsProfile), String(localized: "Profile")), + (trimmed(config.awsRegion), String(localized: "Region")) + ] where isFlagLike(value) { + issues.append(flagLikeIssue(label)) + } + return issues + } + + /// A preset field reaches the command as one argument, so a value opening with a dash would be + /// read as a flag and a value carrying a space would become two arguments. + private static func isFlagLike(_ value: String) -> Bool { + guard !value.isEmpty else { return false } + if value.hasPrefix("-") { return true } + return value.rangeOfCharacter(from: .whitespacesAndNewlines) != nil + } + + private static func flagLikeIssue(_ label: String) -> String { + String( + format: String(localized: "%@ cannot start with a dash or contain spaces"), + label + ) + } + + private static func trimmed(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func displayQuoted(_ token: String) -> String { + guard token.rangeOfCharacter(from: .whitespacesAndNewlines) != nil else { return token } + return "'" + token.replacingOccurrences(of: "'", with: "'\\''") + "'" + } +} diff --git a/TablePro/Models/Connection/TunnelCommandConfiguration.swift b/TablePro/Models/Connection/TunnelCommandConfiguration.swift new file mode 100644 index 0000000000..cf08d33793 --- /dev/null +++ b/TablePro/Models/Connection/TunnelCommandConfiguration.swift @@ -0,0 +1,72 @@ +// +// TunnelCommandConfiguration.swift +// TablePro +// + +import Foundation + +/// How the forwarding process is described. +/// +/// A preset carries parameters rather than a command line, so the argument vector it produces is +/// fixed and nothing a field holds can become a flag. `.custom` is the only method that stores a +/// command the user wrote. +enum TunnelCommandMethod: String, CaseIterable, Identifiable, Codable, Sendable { + case kubectl + case awsSSM + case custom + + var id: String { rawValue } + + var displayName: String { + switch self { + case .kubectl: return String(localized: "kubectl port-forward") + case .awsSSM: return String(localized: "AWS SSM Session") + case .custom: return String(localized: "Custom Command") + } + } + + var executableName: String { + switch self { + case .kubectl: return "kubectl" + case .awsSSM: return "aws" + case .custom: return "" + } + } +} + +struct TunnelCommandConfiguration: Codable, Hashable, Sendable { + var method: TunnelCommandMethod = .kubectl + var command: String = "" + var executablePath: String = "" + var kubernetesNamespace: String = "" + var kubernetesResource: String = "" + var kubernetesContext: String = "" + var awsTarget: String = "" + var awsProfile: String = "" + var awsRegion: String = "" + + var isValid: Bool { + TunnelCommandBuilder.validationIssues(for: self).isEmpty + } +} + +extension TunnelCommandConfiguration { + private enum CodingKeys: String, CodingKey { + case method, command, executablePath + case kubernetesNamespace, kubernetesResource, kubernetesContext + case awsTarget, awsProfile, awsRegion + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + method = try container.decodeIfPresent(TunnelCommandMethod.self, forKey: .method) ?? .kubectl + command = try container.decodeIfPresent(String.self, forKey: .command) ?? "" + executablePath = try container.decodeIfPresent(String.self, forKey: .executablePath) ?? "" + kubernetesNamespace = try container.decodeIfPresent(String.self, forKey: .kubernetesNamespace) ?? "" + kubernetesResource = try container.decodeIfPresent(String.self, forKey: .kubernetesResource) ?? "" + kubernetesContext = try container.decodeIfPresent(String.self, forKey: .kubernetesContext) ?? "" + awsTarget = try container.decodeIfPresent(String.self, forKey: .awsTarget) ?? "" + awsProfile = try container.decodeIfPresent(String.self, forKey: .awsProfile) ?? "" + awsRegion = try container.decodeIfPresent(String.self, forKey: .awsRegion) ?? "" + } +} diff --git a/TablePro/Models/Connection/TunnelCommandExportBridge.swift b/TablePro/Models/Connection/TunnelCommandExportBridge.swift new file mode 100644 index 0000000000..4d53be3ea2 --- /dev/null +++ b/TablePro/Models/Connection/TunnelCommandExportBridge.swift @@ -0,0 +1,40 @@ +// +// TunnelCommandExportBridge.swift +// TablePro +// + +import Foundation +import TableProImport +import TableProPluginKit + +extension ExportableTunnelCommand { + init(_ config: TunnelCommandConfiguration) { + self.init( + method: config.method.rawValue, + command: config.command.nilIfEmpty, + executablePath: config.executablePath.nilIfEmpty, + kubernetesNamespace: config.kubernetesNamespace.nilIfEmpty, + kubernetesResource: config.kubernetesResource.nilIfEmpty, + kubernetesContext: config.kubernetesContext.nilIfEmpty, + awsTarget: config.awsTarget.nilIfEmpty, + awsProfile: config.awsProfile.nilIfEmpty, + awsRegion: config.awsRegion.nilIfEmpty + ) + } +} + +extension TunnelCommandConfiguration { + init(_ exportable: ExportableTunnelCommand) { + self.init( + method: TunnelCommandMethod(rawValue: exportable.method) ?? .custom, + command: exportable.command ?? "", + executablePath: exportable.executablePath ?? "", + kubernetesNamespace: exportable.kubernetesNamespace ?? "", + kubernetesResource: exportable.kubernetesResource ?? "", + kubernetesContext: exportable.kubernetesContext ?? "", + awsTarget: exportable.awsTarget ?? "", + awsProfile: exportable.awsProfile ?? "", + awsRegion: exportable.awsRegion ?? "" + ) + } +} diff --git a/TablePro/Models/Connection/TunnelCommandFormState.swift b/TablePro/Models/Connection/TunnelCommandFormState.swift new file mode 100644 index 0000000000..31d4ee5950 --- /dev/null +++ b/TablePro/Models/Connection/TunnelCommandFormState.swift @@ -0,0 +1,38 @@ +// +// TunnelCommandFormState.swift +// TablePro +// + +import Foundation + +struct TunnelCommandFormState { + var enabled: Bool = false + var config = TunnelCommandConfiguration() + + func buildConfig() -> TunnelCommandConfiguration { + var trimmed = config + trimmed.command = config.command.trimmingCharacters(in: .whitespacesAndNewlines) + trimmed.executablePath = config.executablePath.trimmingCharacters(in: .whitespacesAndNewlines) + trimmed.kubernetesNamespace = config.kubernetesNamespace.trimmingCharacters(in: .whitespacesAndNewlines) + trimmed.kubernetesResource = config.kubernetesResource.trimmingCharacters(in: .whitespacesAndNewlines) + trimmed.kubernetesContext = config.kubernetesContext.trimmingCharacters(in: .whitespacesAndNewlines) + trimmed.awsTarget = config.awsTarget.trimmingCharacters(in: .whitespacesAndNewlines) + trimmed.awsProfile = config.awsProfile.trimmingCharacters(in: .whitespacesAndNewlines) + trimmed.awsRegion = config.awsRegion.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed + } + + func buildTunnelMode() -> TunnelCommandMode { + enabled ? .inline(buildConfig()) : .disabled + } + + mutating func load(from connection: DatabaseConnection) { + switch connection.tunnelCommandMode { + case .disabled: + enabled = false + case .inline(let stored): + enabled = true + config = stored + } + } +} diff --git a/TablePro/Models/Connection/TunnelCommandLine.swift b/TablePro/Models/Connection/TunnelCommandLine.swift new file mode 100644 index 0000000000..658156b605 --- /dev/null +++ b/TablePro/Models/Connection/TunnelCommandLine.swift @@ -0,0 +1,98 @@ +// +// TunnelCommandLine.swift +// TablePro +// + +import Foundation + +/// Splits a command line into an argument vector without a shell. +/// +/// TablePro never hands the command to `/bin/sh -c`. A shell would put itself between TablePro and +/// the process holding the forward, and the signal that ends the tunnel has to reach the forward +/// itself. Quoting is the POSIX subset a command line actually uses, and nothing is expanded: a +/// `$VAR` stays four characters. Someone who wants the shell writes it themselves, as the first +/// word of the command. +enum TunnelCommandLine { + enum ParseError: Error, Equatable { + case unbalancedQuote + case empty + } + + static let localPortPlaceholder = "{port}" + static let hostPlaceholder = "{host}" + static let remotePortPlaceholder = "{remotePort}" + + static func tokenize(_ commandLine: String) throws -> [String] { + var tokens: [String] = [] + var current = "" + var hasCurrent = false + var quote: Character? + var escaping = false + + for character in commandLine { + if escaping { + current.append(character) + hasCurrent = true + escaping = false + continue + } + if let openQuote = quote { + if character == openQuote { + quote = nil + } else if openQuote == "\"" && character == "\\" { + escaping = true + } else { + current.append(character) + } + hasCurrent = true + continue + } + switch character { + case "'", "\"": + quote = character + hasCurrent = true + case "\\": + escaping = true + case " ", "\t", "\n", "\r": + if hasCurrent { + tokens.append(current) + current = "" + hasCurrent = false + } + default: + current.append(character) + hasCurrent = true + } + } + + if quote != nil || escaping { throw ParseError.unbalancedQuote } + if hasCurrent { tokens.append(current) } + guard !tokens.isEmpty else { throw ParseError.empty } + return tokens.map(expandingTilde) + } + + /// Runs after tokenization, never before, so a substituted value carrying a space stays one + /// argument instead of splitting into two. + static func substitutePlaceholders( + in tokens: [String], + localPort: Int, + remoteHost: String, + remotePort: Int + ) -> [String] { + tokens.map { token in + token + .replacingOccurrences(of: localPortPlaceholder, with: String(localPort)) + .replacingOccurrences(of: hostPlaceholder, with: remoteHost) + .replacingOccurrences(of: remotePortPlaceholder, with: String(remotePort)) + } + } + + static func containsLocalPortPlaceholder(_ commandLine: String) -> Bool { + commandLine.contains(localPortPlaceholder) + } + + private static func expandingTilde(_ token: String) -> String { + guard token == "~" || token.hasPrefix("~/") else { return token } + return (token as NSString).expandingTildeInPath + } +} diff --git a/TablePro/Models/Connection/TunnelCommandMode.swift b/TablePro/Models/Connection/TunnelCommandMode.swift new file mode 100644 index 0000000000..95ea175059 --- /dev/null +++ b/TablePro/Models/Connection/TunnelCommandMode.swift @@ -0,0 +1,46 @@ +// +// TunnelCommandMode.swift +// TablePro +// + +import Foundation + +enum TunnelCommandMode: Hashable, Sendable { + case disabled + case inline(TunnelCommandConfiguration) +} + +extension TunnelCommandMode: 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(TunnelCommandConfiguration.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/TeamLibrary/TeamLibraryModels.swift b/TablePro/Models/TeamLibrary/TeamLibraryModels.swift index b8687784d2..7e91b0b2fe 100644 --- a/TablePro/Models/TeamLibrary/TeamLibraryModels.swift +++ b/TablePro/Models/TeamLibrary/TeamLibraryModels.swift @@ -114,7 +114,7 @@ struct TeamLibraryPullResponse: Codable { Connection( id: $0.id, sourceConnectionId: $0.sourceConnectionId, - payload: $0.payload.sanitizedForImport().withoutStartupCommands(), + payload: $0.payload.sanitizedForImport().withoutStartupCommands().withoutTunnelCommand(), publishedBy: $0.publishedBy, publishedAt: $0.publishedAt ) diff --git a/TablePro/Views/Connection/ConnectionImportSheet.swift b/TablePro/Views/Connection/ConnectionImportSheet.swift index ce96acb2e0..3cfea2c978 100644 --- a/TablePro/Views/Connection/ConnectionImportSheet.swift +++ b/TablePro/Views/Connection/ConnectionImportSheet.swift @@ -5,6 +5,7 @@ // Sheet for previewing and importing connections from a .tablepro file. // +import AppKit import SwiftUI import TableProImport import UniformTypeIdentifiers @@ -271,7 +272,71 @@ struct ConnectionImportSheet: View { } } - let result = ConnectionExportService.performImport(preview, resolutions: resolutions) + let commanded = preview.items.filter { + $0.connection.carriesTunnelCommand && resolutions[$0.id] != .skip + } + guard !commanded.isEmpty else { + runImport(preview, resolutions: resolutions, keepTunnelCommands: false) + return + } + + Task { @MainActor in + let choice = await AlertHelper.confirmThreeWay( + title: String(localized: "Import Tunnel Commands?"), + message: tunnelCommandConfirmation(for: commanded), + first: String(localized: "Import Without Commands"), + second: String(localized: "Import Commands"), + third: String(localized: "Cancel"), + window: NSApp.keyWindow + ) + switch choice { + case 0: + runImport(preview, resolutions: resolutions, keepTunnelCommands: false) + case 1: + runImport(preview, resolutions: resolutions, keepTunnelCommands: true) + default: + break + } + } + } + + /// Names every command the file would store, in full. A tunnel command starts a process on + /// this Mac every time the connection opens, so the answer has to be given against the actual + /// text rather than against the fact that one exists. + private func tunnelCommandConfirmation(for items: [ImportItem]) -> String { + let lines = items.map { item -> String in + let described = item.connection.tunnelCommand + .map { TunnelCommandConfiguration($0) } + .flatMap { + TunnelCommandBuilder.previewCommand( + for: $0, + remoteHost: item.connection.host.isEmpty ? "localhost" : item.connection.host, + remotePort: item.connection.port + ) + } + return "\(item.connection.name)\n\(described ?? "")" + } + return String( + format: String(localized: """ + These connections open their tunnel by running a command on this Mac, every time \ + they connect: + + %@ + """), + lines.joined(separator: "\n\n") + ) + } + + private func runImport( + _ preview: ConnectionImportPreview, + resolutions: [UUID: ImportResolution], + keepTunnelCommands: Bool + ) { + let result = ConnectionExportService.performImport( + preview, + resolutions: resolutions, + keepTunnelCommands: keepTunnelCommands + ) // Only restore credentials from verified encrypted imports (not plaintext files) if wasEncryptedImport, preview.envelope.credentials != nil { diff --git a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator+TunnelExclusivity.swift b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator+TunnelExclusivity.swift index 278b718594..0e3ae82fe9 100644 --- a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator+TunnelExclusivity.swift +++ b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator+TunnelExclusivity.swift @@ -34,6 +34,11 @@ extension ConnectionFormCoordinator { self?.socksProxy.state.enabled = false }) } + if tunnelCommand.state.enabled { + tunnels.append(EnabledTunnel(kind: .tunnelCommand) { [weak self] in + self?.tunnelCommand.state.enabled = false + }) + } return tunnels } diff --git a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift index 58cfcf7ebc..dcc014ffd7 100644 --- a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift +++ b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift @@ -34,6 +34,7 @@ final class ConnectionFormCoordinator { var cloudflareTunnel: CloudflareTunnelPaneViewModel var cloudSQLProxy: CloudSQLProxyPaneViewModel var socksProxy: SOCKSProxyPaneViewModel + var tunnelCommand: TunnelCommandPaneViewModel var ssl: SSLPaneViewModel var customization: CustomizationPaneViewModel var advanced: AdvancedPaneViewModel @@ -82,6 +83,9 @@ final class ConnectionFormCoordinator { if services.pluginManager.supportsSOCKSProxy(for: network.type) { panes.append(.socksProxy) } + if services.pluginManager.supportsTunnelCommand(for: network.type) { + panes.append(.tunnelCommand) + } if services.pluginManager.supportsSSL(for: network.type) { panes.append(.ssl) } @@ -99,6 +103,7 @@ final class ConnectionFormCoordinator { && cloudflareTunnel.validationIssues.isEmpty && cloudSQLProxy.validationIssues.isEmpty && socksProxy.validationIssues.isEmpty + && tunnelCommand.validationIssues.isEmpty && ssl.validationIssues.isEmpty && customization.validationIssues.isEmpty && advanced.validationIssues.isEmpty @@ -124,6 +129,7 @@ final class ConnectionFormCoordinator { self.cloudflareTunnel = CloudflareTunnelPaneViewModel() self.cloudSQLProxy = CloudSQLProxyPaneViewModel() self.socksProxy = SOCKSProxyPaneViewModel() + self.tunnelCommand = TunnelCommandPaneViewModel() self.ssl = SSLPaneViewModel() self.customization = CustomizationPaneViewModel() self.advanced = AdvancedPaneViewModel() @@ -137,6 +143,7 @@ final class ConnectionFormCoordinator { cloudflareTunnel.coordinator = ref cloudSQLProxy.coordinator = ref socksProxy.coordinator = ref + tunnelCommand.coordinator = ref ssl.coordinator = ref customization.coordinator = ref advanced.coordinator = ref @@ -184,6 +191,7 @@ final class ConnectionFormCoordinator { cloudflareTunnel.load(from: existing, storage: storage) cloudSQLProxy.load(from: existing, storage: storage) socksProxy.load(from: existing, storage: storage) + tunnelCommand.load(from: existing) ssl.load(from: existing) customization.load(from: existing) advanced.load(from: existing) @@ -280,6 +288,7 @@ final class ConnectionFormCoordinator { cloudflareTunnelMode: cloudflareTunnel.state.buildTunnelMode(), cloudSQLProxyMode: cloudSQLProxy.state.buildTunnelMode(), socksProxyMode: socksProxy.state.buildTunnelMode(), + tunnelCommandMode: tunnelCommand.state.buildTunnelMode(), safeModeLevel: customization.safeModeLevel, aiPolicy: advanced.aiPolicy, aiRules: aiRules.trimmedRules, @@ -467,7 +476,8 @@ final class ConnectionFormCoordinator { ssh: ssh.state, cloudflare: cloudflareTunnel.state, cloudSQLProxy: cloudSQLProxy.state, - socksProxy: socksProxy.state + socksProxy: socksProxy.state, + tunnelCommand: tunnelCommand.state ) let sslClientKeyPassphrase = ssl.clientKeyPassphrase let sslClientKeyPath = ssl.clientKeyPath @@ -579,6 +589,7 @@ final class ConnectionFormCoordinator { let cloudflare: CloudflareTunnelFormState let cloudSQLProxy: CloudSQLProxyFormState let socksProxy: SOCKSProxyFormState + let tunnelCommand: TunnelCommandFormState } private func persistTestSecrets( diff --git a/TablePro/Views/ConnectionForm/ConnectionFormPane.swift b/TablePro/Views/ConnectionForm/ConnectionFormPane.swift index 11c9b255ef..65e3143241 100644 --- a/TablePro/Views/ConnectionForm/ConnectionFormPane.swift +++ b/TablePro/Views/ConnectionForm/ConnectionFormPane.swift @@ -12,6 +12,7 @@ enum ConnectionFormPane: String, CaseIterable, Identifiable, Hashable { case cloudflareTunnel case cloudSQLProxy case socksProxy + case tunnelCommand case ssl case customization case advanced @@ -27,6 +28,7 @@ enum ConnectionFormPane: String, CaseIterable, Identifiable, Hashable { case .cloudflareTunnel: return String(localized: "Cloudflare Tunnel") case .cloudSQLProxy: return String(localized: "Cloud SQL Auth Proxy") case .socksProxy: return String(localized: "SOCKS Proxy") + case .tunnelCommand: return String(localized: "Tunnel Command") case .ssl: return String(localized: "SSL/TLS") case .customization: return String(localized: "Customization") case .advanced: return String(localized: "Advanced") @@ -42,6 +44,7 @@ enum ConnectionFormPane: String, CaseIterable, Identifiable, Hashable { case .cloudflareTunnel: return "cloud" case .cloudSQLProxy: return "cloud.fill" case .socksProxy: return "arrow.triangle.swap" + case .tunnelCommand: return "terminal" case .ssl: return "lock.fill" case .customization: return "paintbrush" case .advanced: return "gearshape.2" @@ -65,6 +68,8 @@ enum ConnectionFormPane: String, CaseIterable, Identifiable, Hashable { issues = coordinator.cloudSQLProxy.validationIssues case .socksProxy: issues = coordinator.socksProxy.validationIssues + case .tunnelCommand: + issues = coordinator.tunnelCommand.validationIssues case .ssl: issues = coordinator.ssl.validationIssues case .customization: diff --git a/TablePro/Views/ConnectionForm/ConnectionFormView.swift b/TablePro/Views/ConnectionForm/ConnectionFormView.swift index 583a9905d5..d1451b6177 100644 --- a/TablePro/Views/ConnectionForm/ConnectionFormView.swift +++ b/TablePro/Views/ConnectionForm/ConnectionFormView.swift @@ -104,6 +104,8 @@ private struct ConnectionFormDetail: View { CloudSQLProxyPaneView(coordinator: coordinator) case .socksProxy: SOCKSProxyPaneView(coordinator: coordinator) + case .tunnelCommand: + TunnelCommandPaneView(coordinator: coordinator) case .ssl: SSLPaneView(coordinator: coordinator) case .customization: diff --git a/TablePro/Views/ConnectionForm/Panes/TunnelCommandPaneView.swift b/TablePro/Views/ConnectionForm/Panes/TunnelCommandPaneView.swift new file mode 100644 index 0000000000..c60ea4e32c --- /dev/null +++ b/TablePro/Views/ConnectionForm/Panes/TunnelCommandPaneView.swift @@ -0,0 +1,198 @@ +// +// TunnelCommandPaneView.swift +// TablePro +// + +import SwiftUI + +struct TunnelCommandPaneView: View { + @Bindable var coordinator: ConnectionFormCoordinator + + private var viewModel: TunnelCommandPaneViewModel { coordinator.tunnelCommand } + + var body: some View { + Form { + Section { + Toggle(String(localized: "Enable Tunnel Command"), isOn: $coordinator.tunnelCommand.state.enabled) + } footer: { + Text( + """ + Runs a command that forwards a local port to this database, and holds it for \ + as long as the connection is open. The local port is picked here, and the \ + command is started again if it stops. + """ + ) + } + + if coordinator.tunnelCommand.state.enabled { + if !coordinator.otherEnabledTunnels(excluding: .tunnelCommand).isEmpty { + TunnelExclusivityBanner(coordinator: coordinator, currentKind: .tunnelCommand) + } + methodSection + methodFieldsSection + previewSection + } + } + .formStyle(.grouped) + .scrollContentBackground(.hidden) + } + + // MARK: - Sections + + private var methodSection: some View { + Section { + Picker(String(localized: "Method"), selection: $coordinator.tunnelCommand.state.config.method) { + ForEach(TunnelCommandMethod.allCases) { method in + Text(method.displayName).tag(method) + } + } + } footer: { + Text(methodFooter) + } + } + + @ViewBuilder + private var methodFieldsSection: some View { + switch coordinator.tunnelCommand.state.config.method { + case .kubectl: + kubectlSection + executableSection(prompt: "/opt/homebrew/bin/kubectl") + case .awsSSM: + awsSection + executableSection(prompt: "/usr/local/bin/aws") + case .custom: + customSection + } + } + + private var kubectlSection: some View { + Section { + TextField( + String(localized: "Resource"), + text: $coordinator.tunnelCommand.state.config.kubernetesResource, + prompt: Text(verbatim: "service/postgres") + ) + .autocorrectionDisabled() + TextField( + String(localized: "Namespace"), + text: $coordinator.tunnelCommand.state.config.kubernetesNamespace, + prompt: Text(verbatim: "production") + ) + .autocorrectionDisabled() + TextField( + String(localized: "Context"), + text: $coordinator.tunnelCommand.state.config.kubernetesContext, + prompt: Text(verbatim: "optional") + ) + .autocorrectionDisabled() + } header: { + Text("Kubernetes") + } footer: { + Text("The port comes from this connection's own port, forwarded from the resource you name here.") + } + } + + private var awsSection: some View { + Section { + TextField( + String(localized: "Target"), + text: $coordinator.tunnelCommand.state.config.awsTarget, + prompt: Text(verbatim: "i-0123456789abcdef0") + ) + .autocorrectionDisabled() + TextField( + String(localized: "Profile"), + text: $coordinator.tunnelCommand.state.config.awsProfile, + prompt: Text(verbatim: "optional") + ) + .autocorrectionDisabled() + TextField( + String(localized: "Region"), + text: $coordinator.tunnelCommand.state.config.awsRegion, + prompt: Text(verbatim: "optional") + ) + .autocorrectionDisabled() + } header: { + Text("AWS Systems Manager") + } footer: { + Text("The session forwards to this connection's host and port from the target instance, so the target is the bastion rather than the database.") + } + } + + private var customSection: some View { + Section { + TextField( + String(localized: "Command"), + text: $coordinator.tunnelCommand.state.config.command, + prompt: Text(verbatim: "ssh -N -L {port}:{host}:{remotePort} bastion"), + axis: .vertical + ) + .lineLimit(2...5) + .font(.system(.body, design: .monospaced)) + .autocorrectionDisabled() + } header: { + Text("Command") + } footer: { + Text(customCommandFooter) + } + } + + private func executableSection(prompt: String) -> some View { + Section { + TextField( + String(localized: "Executable path"), + text: $coordinator.tunnelCommand.state.config.executablePath, + prompt: Text(verbatim: prompt) + ) + .autocorrectionDisabled() + } footer: { + Text( + """ + Leave blank to find it on your PATH. Apps launched from the Dock do not inherit \ + your shell's PATH, so a tool installed somewhere unusual needs its full path here. + """ + ) + } + } + + @ViewBuilder + private var previewSection: some View { + if let preview = viewModel.previewCommand( + remoteHost: coordinator.network.host, + remotePort: Int(coordinator.network.port) ?? 0 + ) { + Section { + Text(verbatim: preview) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } header: { + Text("Will Run") + } + } + } + + private var customCommandFooter: String { + String( + format: String(localized: """ + %1$@ is the local port, %2$@ and %3$@ are this connection's host and port. \ + The command runs directly rather than through a shell, so start it with \ + /usr/bin/env if you need environment variables. + """), + TunnelCommandLine.localPortPlaceholder, + TunnelCommandLine.hostPlaceholder, + TunnelCommandLine.remotePortPlaceholder + ) + } + + private var methodFooter: String { + switch coordinator.tunnelCommand.state.config.method { + case .kubectl: + return String(localized: "Forwards a port from a Kubernetes resource with kubectl port-forward.") + case .awsSSM: + return String(localized: "Opens an AWS Systems Manager port forwarding session through a target instance.") + case .custom: + return String(localized: "Runs a command you write. It must open the local port itself.") + } + } +} diff --git a/TablePro/Views/ConnectionForm/Support/ConnectionFormEdits.swift b/TablePro/Views/ConnectionForm/Support/ConnectionFormEdits.swift index f157f65a08..e193fa4fc0 100644 --- a/TablePro/Views/ConnectionForm/Support/ConnectionFormEdits.swift +++ b/TablePro/Views/ConnectionForm/Support/ConnectionFormEdits.swift @@ -22,6 +22,7 @@ struct ConnectionFormEdits: Equatable { var cloudflareTunnelMode: CloudflareTunnelMode var cloudSQLProxyMode: CloudSQLProxyMode var socksProxyMode: SOCKSProxyMode + var tunnelCommandMode: TunnelCommandMode var safeModeLevel: SafeModeLevel var aiPolicy: AIConnectionPolicy? var aiRules: String? @@ -56,6 +57,7 @@ struct ConnectionFormEdits: Equatable { result.cloudflareTunnelMode = cloudflareTunnelMode result.cloudSQLProxyMode = cloudSQLProxyMode result.socksProxyMode = socksProxyMode + result.tunnelCommandMode = tunnelCommandMode result.safeModeLevel = safeModeLevel result.aiPolicy = aiPolicy result.aiRules = aiRules diff --git a/TablePro/Views/ConnectionForm/ViewModels/TunnelCommandPaneViewModel.swift b/TablePro/Views/ConnectionForm/ViewModels/TunnelCommandPaneViewModel.swift new file mode 100644 index 0000000000..ab2328dfab --- /dev/null +++ b/TablePro/Views/ConnectionForm/ViewModels/TunnelCommandPaneViewModel.swift @@ -0,0 +1,41 @@ +// +// TunnelCommandPaneViewModel.swift +// TablePro +// + +import Foundation + +@Observable +@MainActor +final class TunnelCommandPaneViewModel { + var state = TunnelCommandFormState() + + var coordinator: WeakCoordinatorRef? + + var validationIssues: [String] { + guard state.enabled else { return [] } + var issues = TunnelCommandBuilder.validationIssues(for: state.buildConfig()) + + for other in coordinator?.value?.otherEnabledTunnels(excluding: .tunnelCommand) ?? [] { + issues.append(String( + format: String(localized: "Cannot use %@ and %@ at the same time"), + other.kind.displayName, + ConnectionTunnelKind.tunnelCommand.displayName + )) + } + + return issues + } + + func previewCommand(remoteHost: String, remotePort: Int) -> String? { + TunnelCommandBuilder.previewCommand( + for: state.buildConfig(), + remoteHost: remoteHost.isEmpty ? "localhost" : remoteHost, + remotePort: remotePort + ) + } + + func load(from connection: DatabaseConnection) { + state.load(from: connection) + } +} diff --git a/TableProTests/Core/Plugins/PluginManagerVariantAccessorTests.swift b/TableProTests/Core/Plugins/PluginManagerVariantAccessorTests.swift index 07d04eba17..d7d9696fbf 100644 --- a/TableProTests/Core/Plugins/PluginManagerVariantAccessorTests.swift +++ b/TableProTests/Core/Plugins/PluginManagerVariantAccessorTests.swift @@ -32,6 +32,7 @@ struct PluginManagerVariantAccessorTests { #expect(manager.supportsSSL(for: .pglite) == false) #expect(manager.supportsCloudflareTunnel(for: .pglite) == false) #expect(manager.supportsSOCKSProxy(for: .pglite) == false) + #expect(manager.supportsTunnelCommand(for: .pglite) == false) } @Test("PostgreSQL keeps the transports PGlite declines, so the two are genuinely distinguished") diff --git a/TableProTests/Core/Process/SupervisedProcessRunnerTests.swift b/TableProTests/Core/Process/SupervisedProcessRunnerTests.swift index afe1f2265f..aa62793300 100644 --- a/TableProTests/Core/Process/SupervisedProcessRunnerTests.swift +++ b/TableProTests/Core/Process/SupervisedProcessRunnerTests.swift @@ -3,6 +3,7 @@ // TableProTests // +import Darwin import Foundation import Testing @@ -98,4 +99,47 @@ struct SupervisedProcessRunnerTests { #expect(lines == ["no trailing newline"]) } + + private func isAlive(_ pid: pid_t) -> Bool { kill(pid, 0) == 0 } + + private func waitUntilGone(_ pid: pid_t, within seconds: Double) async -> Bool { + let deadline = Date().addingTimeInterval(seconds) + while Date() < deadline { + if !isAlive(pid) { return true } + try? await Task.sleep(nanoseconds: 50_000_000) + } + return !isAlive(pid) + } + + /// The whole reason `stop()` may signal a negated pid: Foundation puts every child in a process + /// group of its own. If a toolchain stopped doing that, the same call would signal this + /// process's own group instead. + @Test("A launched process leads its own process group") + func processLeadsItsOwnGroup() async throws { + let runner = try runner(script: "sleep 30") + let pid = try #require(runner.processIdentifier) + + #expect(getpgid(pid) == pid) + #expect(getpgid(pid) != getpgid(0)) + + runner.stop() + _ = await runner.termination + } + + @Test("Stopping takes down a helper the command spawned for itself") + func stopTakesDownDescendants() async throws { + let runner = try runner(script: "sleep 40 & echo $! >&2; wait") + + var helperPid: pid_t? + for await line in runner.stderrLines { + helperPid = pid_t(line.trimmingCharacters(in: .whitespacesAndNewlines)) + break + } + let helper = try #require(helperPid) + #expect(isAlive(helper)) + + runner.stop() + + #expect(await waitUntilGone(helper, within: 5)) + } } diff --git a/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift b/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift index 6d797ac936..815f33d215 100644 --- a/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift +++ b/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift @@ -6,8 +6,8 @@ import Foundation @testable import TablePro import TableProPluginKit -import Testing import TableProSyncTransport +import Testing @Suite("ConnectionStorage Persistence") @MainActor @@ -100,6 +100,9 @@ struct ConnectionStoragePersistenceTests { connection.cloudflareTunnelMode = .inline(CloudflareConfiguration(accessHostname: "db.example.com")) connection.cloudSQLProxyMode = .inline(CloudSQLProxyConfiguration(instanceConnectionName: "p:r:i")) connection.socksProxyMode = .inline(SOCKSProxyConfiguration(host: "proxy.example.com", username: "u")) + connection.tunnelCommandMode = .inline( + TunnelCommandConfiguration(method: .kubectl, kubernetesNamespace: "prod", kubernetesResource: "service/pg") + ) storage.addConnection(connection) storage.saveCloudflareTokenId("token-id", for: connection.id) storage.saveCloudflareTokenSecret("token-secret", for: connection.id) @@ -111,6 +114,7 @@ struct ConnectionStoragePersistenceTests { #expect(duplicate.cloudflareTunnelMode == connection.cloudflareTunnelMode) #expect(duplicate.cloudSQLProxyMode == connection.cloudSQLProxyMode) #expect(duplicate.socksProxyMode == connection.socksProxyMode) + #expect(duplicate.tunnelCommandMode == connection.tunnelCommandMode) #expect(storage.loadCloudflareTokenId(for: duplicate.id) == "token-id") #expect(storage.loadCloudflareTokenSecret(for: duplicate.id) == "token-secret") #expect(storage.loadCloudSQLProxyServiceAccountKey(for: duplicate.id) == "{\"type\":\"service_account\"}") @@ -118,6 +122,7 @@ struct ConnectionStoragePersistenceTests { let reloaded = storage.loadConnections().first { $0.id == duplicate.id } #expect(reloaded?.socksProxyMode == connection.socksProxyMode) + #expect(reloaded?.tunnelCommandMode == connection.tunnelCommandMode) } @Test("deleting a connection removes its SOCKS proxy password") diff --git a/TableProTests/Models/ConnectionTunnelKindTests.swift b/TableProTests/Models/ConnectionTunnelKindTests.swift index 02632c6d1c..408ef8dfbd 100644 --- a/TableProTests/Models/ConnectionTunnelKindTests.swift +++ b/TableProTests/Models/ConnectionTunnelKindTests.swift @@ -14,7 +14,8 @@ struct ConnectionTunnelKindTests { ssh: Bool = false, cloudflare: Bool = false, cloudSQLProxy: Bool = false, - socksProxy: Bool = false + socksProxy: Bool = false, + tunnelCommand: Bool = false ) -> DatabaseConnection { DatabaseConnection( name: "T", @@ -28,6 +29,9 @@ struct ConnectionTunnelKindTests { : .disabled, socksProxyMode: socksProxy ? .inline(SOCKSProxyConfiguration(host: "proxy.example.com")) + : .disabled, + tunnelCommandMode: tunnelCommand + ? .inline(TunnelCommandConfiguration(method: .kubectl, kubernetesResource: "service/pg")) : .disabled ) } @@ -45,22 +49,25 @@ struct ConnectionTunnelKindTests { #expect(connection(cloudflare: true).activeTunnelKind == .cloudflare) #expect(connection(cloudSQLProxy: true).activeTunnelKind == .cloudSQLProxy) #expect(connection(socksProxy: true).activeTunnelKind == .socksProxy) + #expect(connection(tunnelCommand: true).activeTunnelKind == .tunnelCommand) } @Test("every combination of two or more enabled tunnels is a conflict") func allCombinations() { - for mask in 0..<16 { + for mask in 0..<32 { let ssh = mask & 1 != 0 let cloudflare = mask & 2 != 0 let cloudSQLProxy = mask & 4 != 0 let socksProxy = mask & 8 != 0 - let enabledCount = [ssh, cloudflare, cloudSQLProxy, socksProxy].filter { $0 }.count + let tunnelCommand = mask & 16 != 0 + let enabledCount = [ssh, cloudflare, cloudSQLProxy, socksProxy, tunnelCommand].filter { $0 }.count let connection = connection( ssh: ssh, cloudflare: cloudflare, cloudSQLProxy: cloudSQLProxy, - socksProxy: socksProxy + socksProxy: socksProxy, + tunnelCommand: tunnelCommand ) #expect(connection.enabledTunnelKinds.count == enabledCount) if enabledCount == 1 { diff --git a/TableProTests/TunnelCommand/TunnelCommandBuilderTests.swift b/TableProTests/TunnelCommand/TunnelCommandBuilderTests.swift new file mode 100644 index 0000000000..481e5d30a6 --- /dev/null +++ b/TableProTests/TunnelCommand/TunnelCommandBuilderTests.swift @@ -0,0 +1,187 @@ +// +// TunnelCommandBuilderTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("Tunnel command builder") +struct TunnelCommandBuilderTests { + private func kubectlConfig() -> TunnelCommandConfiguration { + var config = TunnelCommandConfiguration() + config.method = .kubectl + config.kubernetesResource = "service/postgres" + config.kubernetesNamespace = "production" + return config + } + + private func awsConfig() -> TunnelCommandConfiguration { + var config = TunnelCommandConfiguration() + config.method = .awsSSM + config.awsTarget = "i-0123456789abcdef0" + return config + } + + @Test("kubectl forwards the local port to the connection's own port") + func kubectlArguments() throws { + let invocation = try TunnelCommandBuilder.invocation( + for: kubectlConfig(), + localPort: 55_001, + remoteHost: "postgres.production.svc", + remotePort: 5_432 + ) + #expect(invocation.executable == "kubectl") + #expect(invocation.arguments == [ + "--namespace=production", + "port-forward", + "--address=127.0.0.1", + "service/postgres", + "55001:5432" + ]) + } + + @Test("kubectl adds the context only when one is named") + func kubectlContext() throws { + var config = kubectlConfig() + config.kubernetesContext = "prod-eks" + let invocation = try TunnelCommandBuilder.invocation( + for: config, localPort: 1, remoteHost: "h", remotePort: 2 + ) + #expect(invocation.arguments.first == "--context=prod-eks") + } + + @Test("AWS SSM forwards to the connection's host from the target instance") + func awsArguments() throws { + var config = awsConfig() + config.awsProfile = "prod" + config.awsRegion = "eu-west-1" + let invocation = try TunnelCommandBuilder.invocation( + for: config, + localPort: 55_002, + remoteHost: "db.internal", + remotePort: 5_432 + ) + #expect(invocation.executable == "aws") + #expect(invocation.arguments == [ + "ssm", + "start-session", + "--target=i-0123456789abcdef0", + "--document-name=AWS-StartPortForwardingSessionToRemoteHost", + "--parameters=host=db.internal,portNumber=5432,localPortNumber=55002", + "--profile=prod", + "--region=eu-west-1" + ]) + } + + /// Every preset flag is written `--flag=value`, so a value is a value even when it looks like + /// a flag. The two positional arguments a preset still needs are validated instead. + @Test("a preset value that looks like a flag stays a value") + func presetValuesCannotBecomeFlags() throws { + var config = awsConfig() + config.awsProfile = "--endpoint-url=http://evil" + let invocation = try TunnelCommandBuilder.invocation( + for: config, localPort: 1, remoteHost: "h", remotePort: 2 + ) + #expect(invocation.arguments.contains("--profile=--endpoint-url=http://evil")) + #expect(!invocation.arguments.contains("--endpoint-url=http://evil")) + #expect(!TunnelCommandBuilder.validationIssues(for: config).isEmpty) + } + + @Test("a positional preset field rejects a leading dash or a space") + func positionalFieldsRejectFlagLikeValues() { + var config = kubectlConfig() + config.kubernetesResource = "--kubeconfig=/tmp/evil" + #expect(!TunnelCommandBuilder.validationIssues(for: config).isEmpty) + + config.kubernetesResource = "service/pg extra-arg" + #expect(!TunnelCommandBuilder.validationIssues(for: config).isEmpty) + + config.kubernetesResource = "service/postgres" + #expect(TunnelCommandBuilder.validationIssues(for: config).isEmpty) + } + + @Test("an executable path overrides the tool looked up on PATH") + func executablePathOverride() throws { + var config = kubectlConfig() + config.executablePath = "~/bin/kubectl" + let invocation = try TunnelCommandBuilder.invocation( + for: config, localPort: 1, remoteHost: "h", remotePort: 2 + ) + #expect(invocation.executable == (NSHomeDirectory() as NSString).appendingPathComponent("bin/kubectl")) + } + + @Test("a custom command substitutes every placeholder") + func customPlaceholders() throws { + var config = TunnelCommandConfiguration() + config.method = .custom + config.command = "ssh -N -L {port}:{host}:{remotePort} bastion" + let invocation = try TunnelCommandBuilder.invocation( + for: config, localPort: 55_003, remoteHost: "db.internal", remotePort: 5_432 + ) + #expect(invocation.executable == "ssh") + #expect(invocation.arguments == ["-N", "-L", "55003:db.internal:5432", "bastion"]) + } + + @Test("a custom command without the local port placeholder is rejected") + func customWithoutLocalPort() { + var config = TunnelCommandConfiguration() + config.method = .custom + config.command = "ssh -N -L 5432:db:5432 bastion" + #expect(!config.isValid) + #expect(throws: TunnelCommandError.missingLocalPortPlaceholder) { + _ = try TunnelCommandBuilder.invocation( + for: config, localPort: 1, remoteHost: "h", remotePort: 2 + ) + } + } + + /// The tokenizer's own error carries no message, so it has to be mapped rather than escape: + /// an imported command is stored without passing through the form's validation, and this is + /// where it is first read. + @Test("a custom command that cannot be split reports why") + func customParseFailuresAreNamed() { + var config = TunnelCommandConfiguration(method: .custom, command: " ") + #expect(throws: TunnelCommandError.commandEmpty) { + _ = try TunnelCommandBuilder.invocation( + for: config, localPort: 1, remoteHost: "h", remotePort: 2 + ) + } + + config.command = "forward --opt 'unterminated {port}" + #expect(throws: TunnelCommandError.unbalancedQuote) { + _ = try TunnelCommandBuilder.invocation( + for: config, localPort: 1, remoteHost: "h", remotePort: 2 + ) + } + } + + @Test("an empty custom command reports one issue, not two") + func emptyCustomCommand() { + var config = TunnelCommandConfiguration() + config.method = .custom + #expect(TunnelCommandBuilder.validationIssues(for: config).count == 1) + } + + @Test("the preview leaves the local port as its placeholder") + func previewKeepsPlaceholder() throws { + let preview = try #require(TunnelCommandBuilder.previewCommand( + for: kubectlConfig(), remoteHost: "postgres", remotePort: 5_432 + )) + #expect(preview.contains("{port}:5432")) + #expect(preview.hasPrefix("kubectl --namespace=production port-forward")) + } + + @Test("the preview quotes an argument that carries a space") + func previewQuotesSpaces() throws { + var config = TunnelCommandConfiguration() + config.method = .custom + config.command = "forward --opt 'a b' {port}" + let preview = try #require(TunnelCommandBuilder.previewCommand( + for: config, remoteHost: "h", remotePort: 1 + )) + #expect(preview == "forward --opt 'a b' {port}") + } +} diff --git a/TableProTests/TunnelCommand/TunnelCommandImportTests.swift b/TableProTests/TunnelCommand/TunnelCommandImportTests.swift new file mode 100644 index 0000000000..590030c064 --- /dev/null +++ b/TableProTests/TunnelCommand/TunnelCommandImportTests.swift @@ -0,0 +1,143 @@ +// +// TunnelCommandImportTests.swift +// TableProTests +// + +import Foundation +import TableProImport +import Testing + +@testable import TablePro + +@Suite("Tunnel command import") +@MainActor +struct TunnelCommandImportTests { + private func exportableCommand() -> ExportableTunnelCommand { + ExportableTunnelCommand( + method: TunnelCommandMethod.custom.rawValue, + command: "/usr/bin/forward --listen {port}", + executablePath: nil, + kubernetesNamespace: nil, + kubernetesResource: nil, + kubernetesContext: nil, + awsTarget: nil, + awsProfile: nil, + awsRegion: nil + ) + } + + private func exportable(withCommand: Bool) -> ExportableConnection { + ExportableConnection( + name: "Cluster Postgres", + host: "db.internal", + port: 5_432, + database: "app", + username: "admin", + type: "PostgreSQL", + sshConfig: nil, + sslConfig: nil, + color: nil, + tagName: nil, + groupName: nil, + sshProfileId: nil, + safeModeLevel: nil, + aiPolicy: nil, + additionalFields: nil, + redisDatabase: nil, + startupCommands: nil, + localOnly: nil, + tunnelCommand: withCommand ? exportableCommand() : nil + ) + } + + private func preview(_ connection: ExportableConnection) -> (ConnectionImportPreview, ImportItem) { + let item = ImportItem(connection: connection, status: .ready) + let envelope = ConnectionExportEnvelope( + formatVersion: 1, + exportedAt: Date(), + appVersion: "Tests", + connections: [connection], + groups: nil, + tags: nil, + credentials: nil + ) + return (ConnectionImportPreview(envelope: envelope, items: [item]), item) + } + + private func prepared( + _ connection: ExportableConnection, + keepTunnelCommands: Bool + ) -> DatabaseConnection? { + let (preview, item) = preview(connection) + let result = ConnectionExportService.prepareImport( + preview, + resolutions: [item.id: .importNew], + tagIdsByName: [:], + groupIdsByName: [:], + keepTunnelCommands: keepTunnelCommands + ) + guard case .add(let connection) = result.operations.first else { return nil } + return connection + } + + @Test("exporting a connection carries its tunnel command") + func exportCarriesTheCommand() throws { + var connection = DatabaseConnection(name: "Cluster", type: .postgresql) + connection.tunnelCommandMode = .inline( + TunnelCommandConfiguration(method: .kubectl, kubernetesResource: "service/pg") + ) + + let envelope = ConnectionExportService.buildEnvelope(for: [connection]) + let exported = try #require(envelope.connections.first?.tunnelCommand) + #expect(exported.method == TunnelCommandMethod.kubectl.rawValue) + #expect(exported.kubernetesResource == "service/pg") + } + + /// The default is the safe one, so a route that never asks the user cannot let a command in by + /// forgetting to opt out. + @Test("importing drops the command unless it was confirmed") + func importDropsTheCommandByDefault() throws { + let imported = try #require(prepared(exportable(withCommand: true), keepTunnelCommands: false)) + #expect(imported.tunnelCommandMode == .disabled) + #expect(imported.host == "db.internal") + } + + @Test("importing keeps the command once it was confirmed") + func importKeepsConfirmedCommand() throws { + let imported = try #require(prepared(exportable(withCommand: true), keepTunnelCommands: true)) + #expect(imported.isTunnelCommandEnabled) + #expect(imported.resolvedTunnelCommandConfig?.command == "/usr/bin/forward --listen {port}") + } + + @Test("the preview keeps the command so the confirmation can name it") + func previewKeepsTheCommand() { + #expect(exportable(withCommand: true).sanitizedForImport().carriesTunnelCommand) + #expect(!exportable(withCommand: false).carriesTunnelCommand) + } + + @Test("stripping the command leaves everything else intact") + func strippingKeepsTheRest() { + let stripped = exportable(withCommand: true).withoutTunnelCommand() + #expect(stripped.tunnelCommand == nil) + #expect(stripped.name == "Cluster Postgres") + #expect(stripped.host == "db.internal") + #expect(stripped.port == 5_432) + #expect(stripped.username == "admin") + } + + @Test("a deeplink can never deliver a tunnel command") + func deeplinkStripsTheCommand() throws { + var connection = DatabaseConnection(name: "Cluster", type: .postgresql) + connection.tunnelCommandMode = .inline( + TunnelCommandConfiguration(method: .custom, command: "/usr/bin/forward --listen {port}") + ) + let link = try #require(ConnectionExportService.buildImportDeeplink(for: connection)) + let url = try #require(URL(string: link)) + + guard case .success(.importConnection(let parsed)) = DeeplinkParser.parse(url) else { + Issue.record("the deeplink did not parse as a connection import") + return + } + #expect(!parsed.carriesTunnelCommand) + } +} diff --git a/TableProTests/TunnelCommand/TunnelCommandLineTests.swift b/TableProTests/TunnelCommand/TunnelCommandLineTests.swift new file mode 100644 index 0000000000..74c4d40499 --- /dev/null +++ b/TableProTests/TunnelCommand/TunnelCommandLineTests.swift @@ -0,0 +1,80 @@ +// +// TunnelCommandLineTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("Tunnel command line") +struct TunnelCommandLineTests { + @Test("splits on whitespace") + func splitsOnWhitespace() throws { + let tokens = try TunnelCommandLine.tokenize("kubectl port-forward\tsvc/pg 1:2") + #expect(tokens == ["kubectl", "port-forward", "svc/pg", "1:2"]) + } + + @Test("keeps a quoted argument whole") + func keepsQuotedArgumentWhole() throws { + #expect(try TunnelCommandLine.tokenize("ssh -o 'ProxyCommand nc %h %p' host") + == ["ssh", "-o", "ProxyCommand nc %h %p", "host"]) + #expect(try TunnelCommandLine.tokenize("a \"b c\" d") == ["a", "b c", "d"]) + } + + @Test("keeps an empty quoted argument") + func keepsEmptyQuotedArgument() throws { + #expect(try TunnelCommandLine.tokenize("cmd '' x") == ["cmd", "", "x"]) + } + + @Test("honours escapes inside and outside double quotes") + func honoursEscapes() throws { + #expect(try TunnelCommandLine.tokenize(#"cmd a\ b"#) == ["cmd", "a b"]) + #expect(try TunnelCommandLine.tokenize(#"cmd "say \"hi\"""#) == ["cmd", #"say "hi""#]) + #expect(try TunnelCommandLine.tokenize(#"cmd 'a\b'"#) == ["cmd", #"a\b"#]) + } + + @Test("expands a leading tilde") + func expandsLeadingTilde() throws { + let tokens = try TunnelCommandLine.tokenize("~/bin/forward --config ~/conf") + #expect(tokens[0] == (NSHomeDirectory() as NSString).appendingPathComponent("bin/forward")) + #expect(tokens[2] == (NSHomeDirectory() as NSString).appendingPathComponent("conf")) + } + + @Test("does not expand shell syntax") + func doesNotExpandShellSyntax() throws { + #expect(try TunnelCommandLine.tokenize("cmd $HOME $(whoami) *") == ["cmd", "$HOME", "$(whoami)", "*"]) + } + + @Test("rejects an unclosed quote or a trailing backslash") + func rejectsUnbalanced() { + #expect(throws: TunnelCommandLine.ParseError.unbalancedQuote) { + _ = try TunnelCommandLine.tokenize("cmd 'unterminated") + } + #expect(throws: TunnelCommandLine.ParseError.unbalancedQuote) { + _ = try TunnelCommandLine.tokenize(#"cmd trailing\"#) + } + } + + @Test("rejects a command with no words") + func rejectsEmpty() { + #expect(throws: TunnelCommandLine.ParseError.empty) { + _ = try TunnelCommandLine.tokenize(" ") + } + } + + /// Substituting after the split is what stops a host carrying a space from becoming two + /// arguments, which is the whole reason the two steps are separate. + @Test("substitution cannot split a token") + func substitutionCannotSplitAToken() throws { + let tokens = try TunnelCommandLine.tokenize("cmd --to={host}:{remotePort} --listen={port}") + let substituted = TunnelCommandLine.substitutePlaceholders( + in: tokens, + localPort: 55_000, + remoteHost: "db one", + remotePort: 5_432 + ) + #expect(substituted == ["cmd", "--to=db one:5432", "--listen=55000"]) + } +} diff --git a/TableProTests/TunnelCommand/TunnelCommandManagerTests.swift b/TableProTests/TunnelCommand/TunnelCommandManagerTests.swift new file mode 100644 index 0000000000..1e12a67388 --- /dev/null +++ b/TableProTests/TunnelCommand/TunnelCommandManagerTests.swift @@ -0,0 +1,286 @@ +// +// TunnelCommandManagerTests.swift +// TableProTests +// + +import Darwin +import Foundation +import Testing + +@testable import TablePro + +/// Stands in for the forwarding process. `.ready` opens a real loopback listener on the port the +/// manager put in the arguments, which is what the readiness probe is looking for. +final class FakeTunnelCommandRunner: SupervisedProcessRunner, @unchecked Sendable { + enum Behavior { + case ready + case exitsDuringStartup(String) + case neverReady + } + + let behavior: Behavior + private(set) var startCallCount = 0 + private(set) var stopCallCount = 0 + private(set) var startedBinaryPath: String? + private(set) var startedArguments: [String] = [] + private(set) var startedEnvironment: [String: String] = [:] + private var listenerFd: Int32? + + let stderrLines: AsyncStream + private let stderrContinuation: AsyncStream.Continuation + + private let lock = NSLock() + private var requested = false + private var terminationResult: SubprocessTermination? + private var terminationContinuation: CheckedContinuation? + + init(behavior: Behavior) { + self.behavior = behavior + var continuation: AsyncStream.Continuation! + stderrLines = AsyncStream { continuation = $0 } + stderrContinuation = continuation + } + + var processIdentifier: Int32? { 4_243 } + + func start(binaryPath: String, arguments: [String], environment: [String: String]) throws { + lock.lock() + startCallCount += 1 + startedBinaryPath = binaryPath + startedArguments = arguments + startedEnvironment = environment + lock.unlock() + + switch behavior { + case .ready: + if let port = Self.parsePort(arguments) { + listenerFd = Self.openListener(port: port) + } + case .exitsDuringStartup(let message): + stderrContinuation.yield(message) + finish(exitCode: 1) + case .neverReady: + break + } + } + + func stop() { + lock.lock() + requested = true + stopCallCount += 1 + lock.unlock() + if let fd = listenerFd { + close(fd) + listenerFd = nil + } + finish(exitCode: 0) + } + + var termination: SubprocessTermination { + 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 = SubprocessTermination(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: "--listen"), index + 1 < arguments.count else { return nil } + return Int(arguments[index + 1]) + } + + 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("Tunnel command manager", .serialized) +struct TunnelCommandManagerTests { + private func customConfig(command: String = "/bin/echo --listen {port}") -> TunnelCommandConfiguration { + TunnelCommandConfiguration(method: .custom, command: command) + } + + @Test("createTunnel returns the port the command was told to listen on") + func readinessSucceeds() async throws { + let fake = FakeTunnelCommandRunner(behavior: .ready) + let manager = TunnelCommandManager(runnerFactory: { fake }) + let id = UUID() + + let port = try await manager.createTunnel( + connectionId: id, config: customConfig(), remoteHost: "db.internal", remotePort: 5_432 + ) + + #expect(port > 0) + #expect(fake.startedBinaryPath == "/bin/echo") + #expect(fake.startedArguments == ["--listen", String(port)]) + #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("the command is launched with the tool paths on PATH") + func launchesWithAugmentedPath() async throws { + let fake = FakeTunnelCommandRunner(behavior: .ready) + let manager = TunnelCommandManager(runnerFactory: { fake }) + _ = try await manager.createTunnel( + connectionId: UUID(), config: customConfig(), remoteHost: "h", remotePort: 1 + ) + let path = fake.startedEnvironment["PATH"] ?? "" + for toolPath in CLIToolEnvironment.toolPaths { + #expect(path.contains(toolPath)) + } + await manager.closeAllTunnels() + } + + @Test("a command that exits during startup fails with its stderr") + func startupFailureCarriesStderr() async { + let fake = FakeTunnelCommandRunner(behavior: .exitsDuringStartup("error: pods \"pg\" not found")) + let manager = TunnelCommandManager(runnerFactory: { fake }) + + await #expect(throws: TunnelCommandError.self) { + _ = try await manager.createTunnel( + connectionId: UUID(), config: self.customConfig(), remoteHost: "h", remotePort: 1 + ) + } + } + + @Test("a missing executable path is reported before anything starts") + func missingExecutablePath() async { + let fake = FakeTunnelCommandRunner(behavior: .ready) + let manager = TunnelCommandManager(runnerFactory: { fake }) + + await #expect(throws: TunnelCommandError.executableNotFound("/nonexistent/forward")) { + _ = try await manager.createTunnel( + connectionId: UUID(), + config: self.customConfig(command: "/nonexistent/forward --listen {port}"), + remoteHost: "h", + remotePort: 1 + ) + } + #expect(fake.startCallCount == 0) + } + + @Test("a bare tool name that is not on PATH is reported by name") + func missingToolOnPath() async { + let fake = FakeTunnelCommandRunner(behavior: .ready) + let manager = TunnelCommandManager(runnerFactory: { fake }, executableLookup: { _ in nil }) + + await #expect(throws: TunnelCommandError.executableNotFound("kubectl")) { + _ = try await manager.createTunnel( + connectionId: UUID(), + config: TunnelCommandConfiguration(method: .kubectl, kubernetesResource: "service/pg"), + remoteHost: "h", + remotePort: 5_432 + ) + } + #expect(fake.startCallCount == 0) + } + + @Test("a command missing the local port placeholder never starts") + func missingPlaceholderNeverStarts() async { + let fake = FakeTunnelCommandRunner(behavior: .ready) + let manager = TunnelCommandManager(runnerFactory: { fake }) + + await #expect(throws: TunnelCommandError.missingLocalPortPlaceholder) { + _ = try await manager.createTunnel( + connectionId: UUID(), + config: self.customConfig(command: "/bin/echo --listen 5432"), + remoteHost: "h", + remotePort: 1 + ) + } + #expect(fake.startCallCount == 0) + } + + /// A cancelled connect has to take the process with it. The tunnel is not registered yet at + /// that point, so nothing else would ever close it and it would hold its port until the next + /// launch swept it. + @Test("cancelling the connect stops the command it started") + func cancellationStopsTheCommand() async throws { + let fake = FakeTunnelCommandRunner(behavior: .neverReady) + let manager = TunnelCommandManager(runnerFactory: { fake }) + + let task = Task { + try await manager.createTunnel( + connectionId: UUID(), config: self.customConfig(), remoteHost: "h", remotePort: 1 + ) + } + while fake.startCallCount == 0 { + try await Task.sleep(nanoseconds: 10_000_000) + } + task.cancel() + _ = try? await task.value + + #expect(fake.stopCallCount >= 1) + } + + @Test("terminateAllProcessesSync stops the running command") + func terminateAllStops() async throws { + let fake = FakeTunnelCommandRunner(behavior: .ready) + let manager = TunnelCommandManager(runnerFactory: { fake }) + _ = try await manager.createTunnel( + connectionId: UUID(), config: customConfig(), remoteHost: "h", remotePort: 1 + ) + + manager.terminateAllProcessesSync() + #expect(fake.stopCallCount >= 1) + + await manager.closeAllTunnels() + #expect(AppStorageEnvironment.shared.defaults.data(forKey: "tunnelCommandStalePids") == nil) + } + + @Test("sweepStalePidsIfNeeded clears records for processes that are gone") + func sweepClearsRecords() async { + let records = [TunnelCommandPidRecord(pid: -1, binaryPath: "/nonexistent", executableName: "kubectl")] + AppStorageEnvironment.shared.defaults.set( + try? JSONEncoder().encode(records), forKey: "tunnelCommandStalePids" + ) + + let manager = TunnelCommandManager(runnerFactory: { FakeTunnelCommandRunner(behavior: .ready) }) + await manager.sweepStalePidsIfNeeded() + + #expect(AppStorageEnvironment.shared.defaults.data(forKey: "tunnelCommandStalePids") == nil) + } +} diff --git a/TableProTests/TunnelCommand/TunnelCommandModelTests.swift b/TableProTests/TunnelCommand/TunnelCommandModelTests.swift new file mode 100644 index 0000000000..e5d55454ce --- /dev/null +++ b/TableProTests/TunnelCommand/TunnelCommandModelTests.swift @@ -0,0 +1,106 @@ +// +// TunnelCommandModelTests.swift +// TableProTests +// + +import Foundation +import TableProImport +import Testing + +@testable import TablePro + +@Suite("Tunnel command model") +struct TunnelCommandModelTests { + private func kubectlConfig() -> TunnelCommandConfiguration { + TunnelCommandConfiguration( + method: .kubectl, + kubernetesNamespace: "production", + kubernetesResource: "service/postgres", + kubernetesContext: "prod-eks" + ) + } + + @Test("an enabled mode round-trips through Codable") + func modeRoundTrips() throws { + let mode = TunnelCommandMode.inline(kubectlConfig()) + let data = try JSONEncoder().encode(mode) + #expect(try JSONDecoder().decode(TunnelCommandMode.self, from: data) == mode) + } + + @Test("a disabled mode round-trips without a configuration") + func disabledRoundTrips() throws { + let data = try JSONEncoder().encode(TunnelCommandMode.disabled) + #expect(try JSONDecoder().decode(TunnelCommandMode.self, from: data) == .disabled) + } + + @Test("a configuration written by an older build decodes with defaults") + func partialConfigurationDecodes() throws { + let json = Data(#"{"method":"custom","command":"forward {port}"}"#.utf8) + let config = try JSONDecoder().decode(TunnelCommandConfiguration.self, from: json) + #expect(config.method == .custom) + #expect(config.command == "forward {port}") + #expect(config.awsTarget.isEmpty) + } + + @Test("a connection carrying an enabled mode reports it") + func connectionReportsMode() { + var connection = DatabaseConnection(name: "K8s", type: .postgresql) + #expect(!connection.isTunnelCommandEnabled) + #expect(connection.resolvedTunnelCommandConfig == nil) + + connection.tunnelCommandMode = .inline(kubectlConfig()) + #expect(connection.isTunnelCommandEnabled) + #expect(connection.resolvedTunnelCommandConfig == kubectlConfig()) + } + + @Test("a connection round-trips the mode through its own Codable") + func connectionCodableRoundTrip() throws { + var connection = DatabaseConnection(name: "K8s", type: .postgresql) + connection.tunnelCommandMode = .inline(kubectlConfig()) + + let data = try JSONEncoder().encode(connection) + let decoded = try JSONDecoder().decode(DatabaseConnection.self, from: data) + #expect(decoded.tunnelCommandMode == connection.tunnelCommandMode) + } + + @Test("form state loads and rebuilds the mode, trimming as it goes") + func formStateRoundTrip() { + var connection = DatabaseConnection(name: "K8s", type: .postgresql) + connection.tunnelCommandMode = .inline(kubectlConfig()) + + var state = TunnelCommandFormState() + state.load(from: connection) + #expect(state.enabled) + #expect(state.buildTunnelMode() == connection.tunnelCommandMode) + + state.config.kubernetesResource = " service/postgres " + #expect(state.buildConfig().kubernetesResource == "service/postgres") + + state.enabled = false + #expect(state.buildTunnelMode() == .disabled) + } + + @Test("the export bridge round-trips every field") + func exportBridgeRoundTrips() { + var config = kubectlConfig() + config.executablePath = "/opt/homebrew/bin/kubectl" + let exportable = ExportableTunnelCommand(config) + #expect(TunnelCommandConfiguration(exportable) == config) + } + + @Test("an unknown exported method decodes as a custom command") + func unknownMethodDecodesAsCustom() { + let exportable = ExportableTunnelCommand( + method: "somethingNewer", + command: "forward {port}", + executablePath: nil, + kubernetesNamespace: nil, + kubernetesResource: nil, + kubernetesContext: nil, + awsTarget: nil, + awsProfile: nil, + awsRegion: nil + ) + #expect(TunnelCommandConfiguration(exportable).method == .custom) + } +} diff --git a/TableProTests/ViewModels/ConnectionFormEditsCoverageTests.swift b/TableProTests/ViewModels/ConnectionFormEditsCoverageTests.swift index de069e5395..bec9073bbe 100644 --- a/TableProTests/ViewModels/ConnectionFormEditsCoverageTests.swift +++ b/TableProTests/ViewModels/ConnectionFormEditsCoverageTests.swift @@ -32,6 +32,7 @@ struct ConnectionFormEditsCoverageTests { "cloudflareTunnelMode", "cloudSQLProxyMode", "socksProxyMode", + "tunnelCommandMode", "safeModeLevel", "aiPolicy", "aiRules", @@ -123,6 +124,7 @@ struct ConnectionFormEditsCoverageTests { cloudflareTunnelMode: original.cloudflareTunnelMode, cloudSQLProxyMode: original.cloudSQLProxyMode, socksProxyMode: original.socksProxyMode, + tunnelCommandMode: original.tunnelCommandMode, safeModeLevel: original.safeModeLevel, aiPolicy: original.aiPolicy, aiRules: original.aiRules, diff --git a/TableProTests/ViewModels/ConnectionFormEditsTests.swift b/TableProTests/ViewModels/ConnectionFormEditsTests.swift index fb37192d1c..39ede3abed 100644 --- a/TableProTests/ViewModels/ConnectionFormEditsTests.swift +++ b/TableProTests/ViewModels/ConnectionFormEditsTests.swift @@ -32,6 +32,7 @@ struct ConnectionFormEditsTests { cloudflareTunnelMode: .disabled, cloudSQLProxyMode: .disabled, socksProxyMode: .disabled, + tunnelCommandMode: .disabled, safeModeLevel: .silent, aiPolicy: nil, aiRules: nil, diff --git a/TableProTests/ViewModels/ConnectionFormTunnelExclusivityTests.swift b/TableProTests/ViewModels/ConnectionFormTunnelExclusivityTests.swift index 51de46ac18..15a067f648 100644 --- a/TableProTests/ViewModels/ConnectionFormTunnelExclusivityTests.swift +++ b/TableProTests/ViewModels/ConnectionFormTunnelExclusivityTests.swift @@ -17,6 +17,7 @@ struct ConnectionFormTunnelExclusivityTests { coordinator.cloudflareTunnel.state.enabled = enabled.contains(.cloudflare) coordinator.cloudSQLProxy.state.enabled = enabled.contains(.cloudSQLProxy) coordinator.socksProxy.state.enabled = enabled.contains(.socksProxy) + coordinator.tunnelCommand.state.enabled = enabled.contains(.tunnelCommand) return coordinator } @@ -41,13 +42,13 @@ struct ConnectionFormTunnelExclusivityTests { } } - @Test("all four enabled reports the three others per kind") + @Test("every toggleable tunnel enabled reports all the others per kind") func allEnabled() { let coordinator = coordinator(enabled: Set(ConnectionTunnelKind.formToggleable)) - #expect(coordinator.enabledTunnels.count == 4) + #expect(coordinator.enabledTunnels.count == ConnectionTunnelKind.formToggleable.count) for kind in ConnectionTunnelKind.formToggleable { let others = coordinator.otherEnabledTunnels(excluding: kind) - #expect(others.count == 3) + #expect(others.count == ConnectionTunnelKind.formToggleable.count - 1) #expect(!others.map(\.kind).contains(kind)) } } @@ -64,15 +65,18 @@ struct ConnectionFormTunnelExclusivityTests { @Test("each pane view model reports cross-tunnel conflicts") func paneViewModelsReportConflicts() { - let coordinator = coordinator(enabled: [.ssh, .cloudflare, .cloudSQLProxy, .socksProxy]) + let coordinator = coordinator(enabled: Set(ConnectionTunnelKind.formToggleable)) coordinator.socksProxy.state.host = "proxy.example.com" coordinator.cloudflareTunnel.state.accessHostname = "db.example.com" coordinator.cloudSQLProxy.state.instanceConnectionName = "p:r:i" coordinator.ssh.state.host = "bastion.example.com" + coordinator.tunnelCommand.state.config.kubernetesResource = "service/postgres" - #expect(coordinator.ssh.validationIssues.count >= 3) - #expect(coordinator.cloudflareTunnel.validationIssues.count >= 3) - #expect(coordinator.cloudSQLProxy.validationIssues.count >= 3) - #expect(coordinator.socksProxy.validationIssues.count >= 3) + let others = ConnectionTunnelKind.formToggleable.count - 1 + #expect(coordinator.ssh.validationIssues.count >= others) + #expect(coordinator.cloudflareTunnel.validationIssues.count >= others) + #expect(coordinator.cloudSQLProxy.validationIssues.count >= others) + #expect(coordinator.socksProxy.validationIssues.count >= others) + #expect(coordinator.tunnelCommand.validationIssues.count >= others) } } diff --git a/docs/connections/connection-form.mdx b/docs/connections/connection-form.mdx index ff7fe2554f..494d8fd7db 100644 --- a/docs/connections/connection-form.mdx +++ b/docs/connections/connection-form.mdx @@ -18,12 +18,13 @@ A driver only ever gets the panes it can use, so this form's sidebar is four ite | **Cloudflare Tunnel** | Connect through `cloudflared`. See [Cloudflare Tunnel](/connections/cloudflare-tunnel) | | **Cloud SQL Auth Proxy** | Google Cloud SQL, for MySQL, PostgreSQL, and SQL Server only. See [Cloud SQL Auth Proxy](/connections/cloud-sql-proxy) | | **SOCKS Proxy** | Route through a SOCKS5 proxy. See [SOCKS Proxy](/connections/socks-proxy) | +| **Tunnel Command** | Hold a `kubectl port-forward`, an AWS SSM session, or a command of your own. See [Tunnel Command](/connections/tunnel-command) | | **SSL/TLS** | Encryption mode and certificates. See [SSL/TLS](/connections/ssl) | | **Customization** | Color, tags, group, Safe Mode | | **Advanced** | Startup commands, pre-connect script, external access, plugin fields | | **AI Rules** | Per-connection guidance the AI assistant sees on every chat turn. See [AI Assistant](/features/ai-assistant) | -Only one of SSH Tunnel, Cloudflare Tunnel, Cloud SQL Auth Proxy, and SOCKS Proxy can be on at a time. Turning on a second offers a button to switch off the first. +Only one of SSH Tunnel, Cloudflare Tunnel, Cloud SQL Auth Proxy, SOCKS Proxy, and Tunnel Command can be on at a time. Turning on a second offers a button to switch off the first. ## General @@ -79,33 +80,33 @@ SQLite, DuckDB, Beancount, Snowflake, and Teradata are not monitored. ## Which drivers get which panes -| Database | Default port | SSH tunnel | SSL/TLS | Cloudflare Tunnel | Cloud SQL Proxy | SOCKS Proxy | -|----------|-------------|-----------|---------|-------------------|-----------------|-------------| -| [MySQL](/databases/mysql) | 3306 | Yes | Yes | Yes | Yes | Yes | -| [MariaDB](/databases/mariadb) | 3306 | Yes | Yes | Yes | No | Yes | -| [PostgreSQL](/databases/postgresql) | 5432 | Yes | Yes | Yes | Yes | Yes | -| [Amazon Redshift](/databases/redshift) | 5439 | Yes | Yes | Yes | No | Yes | -| [CockroachDB](/databases/cockroachdb) | 26257 | Yes | Yes | Yes | No | Yes | -| [PGlite](/databases/pglite) | 5432 | No | No | No | No | No | -| [Microsoft SQL Server](/databases/mssql) | 1433 | Yes | Yes | Yes | Yes | Yes | -| [Oracle](/databases/oracle) | 1521 | Yes | Yes | Yes | No | Yes | -| [Dameng DM8](/databases/dameng) | 5236 | Yes | No | Yes | No | Yes | -| [ClickHouse](/databases/clickhouse) | 8123 | Yes | Yes | Yes | No | Yes | -| [Teradata](/databases/teradata) | 1025 | Yes | Yes | Yes | No | Yes | -| [Trino](/databases/trino) | 8080 | Yes | Yes | Yes | No | Yes | -| [MongoDB](/databases/mongodb) | 27017 | Yes | Yes | Yes | No | Yes | -| [Redis](/databases/redis) | 6379 | Yes | Yes | Yes | No | Yes | -| [Cassandra / ScyllaDB](/databases/cassandra) | 9042 | Yes | Yes | Yes | No | Yes | -| [etcd](/databases/etcd) | 2379 | Yes | Yes | Yes | No | Yes | -| [SurrealDB](/databases/surrealdb) | 8000 | Yes | Yes | Yes | No | Yes | -| [Elasticsearch](/databases/elasticsearch) | 9200 | No | Yes | No | No | No | -| [Snowflake](/databases/snowflake) | 443 | No | No | No | No | No | -| [SQLite](/databases/sqlite) | File | No | No | No | No | No | -| [DuckDB](/databases/duckdb) | File | No | No | No | No | No | -| [Beancount](/databases/beancount) | File | No | No | No | No | No | -| [DynamoDB](/databases/dynamodb) | AWS API | No | No | No | No | No | -| [BigQuery](/databases/bigquery) | Cloud API | No | No | No | No | No | -| [Cloudflare D1](/databases/cloudflare-d1) | Cloud API | No | No | No | No | No | -| [libSQL / Turso](/databases/libsql) | URL | No | No | No | No | No | +| Database | Default port | SSH tunnel | SSL/TLS | Cloudflare Tunnel | Cloud SQL Proxy | SOCKS Proxy | Tunnel Command | +|----------|-------------|-----------|---------|-------------------|-----------------|-------------|----------------| +| [MySQL](/databases/mysql) | 3306 | Yes | Yes | Yes | Yes | Yes | Yes | +| [MariaDB](/databases/mariadb) | 3306 | Yes | Yes | Yes | No | Yes | Yes | +| [PostgreSQL](/databases/postgresql) | 5432 | Yes | Yes | Yes | Yes | Yes | Yes | +| [Amazon Redshift](/databases/redshift) | 5439 | Yes | Yes | Yes | No | Yes | Yes | +| [CockroachDB](/databases/cockroachdb) | 26257 | Yes | Yes | Yes | No | Yes | Yes | +| [PGlite](/databases/pglite) | 5432 | No | No | No | No | No | No | +| [Microsoft SQL Server](/databases/mssql) | 1433 | Yes | Yes | Yes | Yes | Yes | Yes | +| [Oracle](/databases/oracle) | 1521 | Yes | Yes | Yes | No | Yes | Yes | +| [Dameng DM8](/databases/dameng) | 5236 | Yes | No | Yes | No | Yes | Yes | +| [ClickHouse](/databases/clickhouse) | 8123 | Yes | Yes | Yes | No | Yes | Yes | +| [Teradata](/databases/teradata) | 1025 | Yes | Yes | Yes | No | Yes | Yes | +| [Trino](/databases/trino) | 8080 | Yes | Yes | Yes | No | Yes | Yes | +| [MongoDB](/databases/mongodb) | 27017 | Yes | Yes | Yes | No | Yes | Yes | +| [Redis](/databases/redis) | 6379 | Yes | Yes | Yes | No | Yes | Yes | +| [Cassandra / ScyllaDB](/databases/cassandra) | 9042 | Yes | Yes | Yes | No | Yes | Yes | +| [etcd](/databases/etcd) | 2379 | Yes | Yes | Yes | No | Yes | Yes | +| [SurrealDB](/databases/surrealdb) | 8000 | Yes | Yes | Yes | No | Yes | Yes | +| [Elasticsearch](/databases/elasticsearch) | 9200 | No | Yes | No | No | No | No | +| [Snowflake](/databases/snowflake) | 443 | No | No | No | No | No | No | +| [SQLite](/databases/sqlite) | File | No | No | No | No | No | No | +| [DuckDB](/databases/duckdb) | File | No | No | No | No | No | No | +| [Beancount](/databases/beancount) | File | No | No | No | No | No | No | +| [DynamoDB](/databases/dynamodb) | AWS API | No | No | No | No | No | No | +| [BigQuery](/databases/bigquery) | Cloud API | No | No | No | No | No | No | +| [Cloudflare D1](/databases/cloudflare-d1) | Cloud API | No | No | No | No | No | No | +| [libSQL / Turso](/databases/libsql) | URL | No | No | No | No | No | No | A driver with no SSL/TLS pane is either a local file or an HTTPS API that manages its own encryption. [SSL/TLS](/connections/ssl) has the per-driver defaults. diff --git a/docs/connections/tunnel-command.mdx b/docs/connections/tunnel-command.mdx new file mode 100644 index 0000000000..214661e066 --- /dev/null +++ b/docs/connections/tunnel-command.mdx @@ -0,0 +1,107 @@ +--- +title: Tunnel Command +description: Reach a database through kubectl port-forward, an AWS SSM session, or any command that opens a local port +--- + +A `kubectl port-forward` running in a terminal is a window you cannot close for as long as you need the database, and a tab you have to notice when it dies. Move it into the connection and it starts on connect, stops on disconnect, and comes back on its own. + + + Tunnel Command pane showing the kubectl method with namespace, resource and a Will Run section + Tunnel Command pane showing the kubectl method with namespace, resource and a Will Run section + + +## Setting up + + + + Select **Tunnel Command** and turn **Enable Tunnel Command** on. One method per connection: anything else already enabled has a button here to switch it off. + + + **kubectl port-forward** and **AWS SSM Session** ask for the two or three things that vary. **Custom Command** takes a command line. + + + It shows the exact argument list, with `{port}` standing in for the port that gets allocated on connect. + + + On **General**, click **Test Connection**. + + + +The **Host** and **Port** on the General pane stay the database's own. They are what the forward points at, so a kubectl forward reads the port from there and an SSM session forwards to that host and port from the target instance. + +## Methods + +| Method | Asks for | Runs | +|---|---|---| +| **kubectl port-forward** | Resource, namespace, context | `kubectl port-forward` against the resource, on `127.0.0.1` | +| **AWS SSM Session** | Target instance, profile, region | `aws ssm start-session` with the `AWS-StartPortForwardingSessionToRemoteHost` document | +| **Custom Command** | A command line | Whatever you wrote | + +Pick a preset if one fits. A preset takes named values rather than a command line, so nothing typed into it can turn into an extra argument, and the values travel between machines that have the same tools installed. + +### Where the tool comes from + +Both presets look the tool up on `PATH`, with `/usr/local/bin` and `/opt/homebrew/bin` added, because an app started from the Dock inherits a minimal `PATH` rather than your shell's. **Executable path** overrides that with a full path. + +An SSM session also needs the [Session Manager plugin](https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html), which the AWS CLI finds the same way. + +### Custom commands + +Three placeholders are substituted before the command runs: + +| Placeholder | Value | +|---|---| +| `{port}` | The loopback port allocated for this connection. Required | +| `{host}` | The **Host** field on the General pane | +| `{remotePort}` | The **Port** field on the General pane | + +```bash +ssh -N -L {port}:{host}:{remotePort} bastion.example.com +``` + +The command is split into arguments the way a shell would split it, quoting included, and then run directly. Nothing is expanded: `$HOME` stays four characters and `*` stays one. For environment variables, start the command with `/usr/bin/env`: + +```bash +/usr/bin/env KUBECONFIG=~/.kube/staging kubectl port-forward -n prod service/pg {port}:5432 +``` + +Substitution happens after the split, so a value carrying a space stays a single argument. + +## While the connection is open + +The process is held for the life of the connection and killed with its whole process group on disconnect, which is what takes an `aws ssm` session's helper down with it. If it exits on its own while the connection is still open, the connection reconnects and starts it again, up to ten attempts with a widening delay. That covers an SSM session hitting its idle timeout and a `kubectl port-forward` losing its pod. + +The local port gets 30 seconds to answer. Past that the connect fails, and the error carries the last lines the command printed to standard error, which is usually where the real reason is. + +[SSL/TLS](/connections/ssl) still applies, with one unavoidable adjustment: the driver dials a loopback port that no server certificate names, so **Verify CA** and **Verify Identity** fall back to **Required** and certificate paths are dropped. + +## Sharing a connection that has one + +A tunnel command travels under tighter rules than the rest of a connection. + +| Route | What happens | +|---|---| +| iCloud sync | Never carried. The command names tools on one Mac | +| `tablepro://` link, team library | Stripped before the connection is offered | +| `.tablepro` file | Offered, and importing prints every command and asks. **Import Without Commands** is the default | +| Duplicating a connection | Copied, like every other setting | + +Editing `connections.json` by hand is detected, and a connection whose command arrived that way refuses to run it until you open the connection in TablePro and save it again. + +## Troubleshooting + +### kubectl was not found + +The tool is not on the `PATH` an app inherits. Run `which kubectl` in a terminal and put the answer in **Executable path**. + +### The tunnel command exited before the port was open + +The command failed on its own terms and its output follows the message. A wrong namespace, an expired credential, and a resource that does not exist all land here. Run the same command in a terminal to confirm. + +### The tunnel command did not open its local port in time + +Thirty seconds passed with the process still running and nothing listening. An SSM session waiting on a target that is not registered with Systems Manager behaves this way, as does a `kubectl` context pointing at an unreachable cluster. + +### The connection keeps reconnecting + +The command is exiting shortly after it starts. An SSM idle timeout is the common cause: raise it in the session preferences for the account, or expect a reconnect on the first query after a quiet period. diff --git a/docs/docs.json b/docs/docs.json index c70cf48c01..29ace57ef1 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -131,6 +131,7 @@ "connections/cloudflare-tunnel", "connections/cloud-sql-proxy", "connections/socks-proxy", + "connections/tunnel-command", "connections/aws-iam", "connections/ssl", "features/connection-sharing", diff --git a/docs/images/tunnel-command-pane-dark.png b/docs/images/tunnel-command-pane-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..ae569d1ba0cbb4f579986ec7042ee840b2febf22 GIT binary patch literal 5899 zcmeAS@N?(olHy`uVBq!ia0y~yV3S~AU_QXX1QhX5ePYbOAa=>q#WAGf)|(rKybKIH zhc_^-JSLx}*wdh@d}8JncAz?j^&TLyVIm7blEF;~q?8y^!V{#D7&1YH5vYjKp7>5?4HFfea$9d|C-oia%w- zWWfb90r!m-qC^HJsCFePEJs6hG(>?(g_w*_T#g4duV7hvw2g;9T!&eRl2F($0reS( zE1)KU%QE}{36ULU!8w|fh>7&ks3$TXfjiTqAxc!`L|n-<8luEi^rRH_pwT6wS|bvk z;NT~w%qPy3qfKUF!V;opG(?F@q%;oE4P|d;fRb%O!2fHjH#!@%GAK*}Y{&qt2dEw90PcDtYei;b8iS;j0f~)i46;^aHpDeJwBk1gNh<>qy8(ws jAhzNP4-6Mz8nc7r?#=ZoYvZl>K>_IL>gTe~DWM4fq#WAGf)|(rKybKIH zhc|p`znz}1*wdh@d}8JncAz?j^&TLyVIm7blEF;~q?8y^!V{#D7&1YH5vYjKp7>5?4HFfea$9d|C-oia%w- zWWfb90r!m-qC^HJsCFePEJs6hG(>?(g_w*_T#g4duV7hvw2g;9T!&eRl2F($0reS( zE1)KU%QE}{36ULU!8w|fh>7&ks3$TXfjiTqAxc!`L|n-<8luEi^rRH_pwT6wS|bvk z;NT~w%qPy3qfKUF!V;opG(?F@q%;oE4P|d;fRb%O!2fHjH#!@%GAK*}Y{&qt2dEw90PcDtYei;b8iS;j0f~)i46;^aHpDeJwBk1gNh<>qy8(ws jAhzNP4-6Mz8nc7r(6fNQXD9u62?{_@S3j3^P6