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

### Removed

- 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.
- 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.

### Changed
Expand Down
210 changes: 96 additions & 114 deletions TablePro/Core/SSH/LibSSH2TunnelFactory.swift

Large diffs are not rendered by default.

19 changes: 19 additions & 0 deletions TablePro/Core/SSH/ResolvedSSHTarget.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//
// ResolvedSSHTarget.swift
// TablePro
//

import Foundation

struct ResolvedSSHTarget: Sendable, Hashable {
let originalHost: String
let host: String
let port: Int
let username: String
let identityFiles: [String]
let agentSocketPath: String
let identitiesOnly: Bool
let useKeychain: Bool
let addKeysToAgent: Bool
let proxyJump: [SSHJumpHost]
}
73 changes: 73 additions & 0 deletions TablePro/Core/SSH/SSHConfigCache.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
//
// SSHConfigCache.swift
// TablePro
//

import Foundation
import os

actor SSHConfigCache {
static let shared = SSHConfigCache()

private static let logger = Logger(subsystem: "com.TablePro", category: "SSHConfigCache")

private var cachedDocument: SSHConfigDocument?
private var cachedMtimes: [String: Date] = [:]
private let configPath: String

init(configPath: String = SSHConfigParser.defaultConfigPath) {
self.configPath = configPath
}

/// The main config file's mtime is always part of the cache key, even when
/// it isn't readable (treated as `.distantPast` so a freshly created file
/// busts the cache). Tracked Include files are checked too. A pre-existing
/// Include glob that newly matches a file without any main-file edit is
/// the one residual gap; touching the main file forces a re-parse.
func current() -> SSHConfigDocument {
if let cached = cachedDocument, mtimesUnchanged() {
return cached
}
return reload()
}

func invalidate() {
cachedDocument = nil
cachedMtimes = [:]
}

// MARK: - Private

private func reload() -> SSHConfigDocument {
let document = SSHConfigParser.parseDocument(path: configPath)
cachedDocument = document
var mtimes = Self.collectMtimes(for: document.sourcePaths)
mtimes[configPath] = Self.mtime(at: configPath) ?? .distantPast
cachedMtimes = mtimes
return document
}

private func mtimesUnchanged() -> Bool {
let trackedPaths = Set(cachedMtimes.keys).union([configPath])
let current = Self.collectMtimes(for: Array(trackedPaths))

if current.count != cachedMtimes.count { return false }
for (path, cachedDate) in cachedMtimes where current[path] != cachedDate {
return false
}
return true
}

private static func collectMtimes(for paths: [String]) -> [String: Date] {
var result: [String: Date] = [:]
for path in paths {
result[path] = mtime(at: path) ?? .distantPast
}
return result
}

private static func mtime(at path: String) -> Date? {
guard let attrs = try? FileManager.default.attributesOfItem(atPath: path) else { return nil }
return attrs[.modificationDate] as? Date
}
}
64 changes: 64 additions & 0 deletions TablePro/Core/SSH/SSHConfigDocument.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
//
// SSHConfigDocument.swift
// TablePro
//

import Foundation

struct SSHConfigDocument: Sendable, Hashable {
let blocks: [SSHConfigBlock]
let sourcePaths: [String]

static let empty = SSHConfigDocument(blocks: [], sourcePaths: [])
}

struct SSHConfigBlock: Sendable, Hashable {
let criteria: SSHConfigCriteria
let directives: [SSHDirective]
}

enum SSHConfigCriteria: Sendable, Hashable {
case global
case host(patterns: [HostPattern])
case match(conditions: [MatchCondition])
}

struct HostPattern: Sendable, Hashable {
let glob: String
let negated: Bool
}

enum MatchCondition: Sendable, Hashable {
case all
case canonical
case final
case host(patterns: [HostPattern])
case originalHost(patterns: [HostPattern])
case user(patterns: [HostPattern])
case localUser(patterns: [HostPattern])
case exec(command: String)
}

enum CanonicalizeMode: String, Sendable, Hashable {
case no
case yes
case always
}

enum SSHDirective: Sendable, Hashable {
case hostName(String)
case port(Int)
case user(String)
case identityFile(String)
case identityAgent(String)
case identitiesOnly(Bool)
case addKeysToAgent(Bool)
case useKeychain(Bool)
case proxyJump(String)
case canonicalizeHostname(CanonicalizeMode)
case canonicalDomains([String])
case canonicalizePermittedCNAMEs(String)
case canonicalizeFallbackLocal(Bool)
case canonicalizeMaxDots(Int)
case unrecognized(key: String, value: String)
}
Loading
Loading