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
13 changes: 12 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Drivers populate allowed enum values directly in column metadata instead of parsing them downstream
- PluginKit ABI bumped to version 13; all registry plugins need to be re-tagged
- New PostgreSQL, SQL Server, Redshift, and CockroachDB connections default SSL mode to Preferred, matching libpq and FreeTDS native behavior
- New PostgreSQL, MySQL, MariaDB, SQL Server, Redshift, and CockroachDB connections default SSL mode to Preferred, matching libpq, libmariadb 2-pass, and FreeTDS native behavior
- Oracle SSL mode is now honored: Required, Verify CA, and Verify Identity wire through to OracleNIO TCPS (was silently ignored)
- Cassandra SSL mode is now honored via the standard SSL pane (was silently ignored because the plugin read from a hidden field that was never set)
- MySQL and MariaDB Preferred SSL mode now performs a real 2-pass connect: tries TLS first, falls back to plaintext only on SSL handshake errors (CR_SSL_CONNECTION_ERROR, CR_SERVER_HANDSHAKE_ERR, ER_HANDSHAKE_ERROR)
- SSL pane shows per-engine guidance explaining how that driver handles Preferred, when TLS is required by hosted providers, and any driver-specific quirks
- Failed connections caused by SSL/TLS handshake errors now show a structured message that names the cause (server requires encryption, server rejects encryption, untrusted certificate, hostname mismatch, client cert required, cipher mismatch) and recommends a specific SSL Mode to switch to. Covers PostgreSQL, MySQL/MariaDB, SQL Server, Oracle, MongoDB, Redis, Cassandra, and ClickHouse.
- SSL pane warns inline when a driver does not support TLS fallback for Preferred mode (MongoDB, Redis, Cassandra, ScyllaDB, ClickHouse, Oracle, etcd), so the user knows Preferred behaves the same as Required for that engine.
- Welcome screen connection errors (single-click connect, sample database launch) also surface the structured SSL handshake message when applicable
- All driver SSL mapping logic now lives in dedicated `XxxSSLMapping` files (PostgreSQL, MSSQL, Cassandra, MongoDB, Oracle); ClickHouse and Redis keep their existing encapsulated helpers

### Fixed

