Skip to content

Commit d8adced

Browse files
authored
feat(connections): resolve connection passwords from file, env, or command (#1254) (#1478)
* feat(connections): resolve connection passwords from file, env, or command (#1254) * refactor(connections): cap command output, precise timeout, log malformed source (#1254) * fix(connections): preserve password source across tunnel, duplicate, form save, and sync (#1254) --------- Signed-off-by: Ngô Quốc Đạt <datlechin@gmail.com>
1 parent b9764dd commit d8adced

15 files changed

Lines changed: 672 additions & 0 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- 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)
13+
1014
### Fixed
1115

1216
- Moving a connection into or out of a group now syncs across devices, instead of leaving it ungrouped on your other Macs.

TablePro/Core/Database/DatabaseDriver.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,9 @@ enum DatabaseDriverFactory {
479479
return try await resolveIAMPassword(for: connection, fields: fields)
480480
}
481481
if let override { return override }
482+
if let passwordSource = connection.passwordSource {
483+
return try await PasswordSourceResolver.resolve(passwordSource)
484+
}
482485
if connection.usePgpass {
483486
let pgpassHost = connection.additionalFields["pgpassOriginalHost"] ?? connection.host
484487
let pgpassPort = connection.additionalFields["pgpassOriginalPort"]

TablePro/Core/Database/DatabaseManager+Tunnel.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ extension DatabaseManager {
4141
type: connection.type,
4242
sshConfig: SSHConfiguration(),
4343
sslConfig: tunnelSSL,
44+
passwordSource: connection.passwordSource,
4445
additionalFields: effectiveFields
4546
)
4647
}

TablePro/Core/Storage/ConnectionStorage.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,7 @@ final class ConnectionStorage {
278278
startupCommands: connection.startupCommands,
279279
sortOrder: connection.sortOrder,
280280
localOnly: connection.localOnly,
281+
passwordSource: connection.passwordSource,
281282
additionalFields: connection.additionalFields.isEmpty ? nil : connection.additionalFields
282283
)
283284

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

595+
// Password source (file, env, or command) for connections provisioned outside the app
596+
let passwordSource: PasswordSource?
597+
594598
init(from connection: DatabaseConnection) {
595599
self.id = connection.id
596600
self.name = connection.name
@@ -676,6 +680,9 @@ private struct StoredConnection: Codable {
676680

677681
// Plugin-driven additional fields
678682
self.additionalFields = connection.additionalFields.isEmpty ? nil : connection.additionalFields
683+
684+
// Password source (not synced to iCloud; see SyncRecordMapper)
685+
self.passwordSource = connection.passwordSource
679686
}
680687

681688
private enum CodingKeys: String, CodingKey {
@@ -698,6 +705,7 @@ private struct StoredConnection: Codable {
698705
case localOnly
699706
case isSample
700707
case isFavorite
708+
case passwordSource
701709
}
702710

703711
func encode(to encoder: Encoder) throws {
@@ -741,6 +749,7 @@ private struct StoredConnection: Codable {
741749
try container.encode(localOnly, forKey: .localOnly)
742750
try container.encode(isSample, forKey: .isSample)
743751
try container.encode(isFavorite, forKey: .isFavorite)
752+
try container.encodeIfPresent(passwordSource, forKey: .passwordSource)
744753
}
745754

746755
// Custom decoder to handle migration from old format
@@ -807,6 +816,7 @@ private struct StoredConnection: Codable {
807816
sshTunnelModeJson = try container.decodeIfPresent(Data.self, forKey: .sshTunnelModeJson)
808817
cloudflareTunnelModeJson = try container.decodeIfPresent(Data.self, forKey: .cloudflareTunnelModeJson)
809818
additionalFields = try container.decodeIfPresent([String: String].self, forKey: .additionalFields)
819+
passwordSource = PasswordSource.resilientlyDecoded(from: container, forKey: .passwordSource)
810820
localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly) ?? false
811821
isSample = try container.decodeIfPresent(Bool.self, forKey: .isSample) ?? false
812822
isFavorite = try container.decodeIfPresent(Bool.self, forKey: .isFavorite) ?? false
@@ -914,6 +924,7 @@ private struct StoredConnection: Codable {
914924
localOnly: localOnly,
915925
isSample: isSample,
916926
isFavorite: isFavorite,
927+
passwordSource: passwordSource,
917928
additionalFields: mergedFields
918929
)
919930
}

TablePro/Core/Sync/SyncCoordinator.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,7 @@ final class SyncCoordinator {
517517
}
518518
var merged = remoteConnection
519519
merged.localOnly = connections[index].localOnly
520+
merged.passwordSource = connections[index].passwordSource
520521
connections[index] = merged
521522
} else {
522523
connections.append(remoteConnection)

TablePro/Core/Sync/SyncRecordMapper.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,8 @@ struct SyncRecordMapper {
113113
// the sync schema in the future, apply path contraction to its snapshot.
114114
// cloudflareTunnelMode is also NOT synced: it is device-local runtime
115115
// config and its service-token secrets live in the Keychain.
116+
// passwordSource is also NOT synced: its file path, env var, or command
117+
// is device-local and may not exist or resolve on another Mac.
116118
do {
117119
let sshData = try encoder.encode(Self.makePortable(connection.sshConfig))
118120
record["sshConfigJson"] = sshData as CKRecordValue
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
//
2+
// PasswordSourceResolver.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
import os
8+
9+
/// Resolves a connection password from an external source declared in connections.json.
10+
/// File and command sources require a non-sandboxed build; TablePro ships with the hardened
11+
/// runtime and no App Sandbox, so spawning a process and reading arbitrary files is allowed.
12+
enum PasswordSourceResolver {
13+
private static let logger = Logger(subsystem: "com.TablePro", category: "PasswordSourceResolver")
14+
15+
private static let commandTimeoutSeconds: UInt64 = 30
16+
private static let maxOutputBytes = 1_048_576
17+
18+
enum ResolutionError: LocalizedError {
19+
case fileNotFound(path: String)
20+
case fileUnreadable(path: String)
21+
case environmentVariableNotSet(name: String)
22+
case commandFailed(exitCode: Int32, stderr: String)
23+
case commandTimedOut
24+
case outputTooLarge
25+
case emptyPassword
26+
27+
var errorDescription: String? {
28+
switch self {
29+
case let .fileNotFound(path):
30+
return String(format: String(localized: "Password file not found: %@"), path)
31+
case let .fileUnreadable(path):
32+
return String(format: String(localized: "Could not read password file: %@"), path)
33+
case let .environmentVariableNotSet(name):
34+
return String(
35+
format: String(localized: """
36+
Environment variable %@ is not set in TablePro's environment. \
37+
Apps launched from the Dock do not inherit shell exports. Launch TablePro \
38+
from a terminal, or set the variable with launchctl setenv.
39+
"""),
40+
name
41+
)
42+
case let .commandFailed(exitCode, stderr):
43+
let message = stderr.trimmingCharacters(in: .whitespacesAndNewlines)
44+
if message.isEmpty {
45+
return String(format: String(localized: "Password command failed with exit code %d"), exitCode)
46+
}
47+
return String(format: String(localized: "Password command failed (exit %d): %@"), exitCode, message)
48+
case .commandTimedOut:
49+
return String(localized: "Password command timed out after 30 seconds")
50+
case .outputTooLarge:
51+
return String(localized: "Password command produced too much output")
52+
case .emptyPassword:
53+
return String(localized: "The password source produced an empty password")
54+
}
55+
}
56+
}
57+
58+
static func resolve(_ source: PasswordSource) async throws -> String {
59+
switch source {
60+
case let .file(path):
61+
return try resolveFile(path: path)
62+
case let .env(variable):
63+
return try resolveEnvironment(variable: variable)
64+
case let .command(shell):
65+
return try await resolveCommand(shell: shell, timeoutSeconds: commandTimeoutSeconds)
66+
}
67+
}
68+
69+
private static func resolveFile(path: String) throws -> String {
70+
let expandedPath = (path as NSString).expandingTildeInPath
71+
guard FileManager.default.fileExists(atPath: expandedPath) else {
72+
throw ResolutionError.fileNotFound(path: expandedPath)
73+
}
74+
warnIfPermissionsInsecure(path: expandedPath)
75+
guard let contents = try? String(contentsOfFile: expandedPath, encoding: .utf8) else {
76+
throw ResolutionError.fileUnreadable(path: expandedPath)
77+
}
78+
return try nonEmpty(contents.trimmingCharacters(in: .whitespacesAndNewlines))
79+
}
80+
81+
private static func resolveEnvironment(variable: String) throws -> String {
82+
guard let value = ProcessInfo.processInfo.environment[variable] else {
83+
throw ResolutionError.environmentVariableNotSet(name: variable)
84+
}
85+
return try nonEmpty(value.trimmingCharacters(in: .whitespacesAndNewlines))
86+
}
87+
88+
static func resolveCommand(shell: String, timeoutSeconds: UInt64) async throws -> String {
89+
let output = try await Task.detached(priority: .userInitiated) { () throws -> String in
90+
let process = Process()
91+
process.executableURL = URL(fileURLWithPath: "/bin/bash")
92+
process.arguments = ["-c", shell]
93+
process.environment = augmentedEnvironment()
94+
process.standardInput = FileHandle.nullDevice
95+
96+
let stdoutPipe = Pipe()
97+
let stderrPipe = Pipe()
98+
process.standardOutput = stdoutPipe
99+
process.standardError = stderrPipe
100+
101+
let stdoutCollector = PipeDataCollector(maxBytes: maxOutputBytes)
102+
let stderrCollector = PipeDataCollector(maxBytes: maxOutputBytes)
103+
stdoutPipe.fileHandleForReading.readabilityHandler = { handle in
104+
let chunk = handle.availableData
105+
guard !chunk.isEmpty else { return }
106+
stdoutCollector.append(chunk)
107+
if stdoutCollector.overflowed, process.isRunning {
108+
process.terminate()
109+
}
110+
}
111+
stderrPipe.fileHandleForReading.readabilityHandler = { handle in
112+
let chunk = handle.availableData
113+
if !chunk.isEmpty { stderrCollector.append(chunk) }
114+
}
115+
116+
try process.run()
117+
118+
let didTimeout = AtomicFlag()
119+
let timeoutTask = Task.detached {
120+
try await Task.sleep(nanoseconds: timeoutSeconds * 1_000_000_000)
121+
if process.isRunning {
122+
didTimeout.set()
123+
process.terminate()
124+
}
125+
}
126+
127+
process.waitUntilExit()
128+
timeoutTask.cancel()
129+
130+
stdoutPipe.fileHandleForReading.readabilityHandler = nil
131+
stderrPipe.fileHandleForReading.readabilityHandler = nil
132+
133+
if stdoutCollector.overflowed {
134+
throw ResolutionError.outputTooLarge
135+
}
136+
if didTimeout.isSet {
137+
throw ResolutionError.commandTimedOut
138+
}
139+
if process.terminationStatus != 0 {
140+
throw ResolutionError.commandFailed(
141+
exitCode: process.terminationStatus,
142+
stderr: stderrCollector.string
143+
)
144+
}
145+
return stdoutCollector.string
146+
}.value
147+
148+
guard !output.contains("\0") else {
149+
throw ResolutionError.emptyPassword
150+
}
151+
return try nonEmpty(output.trimmingCharacters(in: .whitespacesAndNewlines))
152+
}
153+
154+
private static func augmentedEnvironment() -> [String: String] {
155+
var environment = ProcessInfo.processInfo.environment
156+
let toolPaths = ["/usr/local/bin", "/opt/homebrew/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]
157+
var pathComponents = (environment["PATH"] ?? "").split(separator: ":").map(String.init)
158+
for toolPath in toolPaths where !pathComponents.contains(toolPath) {
159+
pathComponents.append(toolPath)
160+
}
161+
environment["PATH"] = pathComponents.joined(separator: ":")
162+
return environment
163+
}
164+
165+
private static func warnIfPermissionsInsecure(path: String) {
166+
guard let attributes = try? FileManager.default.attributesOfItem(atPath: path),
167+
let permissions = attributes[.posixPermissions] as? Int else {
168+
return
169+
}
170+
if permissions & 0o077 != 0 {
171+
logger.warning("Password file is group or world accessible; restrict it with chmod 600")
172+
}
173+
}
174+
175+
private static func nonEmpty(_ password: String) throws -> String {
176+
guard !password.isEmpty else {
177+
throw ResolutionError.emptyPassword
178+
}
179+
return password
180+
}
181+
}
182+
183+
private final class PipeDataCollector: @unchecked Sendable {
184+
private let lock = NSLock()
185+
private let maxBytes: Int
186+
private var data = Data()
187+
private var didOverflow = false
188+
189+
init(maxBytes: Int) {
190+
self.maxBytes = maxBytes
191+
}
192+
193+
func append(_ chunk: Data) {
194+
lock.lock()
195+
defer { lock.unlock() }
196+
let remaining = maxBytes - data.count
197+
guard remaining > 0 else {
198+
didOverflow = true
199+
return
200+
}
201+
if chunk.count > remaining {
202+
data.append(chunk.prefix(remaining))
203+
didOverflow = true
204+
} else {
205+
data.append(chunk)
206+
}
207+
}
208+
209+
var overflowed: Bool {
210+
lock.lock()
211+
defer { lock.unlock() }
212+
return didOverflow
213+
}
214+
215+
var string: String {
216+
lock.lock()
217+
defer { lock.unlock() }
218+
return String(data: data, encoding: .utf8) ?? ""
219+
}
220+
}
221+
222+
private final class AtomicFlag: @unchecked Sendable {
223+
private let lock = NSLock()
224+
private var value = false
225+
226+
func set() {
227+
lock.lock()
228+
value = true
229+
lock.unlock()
230+
}
231+
232+
var isSet: Bool {
233+
lock.lock()
234+
defer { lock.unlock() }
235+
return value
236+
}
237+
}

TablePro/Models/Connection/DatabaseConnection.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,7 @@ struct DatabaseConnection: Identifiable, Hashable {
334334
var localOnly: Bool = false
335335
var isSample: Bool = false
336336
var isFavorite: Bool = false
337+
var passwordSource: PasswordSource?
337338

338339
var mongoAuthSource: String? {
339340
get { additionalFields["mongoAuthSource"]?.nilIfEmpty }
@@ -430,6 +431,7 @@ struct DatabaseConnection: Identifiable, Hashable {
430431
localOnly: Bool = false,
431432
isSample: Bool = false,
432433
isFavorite: Bool = false,
434+
passwordSource: PasswordSource? = nil,
433435
additionalFields: [String: String]? = nil
434436
) {
435437
self.id = id
@@ -472,6 +474,7 @@ struct DatabaseConnection: Identifiable, Hashable {
472474
self.localOnly = localOnly
473475
self.isSample = isSample
474476
self.isFavorite = isFavorite
477+
self.passwordSource = passwordSource
475478
if let additionalFields {
476479
self.additionalFields = additionalFields
477480
} else {
@@ -520,6 +523,7 @@ extension DatabaseConnection: Codable {
520523
case sshConfig, sslConfig, color, tagId, groupId, sshProfileId
521524
case sshTunnelMode, cloudflareTunnelMode, safeModeLevel, aiPolicy, aiRules, aiAlwaysAllowedTools, externalAccess, additionalFields
522525
case redisDatabase, startupCommands, sortOrder, localOnly, isSample, isFavorite
526+
case passwordSource
523527
}
524528

525529
init(from decoder: Decoder) throws {
@@ -549,6 +553,7 @@ extension DatabaseConnection: Codable {
549553
localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly) ?? false
550554
isSample = try container.decodeIfPresent(Bool.self, forKey: .isSample) ?? false
551555
isFavorite = try container.decodeIfPresent(Bool.self, forKey: .isFavorite) ?? false
556+
passwordSource = PasswordSource.resilientlyDecoded(from: container, forKey: .passwordSource)
552557
cloudflareTunnelMode = try container.decodeIfPresent(CloudflareTunnelMode.self, forKey: .cloudflareTunnelMode) ?? .disabled
553558

554559
// Migrate from legacy fields if sshTunnelMode is not present
@@ -600,6 +605,7 @@ extension DatabaseConnection: Codable {
600605
try container.encode(localOnly, forKey: .localOnly)
601606
try container.encode(isSample, forKey: .isSample)
602607
try container.encode(isFavorite, forKey: .isFavorite)
608+
try container.encodeIfPresent(passwordSource, forKey: .passwordSource)
603609
}
604610
}
605611

0 commit comments

Comments
 (0)