From f35b82b7c496db824b83bcd0d3dbd7e4947afd5b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 18 May 2026 00:59:39 +0700 Subject: [PATCH 01/13] fix(plugins): honor SSL config in Oracle and Cassandra, real 2-pass prefer for MySQL/MariaDB --- CHANGELOG.md | 8 +- .../CassandraPlugin.swift | 18 +- .../MariaDBPluginConnection.swift | 192 +++++++++--------- .../OracleDriverPlugin/OracleConnection.swift | 52 ++++- Plugins/OracleDriverPlugin/OraclePlugin.swift | 3 +- .../Core/Plugins/PluginMetadataRegistry.swift | 6 +- TablePro/Core/Storage/ConnectionStorage.swift | 8 +- TableProTests/Models/DatabaseTypeTests.swift | 10 +- 8 files changed, 178 insertions(+), 119 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0475b72e..c9eec787d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,11 +21,17 @@ 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) ### 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 diff --git a/Plugins/CassandraDriverPlugin/CassandraPlugin.swift b/Plugins/CassandraDriverPlugin/CassandraPlugin.swift index ff7e2f22f..adf530fdc 100644 --- a/Plugins/CassandraDriverPlugin/CassandraPlugin.swift +++ b/Plugins/CassandraDriverPlugin/CassandraPlugin.swift @@ -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 @@ -900,10 +893,9 @@ 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, @@ -911,8 +903,8 @@ internal final class CassandraPluginDriver: PluginDatabaseDriver, @unchecked Sen username: config.username.isEmpty ? nil : config.username, password: config.password.isEmpty ? nil : config.password, keyspace: keyspace, - sslMode: sslMode, - sslCaCertPath: sslCaCertPath + sslMode: config.ssl.mode.rawValue, + sslCaCertPath: resolvedCaPath ) if let keyspace { diff --git a/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift b/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift index 04f36644e..6bbb1c6c5 100644 --- a/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift +++ b/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift @@ -199,133 +199,131 @@ final class MariaDBPluginConnection: @unchecked Sendable { // MARK: - Connection Management + private static let sslOnlyErrorCodes: Set = [ + 2_026, + 2_012, + 1_043 + ] + func connect() async throws { try await pluginDispatchAsync(on: queue) { [self] in - guard let mysql = mysql_init(nil) else { - throw MariaDBPluginError.initFailed + let mode = self.sslConfig.mode + let handle: UnsafeMutablePointer + do { + handle = try self.attemptConnect(enforceSSL: mode != .disabled) + } catch let error as MariaDBPluginError where mode == .preferred && Self.sslOnlyErrorCodes.contains(error.code) { + logger.notice("MySQL SSL handshake failed (code \(error.code)); falling back to plaintext for .preferred mode") + handle = try self.attemptConnect(enforceSSL: false) } - self.mysql = mysql + if let versionPtr = mysql_get_server_info(handle) { + self._cachedServerVersion = String(cString: versionPtr) + } - var reconnect: my_bool = 0 - mysql_options(mysql, MYSQL_OPT_RECONNECT, &reconnect) + self.stateLock.lock() + self.mysql = handle + self._isConnected = true + self.stateLock.unlock() + } + } - var timeout: UInt32 = 10 - mysql_options(mysql, MYSQL_OPT_CONNECT_TIMEOUT, &timeout) + private func attemptConnect(enforceSSL: Bool) throws -> UnsafeMutablePointer { + guard let mysql = mysql_init(nil) else { + throw MariaDBPluginError.initFailed + } - var readTimeout: UInt32 = 30 - mysql_options(mysql, MYSQL_OPT_READ_TIMEOUT, &readTimeout) + var reconnect: my_bool = 0 + mysql_options(mysql, MYSQL_OPT_RECONNECT, &reconnect) - var writeTimeout: UInt32 = 30 - mysql_options(mysql, MYSQL_OPT_WRITE_TIMEOUT, &writeTimeout) + var timeout: UInt32 = 10 + mysql_options(mysql, MYSQL_OPT_CONNECT_TIMEOUT, &timeout) - var protocol_tcp = UInt32(MYSQL_PROTOCOL_TCP.rawValue) - mysql_options(mysql, MYSQL_OPT_PROTOCOL, &protocol_tcp) + var readTimeout: UInt32 = 30 + mysql_options(mysql, MYSQL_OPT_READ_TIMEOUT, &readTimeout) - switch self.sslConfig.mode { - case .disabled, .preferred: - var sslEnforce: my_bool = 0 - mysql_options(mysql, MYSQL_OPT_SSL_ENFORCE, &sslEnforce) - var sslVerify: my_bool = 0 - mysql_options(mysql, MYSQL_OPT_SSL_VERIFY_SERVER_CERT, &sslVerify) + var writeTimeout: UInt32 = 30 + mysql_options(mysql, MYSQL_OPT_WRITE_TIMEOUT, &writeTimeout) - case .required: - var sslEnforce: my_bool = 1 - mysql_options(mysql, MYSQL_OPT_SSL_ENFORCE, &sslEnforce) - var sslVerify: my_bool = 0 - mysql_options(mysql, MYSQL_OPT_SSL_VERIFY_SERVER_CERT, &sslVerify) + var protocol_tcp = UInt32(MYSQL_PROTOCOL_TCP.rawValue) + mysql_options(mysql, MYSQL_OPT_PROTOCOL, &protocol_tcp) - case .verifyCa, .verifyIdentity: - var sslEnforce: my_bool = 1 - mysql_options(mysql, MYSQL_OPT_SSL_ENFORCE, &sslEnforce) - var sslVerify: my_bool = 1 - mysql_options(mysql, MYSQL_OPT_SSL_VERIFY_SERVER_CERT, &sslVerify) - } + var sslEnforce: my_bool = enforceSSL ? 1 : 0 + mysql_options(mysql, MYSQL_OPT_SSL_ENFORCE, &sslEnforce) - if self.sslConfig.verifiesCertificate, !self.sslConfig.caCertificatePath.isEmpty { - _ = self.sslConfig.caCertificatePath.withCString { path in - mysql_options(mysql, MYSQL_OPT_SSL_CA, path) - } - } - if !self.sslConfig.clientCertificatePath.isEmpty { - _ = self.sslConfig.clientCertificatePath.withCString { path in - mysql_options(mysql, MYSQL_OPT_SSL_CERT, path) - } - } - if !self.sslConfig.clientKeyPath.isEmpty { - _ = self.sslConfig.clientKeyPath.withCString { path in - mysql_options(mysql, MYSQL_OPT_SSL_KEY, path) - } - } + var sslVerify: my_bool = sslConfig.verifiesCertificate ? 1 : 0 + mysql_options(mysql, MYSQL_OPT_SSL_VERIFY_SERVER_CERT, &sslVerify) - mysql_options(mysql, MYSQL_SET_CHARSET_NAME, "utf8mb4") + if sslConfig.verifiesCertificate, !sslConfig.caCertificatePath.isEmpty { + _ = sslConfig.caCertificatePath.withCString { mysql_options(mysql, MYSQL_OPT_SSL_CA, $0) } + } + if !sslConfig.clientCertificatePath.isEmpty { + _ = sslConfig.clientCertificatePath.withCString { mysql_options(mysql, MYSQL_OPT_SSL_CERT, $0) } + } + if !sslConfig.clientKeyPath.isEmpty { + _ = sslConfig.clientKeyPath.withCString { mysql_options(mysql, MYSQL_OPT_SSL_KEY, $0) } + } - let dbToUse = self.database.isEmpty ? nil : self.database - let passToUse = self.password + mysql_options(mysql, MYSQL_SET_CHARSET_NAME, "utf8mb4") - let result: UnsafeMutablePointer? + let dbToUse = database.isEmpty ? nil : database + let passToUse = password - if let db = dbToUse, let pass = passToUse { - result = self.host.withCString { hostPtr in - self.user.withCString { userPtr in - pass.withCString { passPtr in - db.withCString { dbPtr in - mysql_real_connect( - mysql, hostPtr, userPtr, passPtr, dbPtr, - self.port, nil, 0 - ) - } - } - } - } - } else if let db = dbToUse { - result = self.host.withCString { hostPtr in - self.user.withCString { userPtr in + let result: UnsafeMutablePointer? + if let db = dbToUse, let pass = passToUse { + result = host.withCString { hostPtr in + user.withCString { userPtr in + pass.withCString { passPtr in db.withCString { dbPtr in - mysql_real_connect( - mysql, hostPtr, userPtr, nil, dbPtr, - self.port, nil, 0 - ) + mysql_real_connect(mysql, hostPtr, userPtr, passPtr, dbPtr, port, nil, 0) } } } - } else if let pass = passToUse { - result = self.host.withCString { hostPtr in - self.user.withCString { userPtr in - pass.withCString { passPtr in - mysql_real_connect( - mysql, hostPtr, userPtr, passPtr, nil, - self.port, nil, 0 - ) - } + } + } else if let db = dbToUse { + result = host.withCString { hostPtr in + user.withCString { userPtr in + db.withCString { dbPtr in + mysql_real_connect(mysql, hostPtr, userPtr, nil, dbPtr, port, nil, 0) } } - } else { - result = self.host.withCString { hostPtr in - self.user.withCString { userPtr in - mysql_real_connect( - mysql, hostPtr, userPtr, nil, nil, - self.port, nil, 0 - ) + } + } else if let pass = passToUse { + result = host.withCString { hostPtr in + user.withCString { userPtr in + pass.withCString { passPtr in + mysql_real_connect(mysql, hostPtr, userPtr, passPtr, nil, port, nil, 0) } } } - - if result == nil { - let error = self.getError() - mysql_close(mysql) - self.mysql = nil - throw error + } else { + result = host.withCString { hostPtr in + user.withCString { userPtr in + mysql_real_connect(mysql, hostPtr, userPtr, nil, nil, port, nil, 0) + } } + } - if let versionPtr = mysql_get_server_info(mysql) { - self._cachedServerVersion = String(cString: versionPtr) - } + if result == nil { + let error = readError(from: mysql) + mysql_close(mysql) + throw error + } + return mysql + } - self.stateLock.lock() - self._isConnected = true - self.stateLock.unlock() + private func readError(from mysql: UnsafeMutablePointer) -> MariaDBPluginError { + let code = mysql_errno(mysql) + let message: String + if let msgPtr = mysql_error(mysql) { + message = String(cString: msgPtr) + } else { + message = "Unknown error" + } + var sqlState: String? + if let statePtr = mysql_sqlstate(mysql), statePtr[0] != 0 { + sqlState = String(cString: statePtr) } + return MariaDBPluginError(code: code, message: message, sqlState: sqlState) } func disconnect() { diff --git a/Plugins/OracleDriverPlugin/OracleConnection.swift b/Plugins/OracleDriverPlugin/OracleConnection.swift index b60493809..a3cbf7948 100644 --- a/Plugins/OracleDriverPlugin/OracleConnection.swift +++ b/Plugins/OracleDriverPlugin/OracleConnection.swift @@ -9,6 +9,7 @@ import Foundation import Logging import NIOCore +import NIOSSL import OracleNIO import OSLog import TableProPluginKit @@ -116,6 +117,7 @@ final class OracleConnectionWrapper: @unchecked Sendable { private let password: String private let database: String private let serviceName: String + private let sslConfig: SSLConfiguration private struct LockedState: Sendable { var isConnected = false @@ -131,25 +133,36 @@ final class OracleConnectionWrapper: @unchecked Sendable { // MARK: - Initialization - init(host: String, port: Int, user: String, password: String, database: String, serviceName: String = "") { + init( + host: String, + port: Int, + user: String, + password: String, + database: String, + serviceName: String = "", + sslConfig: SSLConfiguration = SSLConfiguration() + ) { self.host = host self.port = port self.user = user self.password = password self.database = database self.serviceName = serviceName + self.sslConfig = sslConfig } // MARK: - Connection func connect() async throws { let service = serviceName.isEmpty ? database : serviceName + let tls = try buildTLS() let config = OracleNIO.OracleConnection.Configuration( host: host, port: port, service: .serviceName(service), username: user, - password: password + password: password, + tls: tls ) let connectionId = Self.connectionCounter.withLock { state -> Int in @@ -196,6 +209,41 @@ final class OracleConnectionWrapper: @unchecked Sendable { } } + private func buildTLS() throws -> OracleNIO.OracleConnection.Configuration.TLS { + switch sslConfig.mode { + case .disabled: + return .disable + case .preferred: + osLogger.warning("Oracle SSL mode 'Preferred' is not supported by OracleNIO; falling back to plain TCP. Use 'Required' to enforce TCPS.") + return .disable + case .required, .verifyCa, .verifyIdentity: + var tlsConfiguration = TLSConfiguration.makeClientConfiguration() + tlsConfiguration.certificateVerification = certificateVerification(for: sslConfig.mode) + if sslConfig.verifiesCertificate, !sslConfig.caCertificatePath.isEmpty { + let caCerts = try NIOSSLCertificate.fromPEMFile(sslConfig.caCertificatePath) + tlsConfiguration.trustRoots = .certificates(caCerts) + } + if !sslConfig.clientCertificatePath.isEmpty { + let clientCerts = try NIOSSLCertificate.fromPEMFile(sslConfig.clientCertificatePath) + tlsConfiguration.certificateChain = clientCerts.map { .certificate($0) } + } + if !sslConfig.clientKeyPath.isEmpty { + let key = try NIOSSLPrivateKey(file: sslConfig.clientKeyPath, format: .pem) + tlsConfiguration.privateKey = .privateKey(key) + } + let sslContext = try NIOSSLContext(configuration: tlsConfiguration) + return .require(sslContext) + } + } + + private func certificateVerification(for mode: SSLMode) -> CertificateVerification { + switch mode { + case .verifyIdentity: return .fullVerification + case .verifyCa: return .noHostnameVerification + case .required, .preferred, .disabled: return .none + } + } + func disconnect() { let connection = state.withLock { current -> OracleNIO.OracleConnection? in guard current.isConnected else { return nil } diff --git a/Plugins/OracleDriverPlugin/OraclePlugin.swift b/Plugins/OracleDriverPlugin/OraclePlugin.swift index f6329f079..c18cd2192 100644 --- a/Plugins/OracleDriverPlugin/OraclePlugin.swift +++ b/Plugins/OracleDriverPlugin/OraclePlugin.swift @@ -192,7 +192,8 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { user: config.username, password: config.password, database: config.database, - serviceName: serviceName + serviceName: serviceName, + sslConfig: config.ssl ) try await conn.connect() self.oracleConn = conn diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/TablePro/Core/Plugins/PluginMetadataRegistry.swift index 7e85c06a7..01e3eabed 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry.swift @@ -444,7 +444,8 @@ final class PluginMetadataRegistry: @unchecked Sendable { supportsQueryProgress: false, requiresReconnectForDatabaseSwitch: false, supportsDropDatabase: true, - supportsRenameColumn: true + supportsRenameColumn: true, + defaultSSLMode: .preferred ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", @@ -490,7 +491,8 @@ final class PluginMetadataRegistry: @unchecked Sendable { supportsQueryProgress: false, requiresReconnectForDatabaseSwitch: false, supportsDropDatabase: true, - supportsRenameColumn: true + supportsRenameColumn: true, + defaultSSLMode: .preferred ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index 0d802cea8..3c3c63a21 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -766,9 +766,15 @@ private struct StoredConnection: Codable { resolvedTunnelMode = .disabled } + var resolvedSSLCaPath = sslCaCertificatePath + if type == "Cassandra", resolvedSSLCaPath.isEmpty, + let legacy = additionalFields?["sslCaCertPath"], !legacy.isEmpty { + resolvedSSLCaPath = legacy + } + let sslConfig = SSLConfiguration( mode: SSLMode(rawValue: sslMode) ?? .disabled, - caCertificatePath: sslCaCertificatePath, + caCertificatePath: resolvedSSLCaPath, clientCertificatePath: sslClientCertificatePath, clientKeyPath: sslClientKeyPath ) diff --git a/TableProTests/Models/DatabaseTypeTests.swift b/TableProTests/Models/DatabaseTypeTests.swift index f7f644111..25c961e1e 100644 --- a/TableProTests/Models/DatabaseTypeTests.swift +++ b/TableProTests/Models/DatabaseTypeTests.swift @@ -138,9 +138,15 @@ struct DatabaseTypeTests { #expect(DatabaseType.mssql.defaultSSLMode == .preferred) } - @Test("Binary on/off engines default SSL mode to disabled", arguments: [ + @Test("libmariadb-family engines default SSL mode to preferred (2-pass connect)", arguments: [ DatabaseType.mysql, - DatabaseType.mariadb, + DatabaseType.mariadb + ]) + func testMariaDBClientEnginesDefaultSSLPreferred(type: DatabaseType) { + #expect(type.defaultSSLMode == .preferred) + } + + @Test("Binary on/off engines default SSL mode to disabled", arguments: [ DatabaseType.mongodb, DatabaseType.redis, DatabaseType.cassandra, From 9f8d89e922fad9e70365b7f2df2bf001832fa8ce Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 18 May 2026 01:04:20 +0700 Subject: [PATCH 02/13] feat(connection-form): per-engine SSL pane guidance explaining driver semantics --- CHANGELOG.md | 1 + .../Connection/DatabaseConnection.swift | 32 +++++++++++++++++++ .../Views/Connection/ConnectionSSLView.swift | 13 +++++--- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9eec787d..c24256e07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 ### Fixed diff --git a/TablePro/Models/Connection/DatabaseConnection.swift b/TablePro/Models/Connection/DatabaseConnection.swift index d6c5da54b..673f4bfde 100644 --- a/TablePro/Models/Connection/DatabaseConnection.swift +++ b/TablePro/Models/Connection/DatabaseConnection.swift @@ -109,6 +109,38 @@ extension DatabaseType { PluginMetadataRegistry.shared.snapshot(forTypeId: rawValue)?.capabilities.defaultSSLMode ?? .disabled } + var sslPaneTooltip: String { + switch rawValue { + case "PostgreSQL", "Redshift", "CockroachDB": + return String(localized: """ + Preferred tries TLS first, falls back to plain. Matches psql and DataGrip defaults. \ + Required by AWS RDS, Cloud SQL, Heroku, Supabase, Neon. + """) + case "MySQL", "MariaDB": + return String(localized: """ + Preferred performs a 2-pass connect: tries TLS first, falls back to plain only on \ + SSL handshake errors. Required by Cloud SQL and Azure MySQL. + """) + case "SQL Server": + return String(localized: "Preferred requests TLS; the server decides. Required by SQL Server 2022 and Azure SQL Database.") + case "MongoDB": + return String(localized: "MongoDB driver has no TLS fallback. Preferred and Required both force TLS. Use Required for MongoDB Atlas and other hosted instances.") + case "Redis": + return String(localized: """ + Redis driver has no TLS fallback. Preferred and Required both force TLS. \ + Use Required for Redis Cloud, Upstash, and AWS ElastiCache encrypted endpoints. + """) + case "Oracle": + return String(localized: "OracleNIO has no TLS fallback. Preferred connects in plain TCP. Use Required for TCPS to Oracle Autonomous Database.") + case "Cassandra", "ScyllaDB": + return String(localized: "Use Required for AstraDB, DataStax Astra, and other hosted Cassandra deployments.") + case "ClickHouse": + return String(localized: "Use Required for ClickHouse Cloud and other managed instances.") + default: + return "" + } + } + var explainVariants: [ExplainVariant] { PluginMetadataRegistry.shared.snapshot(forTypeId: rawValue)?.explainVariants ?? [] } diff --git a/TablePro/Views/Connection/ConnectionSSLView.swift b/TablePro/Views/Connection/ConnectionSSLView.swift index 682d63fce..e99ec9458 100644 --- a/TablePro/Views/Connection/ConnectionSSLView.swift +++ b/TablePro/Views/Connection/ConnectionSSLView.swift @@ -27,11 +27,16 @@ struct ConnectionSSLView: View { } } } footer: { - if sslMode != .disabled { - Text(sslMode.description) - .font(.caption) - .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 6) { + if !databaseType.sslPaneTooltip.isEmpty { + Text(databaseType.sslPaneTooltip) + } + if sslMode != .disabled { + Text(sslMode.description) + } } + .font(.caption) + .foregroundStyle(.secondary) } if sslMode != .disabled { From 910cbd4d15e05c0df4a8f0edb412f36788c0d275 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 18 May 2026 01:09:58 +0700 Subject: [PATCH 03/13] feat(plugins): translate SSL handshake errors into actionable user messages --- CHANGELOG.md | 1 + .../MariaDBPluginConnection.swift | 28 ++++++++- .../LibPQPluginConnection.swift | 29 +++++++++ .../TableProPluginKit/SSLHandshakeError.swift | 62 +++++++++++++++++++ TablePro/Resources/Localizable.xcstrings | 24 +++++++ .../ConnectionFormCoordinator.swift | 17 ++++- 6 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 Plugins/TableProPluginKit/SSLHandshakeError.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index c24256e07..312cd2907 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. PostgreSQL and MySQL/MariaDB are translated; other drivers fall back to the raw driver message. ### Fixed diff --git a/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift b/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift index 6bbb1c6c5..132c6b8d8 100644 --- a/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift +++ b/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift @@ -213,7 +213,19 @@ final class MariaDBPluginConnection: @unchecked Sendable { handle = try self.attemptConnect(enforceSSL: mode != .disabled) } catch let error as MariaDBPluginError where mode == .preferred && Self.sslOnlyErrorCodes.contains(error.code) { logger.notice("MySQL SSL handshake failed (code \(error.code)); falling back to plaintext for .preferred mode") - handle = try self.attemptConnect(enforceSSL: false) + do { + handle = try self.attemptConnect(enforceSSL: false) + } catch let fallbackError as MariaDBPluginError { + if let sslError = Self.classifySSLError(fallbackError) { + throw sslError + } + throw fallbackError + } + } catch let error as MariaDBPluginError { + if let sslError = Self.classifySSLError(error) { + throw sslError + } + throw error } if let versionPtr = mysql_get_server_info(handle) { @@ -227,6 +239,20 @@ final class MariaDBPluginConnection: @unchecked Sendable { } } + static func classifySSLError(_ error: MariaDBPluginError) -> SSLHandshakeError? { + let lower = error.message.lowercased() + if lower.contains("insecure transport") || lower.contains("require_secure_transport") { + return .serverRejectedPlaintext(serverMessage: error.message) + } + if Self.sslOnlyErrorCodes.contains(error.code) { + if lower.contains("certificate") { + return .untrustedCertificate(serverMessage: error.message) + } + return .cipherMismatch(serverMessage: error.message) + } + return nil + } + private func attemptConnect(enforceSSL: Bool) throws -> UnsafeMutablePointer { guard let mysql = mysql_init(nil) else { throw MariaDBPluginError.initFailed diff --git a/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift b/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift index ad5c69b32..8b102ef50 100644 --- a/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift +++ b/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift @@ -196,6 +196,9 @@ final class LibPQPluginConnection: @unchecked Sendable { if PQstatus(connection) != CONNECTION_OK { let error = self.getError(from: connection) PQfinish(connection) + if let sslError = Self.classifySSLError(error.message) { + throw sslError + } throw error } @@ -734,6 +737,32 @@ final class LibPQPluginConnection: @unchecked Sendable { // MARK: - Private Helpers + static func classifySSLError(_ message: String) -> SSLHandshakeError? { + let lower = message.lowercased() + if lower.contains("no pg_hba.conf entry") && lower.contains("no encryption") { + return .serverRejectedPlaintext(serverMessage: message) + } + if lower.contains("no pg_hba.conf entry") && lower.contains("ssl") { + return .serverRequiresPlaintext(serverMessage: message) + } + if lower.contains("server does not support ssl") || lower.contains("ssl is not enabled on the server") { + return .serverRequiresPlaintext(serverMessage: message) + } + if lower.contains("certificate verify failed") || lower.contains("self-signed certificate") || lower.contains("unable to get local issuer certificate") { + return .untrustedCertificate(serverMessage: message) + } + if lower.contains("server certificate") && lower.contains("does not match host name") { + return .hostnameMismatch(serverMessage: message) + } + if lower.contains("certificate required") || lower.contains("connection requires a valid client certificate") { + return .clientCertRequired(serverMessage: message) + } + if lower.contains("ssl error") || lower.contains("tls handshake") || lower.contains("ssl handshake") { + return .cipherMismatch(serverMessage: message) + } + return nil + } + private func getError(from conn: OpaquePointer) -> LibPQPluginError { var message = "Unknown error" if let msgPtr = PQerrorMessage(conn) { diff --git a/Plugins/TableProPluginKit/SSLHandshakeError.swift b/Plugins/TableProPluginKit/SSLHandshakeError.swift new file mode 100644 index 000000000..bda50448c --- /dev/null +++ b/Plugins/TableProPluginKit/SSLHandshakeError.swift @@ -0,0 +1,62 @@ +import Foundation + +public enum SSLHandshakeError: Error, LocalizedError, Sendable { + case serverRejectedPlaintext(serverMessage: String) + case serverRequiresPlaintext(serverMessage: String) + case untrustedCertificate(serverMessage: String) + case hostnameMismatch(serverMessage: String) + case clientCertRequired(serverMessage: String) + case cipherMismatch(serverMessage: String) + case unknown(serverMessage: String) + + public var serverMessage: String { + switch self { + case .serverRejectedPlaintext(let msg), + .serverRequiresPlaintext(let msg), + .untrustedCertificate(let msg), + .hostnameMismatch(let msg), + .clientCertRequired(let msg), + .cipherMismatch(let msg), + .unknown(let msg): + return msg + } + } + + public var errorDescription: String? { + switch self { + case .serverRejectedPlaintext: + return String(localized: "The server requires an encrypted connection but TablePro is configured to connect in plain text.") + case .serverRequiresPlaintext: + return String(localized: "The server does not accept encrypted connections but TablePro is configured to require TLS.") + case .untrustedCertificate: + return String(localized: "The server's TLS certificate could not be verified against any trusted root.") + case .hostnameMismatch: + return String(localized: "The server's TLS certificate does not match the hostname being connected to.") + case .clientCertRequired: + return String(localized: "The server requires a client certificate for TLS mutual authentication.") + case .cipherMismatch: + return String(localized: "The server and TablePro could not agree on a TLS cipher or protocol version.") + case .unknown: + return String(localized: "TLS handshake failed.") + } + } + + public var recoverySuggestion: String? { + switch self { + case .serverRejectedPlaintext: + return String(localized: "Open the connection editor, switch to the SSL tab, and set Mode to Required (or stricter).") + case .serverRequiresPlaintext: + return String(localized: "Open the connection editor, switch to the SSL tab, and set Mode to Disabled.") + case .untrustedCertificate: + return String(localized: "Switch SSL Mode to Verify CA and provide the server's CA certificate path, or use Required to skip verification.") + case .hostnameMismatch: + return String(localized: "Switch SSL Mode to Verify CA (validates the CA chain but skips hostname check), or update the host field to match the certificate.") + case .clientCertRequired: + return String(localized: "Provide the client certificate and key paths in the SSL tab.") + case .cipherMismatch: + return String(localized: "Update the server's TLS configuration or use a newer database server version that supports modern ciphers.") + case .unknown: + return nil + } + } +} diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 6a24b55d3..afebc68aa 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -28933,6 +28933,9 @@ } } } + }, + "MongoDB driver has no TLS fallback. Preferred and Required both force TLS. Use Required for MongoDB Atlas and other hosted instances." : { + }, "MongoDB query language. Use to import into MongoDB." : { "localizations" : { @@ -33343,6 +33346,9 @@ } } } + }, + "OracleNIO has no TLS fallback. Preferred connects in plain TCP. Use Required for TCPS to Oracle Autonomous Database." : { + }, "Orange" : { "localizations" : { @@ -35117,6 +35123,15 @@ } } } + }, + "Preferred performs a 2-pass connect: tries TLS first, falls back to plain only on SSL handshake errors. Required by Cloud SQL and Azure MySQL." : { + + }, + "Preferred requests TLS; the server decides. Required by SQL Server 2022 and Azure SQL Database." : { + + }, + "Preferred tries TLS first, falls back to plain. Matches psql and DataGrip defaults. Required by AWS RDS, Cloud SQL, Heroku, Supabase, Neon." : { + }, "Preserve all values as strings" : { "extractionState" : "stale", @@ -37485,6 +37500,9 @@ } } } + }, + "Redis driver has no TLS fallback. Preferred and Required both force TLS. Use Required for Redis Cloud, Upstash, and AWS ElastiCache encrypted endpoints." : { + }, "Redo" : { "localizations" : { @@ -50632,6 +50650,12 @@ }, "Use Password File" : { + }, + "Use Required for AstraDB, DataStax Astra, and other hosted Cassandra deployments." : { + + }, + "Use Required for ClickHouse Cloud and other managed instances." : { + }, "Use SSL if available" : { "localizations" : { diff --git a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift index 1b0c5cf67..9b5fb21f5 100644 --- a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift +++ b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift @@ -540,7 +540,7 @@ final class ConnectionFormCoordinator { } else { AlertHelper.showErrorSheet( title: String(localized: "Connection Test Failed"), - message: error.localizedDescription, + message: Self.formatErrorMessage(error), window: window ) } @@ -549,6 +549,21 @@ final class ConnectionFormCoordinator { } } + static func formatErrorMessage(_ error: Error) -> String { + if let sslError = error as? SSLHandshakeError { + var parts: [String] = [] + if let description = sslError.errorDescription { + parts.append(description) + } + if let suggestion = sslError.recoverySuggestion { + parts.append(suggestion) + } + parts.append(String(format: String(localized: "Server response: %@"), sslError.serverMessage)) + return parts.joined(separator: "\n\n") + } + return error.localizedDescription + } + func cleanupTestSecrets(for testId: UUID) { services.connectionStorage.deletePassword(for: testId) services.connectionStorage.deleteSSHPassword(for: testId) From a51286198a567424f9e0a5361bd55d219052d6f9 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 18 May 2026 01:16:46 +0700 Subject: [PATCH 04/13] feat(plugins): extend SSL handshake error translation to all 8 supported drivers --- CHANGELOG.md | 2 +- .../CassandraPlugin.swift | 23 ++++++++++++++ .../ClickHousePlugin.swift | 26 ++++++++++++++++ .../MSSQLDriverPlugin/FreeTDSConnection.swift | 23 ++++++++++++++ .../MongoDBConnection.swift | 23 ++++++++++++++ .../OracleDriverPlugin/OracleConnection.swift | 31 +++++++++++++++++++ .../RedisPluginConnection.swift | 23 ++++++++++++++ 7 files changed, 150 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 312cd2907..4cb982f4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. PostgreSQL and MySQL/MariaDB are translated; other drivers fall back to the raw driver message. +- 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. ### Fixed diff --git a/Plugins/CassandraDriverPlugin/CassandraPlugin.swift b/Plugins/CassandraDriverPlugin/CassandraPlugin.swift index adf530fdc..25cfb4a2a 100644 --- a/Plugins/CassandraDriverPlugin/CassandraPlugin.swift +++ b/Plugins/CassandraDriverPlugin/CassandraPlugin.swift @@ -221,6 +221,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) } @@ -837,6 +840,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") && (lower.contains("handshake") || lower.contains("verify")) { + return .cipherMismatch(serverMessage: message) + } + return nil + } } // MARK: - Raw Result diff --git a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift index 9445ceb92..16d96e512 100644 --- a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift +++ b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift @@ -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 } @@ -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 diff --git a/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift b/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift index 40b32b06b..d33091830 100644 --- a/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift +++ b/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift @@ -166,6 +166,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)") } @@ -527,4 +530,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") { + return .cipherMismatch(serverMessage: message) + } + return nil + } } diff --git a/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift b/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift index 198828edc..a4e995d8a 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift @@ -322,6 +322,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) } @@ -1272,6 +1275,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") { + return .clientCertRequired(serverMessage: message) + } + return nil + } } // bsonToDict and bsonToJson take bson_t parameters (a CLibMongoc type), diff --git a/Plugins/OracleDriverPlugin/OracleConnection.swift b/Plugins/OracleDriverPlugin/OracleConnection.swift index a3cbf7948..bfe09108f 100644 --- a/Plugins/OracleDriverPlugin/OracleConnection.swift +++ b/Plugins/OracleDriverPlugin/OracleConnection.swift @@ -186,14 +186,45 @@ final class OracleConnectionWrapper: @unchecked Sendable { } catch let sqlError as OracleSQLError { let detail = sqlError.serverInfo?.message ?? sqlError.description osLogger.error("Oracle connection failed: \(detail)") + if let sslError = Self.classifySSLError(detail) { + throw sslError + } throw OracleError(message: detail, category: classifyConnectError(sqlError)) + } catch let nioSslError as NIOSSLError { + let detail = String(describing: nioSslError) + osLogger.error("Oracle TLS error: \(detail)") + throw Self.classifySSLError(detail) ?? SSLHandshakeError.unknown(serverMessage: detail) } catch { let detail = String(describing: error) osLogger.error("Oracle connection failed: \(detail)") + if let sslError = Self.classifySSLError(detail) { + throw sslError + } throw OracleError(message: detail, category: .connectionFailed) } } + static func classifySSLError(_ message: String) -> SSLHandshakeError? { + let lower = message.lowercased() + if lower.contains("ora-28759") || lower.contains("failure to open file") && lower.contains("wallet") { + return .clientCertRequired(serverMessage: message) + } + if lower.contains("ora-29024") { + return .cipherMismatch(serverMessage: message) + } + if lower.contains("ora-28860") { + return .cipherMismatch(serverMessage: message) + } + if lower.contains("ora-12537") || lower.contains("ora-12606") { + return .serverRejectedPlaintext(serverMessage: message) + } + if lower.contains("certificate") && (lower.contains("verify") || lower.contains("untrusted")) { + return .untrustedCertificate(serverMessage: message) + } + return nil + } + + private func classifyConnectError(_ error: OracleSQLError) -> OracleError.Category { let codeDescription = error.code.description if codeDescription.hasPrefix("unsupportedVerifierType") { diff --git a/Plugins/RedisDriverPlugin/RedisPluginConnection.swift b/Plugins/RedisDriverPlugin/RedisPluginConnection.swift index f88d3e48c..526dab30f 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginConnection.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginConnection.swift @@ -393,6 +393,26 @@ final class RedisPluginConnection: @unchecked Sendable { throw RedisPluginError.hiredisUnavailable #endif } + + static func classifySSLError(_ message: String) -> SSLHandshakeError? { + let lower = message.lowercased() + if lower.contains("certificate verify failed") || lower.contains("unable to get local issuer") { + return .untrustedCertificate(serverMessage: message) + } + if lower.contains("hostname") { + return .hostnameMismatch(serverMessage: message) + } + if lower.contains("sslv3") || lower.contains("unsupported protocol") || lower.contains("no shared cipher") { + return .cipherMismatch(serverMessage: message) + } + if lower.contains("ssl handshake failed") || lower.contains("tlsv1") { + return .cipherMismatch(serverMessage: message) + } + if lower.contains("client certificate") { + return .clientCertRequired(serverMessage: message) + } + return nil + } } // MARK: - Synchronous Helpers (must be called on the serial queue) @@ -440,6 +460,9 @@ private extension RedisPluginConnection { let errMsg = withUnsafePointer(to: &ctx.pointee.errstr) { ptr in ptr.withMemoryRebound(to: CChar.self, capacity: 128) { String(cString: $0) } } + if let sslError = Self.classifySSLError(errMsg) { + throw sslError + } throw RedisPluginError(code: Int(result), message: "SSL handshake failed: \(errMsg)") } From fc2d4693ef7df29fff4f96b6011018dde0f78e92 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 18 May 2026 01:20:03 +0700 Subject: [PATCH 05/13] refactor(plugins): extract Cassandra and MongoDB SSL mapping into dedicated enums --- .../CassandraPlugin.swift | 36 +++++++------------ .../CassandraSSLMapping.swift | 18 ++++++++++ .../MongoDBConnection.swift | 24 +------------ .../MongoDBSSLMapping.swift | 30 ++++++++++++++++ TablePro/Resources/Localizable.xcstrings | 3 ++ 5 files changed, 65 insertions(+), 46 deletions(-) create mode 100644 Plugins/CassandraDriverPlugin/CassandraSSLMapping.swift create mode 100644 Plugins/MongoDBDriverPlugin/MongoDBSSLMapping.swift diff --git a/Plugins/CassandraDriverPlugin/CassandraPlugin.swift b/Plugins/CassandraDriverPlugin/CassandraPlugin.swift index 25cfb4a2a..03e40b865 100644 --- a/Plugins/CassandraDriverPlugin/CassandraPlugin.swift +++ b/Plugins/CassandraDriverPlugin/CassandraPlugin.swift @@ -136,7 +136,7 @@ private actor CassandraConnectionActor { username: String?, password: String?, keyspace: String?, - sslMode: String, + sslMode: SSLMode, sslCaCertPath: String? ) throws { cluster = cass_cluster_new() @@ -151,34 +151,24 @@ 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, + 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)) } - } 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) @@ -922,11 +912,11 @@ internal final class CassandraPluginDriver: PluginDatabaseDriver, @unchecked Sen 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: config.ssl.mode.rawValue, + sslMode: config.ssl.mode, sslCaCertPath: resolvedCaPath ) diff --git a/Plugins/CassandraDriverPlugin/CassandraSSLMapping.swift b/Plugins/CassandraDriverPlugin/CassandraSSLMapping.swift new file mode 100644 index 000000000..3ebf1574d --- /dev/null +++ b/Plugins/CassandraDriverPlugin/CassandraSSLMapping.swift @@ -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) + } + } +} diff --git a/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift b/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift index a4e995d8a..ba5d4e35a 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift @@ -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)") diff --git a/Plugins/MongoDBDriverPlugin/MongoDBSSLMapping.swift b/Plugins/MongoDBDriverPlugin/MongoDBSSLMapping.swift new file mode 100644 index 000000000..2d4d59080 --- /dev/null +++ b/Plugins/MongoDBDriverPlugin/MongoDBSSLMapping.swift @@ -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 + } +} diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index afebc68aa..1618fb1e6 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -41783,6 +41783,9 @@ } } } + }, + "Server response: %@" : { + }, "Serverless SQLite at the edge" : { From 5d5d0ddff50fe918dc587ec3702d89bcfa2c14bb Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 18 May 2026 01:23:37 +0700 Subject: [PATCH 06/13] feat(connection-form): warn when Preferred SSL mode has no driver-level fallback --- CHANGELOG.md | 1 + ...ginMetadataRegistry+RegistryDefaults.swift | 21 ++++-- .../Core/Plugins/PluginMetadataRegistry.swift | 7 +- .../Connection/DatabaseConnection.swift | 4 ++ .../Views/Connection/ConnectionSSLView.swift | 5 ++ TableProTests/Models/DatabaseTypeTests.swift | 25 +++++++ .../PluginTestSources/MongoDBSSLMapping.swift | 30 ++++++++ .../Plugins/MongoDBSSLMappingTests.swift | 69 +++++++++++++++++++ .../Plugins/SSLHandshakeErrorTests.swift | 64 +++++++++++++++++ 9 files changed, 217 insertions(+), 9 deletions(-) create mode 100644 TableProTests/PluginTestSources/MongoDBSSLMapping.swift create mode 100644 TableProTests/Plugins/MongoDBSSLMappingTests.swift create mode 100644 TableProTests/Plugins/SSLHandshakeErrorTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cb982f4b..6db09ac08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. ### Fixed diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index 5df7cb40a..7637a0b4e 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -528,7 +528,8 @@ extension PluginMetadataRegistry { supportsReadOnlyMode: false, supportsQueryProgress: false, requiresReconnectForDatabaseSwitch: false, - supportsDropDatabase: true + supportsDropDatabase: true, + supportsOpportunisticTLS: false ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", @@ -609,7 +610,8 @@ extension PluginMetadataRegistry { supportsReadOnlyMode: false, supportsQueryProgress: false, requiresReconnectForDatabaseSwitch: false, - supportsDropDatabase: false + supportsDropDatabase: false, + supportsOpportunisticTLS: false ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", @@ -717,7 +719,8 @@ extension PluginMetadataRegistry { supportsQueryProgress: false, requiresReconnectForDatabaseSwitch: false, supportsDropDatabase: false, - supportsRenameColumn: true + supportsRenameColumn: true, + supportsOpportunisticTLS: false ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", @@ -777,7 +780,8 @@ extension PluginMetadataRegistry { supportsQueryProgress: true, requiresReconnectForDatabaseSwitch: false, supportsDropDatabase: true, - supportsModifyPrimaryKey: false + supportsModifyPrimaryKey: false, + supportsOpportunisticTLS: false ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", @@ -877,7 +881,8 @@ extension PluginMetadataRegistry { supportsModifyColumn: false, supportsAddIndex: false, supportsDropIndex: false, - supportsModifyPrimaryKey: false + supportsModifyPrimaryKey: false, + supportsOpportunisticTLS: false ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", @@ -938,7 +943,8 @@ extension PluginMetadataRegistry { supportsModifyColumn: false, supportsAddIndex: false, supportsDropIndex: false, - supportsModifyPrimaryKey: false + supportsModifyPrimaryKey: false, + supportsOpportunisticTLS: false ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", @@ -994,7 +1000,8 @@ extension PluginMetadataRegistry { supportsReadOnlyMode: false, supportsQueryProgress: false, requiresReconnectForDatabaseSwitch: false, - supportsDropDatabase: false + supportsDropDatabase: false, + supportsOpportunisticTLS: false ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/TablePro/Core/Plugins/PluginMetadataRegistry.swift index 01e3eabed..2a2fef040 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry.swift @@ -60,6 +60,7 @@ struct PluginMetadataSnapshot: Sendable { var supportsDropIndex: Bool = true var supportsModifyPrimaryKey: Bool = true var defaultSSLMode: SSLMode = .disabled + var supportsOpportunisticTLS: Bool = true static let defaults = CapabilityFlags( supportsSchemaSwitching: false, @@ -80,7 +81,8 @@ struct PluginMetadataSnapshot: Sendable { supportsAddIndex: true, supportsDropIndex: true, supportsModifyPrimaryKey: true, - defaultSSLMode: .disabled + defaultSSLMode: .disabled, + supportsOpportunisticTLS: true ) } @@ -882,7 +884,8 @@ final class PluginMetadataRegistry: @unchecked Sendable { supportsAddIndex: driverType.supportsAddIndex, supportsDropIndex: driverType.supportsDropIndex, supportsModifyPrimaryKey: driverType.supportsModifyPrimaryKey, - defaultSSLMode: existingSnapshot?.capabilities.defaultSSLMode ?? .disabled + defaultSSLMode: existingSnapshot?.capabilities.defaultSSLMode ?? .disabled, + supportsOpportunisticTLS: existingSnapshot?.capabilities.supportsOpportunisticTLS ?? true ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: driverType.defaultSchemaName, diff --git a/TablePro/Models/Connection/DatabaseConnection.swift b/TablePro/Models/Connection/DatabaseConnection.swift index 673f4bfde..6496289b4 100644 --- a/TablePro/Models/Connection/DatabaseConnection.swift +++ b/TablePro/Models/Connection/DatabaseConnection.swift @@ -109,6 +109,10 @@ extension DatabaseType { PluginMetadataRegistry.shared.snapshot(forTypeId: rawValue)?.capabilities.defaultSSLMode ?? .disabled } + var supportsOpportunisticTLS: Bool { + PluginMetadataRegistry.shared.snapshot(forTypeId: rawValue)?.capabilities.supportsOpportunisticTLS ?? true + } + var sslPaneTooltip: String { switch rawValue { case "PostgreSQL", "Redshift", "CockroachDB": diff --git a/TablePro/Views/Connection/ConnectionSSLView.swift b/TablePro/Views/Connection/ConnectionSSLView.swift index e99ec9458..0bbadba9d 100644 --- a/TablePro/Views/Connection/ConnectionSSLView.swift +++ b/TablePro/Views/Connection/ConnectionSSLView.swift @@ -26,6 +26,11 @@ struct ConnectionSSLView: View { Text(mode.displayLabel).tag(mode) } } + if sslMode == .preferred, !databaseType.supportsOpportunisticTLS { + Label(String(localized: "This driver has no TLS fallback — Preferred behaves the same as Required."), systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + .font(.caption) + } } footer: { VStack(alignment: .leading, spacing: 6) { if !databaseType.sslPaneTooltip.isEmpty { diff --git a/TableProTests/Models/DatabaseTypeTests.swift b/TableProTests/Models/DatabaseTypeTests.swift index 25c961e1e..ee4d1616b 100644 --- a/TableProTests/Models/DatabaseTypeTests.swift +++ b/TableProTests/Models/DatabaseTypeTests.swift @@ -170,6 +170,31 @@ struct DatabaseTypeTests { #expect(DatabaseType(rawValue: "FutureDB").defaultSSLMode == .disabled) } + @Test("Drivers with native prefer support report supportsOpportunisticTLS=true", arguments: [ + DatabaseType.postgresql, + DatabaseType.redshift, + DatabaseType.cockroachdb, + DatabaseType.mysql, + DatabaseType.mariadb, + DatabaseType.mssql + ]) + func testOpportunisticTLSSupported(type: DatabaseType) { + #expect(type.supportsOpportunisticTLS == true) + } + + @Test("Binary-TLS drivers report supportsOpportunisticTLS=false", arguments: [ + DatabaseType.mongodb, + DatabaseType.redis, + DatabaseType.cassandra, + DatabaseType.scylladb, + DatabaseType.clickhouse, + DatabaseType.oracle, + DatabaseType.etcd + ]) + func testOpportunisticTLSUnsupported(type: DatabaseType) { + #expect(type.supportsOpportunisticTLS == false) + } + @Test("Unknown type round-trips via rawValue") func testUnknownTypeRoundTrip() { #expect(DatabaseType(rawValue: "FutureDB").rawValue == "FutureDB") diff --git a/TableProTests/PluginTestSources/MongoDBSSLMapping.swift b/TableProTests/PluginTestSources/MongoDBSSLMapping.swift new file mode 100644 index 000000000..2d4d59080 --- /dev/null +++ b/TableProTests/PluginTestSources/MongoDBSSLMapping.swift @@ -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 + } +} diff --git a/TableProTests/Plugins/MongoDBSSLMappingTests.swift b/TableProTests/Plugins/MongoDBSSLMappingTests.swift new file mode 100644 index 000000000..d4f5ff838 --- /dev/null +++ b/TableProTests/Plugins/MongoDBSSLMappingTests.swift @@ -0,0 +1,69 @@ +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("MongoDBSSLMapping") +struct MongoDBSSLMappingTests { + @Test("Disabled returns empty parameter list") + func testDisabled() { + let params = MongoDBSSLMapping.uriParameters(for: SSLConfiguration(mode: .disabled)) + #expect(params.isEmpty) + } + + @Test("Preferred enables TLS with invalid-cert tolerance") + func testPreferred() { + let params = MongoDBSSLMapping.uriParameters(for: SSLConfiguration(mode: .preferred)) + #expect(params.contains("tls=true")) + #expect(params.contains("tlsAllowInvalidCertificates=true")) + } + + @Test("Required enables TLS with invalid-cert tolerance (same as Preferred for this driver)") + func testRequired() { + let params = MongoDBSSLMapping.uriParameters(for: SSLConfiguration(mode: .required)) + #expect(params.contains("tls=true")) + #expect(params.contains("tlsAllowInvalidCertificates=true")) + } + + @Test("Verify CA enables TLS with hostname tolerance only") + func testVerifyCA() { + let config = SSLConfiguration(mode: .verifyCa, caCertificatePath: "/tmp/ca.pem") + let params = MongoDBSSLMapping.uriParameters(for: config) + #expect(params.contains("tls=true")) + #expect(params.contains("tlsAllowInvalidHostnames=true")) + #expect(params.contains { $0.hasPrefix("tlsCAFile=") }) + } + + @Test("Verify Identity enables TLS with full validation") + func testVerifyIdentity() { + let config = SSLConfiguration(mode: .verifyIdentity, caCertificatePath: "/tmp/ca.pem") + let params = MongoDBSSLMapping.uriParameters(for: config) + #expect(params.contains("tls=true")) + #expect(!params.contains("tlsAllowInvalidCertificates=true")) + #expect(!params.contains("tlsAllowInvalidHostnames=true")) + #expect(params.contains { $0.hasPrefix("tlsCAFile=") }) + } + + @Test("Client certificate path is included when set") + func testClientCert() { + let config = SSLConfiguration( + mode: .verifyIdentity, + caCertificatePath: "/tmp/ca.pem", + clientCertificatePath: "/tmp/client.pem" + ) + let params = MongoDBSSLMapping.uriParameters(for: config) + #expect(params.contains { $0.hasPrefix("tlsCertificateKeyFile=") }) + } + + @Test("Paths with special characters are URL-encoded") + func testPathEncoding() { + let config = SSLConfiguration( + mode: .verifyIdentity, + caCertificatePath: "/path with spaces/ca.pem" + ) + let params = MongoDBSSLMapping.uriParameters(for: config) + let caParam = params.first { $0.hasPrefix("tlsCAFile=") } + #expect(caParam?.contains(" ") == false) + #expect(caParam?.contains("%20") == true) + } +} diff --git a/TableProTests/Plugins/SSLHandshakeErrorTests.swift b/TableProTests/Plugins/SSLHandshakeErrorTests.swift new file mode 100644 index 000000000..341f715f0 --- /dev/null +++ b/TableProTests/Plugins/SSLHandshakeErrorTests.swift @@ -0,0 +1,64 @@ +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("SSLHandshakeError") +struct SSLHandshakeErrorTests { + @Test("serverRejectedPlaintext suggests switching to Required") + func testServerRejectedPlaintext() { + let error = SSLHandshakeError.serverRejectedPlaintext(serverMessage: "FATAL: no pg_hba.conf entry") + #expect(error.errorDescription?.contains("requires") == true) + #expect(error.recoverySuggestion?.contains("Required") == true) + #expect(error.serverMessage.contains("pg_hba")) + } + + @Test("serverRequiresPlaintext suggests switching to Disabled") + func testServerRequiresPlaintext() { + let error = SSLHandshakeError.serverRequiresPlaintext(serverMessage: "Server does not support SSL") + #expect(error.errorDescription?.contains("does not accept") == true) + #expect(error.recoverySuggestion?.contains("Disabled") == true) + } + + @Test("untrustedCertificate suggests Verify CA") + func testUntrustedCertificate() { + let error = SSLHandshakeError.untrustedCertificate(serverMessage: "self-signed certificate") + #expect(error.errorDescription?.contains("could not be verified") == true) + #expect(error.recoverySuggestion?.contains("Verify CA") == true) + } + + @Test("hostnameMismatch suggests fix path") + func testHostnameMismatch() { + let error = SSLHandshakeError.hostnameMismatch(serverMessage: "hostname does not match certificate") + #expect(error.errorDescription?.contains("hostname") == true) + #expect(error.recoverySuggestion != nil) + } + + @Test("clientCertRequired suggests providing client cert") + func testClientCertRequired() { + let error = SSLHandshakeError.clientCertRequired(serverMessage: "client certificate required") + #expect(error.recoverySuggestion?.contains("client certificate") == true) + } + + @Test("cipherMismatch suggests server update") + func testCipherMismatch() { + let error = SSLHandshakeError.cipherMismatch(serverMessage: "no shared cipher") + #expect(error.recoverySuggestion != nil) + } + + @Test("All cases expose the original server message") + func testServerMessageRoundTrip() { + let cases: [SSLHandshakeError] = [ + .serverRejectedPlaintext(serverMessage: "msg-1"), + .serverRequiresPlaintext(serverMessage: "msg-2"), + .untrustedCertificate(serverMessage: "msg-3"), + .hostnameMismatch(serverMessage: "msg-4"), + .clientCertRequired(serverMessage: "msg-5"), + .cipherMismatch(serverMessage: "msg-6"), + .unknown(serverMessage: "msg-7") + ] + for error in cases { + #expect(!error.serverMessage.isEmpty) + } + } +} From e7ce01c310cd664afe6b371a4ccf1569ef777c70 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 18 May 2026 01:29:32 +0700 Subject: [PATCH 07/13] docs(ssl): document SSL/TLS feature; tests for per-plugin classifiers; centralize error formatting --- CHANGELOG.md | 1 + .../TableProPluginKit/SSLHandshakeError.swift | 15 ++ .../ViewModels/WelcomeViewModel+Sample.swift | 2 +- TablePro/ViewModels/WelcomeViewModel.swift | 2 +- .../ConnectionFormCoordinator.swift | 17 +- .../PluginSSLClassifiers.swift | 162 +++++++++++++ .../Plugins/PluginSSLClassifierTests.swift | 214 ++++++++++++++++++ docs/docs.json | 1 + docs/features/ssl.mdx | 93 ++++++++ 9 files changed, 489 insertions(+), 18 deletions(-) create mode 100644 TableProTests/PluginTestSources/PluginSSLClassifiers.swift create mode 100644 TableProTests/Plugins/PluginSSLClassifierTests.swift create mode 100644 docs/features/ssl.mdx diff --git a/CHANGELOG.md b/CHANGELOG.md index 6db09ac08..5d864dc98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 ### Fixed diff --git a/Plugins/TableProPluginKit/SSLHandshakeError.swift b/Plugins/TableProPluginKit/SSLHandshakeError.swift index bda50448c..ec8f261ea 100644 --- a/Plugins/TableProPluginKit/SSLHandshakeError.swift +++ b/Plugins/TableProPluginKit/SSLHandshakeError.swift @@ -41,6 +41,21 @@ public enum SSLHandshakeError: Error, LocalizedError, Sendable { } } + public static func formatted(_ error: Error) -> String { + guard let sslError = error as? SSLHandshakeError else { + return error.localizedDescription + } + var parts: [String] = [] + if let description = sslError.errorDescription { + parts.append(description) + } + if let suggestion = sslError.recoverySuggestion { + parts.append(suggestion) + } + parts.append(String(format: String(localized: "Server response: %@"), sslError.serverMessage)) + return parts.joined(separator: "\n\n") + } + public var recoverySuggestion: String? { switch self { case .serverRejectedPlaintext: diff --git a/TablePro/ViewModels/WelcomeViewModel+Sample.swift b/TablePro/ViewModels/WelcomeViewModel+Sample.swift index 5c898a95a..58854eac6 100644 --- a/TablePro/ViewModels/WelcomeViewModel+Sample.swift +++ b/TablePro/ViewModels/WelcomeViewModel+Sample.swift @@ -197,7 +197,7 @@ extension WelcomeViewModel { func openSampleDatabase() { SampleDatabaseLauncher.open { [weak self] error in guard let self else { return } - self.connectionError = error.localizedDescription + self.connectionError = SSLHandshakeError.formatted(error) self.showConnectionError = true } } diff --git a/TablePro/ViewModels/WelcomeViewModel.swift b/TablePro/ViewModels/WelcomeViewModel.swift index 3adc1d1a8..14c0bd668 100644 --- a/TablePro/ViewModels/WelcomeViewModel.swift +++ b/TablePro/ViewModels/WelcomeViewModel.swift @@ -604,7 +604,7 @@ final class WelcomeViewModel { Self.logger.error("Failed to connect: \(error.localizedDescription, privacy: .public)") WindowManager.shared.closeWindow(for: connection.id) - connectionError = error.localizedDescription + connectionError = SSLHandshakeError.formatted(error) showConnectionError = true } } diff --git a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift index 9b5fb21f5..23b9e6f74 100644 --- a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift +++ b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift @@ -540,7 +540,7 @@ final class ConnectionFormCoordinator { } else { AlertHelper.showErrorSheet( title: String(localized: "Connection Test Failed"), - message: Self.formatErrorMessage(error), + message: SSLHandshakeError.formatted(error), window: window ) } @@ -549,21 +549,6 @@ final class ConnectionFormCoordinator { } } - static func formatErrorMessage(_ error: Error) -> String { - if let sslError = error as? SSLHandshakeError { - var parts: [String] = [] - if let description = sslError.errorDescription { - parts.append(description) - } - if let suggestion = sslError.recoverySuggestion { - parts.append(suggestion) - } - parts.append(String(format: String(localized: "Server response: %@"), sslError.serverMessage)) - return parts.joined(separator: "\n\n") - } - return error.localizedDescription - } - func cleanupTestSecrets(for testId: UUID) { services.connectionStorage.deletePassword(for: testId) services.connectionStorage.deleteSSHPassword(for: testId) diff --git a/TableProTests/PluginTestSources/PluginSSLClassifiers.swift b/TableProTests/PluginTestSources/PluginSSLClassifiers.swift new file mode 100644 index 000000000..a25efc911 --- /dev/null +++ b/TableProTests/PluginTestSources/PluginSSLClassifiers.swift @@ -0,0 +1,162 @@ +import Foundation +import TableProPluginKit + +enum LibPQClassifier { + static func classifySSLError(_ message: String) -> SSLHandshakeError? { + let lower = message.lowercased() + if lower.contains("no pg_hba.conf entry") && lower.contains("no encryption") { + return .serverRejectedPlaintext(serverMessage: message) + } + if lower.contains("no pg_hba.conf entry") && lower.contains("ssl") { + return .serverRequiresPlaintext(serverMessage: message) + } + if lower.contains("server does not support ssl") || lower.contains("ssl is not enabled on the server") { + return .serverRequiresPlaintext(serverMessage: message) + } + if lower.contains("certificate verify failed") || lower.contains("self-signed certificate") || lower.contains("unable to get local issuer certificate") { + return .untrustedCertificate(serverMessage: message) + } + if lower.contains("server certificate") && lower.contains("does not match host name") { + return .hostnameMismatch(serverMessage: message) + } + if lower.contains("certificate required") || lower.contains("connection requires a valid client certificate") { + return .clientCertRequired(serverMessage: message) + } + if lower.contains("ssl error") || lower.contains("tls handshake") || lower.contains("ssl handshake") { + return .cipherMismatch(serverMessage: message) + } + return nil + } +} + +enum MariaDBClassifier { + static let sslOnlyErrorCodes: Set = [2_026, 2_012, 1_043] + + static func classifySSLError(code: UInt32, message: String) -> SSLHandshakeError? { + let lower = message.lowercased() + if lower.contains("insecure transport") || lower.contains("require_secure_transport") { + return .serverRejectedPlaintext(serverMessage: message) + } + if sslOnlyErrorCodes.contains(code) { + if lower.contains("certificate") { + return .untrustedCertificate(serverMessage: message) + } + return .cipherMismatch(serverMessage: message) + } + return nil + } +} + +enum FreeTDSClassifier { + 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") { + return .cipherMismatch(serverMessage: message) + } + return nil + } +} + +enum MongoDBClassifier { + 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") { + return .clientCertRequired(serverMessage: message) + } + return nil + } +} + +enum RedisClassifier { + static func classifySSLError(_ message: String) -> SSLHandshakeError? { + let lower = message.lowercased() + if lower.contains("certificate verify failed") || lower.contains("unable to get local issuer") { + return .untrustedCertificate(serverMessage: message) + } + if lower.contains("hostname") { + return .hostnameMismatch(serverMessage: message) + } + if lower.contains("sslv3") || lower.contains("unsupported protocol") || lower.contains("no shared cipher") { + return .cipherMismatch(serverMessage: message) + } + if lower.contains("ssl handshake failed") || lower.contains("tlsv1") { + return .cipherMismatch(serverMessage: message) + } + if lower.contains("client certificate") { + return .clientCertRequired(serverMessage: message) + } + return nil + } +} + +enum OracleClassifier { + static func classifySSLError(_ message: String) -> SSLHandshakeError? { + let lower = message.lowercased() + if lower.contains("ora-28759") || lower.contains("failure to open file") && lower.contains("wallet") { + return .clientCertRequired(serverMessage: message) + } + if lower.contains("ora-29024") { + return .cipherMismatch(serverMessage: message) + } + if lower.contains("ora-28860") { + return .cipherMismatch(serverMessage: message) + } + if lower.contains("ora-12537") || lower.contains("ora-12606") { + return .serverRejectedPlaintext(serverMessage: message) + } + if lower.contains("certificate") && (lower.contains("verify") || lower.contains("untrusted")) { + return .untrustedCertificate(serverMessage: message) + } + return nil + } +} + +enum ClickHouseClassifier { + 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 + } +} diff --git a/TableProTests/Plugins/PluginSSLClassifierTests.swift b/TableProTests/Plugins/PluginSSLClassifierTests.swift new file mode 100644 index 000000000..5519c690b --- /dev/null +++ b/TableProTests/Plugins/PluginSSLClassifierTests.swift @@ -0,0 +1,214 @@ +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("LibPQ SSL Classifier") +struct LibPQClassifierTests { + @Test("Classifies the AWS RDS rejection in #1298 as serverRejectedPlaintext") + func testRDSPattern() { + let msg = "FATAL: no pg_hba.conf entry for host \"1.2.3.4\", user \"u\", database \"d\", no encryption" + guard case .serverRejectedPlaintext = LibPQClassifier.classifySSLError(msg) else { + Issue.record("Expected serverRejectedPlaintext") + return + } + } + + @Test("Classifies SSL-required as serverRequiresPlaintext") + func testSSLRequired() { + let msg = "FATAL: no pg_hba.conf entry for host \"1.2.3.4\", user \"u\", database \"d\", SSL on" + guard case .serverRequiresPlaintext = LibPQClassifier.classifySSLError(msg) else { + Issue.record("Expected serverRequiresPlaintext") + return + } + } + + @Test("Classifies server-no-ssl-support as serverRequiresPlaintext") + func testServerNoSSL() { + let msg = "server does not support SSL, but SSL was required" + guard case .serverRequiresPlaintext = LibPQClassifier.classifySSLError(msg) else { + Issue.record("Expected serverRequiresPlaintext") + return + } + } + + @Test("Classifies cert verify failure as untrustedCertificate") + func testCertVerify() { + let msg = "SSL error: certificate verify failed" + guard case .untrustedCertificate = LibPQClassifier.classifySSLError(msg) else { + Issue.record("Expected untrustedCertificate") + return + } + } + + @Test("Classifies hostname mismatch") + func testHostnameMismatch() { + let msg = "server certificate for \"foo\" does not match host name \"bar\"" + guard case .hostnameMismatch = LibPQClassifier.classifySSLError(msg) else { + Issue.record("Expected hostnameMismatch") + return + } + } + + @Test("Non-SSL error returns nil") + func testNonSSL() { + #expect(LibPQClassifier.classifySSLError("FATAL: password authentication failed") == nil) + #expect(LibPQClassifier.classifySSLError("connection refused") == nil) + } +} + +@Suite("MariaDB SSL Classifier") +struct MariaDBClassifierTests { + @Test("CR_SSL_CONNECTION_ERROR with cipher message → cipherMismatch") + func testSSLConnectionError() { + guard case .cipherMismatch = MariaDBClassifier.classifySSLError(code: 2_026, message: "SSL connection error: no shared cipher") else { + Issue.record("Expected cipherMismatch") + return + } + } + + @Test("CR_SSL_CONNECTION_ERROR with certificate keyword → untrustedCertificate") + func testSSLCertError() { + guard case .untrustedCertificate = MariaDBClassifier.classifySSLError(code: 2_026, message: "SSL certificate not trusted") else { + Issue.record("Expected untrustedCertificate") + return + } + } + + @Test("require_secure_transport → serverRejectedPlaintext") + func testRequireSecureTransport() { + guard case .serverRejectedPlaintext = MariaDBClassifier.classifySSLError(code: 1_045, message: "Connections using insecure transport are prohibited while --require_secure_transport=ON") else { + Issue.record("Expected serverRejectedPlaintext") + return + } + } + + @Test("Auth error 1045 not retried (returns nil)") + func testAuthError() { + #expect(MariaDBClassifier.classifySSLError(code: 1_045, message: "Access denied for user 'foo'@'bar'") == nil) + } + + @Test("Network error 2002 not retried") + func testNetworkError() { + #expect(MariaDBClassifier.classifySSLError(code: 2_002, message: "Can't connect to MySQL server") == nil) + } +} + +@Suite("FreeTDS SSL Classifier") +struct FreeTDSClassifierTests { + @Test("Server requires encryption → serverRejectedPlaintext") + func testServerRequires() { + guard case .serverRejectedPlaintext = FreeTDSClassifier.classifySSLError("Server requires encryption") else { + Issue.record("Expected serverRejectedPlaintext") + return + } + } + + @Test("OpenSSL handshake → cipherMismatch") + func testOpenSSL() { + guard case .cipherMismatch = FreeTDSClassifier.classifySSLError("OpenSSL: SSL_connect failed") else { + Issue.record("Expected cipherMismatch") + return + } + } +} + +@Suite("MongoDB SSL Classifier") +struct MongoDBClassifierTests { + @Test("TLS handshake failed → cipherMismatch") + func testTLSHandshake() { + guard case .cipherMismatch = MongoDBClassifier.classifySSLError("TLS handshake failed: bad cipher") else { + Issue.record("Expected cipherMismatch") + return + } + } + + @Test("Hostname verification failure → hostnameMismatch") + func testHostnameVerification() { + guard case .hostnameMismatch = MongoDBClassifier.classifySSLError("hostname verification failed") else { + Issue.record("Expected hostnameMismatch") + return + } + } + + @Test("TLS required → serverRejectedPlaintext") + func testTLSRequired() { + guard case .serverRejectedPlaintext = MongoDBClassifier.classifySSLError("TLS required by Atlas cluster") else { + Issue.record("Expected serverRejectedPlaintext") + return + } + } +} + +@Suite("Redis SSL Classifier") +struct RedisClassifierTests { + @Test("No shared cipher → cipherMismatch") + func testNoSharedCipher() { + guard case .cipherMismatch = RedisClassifier.classifySSLError("SSL_connect: no shared cipher") else { + Issue.record("Expected cipherMismatch") + return + } + } + + @Test("Cert verify failed → untrustedCertificate") + func testCertVerify() { + guard case .untrustedCertificate = RedisClassifier.classifySSLError("certificate verify failed (self-signed)") else { + Issue.record("Expected untrustedCertificate") + return + } + } +} + +@Suite("Oracle SSL Classifier") +struct OracleClassifierTests { + @Test("ORA-29024 → cipherMismatch") + func testORA29024() { + guard case .cipherMismatch = OracleClassifier.classifySSLError("ORA-29024: Certificate validation failure") else { + Issue.record("Expected cipherMismatch") + return + } + } + + @Test("ORA-12606 → serverRejectedPlaintext") + func testORA12606() { + guard case .serverRejectedPlaintext = OracleClassifier.classifySSLError("ORA-12606: TNS: Application timeout occurred") else { + Issue.record("Expected serverRejectedPlaintext") + return + } + } + + @Test("ORA-28759 → clientCertRequired") + func testORA28759() { + guard case .clientCertRequired = OracleClassifier.classifySSLError("ORA-28759: failure to open file") else { + Issue.record("Expected clientCertRequired") + return + } + } +} + +@Suite("ClickHouse SSL Classifier") +struct ClickHouseClassifierTests { + @Test("URLError.secureConnectionFailed → cipherMismatch") + func testSecureConnectionFailed() { + let error = URLError(.secureConnectionFailed) + guard case .cipherMismatch = ClickHouseClassifier.classifySSLError(error) else { + Issue.record("Expected cipherMismatch") + return + } + } + + @Test("URLError.serverCertificateUntrusted → untrustedCertificate") + func testCertUntrusted() { + let error = URLError(.serverCertificateUntrusted) + guard case .untrustedCertificate = ClickHouseClassifier.classifySSLError(error) else { + Issue.record("Expected untrustedCertificate") + return + } + } + + @Test("Non-SSL error returns nil") + func testNonSSL() { + let error = URLError(.notConnectedToInternet) + #expect(ClickHouseClassifier.classifySSLError(error) == nil) + } +} diff --git a/docs/docs.json b/docs/docs.json index 18784acfe..a17bac936 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -132,6 +132,7 @@ "pages": [ "features/safe-mode", "features/ssh-profiles", + "features/ssl", "features/connection-sharing" ] }, diff --git a/docs/features/ssl.mdx b/docs/features/ssl.mdx new file mode 100644 index 000000000..eb2d76a24 --- /dev/null +++ b/docs/features/ssl.mdx @@ -0,0 +1,93 @@ +--- +title: SSL/TLS +description: Configure encrypted database connections, per-engine defaults, and certificate verification +--- + +# SSL/TLS + +TablePro supports five SSL modes that map to each driver's native TLS capabilities. New connections start with the mode that matches the driver's documented default. + +## Modes + +| Mode | Behavior | +|---|---| +| Disabled | Plain TCP, no TLS negotiation | +| Preferred | Try TLS first, fall back to plain if the server doesn't support it (where the driver allows) | +| Required | Force TLS; fail if the server rejects encryption. No certificate validation. | +| Verify CA | Force TLS and validate the server certificate against the trust store. Hostname not checked. | +| Verify Identity | Force TLS, validate the certificate, and require the hostname to match the certificate subject | + +## Per-engine defaults + +New connections pick the mode that matches each driver's native behavior. Open the SSL tab on any connection to see the engine-specific guidance. + +| Engine | Default | Notes | +|---|---|---| +| PostgreSQL, Redshift, CockroachDB | Preferred | libpq `sslmode=prefer`. Matches `psql` and DataGrip. | +| MySQL, MariaDB | Preferred | 2-pass connect: try TLS first, fall back to plain on SSL handshake error | +| SQL Server | Preferred | FreeTDS `encryption=request`. SQL Server 2022 enforces TLS. | +| MongoDB, Redis, Cassandra, ClickHouse, Oracle, etcd | Disabled | Drivers have no TLS fallback. Pick Required for hosted services. | +| SQLite, DuckDB | N/A | No network protocol | + +## Required for hosted services + +These services require TLS out of the box. Pick Preferred or Required for these: + +- AWS RDS (PostgreSQL, MySQL, MariaDB), Aurora +- Google Cloud SQL (PostgreSQL, MySQL, SQL Server) +- Azure SQL Database, Azure Database for PostgreSQL/MySQL +- Heroku Postgres, Supabase, Neon, PlanetScale +- MongoDB Atlas (uses `mongodb+srv://` which enables TLS automatically) +- Redis Cloud, Upstash, AWS ElastiCache encrypted endpoints +- AstraDB / DataStax Astra (Cassandra) +- Oracle Autonomous Database (TCPS on port 1522/2484) +- ClickHouse Cloud + +## Troubleshooting + +### "FATAL: no pg_hba.conf entry for host ... no encryption" + +PostgreSQL server requires SSL. Switch SSL Mode to **Preferred** or **Required**. + +### "Connections using insecure transport are prohibited" + +MySQL server has `require_secure_transport=ON`. Switch SSL Mode to **Preferred** or **Required**. + +### "SSL handshake failed" / "tls handshake failed" + +Driver and server can't agree on a TLS version or cipher. Update the server, or for development try **Required** instead of **Verify CA**/**Verify Identity** to skip certificate validation. + +### "certificate verify failed" / "self-signed certificate" + +Server uses a certificate that isn't in your system trust store. Set SSL Mode to **Verify CA** and provide the CA certificate path, or use **Required** to skip certificate validation entirely. + +### "hostname does not match certificate" + +The certificate's CN/SAN doesn't include the host you're connecting to. Switch from **Verify Identity** to **Verify CA** (validates the chain but skips hostname), or update the host field to match the certificate. + +### "client certificate required" + +Server requires mutual TLS. Fill in the **Client Certificate** and **Client Key** paths in the SSL tab. + +## Preferred fallback behavior + +Preferred mode tries TLS first. What happens if the server doesn't support TLS depends on the driver: + +- **PostgreSQL, Redshift, CockroachDB**: libpq falls back to plain TCP natively +- **SQL Server**: FreeTDS `encryption=request` falls back to plain +- **MySQL, MariaDB**: 2-pass connect tries TLS, then plain on SSL-specific handshake errors (CR_SSL_CONNECTION_ERROR, CR_SERVER_HANDSHAKE_ERR, ER_HANDSHAKE_ERROR). Auth and network errors are not retried. +- **MongoDB, Redis, Cassandra, ClickHouse, Oracle, etcd**: Drivers have no fallback. Preferred behaves the same as Required. The SSL pane shows a warning when you pick Preferred for these engines. + +## Connection failures + +When a connection fails because of an SSL handshake problem, TablePro shows a structured message that names the cause and recommends a specific SSL Mode to switch to. The original driver error is shown below the suggestion. + +For example, connecting to AWS RDS PostgreSQL with SSL Mode = Disabled produces: + +``` +The server requires an encrypted connection but TablePro is configured to connect in plain text. + +Open the connection editor, switch to the SSL tab, and set Mode to Required (or stricter). + +Server response: FATAL: no pg_hba.conf entry for host "...", user "...", database "...", no encryption +``` From 4c4629c3f05551a6a939dc43b82b7240abcf049c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 18 May 2026 01:32:37 +0700 Subject: [PATCH 08/13] docs(databases): update per-engine SSL guidance and link to features/ssl --- docs/databases/cassandra.mdx | 2 +- docs/databases/clickhouse.mdx | 2 +- docs/databases/mongodb.mdx | 2 +- docs/databases/mysql.mdx | 2 +- docs/databases/oracle.mdx | 2 ++ docs/databases/postgresql.mdx | 2 +- 6 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/databases/cassandra.mdx b/docs/databases/cassandra.mdx index 38bc2fa9f..4cace5c92 100644 --- a/docs/databases/cassandra.mdx +++ b/docs/databases/cassandra.mdx @@ -36,7 +36,7 @@ See [Connection URL Reference](/databases/connection-urls#cassandra--scylladb) f **DataStax Astra DB**: Port 29042, use Client ID/Secret, needs Secure Connect Bundle (SSL/TLS) **Remote**: Use [SSH tunneling](/databases/ssh-tunneling) for production -**SSL/TLS**: Enable in connection form. Astra DB requires Secure Connect Bundle. +**SSL/TLS**: The Cassandra driver has no TLS fallback. **Preferred** behaves the same as **Required** (the SSL pane shows a warning). Use **Required** for AstraDB, **Verify CA** with a CA certificate path for private PKI. See [SSL/TLS](/features/ssl) for details. ## Features diff --git a/docs/databases/clickhouse.mdx b/docs/databases/clickhouse.mdx index 1a3263b22..d92907992 100644 --- a/docs/databases/clickhouse.mdx +++ b/docs/databases/clickhouse.mdx @@ -29,7 +29,7 @@ Uses HTTP API (HTTPS on port 8443 with SSL/TLS enabled). Cloud providers typical **ClickHouse Cloud**: Port 8443, enable SSL/TLS **Remote**: Use [SSH tunneling](/databases/ssh-tunneling) for unencrypted HTTP -**SSL/TLS**: Enable in connection form for HTTPS (port 8443). Cloud requires HTTPS. +**SSL/TLS**: ClickHouse over HTTP uses URLSession; setting any non-Disabled SSL Mode switches the URL scheme to `https`. **Required** skips cert verification, **Verify CA** validates against the supplied CA, **Verify Identity** relies on system default HTTPS trust (cert chain + hostname). Use **Required** for ClickHouse Cloud (port 8443). See [SSL/TLS](/features/ssl) for details. ## Connection URL diff --git a/docs/databases/mongodb.mdx b/docs/databases/mongodb.mdx index 76b045260..d9355b6f8 100644 --- a/docs/databases/mongodb.mdx +++ b/docs/databases/mongodb.mdx @@ -69,7 +69,7 @@ SSH tunneling only forwards the first host. Other members must be reachable from ## SSL/TLS -Configure in **SSL/TLS** section. MongoDB Atlas requires SSL/TLS - use **Required** or **Verify CA**. For unencrypted alternatives, use [SSH tunneling](/databases/ssh-tunneling). +The MongoDB driver has no TLS fallback. **Preferred** behaves the same as **Required** (the SSL pane shows a warning). MongoDB Atlas requires TLS, so use **Required** or **Verify CA**. For unencrypted local instances, use **Disabled** or [SSH tunneling](/databases/ssh-tunneling). See [SSL/TLS](/features/ssl) for details. ## Connection URL diff --git a/docs/databases/mysql.mdx b/docs/databases/mysql.mdx index 9e12ee71b..80303d242 100644 --- a/docs/databases/mysql.mdx +++ b/docs/databases/mysql.mdx @@ -96,7 +96,7 @@ SELECT * FROM category_tree; ### SSL/TLS -Configure in **SSL/TLS** section. Cloud providers (AWS RDS, Google Cloud SQL, Azure) typically require SSL. Use **Verify CA** with the provider's certificate. Optionally provide client cert/key for mutual TLS. For unencrypted alternatives, use [SSH tunneling](/databases/ssh-tunneling). +New connections default to **Preferred**, which does a 2-pass connect: tries TLS first, falls back to plain only on SSL handshake errors (auth and network errors are not retried). Works for Cloud SQL, Azure MySQL, and local Docker. Use **Verify CA** with the provider's certificate for stricter validation. See [SSL/TLS](/features/ssl) for details. ## Performance Tips diff --git a/docs/databases/oracle.mdx b/docs/databases/oracle.mdx index 7b0bf91d0..9b20235a1 100644 --- a/docs/databases/oracle.mdx +++ b/docs/databases/oracle.mdx @@ -49,6 +49,8 @@ The Oracle driver is available as a downloadable plugin. When you select Oracle **Oracle Cloud (ADB)**: Port 1522, service name format `mydb_tp`, requires TLS wallet download from Oracle Cloud Console +**SSL/TLS**: OracleNIO has no TLS fallback. **Preferred** connects in plain TCP (the SSL pane shows a warning). Use **Required** for TCPS to Oracle Autonomous Database, **Verify CA** with a CA certificate path for strict validation. See [SSL/TLS](/features/ssl) for details. + ## Connection URL ```text diff --git a/docs/databases/postgresql.mdx b/docs/databases/postgresql.mdx index 64538cf45..19eb72abc 100644 --- a/docs/databases/postgresql.mdx +++ b/docs/databases/postgresql.mdx @@ -105,7 +105,7 @@ Supports `jsonb` (formatted JSON), `array`, `uuid`, `inet` (IP), `timestamp with **Permission denied**: Grant access with `GRANT ALL ON DATABASE/SCHEMA/TABLES TO username;` -**SSL/TLS**: Cloud providers (AWS RDS, Heroku, Supabase) typically require SSL. Use **Required** or **Verify CA**. For unencrypted alternatives, use [SSH tunneling](/databases/ssh-tunneling). +**SSL/TLS**: New connections default to **Preferred** (libpq `sslmode=prefer`), which tries TLS first and falls back to plain. Works for both local Postgres and hosted providers (AWS RDS, Cloud SQL, Heroku, Supabase, Neon). Pick **Verify CA** when you need to validate the server certificate. See [SSL/TLS](/features/ssl) for details. ## Advanced Configuration From 206db0df7003f68d20b473ec88610575d4ef1839 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 18 May 2026 01:33:50 +0700 Subject: [PATCH 09/13] refactor(plugin-oracle): extract TLS builder into dedicated OracleSSLMapping --- CHANGELOG.md | 5 +++ .../OracleDriverPlugin/OracleConnection.swift | 37 +-------------- .../OracleDriverPlugin/OracleSSLMapping.swift | 45 +++++++++++++++++++ 3 files changed, 51 insertions(+), 36 deletions(-) create mode 100644 Plugins/OracleDriverPlugin/OracleSSLMapping.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d864dc98..7e9b07b4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 + +### Known limitations + +- iOS app SSL form still uses a binary Toggle for non-MSSQL engines (mysql, mariadb, postgresql, redshift). Verify CA and Verify Identity are not yet exposed on iOS. macOS users get the full per-engine picker. Will be addressed in a follow-up. ### Fixed diff --git a/Plugins/OracleDriverPlugin/OracleConnection.swift b/Plugins/OracleDriverPlugin/OracleConnection.swift index bfe09108f..19f79ef7d 100644 --- a/Plugins/OracleDriverPlugin/OracleConnection.swift +++ b/Plugins/OracleDriverPlugin/OracleConnection.swift @@ -155,7 +155,7 @@ final class OracleConnectionWrapper: @unchecked Sendable { func connect() async throws { let service = serviceName.isEmpty ? database : serviceName - let tls = try buildTLS() + let tls = try OracleSSLMapping.tls(for: sslConfig) let config = OracleNIO.OracleConnection.Configuration( host: host, port: port, @@ -240,41 +240,6 @@ final class OracleConnectionWrapper: @unchecked Sendable { } } - private func buildTLS() throws -> OracleNIO.OracleConnection.Configuration.TLS { - switch sslConfig.mode { - case .disabled: - return .disable - case .preferred: - osLogger.warning("Oracle SSL mode 'Preferred' is not supported by OracleNIO; falling back to plain TCP. Use 'Required' to enforce TCPS.") - return .disable - case .required, .verifyCa, .verifyIdentity: - var tlsConfiguration = TLSConfiguration.makeClientConfiguration() - tlsConfiguration.certificateVerification = certificateVerification(for: sslConfig.mode) - if sslConfig.verifiesCertificate, !sslConfig.caCertificatePath.isEmpty { - let caCerts = try NIOSSLCertificate.fromPEMFile(sslConfig.caCertificatePath) - tlsConfiguration.trustRoots = .certificates(caCerts) - } - if !sslConfig.clientCertificatePath.isEmpty { - let clientCerts = try NIOSSLCertificate.fromPEMFile(sslConfig.clientCertificatePath) - tlsConfiguration.certificateChain = clientCerts.map { .certificate($0) } - } - if !sslConfig.clientKeyPath.isEmpty { - let key = try NIOSSLPrivateKey(file: sslConfig.clientKeyPath, format: .pem) - tlsConfiguration.privateKey = .privateKey(key) - } - let sslContext = try NIOSSLContext(configuration: tlsConfiguration) - return .require(sslContext) - } - } - - private func certificateVerification(for mode: SSLMode) -> CertificateVerification { - switch mode { - case .verifyIdentity: return .fullVerification - case .verifyCa: return .noHostnameVerification - case .required, .preferred, .disabled: return .none - } - } - func disconnect() { let connection = state.withLock { current -> OracleNIO.OracleConnection? in guard current.isConnected else { return nil } diff --git a/Plugins/OracleDriverPlugin/OracleSSLMapping.swift b/Plugins/OracleDriverPlugin/OracleSSLMapping.swift new file mode 100644 index 000000000..5f5101092 --- /dev/null +++ b/Plugins/OracleDriverPlugin/OracleSSLMapping.swift @@ -0,0 +1,45 @@ +import Foundation +import Logging +import NIOSSL +import OracleNIO +import OSLog +import TableProPluginKit + +private let osLogger = Logger(subsystem: "com.TablePro.OracleDriver", category: "OracleSSLMapping") + +enum OracleSSLMapping { + static func tls(for sslConfig: SSLConfiguration) throws -> OracleNIO.OracleConnection.Configuration.TLS { + switch sslConfig.mode { + case .disabled: + return .disable + case .preferred: + osLogger.warning("Oracle SSL mode 'Preferred' is not supported by OracleNIO; falling back to plain TCP. Use 'Required' to enforce TCPS.") + return .disable + case .required, .verifyCa, .verifyIdentity: + var tlsConfiguration = TLSConfiguration.makeClientConfiguration() + tlsConfiguration.certificateVerification = certificateVerification(for: sslConfig.mode) + if sslConfig.verifiesCertificate, !sslConfig.caCertificatePath.isEmpty { + let caCerts = try NIOSSLCertificate.fromPEMFile(sslConfig.caCertificatePath) + tlsConfiguration.trustRoots = .certificates(caCerts) + } + if !sslConfig.clientCertificatePath.isEmpty { + let clientCerts = try NIOSSLCertificate.fromPEMFile(sslConfig.clientCertificatePath) + tlsConfiguration.certificateChain = clientCerts.map { .certificate($0) } + } + if !sslConfig.clientKeyPath.isEmpty { + let key = try NIOSSLPrivateKey(file: sslConfig.clientKeyPath, format: .pem) + tlsConfiguration.privateKey = .privateKey(key) + } + let sslContext = try NIOSSLContext(configuration: tlsConfiguration) + return .require(sslContext) + } + } + + static func certificateVerification(for mode: SSLMode) -> CertificateVerification { + switch mode { + case .verifyIdentity: return .fullVerification + case .verifyCa: return .noHostnameVerification + case .required, .preferred, .disabled: return .none + } + } +} From ed5c8a14a7bacc01dfd0f68f6568e31c8d57e495 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 18 May 2026 11:09:37 +0700 Subject: [PATCH 10/13] fix(changelog,plugin-oracle): drop non-canonical CHANGELOG section, narrow Oracle classifier to true SSL errors --- CHANGELOG.md | 4 ---- Plugins/OracleDriverPlugin/OracleConnection.swift | 3 --- .../PluginTestSources/PluginSSLClassifiers.swift | 3 --- TableProTests/Plugins/PluginSSLClassifierTests.swift | 9 +++------ 4 files changed, 3 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e9b07b4c..2afcb236b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,10 +31,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 -### Known limitations - -- iOS app SSL form still uses a binary Toggle for non-MSSQL engines (mysql, mariadb, postgresql, redshift). Verify CA and Verify Identity are not yet exposed on iOS. macOS users get the full per-engine picker. Will be addressed in a follow-up. - ### 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) diff --git a/Plugins/OracleDriverPlugin/OracleConnection.swift b/Plugins/OracleDriverPlugin/OracleConnection.swift index 19f79ef7d..28e4d7403 100644 --- a/Plugins/OracleDriverPlugin/OracleConnection.swift +++ b/Plugins/OracleDriverPlugin/OracleConnection.swift @@ -215,9 +215,6 @@ final class OracleConnectionWrapper: @unchecked Sendable { if lower.contains("ora-28860") { return .cipherMismatch(serverMessage: message) } - if lower.contains("ora-12537") || lower.contains("ora-12606") { - return .serverRejectedPlaintext(serverMessage: message) - } if lower.contains("certificate") && (lower.contains("verify") || lower.contains("untrusted")) { return .untrustedCertificate(serverMessage: message) } diff --git a/TableProTests/PluginTestSources/PluginSSLClassifiers.swift b/TableProTests/PluginTestSources/PluginSSLClassifiers.swift index a25efc911..da66d09c9 100644 --- a/TableProTests/PluginTestSources/PluginSSLClassifiers.swift +++ b/TableProTests/PluginTestSources/PluginSSLClassifiers.swift @@ -125,9 +125,6 @@ enum OracleClassifier { if lower.contains("ora-28860") { return .cipherMismatch(serverMessage: message) } - if lower.contains("ora-12537") || lower.contains("ora-12606") { - return .serverRejectedPlaintext(serverMessage: message) - } if lower.contains("certificate") && (lower.contains("verify") || lower.contains("untrusted")) { return .untrustedCertificate(serverMessage: message) } diff --git a/TableProTests/Plugins/PluginSSLClassifierTests.swift b/TableProTests/Plugins/PluginSSLClassifierTests.swift index 5519c690b..82f7ee681 100644 --- a/TableProTests/Plugins/PluginSSLClassifierTests.swift +++ b/TableProTests/Plugins/PluginSSLClassifierTests.swift @@ -169,12 +169,9 @@ struct OracleClassifierTests { } } - @Test("ORA-12606 → serverRejectedPlaintext") - func testORA12606() { - guard case .serverRejectedPlaintext = OracleClassifier.classifySSLError("ORA-12606: TNS: Application timeout occurred") else { - Issue.record("Expected serverRejectedPlaintext") - return - } + @Test("Network timeout (ORA-12606) is not classified as SSL") + func testTimeoutNotSSL() { + #expect(OracleClassifier.classifySSLError("ORA-12606: TNS: Application timeout occurred") == nil) } @Test("ORA-28759 → clientCertRequired") From f44b45a47e0bfa275001cb76d7817acb1b518850 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 18 May 2026 11:14:25 +0700 Subject: [PATCH 11/13] fix(plugin-mssql): import TableProPluginKit so FreeTDSConnection sees SSLHandshakeError --- Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift b/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift index d33091830..fe9ede42a 100644 --- a/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift +++ b/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift @@ -13,6 +13,7 @@ import CFreeTDS import Foundation import os import TableProMSSQLCore +import TableProPluginKit private let freetdsLogger = Logger(subsystem: "com.TablePro", category: "FreeTDSConnection") From 16c66ca35f9e1e555b87f2f60af07532898510af Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 18 May 2026 11:16:48 +0700 Subject: [PATCH 12/13] fix(welcome): import TableProPluginKit for SSLHandshakeError --- TablePro/ViewModels/WelcomeViewModel+Sample.swift | 1 + TablePro/ViewModels/WelcomeViewModel.swift | 1 + 2 files changed, 2 insertions(+) diff --git a/TablePro/ViewModels/WelcomeViewModel+Sample.swift b/TablePro/ViewModels/WelcomeViewModel+Sample.swift index 58854eac6..6731363b0 100644 --- a/TablePro/ViewModels/WelcomeViewModel+Sample.swift +++ b/TablePro/ViewModels/WelcomeViewModel+Sample.swift @@ -7,6 +7,7 @@ import AppKit import Combine import Foundation import os +import TableProPluginKit @MainActor internal enum SampleDatabaseLauncher { diff --git a/TablePro/ViewModels/WelcomeViewModel.swift b/TablePro/ViewModels/WelcomeViewModel.swift index 14c0bd668..7cc1d1ee7 100644 --- a/TablePro/ViewModels/WelcomeViewModel.swift +++ b/TablePro/ViewModels/WelcomeViewModel.swift @@ -7,6 +7,7 @@ import AppKit import Combine import os import SwiftUI +import TableProPluginKit enum WelcomeActiveSheet: Identifiable { case newGroup(parentId: UUID?) From e593ed42c6f5118d032cca4575b6d1258efc0e07 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 18 May 2026 11:27:50 +0700 Subject: [PATCH 13/13] fix(ssl): security-review fixes (sanitize error messages, accurate Oracle warning, throw on Cassandra cert load fail, tighten classifier patterns) --- .../CassandraPlugin.swift | 26 ++++++++++++++----- .../MSSQLDriverPlugin/FreeTDSConnection.swift | 2 +- .../MongoDBConnection.swift | 2 +- .../TableProPluginKit/SSLHandshakeError.swift | 22 ++++++++++++++-- TablePro/Resources/Localizable.xcstrings | 3 --- .../Views/Connection/ConnectionSSLView.swift | 11 ++++++-- .../PluginSSLClassifiers.swift | 4 +-- .../Plugins/SSLHandshakeErrorTests.swift | 23 ++++++++++++++++ 8 files changed, 75 insertions(+), 18 deletions(-) diff --git a/Plugins/CassandraDriverPlugin/CassandraPlugin.swift b/Plugins/CassandraDriverPlugin/CassandraPlugin.swift index 03e40b865..5e743949a 100644 --- a/Plugins/CassandraDriverPlugin/CassandraPlugin.swift +++ b/Plugins/CassandraDriverPlugin/CassandraPlugin.swift @@ -160,14 +160,26 @@ private actor CassandraConnectionActor { cass_ssl_set_verify_flags(ssl, CassandraSSLMapping.verifyFlags(for: sslMode)) - if sslMode == .verifyCa || sslMode == .verifyIdentity, - let caCertPath = sslCaCertPath, !caCertPath.isEmpty, - let certData = FileManager.default.contents(atPath: caCertPath), - let certString = String(data: certData, encoding: .utf8) { + 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 { - Self.logger.warning("Failed to add CA certificate, proceeding without verification") - cass_ssl_set_verify_flags(ssl, Int32(CASS_SSL_VERIFY_NONE.rawValue)) + cass_ssl_free(ssl) + cass_cluster_free(cluster) + self.cluster = nil + throw SSLHandshakeError.untrustedCertificate(serverMessage: "CA certificate at \(caCertPath) is not a valid PEM") } } @@ -845,7 +857,7 @@ private actor CassandraConnectionActor { break } let lower = message.lowercased() - if lower.contains("ssl") && (lower.contains("handshake") || lower.contains("verify")) { + if lower.contains("ssl handshake") || lower.contains("tls handshake") || lower.contains("ssl_connect") { return .cipherMismatch(serverMessage: message) } return nil diff --git a/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift b/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift index fe9ede42a..d577cfcbf 100644 --- a/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift +++ b/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift @@ -546,7 +546,7 @@ nonisolated final class FreeTDSConnection: @unchecked Sendable { if lower.contains("does not match host") { return .hostnameMismatch(serverMessage: message) } - if lower.contains("ssl handshake") || lower.contains("tls handshake") || lower.contains("openssl") { + if lower.contains("ssl handshake") || lower.contains("tls handshake") || lower.contains("openssl error") { return .cipherMismatch(serverMessage: message) } return nil diff --git a/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift b/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift index ba5d4e35a..f82187aa4 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift @@ -1268,7 +1268,7 @@ private extension MongoDBConnection { if lower.contains("tls required") || lower.contains("ssl required") { return .serverRejectedPlaintext(serverMessage: message) } - if lower.contains("client certificate") { + if lower.contains("client certificate required") || lower.contains("peer did not return a certificate") { return .clientCertRequired(serverMessage: message) } return nil diff --git a/Plugins/TableProPluginKit/SSLHandshakeError.swift b/Plugins/TableProPluginKit/SSLHandshakeError.swift index ec8f261ea..5c0124e77 100644 --- a/Plugins/TableProPluginKit/SSLHandshakeError.swift +++ b/Plugins/TableProPluginKit/SSLHandshakeError.swift @@ -52,10 +52,25 @@ public enum SSLHandshakeError: Error, LocalizedError, Sendable { if let suggestion = sslError.recoverySuggestion { parts.append(suggestion) } - parts.append(String(format: String(localized: "Server response: %@"), sslError.serverMessage)) + parts.append(String(format: String(localized: "Server response: %@"), sanitize(sslError.serverMessage))) return parts.joined(separator: "\n\n") } + static func sanitize(_ message: String) -> String { + var redacted = message + let userInfo = try? NSRegularExpression(pattern: "://[^/@\\s]+:[^/@\\s]+@", options: []) + if let userInfo { + let range = NSRange(redacted.startIndex..