- PostgreSQL connections to AWS RDS, Cloud SQL, Azure, and other hosted Postgres now succeed out of the box instead of failing with "no pg_hba.conf entry for host" (#1298)
- Oracle: SSL/TCPS settings from the SSL pane are now respected; previously every Oracle connection was plain TCP regardless of SSL mode
- Cassandra: SSL settings from the SSL pane are now respected; previously every Cassandra connection was plain TCP because the plugin read from a non-existent "sslMode" field
- MySQL/MariaDB Cloud SQL, Azure Database, and other hosted MySQL servers that require TLS no longer fail with "Connections using insecure transport are prohibited" when SSL mode is Preferred

## [0.42.0] - 2026-05-16

Expand Down
87 changes: 52 additions & 35 deletions Plugins/CassandraDriverPlugin/CassandraPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,7 @@ internal final class CassandraPlugin: NSObject, TableProPlugin, DriverPlugin {
static let databaseDisplayName = "Cassandra / ScyllaDB"
static let iconName = "cassandra-icon"
static let defaultPort = 9042
static let additionalConnectionFields: [ConnectionField] = [
ConnectionField(
id: "sslCaCertPath",
label: "CA Certificate",
placeholder: "/path/to/ca-cert.pem",
section: .advanced
),
]
static let additionalConnectionFields: [ConnectionField] = []
static let additionalDatabaseTypeIds: [String] = ["ScyllaDB"]

// MARK: - UI/Capability Metadata
Expand Down Expand Up @@ -143,7 +136,7 @@ private actor CassandraConnectionActor {
username: String?,
password: String?,
keyspace: String?,
sslMode: String,
sslMode: SSLMode,
sslCaCertPath: String?
) throws {
cluster = cass_cluster_new()
Expand All @@ -158,34 +151,36 @@ private actor CassandraConnectionActor {
cass_cluster_set_credentials(cluster, username, password)
}

// SSL/TLS
if sslMode != "Disabled" {
if sslMode != .disabled {
guard let ssl = cass_ssl_new() else {
cass_cluster_free(cluster)
self.cluster = nil
throw CassandraPluginError.connectionFailed("Failed to create SSL context")
}

if sslMode == "Verify CA" || sslMode == "Verify Identity" {
if sslMode == "Verify Identity" {
let flags = Int32(CASS_SSL_VERIFY_PEER_CERT.rawValue | CASS_SSL_VERIFY_PEER_IDENTITY.rawValue)
cass_ssl_set_verify_flags(ssl, flags)
} else {
cass_ssl_set_verify_flags(ssl, Int32(CASS_SSL_VERIFY_PEER_CERT.rawValue))
}
cass_ssl_set_verify_flags(ssl, CassandraSSLMapping.verifyFlags(for: sslMode))

if let caCertPath = sslCaCertPath, !caCertPath.isEmpty,
let certData = FileManager.default.contents(atPath: caCertPath),
let certString = String(data: certData, encoding: .utf8) {
let rc = cass_ssl_add_trusted_cert(ssl, certString)
if rc != CASS_OK {
Self.logger.warning("Failed to add CA certificate, proceeding without verification")
cass_ssl_set_verify_flags(ssl, Int32(CASS_SSL_VERIFY_NONE.rawValue))
}
if sslMode == .verifyCa || sslMode == .verifyIdentity {
guard let caCertPath = sslCaCertPath, !caCertPath.isEmpty else {
cass_ssl_free(ssl)
cass_cluster_free(cluster)
self.cluster = nil
throw SSLHandshakeError.untrustedCertificate(serverMessage: "Verify CA or Verify Identity requires a CA certificate path")
}
guard let certData = FileManager.default.contents(atPath: caCertPath),
let certString = String(data: certData, encoding: .utf8) else {
cass_ssl_free(ssl)
cass_cluster_free(cluster)
self.cluster = nil
throw SSLHandshakeError.untrustedCertificate(serverMessage: "Could not read CA certificate at \(caCertPath)")
}
let rc = cass_ssl_add_trusted_cert(ssl, certString)
if rc != CASS_OK {
cass_ssl_free(ssl)
cass_cluster_free(cluster)
self.cluster = nil
throw SSLHandshakeError.untrustedCertificate(serverMessage: "CA certificate at \(caCertPath) is not a valid PEM")
}
} else {
// "Preferred" / "Required" — encrypt but skip cert verification
cass_ssl_set_verify_flags(ssl, Int32(CASS_SSL_VERIFY_NONE.rawValue))
}

cass_cluster_set_ssl(cluster, ssl)
Expand Down Expand Up @@ -228,6 +223,9 @@ private actor CassandraConnectionActor {
cass_session_free(newSession)
cass_cluster_free(cluster)
self.cluster = nil
if let sslError = Self.classifySSLError(rc: rc, message: errorMessage) {
throw sslError
}
throw CassandraPluginError.connectionFailed(errorMessage)
}

Expand Down Expand Up @@ -844,6 +842,26 @@ private actor CassandraConnectionActor {
private func escapeIdentifier(_ value: String) -> String {
value.replacingOccurrences(of: "\"", with: "\"\"")
}

static func classifySSLError(rc: CassError, message: String) -> SSLHandshakeError? {
switch rc {
case CASS_ERROR_SSL_NO_PEER_CERT, CASS_ERROR_SSL_INVALID_PEER_CERT:
return .untrustedCertificate(serverMessage: message)
case CASS_ERROR_SSL_IDENTITY_MISMATCH:
return .hostnameMismatch(serverMessage: message)
case CASS_ERROR_SSL_INVALID_PRIVATE_KEY, CASS_ERROR_SSL_INVALID_CERT:
return .clientCertRequired(serverMessage: message)
case CASS_ERROR_SSL_PROTOCOL_ERROR:
return .cipherMismatch(serverMessage: message)
default:
break
}
let lower = message.lowercased()
if lower.contains("ssl handshake") || lower.contains("tls handshake") || lower.contains("ssl_connect") {
return .cipherMismatch(serverMessage: message)
}
return nil
}
}

// MARK: - Raw Result
Expand Down Expand Up @@ -900,19 +918,18 @@ internal final class CassandraPluginDriver: PluginDatabaseDriver, @unchecked Sen
// MARK: - Connection

func connect() async throws {
let sslMode = config.additionalFields["sslMode"] ?? "Disabled"
let sslCaCertPath = config.additionalFields["sslCaCertPath"]

let keyspace = config.database.isEmpty ? nil : config.database
let legacyCaPath = config.additionalFields["sslCaCertPath"]
let resolvedCaPath = config.ssl.caCertificatePath.isEmpty ? legacyCaPath : config.ssl.caCertificatePath

try await connectionActor.connect(
host: config.host,
port: Int(config.port) ?? 9042,
port: Int(config.port) ?? 9_042,
username: config.username.isEmpty ? nil : config.username,
password: config.password.isEmpty ? nil : config.password,
keyspace: keyspace,
sslMode: sslMode,
sslCaCertPath: sslCaCertPath
sslMode: config.ssl.mode,
sslCaCertPath: resolvedCaPath
)

if let keyspace {
Expand Down
18 changes: 18 additions & 0 deletions Plugins/CassandraDriverPlugin/CassandraSSLMapping.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import CCassandra
import Foundation
import TableProPluginKit

enum CassandraSSLMapping {
static func verifyFlags(for mode: SSLMode) -> Int32 {
switch mode {
case .disabled:
return Int32(CASS_SSL_VERIFY_NONE.rawValue)
case .preferred, .required:
return Int32(CASS_SSL_VERIFY_NONE.rawValue)
case .verifyCa:
return Int32(CASS_SSL_VERIFY_PEER_CERT.rawValue)
case .verifyIdentity:
return Int32(CASS_SSL_VERIFY_PEER_CERT.rawValue | CASS_SSL_VERIFY_PEER_IDENTITY.rawValue)
}
}
}
26 changes: 26 additions & 0 deletions Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,9 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable {
session = nil
lock.unlock()
Self.logger.error("Connection test failed: \(error.localizedDescription)")
if let sslError = Self.classifySSLError(error) {
throw sslError
}
throw ClickHouseError.connectionFailed
}

Expand Down Expand Up @@ -1334,6 +1337,29 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable {
"ALTER TABLE \(quoteIdentifier(table)) DROP INDEX \(quoteIdentifier(indexName))"
}

static func classifySSLError(_ error: Error) -> SSLHandshakeError? {
let urlError = error as? URLError ?? (error as NSError).underlyingErrors.compactMap { $0 as? URLError }.first
if let urlError {
switch urlError.code {
case .serverCertificateUntrusted, .serverCertificateNotYetValid, .serverCertificateHasUnknownRoot, .serverCertificateHasBadDate:
return .untrustedCertificate(serverMessage: urlError.localizedDescription)
case .clientCertificateRequired, .clientCertificateRejected:
return .clientCertRequired(serverMessage: urlError.localizedDescription)
case .secureConnectionFailed:
return .cipherMismatch(serverMessage: urlError.localizedDescription)
default:
break
}
}
let message = error.localizedDescription.lowercased()
if message.contains("certificate") && (message.contains("untrusted") || message.contains("verify failed")) {
return .untrustedCertificate(serverMessage: error.localizedDescription)
}
if message.contains("hostname") {
return .hostnameMismatch(serverMessage: error.localizedDescription)
}
return nil
}
}

// MARK: - TLS Delegate
Expand Down
24 changes: 24 additions & 0 deletions Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import CFreeTDS
import Foundation
import os
import TableProMSSQLCore
import TableProPluginKit

private let freetdsLogger = Logger(subsystem: "com.TablePro", category: "FreeTDSConnection")

Expand Down Expand Up @@ -166,6 +167,9 @@ nonisolated final class FreeTDSConnection: @unchecked Sendable {
guard let proc = dbopen(login, serverName) else {
let detail = freetdsGetError(for: nil)
let msg = detail.isEmpty ? "Check host, port, credentials, and TLS settings" : detail
if let sslError = FreeTDSConnection.classifySSLError(detail) {
throw sslError
}
throw MSSQLCoreError.connectionFailed("Failed to connect to \(options.host):\(options.port): \(msg)")
}

Expand Down Expand Up @@ -527,4 +531,24 @@ nonisolated final class FreeTDSConnection: @unchecked Sendable {
}
return raw
}

static func classifySSLError(_ message: String) -> SSLHandshakeError? {
let lower = message.lowercased()
if lower.contains("encryption is required") || lower.contains("server requires encryption") {
return .serverRejectedPlaintext(serverMessage: message)
}
if lower.contains("encryption not supported") || lower.contains("server does not support encryption") {
return .serverRequiresPlaintext(serverMessage: message)
}
if lower.contains("certificate verify failed") || lower.contains("certificate is not trusted") {
return .untrustedCertificate(serverMessage: message)
}
if lower.contains("does not match host") {
return .hostnameMismatch(serverMessage: message)
}
if lower.contains("ssl handshake") || lower.contains("tls handshake") || lower.contains("openssl error") {
return .cipherMismatch(serverMessage: message)
}
return nil
}
}
47 changes: 24 additions & 23 deletions Plugins/MongoDBDriverPlugin/MongoDBConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -222,29 +222,7 @@ final class MongoDBConnection: @unchecked Sendable {
"authSource=\(encodedAuthSource)"
]

if ssl.isEnabled {
params.append("tls=true")
switch ssl.mode {
case .preferred, .required:
params.append("tlsAllowInvalidCertificates=true")
case .verifyCa:
params.append("tlsAllowInvalidHostnames=true")
case .disabled, .verifyIdentity:
break
}
if ssl.verifiesCertificate, !ssl.caCertificatePath.isEmpty {
let encodedCaPath = ssl.caCertificatePath
.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)
?? ssl.caCertificatePath
params.append("tlsCAFile=\(encodedCaPath)")
}
if !ssl.clientCertificatePath.isEmpty {
let encodedCertPath = ssl.clientCertificatePath
.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)
?? ssl.clientCertificatePath
params.append("tlsCertificateKeyFile=\(encodedCertPath)")
}
}
params.append(contentsOf: MongoDBSSLMapping.uriParameters(for: ssl))

if let rp = readPreference, !rp.isEmpty {
params.append("readPreference=\(rp)")
Expand Down Expand Up @@ -322,6 +300,9 @@ final class MongoDBConnection: @unchecked Sendable {
let errorMsg = bsonErrorMessage(&error)
mongoc_client_destroy(newClient)
logger.error("MongoDB ping failed: \(errorMsg)")
if let sslError = Self.classifySSLError(errorMsg) {
throw sslError
}
throw MongoDBError(code: error.code, message: errorMsg)
}

Expand Down Expand Up @@ -1272,6 +1253,26 @@ private extension MongoDBConnection {
return nil
#endif
}

static func classifySSLError(_ message: String) -> SSLHandshakeError? {
let lower = message.lowercased()
if lower.contains("ssl handshake failed") || lower.contains("tls handshake failed") {
return .cipherMismatch(serverMessage: message)
}
if lower.contains("certificate verify failed") || lower.contains("ssl certificate") {
return .untrustedCertificate(serverMessage: message)
}
if lower.contains("hostname") && lower.contains("verification") {
return .hostnameMismatch(serverMessage: message)
}
if lower.contains("tls required") || lower.contains("ssl required") {
return .serverRejectedPlaintext(serverMessage: message)
}
if lower.contains("client certificate required") || lower.contains("peer did not return a certificate") {
return .clientCertRequired(serverMessage: message)
}
return nil
}
}

// bsonToDict and bsonToJson take bson_t parameters (a CLibMongoc type),
Expand Down
30 changes: 30 additions & 0 deletions Plugins/MongoDBDriverPlugin/MongoDBSSLMapping.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import Foundation
import TableProPluginKit

enum MongoDBSSLMapping {
static func uriParameters(for ssl: SSLConfiguration) -> [String] {
guard ssl.isEnabled else { return [] }
var params: [String] = ["tls=true"]
switch ssl.mode {
case .preferred, .required:
params.append("tlsAllowInvalidCertificates=true")
case .verifyCa:
params.append("tlsAllowInvalidHostnames=true")
case .disabled, .verifyIdentity:
break
}
if ssl.verifiesCertificate, !ssl.caCertificatePath.isEmpty {
let encoded = ssl.caCertificatePath
.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)
?? ssl.caCertificatePath
params.append("tlsCAFile=\(encoded)")
}
if !ssl.clientCertificatePath.isEmpty {
let encoded = ssl.clientCertificatePath
.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)
?? ssl.clientCertificatePath
params.append("tlsCertificateKeyFile=\(encoded)")
}
return params
}
}
Loading
Loading