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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- A connection can read its password from a file, environment variable, or command at connect time instead of the Keychain, so scripts can provision a connection without entering the password by hand. (#1254)

### Fixed

- Moving a connection into or out of a group now syncs across devices, instead of leaving it ungrouped on your other Macs.
Expand Down
3 changes: 3 additions & 0 deletions TablePro/Core/Database/DatabaseDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,9 @@ enum DatabaseDriverFactory {
return try await resolveIAMPassword(for: connection, fields: fields)
}
if let override { return override }
if let passwordSource = connection.passwordSource {
return try await PasswordSourceResolver.resolve(passwordSource)
Comment on lines +482 to +483
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve password sources through tunnel rewriting

When a connection uses SSH or Cloudflare tunneling, DatabaseManager.tunneledConnection(from:localPort:) rebuilds a DatabaseConnection for 127.0.0.1 before createDriver calls this password lookup, but that rebuilt connection does not carry passwordSource. In that environment this new branch is skipped and the driver falls back to Keychain/empty password, so a connection that works directly with passwordSource fails as soon as a tunnel is enabled; propagate the source onto the tunneled connection before resolving the password.

Useful? React with 👍 / 👎.

Comment on lines +482 to +483
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve sourced passwords for dump commands

This resolves passwordSource only for driver creation, but the PostgreSQL backup/restore path bypasses DatabaseDriverFactory: PostgresDumpService.start still sets PGPASSWORD from the Keychain or session.cachedPassword, and cachedPassword is only populated for prompted passwords. A PostgreSQL/Redshift connection that successfully connects via a file/env/command password will therefore run pg_dump/pg_restore with --no-password and no password, causing backup/restore to fail. Please resolve the same source there or cache the resolved password for these subprocesses.

Useful? React with 👍 / 👎.

}
if connection.usePgpass {
let pgpassHost = connection.additionalFields["pgpassOriginalHost"] ?? connection.host
let pgpassPort = connection.additionalFields["pgpassOriginalPort"]
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 @@ -41,6 +41,7 @@ extension DatabaseManager {
type: connection.type,
sshConfig: SSHConfiguration(),
sslConfig: tunnelSSL,
passwordSource: connection.passwordSource,
additionalFields: effectiveFields
)
}
Expand Down
11 changes: 11 additions & 0 deletions TablePro/Core/Storage/ConnectionStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@ final class ConnectionStorage {
startupCommands: connection.startupCommands,
sortOrder: connection.sortOrder,
localOnly: connection.localOnly,
passwordSource: connection.passwordSource,
additionalFields: connection.additionalFields.isEmpty ? nil : connection.additionalFields
)

Expand Down Expand Up @@ -591,6 +592,9 @@ private struct StoredConnection: Codable {
// Plugin-driven additional fields
let additionalFields: [String: String]?

// Password source (file, env, or command) for connections provisioned outside the app
let passwordSource: PasswordSource?

init(from connection: DatabaseConnection) {
self.id = connection.id
self.name = connection.name
Expand Down Expand Up @@ -676,6 +680,9 @@ private struct StoredConnection: Codable {

// Plugin-driven additional fields
self.additionalFields = connection.additionalFields.isEmpty ? nil : connection.additionalFields

// Password source (not synced to iCloud; see SyncRecordMapper)
self.passwordSource = connection.passwordSource
}

private enum CodingKeys: String, CodingKey {
Expand All @@ -698,6 +705,7 @@ private struct StoredConnection: Codable {
case localOnly
case isSample
case isFavorite
case passwordSource
}

func encode(to encoder: Encoder) throws {
Expand Down Expand Up @@ -741,6 +749,7 @@ private struct StoredConnection: Codable {
try container.encode(localOnly, forKey: .localOnly)
try container.encode(isSample, forKey: .isSample)
try container.encode(isFavorite, forKey: .isFavorite)
try container.encodeIfPresent(passwordSource, forKey: .passwordSource)
}

// Custom decoder to handle migration from old format
Expand Down Expand Up @@ -807,6 +816,7 @@ private struct StoredConnection: Codable {
sshTunnelModeJson = try container.decodeIfPresent(Data.self, forKey: .sshTunnelModeJson)
cloudflareTunnelModeJson = try container.decodeIfPresent(Data.self, forKey: .cloudflareTunnelModeJson)
additionalFields = try container.decodeIfPresent([String: String].self, forKey: .additionalFields)
passwordSource = PasswordSource.resilientlyDecoded(from: container, forKey: .passwordSource)
localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly) ?? false
isSample = try container.decodeIfPresent(Bool.self, forKey: .isSample) ?? false
isFavorite = try container.decodeIfPresent(Bool.self, forKey: .isFavorite) ?? false
Expand Down Expand Up @@ -914,6 +924,7 @@ private struct StoredConnection: Codable {
localOnly: localOnly,
isSample: isSample,
isFavorite: isFavorite,
passwordSource: passwordSource,
additionalFields: mergedFields
)
}
Expand Down
1 change: 1 addition & 0 deletions TablePro/Core/Sync/SyncCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,7 @@ final class SyncCoordinator {
}
var merged = remoteConnection
merged.localOnly = connections[index].localOnly
merged.passwordSource = connections[index].passwordSource
connections[index] = merged
} else {
connections.append(remoteConnection)
Expand Down
2 changes: 2 additions & 0 deletions TablePro/Core/Sync/SyncRecordMapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ struct SyncRecordMapper {
// the sync schema in the future, apply path contraction to its snapshot.
// cloudflareTunnelMode is also NOT synced: it is device-local runtime
// config and its service-token secrets live in the Keychain.
// passwordSource is also NOT synced: its file path, env var, or command
// is device-local and may not exist or resolve on another Mac.
Comment on lines +116 to +117
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve local password sources during sync merges

When a Mac already has a connection with a local passwordSource and then receives a CloudKit update for the same connection, this omission means SyncRecordMapper.toConnection creates a remote connection with passwordSource == nil; SyncCoordinator.applyRemoteConnection then replaces the local entry while only copying localOnly. In that iCloud-sync path, any edit from another Mac strips the device-local password source from connections.json, so the next connect falls back to Keychain/empty instead of the configured file/env/command source. Please merge the existing local passwordSource back into the remote record, similar to localOnly, when applying remote updates.

Useful? React with 👍 / 👎.

do {
let sshData = try encoder.encode(Self.makePortable(connection.sshConfig))
record["sshConfigJson"] = sshData as CKRecordValue
Expand Down
237 changes: 237 additions & 0 deletions TablePro/Core/Utilities/Connection/PasswordSourceResolver.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
//
// PasswordSourceResolver.swift
// TablePro
//

import Foundation
import os

/// Resolves a connection password from an external source declared in connections.json.
/// File and command sources require a non-sandboxed build; TablePro ships with the hardened
/// runtime and no App Sandbox, so spawning a process and reading arbitrary files is allowed.
enum PasswordSourceResolver {
private static let logger = Logger(subsystem: "com.TablePro", category: "PasswordSourceResolver")

private static let commandTimeoutSeconds: UInt64 = 30
private static let maxOutputBytes = 1_048_576

enum ResolutionError: LocalizedError {
case fileNotFound(path: String)
case fileUnreadable(path: String)
case environmentVariableNotSet(name: String)
case commandFailed(exitCode: Int32, stderr: String)
case commandTimedOut
case outputTooLarge
case emptyPassword

var errorDescription: String? {
switch self {
case let .fileNotFound(path):
return String(format: String(localized: "Password file not found: %@"), path)
case let .fileUnreadable(path):
return String(format: String(localized: "Could not read password file: %@"), path)
case let .environmentVariableNotSet(name):
return String(
format: String(localized: """
Environment variable %@ is not set in TablePro's environment. \
Apps launched from the Dock do not inherit shell exports. Launch TablePro \
from a terminal, or set the variable with launchctl setenv.
"""),
name
)
case let .commandFailed(exitCode, stderr):
let message = stderr.trimmingCharacters(in: .whitespacesAndNewlines)
if message.isEmpty {
return String(format: String(localized: "Password command failed with exit code %d"), exitCode)
}
return String(format: String(localized: "Password command failed (exit %d): %@"), exitCode, message)
case .commandTimedOut:
return String(localized: "Password command timed out after 30 seconds")
case .outputTooLarge:
return String(localized: "Password command produced too much output")
case .emptyPassword:
return String(localized: "The password source produced an empty password")
}
}
}

static func resolve(_ source: PasswordSource) async throws -> String {
switch source {
case let .file(path):
return try resolveFile(path: path)
case let .env(variable):
return try resolveEnvironment(variable: variable)
case let .command(shell):
return try await resolveCommand(shell: shell, timeoutSeconds: commandTimeoutSeconds)
}
}

private static func resolveFile(path: String) throws -> String {
let expandedPath = (path as NSString).expandingTildeInPath
guard FileManager.default.fileExists(atPath: expandedPath) else {
throw ResolutionError.fileNotFound(path: expandedPath)
}
warnIfPermissionsInsecure(path: expandedPath)
guard let contents = try? String(contentsOfFile: expandedPath, encoding: .utf8) else {
throw ResolutionError.fileUnreadable(path: expandedPath)
}
return try nonEmpty(contents.trimmingCharacters(in: .whitespacesAndNewlines))
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve spaces in file-sourced passwords

For file-based password sources, trimming .whitespacesAndNewlines changes the actual secret when a valid database password starts or ends with a space; the docs only promise to trim a trailing newline. In that case the app will connect with a different password than the file contains, so this should only remove line terminators (or at most one final newline) rather than all surrounding whitespace.

Useful? React with 👍 / 👎.

}

private static func resolveEnvironment(variable: String) throws -> String {
guard let value = ProcessInfo.processInfo.environment[variable] else {
throw ResolutionError.environmentVariableNotSet(name: variable)
}
return try nonEmpty(value.trimmingCharacters(in: .whitespacesAndNewlines))
}

static func resolveCommand(shell: String, timeoutSeconds: UInt64) async throws -> String {
let output = try await Task.detached(priority: .userInitiated) { () throws -> String in
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/bash")
process.arguments = ["-c", shell]
process.environment = augmentedEnvironment()
process.standardInput = FileHandle.nullDevice

let stdoutPipe = Pipe()
let stderrPipe = Pipe()
process.standardOutput = stdoutPipe
process.standardError = stderrPipe

let stdoutCollector = PipeDataCollector(maxBytes: maxOutputBytes)
let stderrCollector = PipeDataCollector(maxBytes: maxOutputBytes)
stdoutPipe.fileHandleForReading.readabilityHandler = { handle in
let chunk = handle.availableData
guard !chunk.isEmpty else { return }
stdoutCollector.append(chunk)
if stdoutCollector.overflowed, process.isRunning {
process.terminate()
}
}
stderrPipe.fileHandleForReading.readabilityHandler = { handle in
let chunk = handle.availableData
if !chunk.isEmpty { stderrCollector.append(chunk) }
}

try process.run()

let didTimeout = AtomicFlag()
let timeoutTask = Task.detached {
try await Task.sleep(nanoseconds: timeoutSeconds * 1_000_000_000)
if process.isRunning {
didTimeout.set()
process.terminate()
}
Comment on lines +121 to +124
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enforce the timeout for TERM-resistant commands

For a password command that traps or ignores SIGTERM, this timeout path sets the flag and calls terminate(), but waitUntilExit() below can still block forever because the process never exits. Since passwordSource.command accepts arbitrary shell snippets and promises a 30-second timeout, a hung or TERM-resistant helper can still hang connection creation indefinitely; kill the process group or escalate to SIGKILL after a grace period.

Useful? React with 👍 / 👎.

}

process.waitUntilExit()
timeoutTask.cancel()

stdoutPipe.fileHandleForReading.readabilityHandler = nil
stderrPipe.fileHandleForReading.readabilityHandler = nil

if stdoutCollector.overflowed {
throw ResolutionError.outputTooLarge
}
if didTimeout.isSet {
throw ResolutionError.commandTimedOut
}
if process.terminationStatus != 0 {
throw ResolutionError.commandFailed(
exitCode: process.terminationStatus,
stderr: stderrCollector.string
)
}
return stdoutCollector.string
}.value

guard !output.contains("\0") else {
throw ResolutionError.emptyPassword
}
return try nonEmpty(output.trimmingCharacters(in: .whitespacesAndNewlines))
}

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 {
return
}
if permissions & 0o077 != 0 {
logger.warning("Password file is group or world accessible; restrict it with chmod 600")
}
}

private static func nonEmpty(_ password: String) throws -> String {
guard !password.isEmpty else {
throw ResolutionError.emptyPassword
}
return password
}
}

private final class PipeDataCollector: @unchecked Sendable {
private let lock = NSLock()
private let maxBytes: Int
private var data = Data()
private var didOverflow = false

init(maxBytes: Int) {
self.maxBytes = maxBytes
}

func append(_ chunk: Data) {
lock.lock()
defer { lock.unlock() }
let remaining = maxBytes - data.count
guard remaining > 0 else {
didOverflow = true
return
}
if chunk.count > remaining {
data.append(chunk.prefix(remaining))
didOverflow = true
} else {
data.append(chunk)
}
}

var overflowed: Bool {
lock.lock()
defer { lock.unlock() }
return didOverflow
}

var string: String {
lock.lock()
defer { lock.unlock() }
return String(data: data, encoding: .utf8) ?? ""
}
}

private final class AtomicFlag: @unchecked Sendable {
private let lock = NSLock()
private var value = false

func set() {
lock.lock()
value = true
lock.unlock()
}

var isSet: Bool {
lock.lock()
defer { lock.unlock() }
return value
}
}
6 changes: 6 additions & 0 deletions TablePro/Models/Connection/DatabaseConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,7 @@ struct DatabaseConnection: Identifiable, Hashable {
var localOnly: Bool = false
var isSample: Bool = false
var isFavorite: Bool = false
var passwordSource: PasswordSource?

var mongoAuthSource: String? {
get { additionalFields["mongoAuthSource"]?.nilIfEmpty }
Expand Down Expand Up @@ -430,6 +431,7 @@ struct DatabaseConnection: Identifiable, Hashable {
localOnly: Bool = false,
isSample: Bool = false,
isFavorite: Bool = false,
passwordSource: PasswordSource? = nil,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve password sources when rebuilding connections

Because this new initializer argument defaults to nil, existing call sites that reconstruct a connection from form state can now silently drop a manually provisioned passwordSource. For example, editing and saving an existing connection in ConnectionFormCoordinator.saveConnection replaces savedConnections[index] with a freshly constructed DatabaseConnection and there is no form field for passwordSource, so the next connect falls back to Keychain/empty password even though the source was present in connections.json; preserve originalConnection?.passwordSource when saving existing connections.

Useful? React with 👍 / 👎.

additionalFields: [String: String]? = nil
) {
self.id = id
Expand Down Expand Up @@ -472,6 +474,7 @@ struct DatabaseConnection: Identifiable, Hashable {
self.localOnly = localOnly
self.isSample = isSample
self.isFavorite = isFavorite
self.passwordSource = passwordSource
if let additionalFields {
self.additionalFields = additionalFields
} else {
Expand Down Expand Up @@ -520,6 +523,7 @@ extension DatabaseConnection: Codable {
case sshConfig, sslConfig, color, tagId, groupId, sshProfileId
case sshTunnelMode, cloudflareTunnelMode, safeModeLevel, aiPolicy, aiRules, aiAlwaysAllowedTools, externalAccess, additionalFields
case redisDatabase, startupCommands, sortOrder, localOnly, isSample, isFavorite
case passwordSource
}

init(from decoder: Decoder) throws {
Expand Down Expand Up @@ -549,6 +553,7 @@ extension DatabaseConnection: Codable {
localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly) ?? false
isSample = try container.decodeIfPresent(Bool.self, forKey: .isSample) ?? false
isFavorite = try container.decodeIfPresent(Bool.self, forKey: .isFavorite) ?? false
passwordSource = PasswordSource.resilientlyDecoded(from: container, forKey: .passwordSource)
cloudflareTunnelMode = try container.decodeIfPresent(CloudflareTunnelMode.self, forKey: .cloudflareTunnelMode) ?? .disabled

// Migrate from legacy fields if sshTunnelMode is not present
Expand Down Expand Up @@ -600,6 +605,7 @@ extension DatabaseConnection: Codable {
try container.encode(localOnly, forKey: .localOnly)
try container.encode(isSample, forKey: .isSample)
try container.encode(isFavorite, forKey: .isFavorite)
try container.encodeIfPresent(passwordSource, forKey: .passwordSource)
}
}

Expand Down
Loading
Loading