Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Tunnel Command pane, with presets for `kubectl port-forward` and `aws ssm start-session` and a custom command line. (#2520)
- Bar chart column in the EXPLAIN tree, with a Metric menu for self cost, self time and row counts. (#2633)

### Fixed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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<String> = [
"preconnectscript",
Expand Down Expand Up @@ -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
)
}

Expand All @@ -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
)
}
}
Expand Down
2 changes: 2 additions & 0 deletions TablePro/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -193,6 +194,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
SSHTunnelManager.shared.terminateAllProcessesSync()
CloudflareTunnelManager.shared.terminateAllProcessesSync()
CloudSQLProxyManager.shared.terminateAllProcessesSync()
TunnelCommandManager.shared.terminateAllProcessesSync()
}

private func persistOpenConnectionsForRecovery() {
Expand Down
1 change: 1 addition & 0 deletions TablePro/Core/Database/CLIExecutableFinder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions TablePro/Core/Database/DatabaseManager+SSH.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions TablePro/Core/Database/DatabaseManager+SystemEvents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
1 change: 1 addition & 0 deletions TablePro/Core/Database/DatabaseManager+Tunnel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
40 changes: 40 additions & 0 deletions TablePro/Core/Database/DatabaseManager+TunnelCommand.swift
Original file line number Diff line number Diff line change
@@ -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.")
)
}
}
5 changes: 5 additions & 0 deletions TablePro/Core/Plugins/PluginManager+Registration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions TablePro/Core/Plugins/PluginMetadataRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions TablePro/Core/Process/CLIToolEnvironment.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
40 changes: 39 additions & 1 deletion TablePro/Core/Process/SupervisedProcessRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// TablePro
//

import Darwin
import Foundation

struct SubprocessTermination: Sendable, Equatable {
Expand Down Expand Up @@ -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?
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading