Skip to content

Commit a9f3d7d

Browse files
authored
fix(ssh): resolve ~/.ssh/config aliases at connect time (#977) (#979)
* fix(ssh): resolve ~/.ssh/config aliases at connect time (#977) * fix(ssh): allow empty username in form when alias supplies User from ssh config * fix(ui): SSH connections in welcome list show database target with SSH badge * refactor(ui): align welcome connection row with native macOS list patterns * refactor(ui): drop SSH lock glyph, use native macOS "via X" subtitle pattern
1 parent 297d885 commit a9f3d7d

40 files changed

Lines changed: 2040 additions & 554 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2323
- Oracle 10G password verifier authentication. Accounts whose `password_versions` includes a 10G hash now connect successfully, matching DBeaver/JDBC/sqlplus behavior. The 10G hash is documented as legacy; rotating to a modern verifier is still recommended (#483)
2424
- Oracle Test Connection now opens a focused diagnostic sheet for auth failures with copy-able diagnostic info, suggested actions, and a link to file an issue
2525
- Oracle connection negotiation now matches python-oracledb's 23ai compile-capability advertisement, including TTC4 explicit boundary, TTC5 token/pipelining/sessionless flags, OCI3 sync, dequeue selectors, and sparse vector features
26+
- SSH tunnel now resolves host aliases from `~/.ssh/config` at connection time, with `ssh_config(5)`-compatible semantics: `Host` glob patterns, negation, all `Match` types (`host`, `originalhost`, `user`, `localuser`, `exec`, `all`, `canonical`, `final`), `ProxyJump` injection, hostname canonicalization, and `Include`. Typing an alias like `aia-bastion` in the SSH host field now works the same as `ssh aia-bastion` in a terminal. Resolution is live: editing `~/.ssh/config` is reflected on the next connection without restarting the app, with mtime-based caching so repeated connections do not re-parse the file. Resolution applies to both the primary SSH host and to jump hosts (#977)
2627

2728
### Removed
2829

30+
- SSH config: the `useSSHConfig` per-connection toggle is gone. `~/.ssh/config` is always consulted at connection time; explicit form values still take precedence over ssh config defaults. Existing connections decode without the field; the CloudKit record column is left dormant for compatibility with older app versions on the same iCloud account.
2931
- Keychain: the legacy-keychain migration (`migrateFromLegacyKeychainIfNeeded`) and the password-sync-state migration (`migratePasswordSyncState`). The first violated Apple's Data Protection keychain contract on sandboxed macOS apps and corrupted user credentials; the second toggled `kSecAttrSynchronizable` at runtime, which Apple does not document as safe. The Sync Passwords settings toggle now applies to new saves only, existing keychain items keep their original sync state, matching Apple's documented behavior. Users with stale items in the legacy keychain can clean them via Keychain Access; the running app no longer touches them.
3032

3133
### Changed

TablePro/Core/SSH/LibSSH2TunnelFactory.swift

Lines changed: 96 additions & 114 deletions
Large diffs are not rendered by default.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
//
2+
// ResolvedSSHTarget.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
8+
struct ResolvedSSHTarget: Sendable, Hashable {
9+
let originalHost: String
10+
let host: String
11+
let port: Int
12+
let username: String
13+
let identityFiles: [String]
14+
let agentSocketPath: String
15+
let identitiesOnly: Bool
16+
let useKeychain: Bool
17+
let addKeysToAgent: Bool
18+
let proxyJump: [SSHJumpHost]
19+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
//
2+
// SSHConfigCache.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
import os
8+
9+
actor SSHConfigCache {
10+
static let shared = SSHConfigCache()
11+
12+
private static let logger = Logger(subsystem: "com.TablePro", category: "SSHConfigCache")
13+
14+
private var cachedDocument: SSHConfigDocument?
15+
private var cachedMtimes: [String: Date] = [:]
16+
private let configPath: String
17+
18+
init(configPath: String = SSHConfigParser.defaultConfigPath) {
19+
self.configPath = configPath
20+
}
21+
22+
/// The main config file's mtime is always part of the cache key, even when
23+
/// it isn't readable (treated as `.distantPast` so a freshly created file
24+
/// busts the cache). Tracked Include files are checked too. A pre-existing
25+
/// Include glob that newly matches a file without any main-file edit is
26+
/// the one residual gap; touching the main file forces a re-parse.
27+
func current() -> SSHConfigDocument {
28+
if let cached = cachedDocument, mtimesUnchanged() {
29+
return cached
30+
}
31+
return reload()
32+
}
33+
34+
func invalidate() {
35+
cachedDocument = nil
36+
cachedMtimes = [:]
37+
}
38+
39+
// MARK: - Private
40+
41+
private func reload() -> SSHConfigDocument {
42+
let document = SSHConfigParser.parseDocument(path: configPath)
43+
cachedDocument = document
44+
var mtimes = Self.collectMtimes(for: document.sourcePaths)
45+
mtimes[configPath] = Self.mtime(at: configPath) ?? .distantPast
46+
cachedMtimes = mtimes
47+
return document
48+
}
49+
50+
private func mtimesUnchanged() -> Bool {
51+
let trackedPaths = Set(cachedMtimes.keys).union([configPath])
52+
let current = Self.collectMtimes(for: Array(trackedPaths))
53+
54+
if current.count != cachedMtimes.count { return false }
55+
for (path, cachedDate) in cachedMtimes where current[path] != cachedDate {
56+
return false
57+
}
58+
return true
59+
}
60+
61+
private static func collectMtimes(for paths: [String]) -> [String: Date] {
62+
var result: [String: Date] = [:]
63+
for path in paths {
64+
result[path] = mtime(at: path) ?? .distantPast
65+
}
66+
return result
67+
}
68+
69+
private static func mtime(at path: String) -> Date? {
70+
guard let attrs = try? FileManager.default.attributesOfItem(atPath: path) else { return nil }
71+
return attrs[.modificationDate] as? Date
72+
}
73+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
//
2+
// SSHConfigDocument.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
8+
struct SSHConfigDocument: Sendable, Hashable {
9+
let blocks: [SSHConfigBlock]
10+
let sourcePaths: [String]
11+
12+
static let empty = SSHConfigDocument(blocks: [], sourcePaths: [])
13+
}
14+
15+
struct SSHConfigBlock: Sendable, Hashable {
16+
let criteria: SSHConfigCriteria
17+
let directives: [SSHDirective]
18+
}
19+
20+
enum SSHConfigCriteria: Sendable, Hashable {
21+
case global
22+
case host(patterns: [HostPattern])
23+
case match(conditions: [MatchCondition])
24+
}
25+
26+
struct HostPattern: Sendable, Hashable {
27+
let glob: String
28+
let negated: Bool
29+
}
30+
31+
enum MatchCondition: Sendable, Hashable {
32+
case all
33+
case canonical
34+
case final
35+
case host(patterns: [HostPattern])
36+
case originalHost(patterns: [HostPattern])
37+
case user(patterns: [HostPattern])
38+
case localUser(patterns: [HostPattern])
39+
case exec(command: String)
40+
}
41+
42+
enum CanonicalizeMode: String, Sendable, Hashable {
43+
case no
44+
case yes
45+
case always
46+
}
47+
48+
enum SSHDirective: Sendable, Hashable {
49+
case hostName(String)
50+
case port(Int)
51+
case user(String)
52+
case identityFile(String)
53+
case identityAgent(String)
54+
case identitiesOnly(Bool)
55+
case addKeysToAgent(Bool)
56+
case useKeychain(Bool)
57+
case proxyJump(String)
58+
case canonicalizeHostname(CanonicalizeMode)
59+
case canonicalDomains([String])
60+
case canonicalizePermittedCNAMEs(String)
61+
case canonicalizeFallbackLocal(Bool)
62+
case canonicalizeMaxDots(Int)
63+
case unrecognized(key: String, value: String)
64+
}

0 commit comments

Comments
 (0)