diff --git a/CHANGELOG.md b/CHANGELOG.md index b0475b72e..2afcb236b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,11 +21,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Drivers populate allowed enum values directly in column metadata instead of parsing them downstream - PluginKit ABI bumped to version 13; all registry plugins need to be re-tagged -- New PostgreSQL, SQL Server, Redshift, and CockroachDB connections default SSL mode to Preferred, matching libpq and FreeTDS native behavior +- New PostgreSQL, MySQL, MariaDB, SQL Server, Redshift, and CockroachDB connections default SSL mode to Preferred, matching libpq, libmariadb 2-pass, and FreeTDS native behavior +- Oracle SSL mode is now honored: Required, Verify CA, and Verify Identity wire through to OracleNIO TCPS (was silently ignored) +- Cassandra SSL mode is now honored via the standard SSL pane (was silently ignored because the plugin read from a hidden field that was never set) +- MySQL and MariaDB Preferred SSL mode now performs a real 2-pass connect: tries TLS first, falls back to plaintext only on SSL handshake errors (CR_SSL_CONNECTION_ERROR, CR_SERVER_HANDSHAKE_ERR, ER_HANDSHAKE_ERROR) +- SSL pane shows per-engine guidance explaining how that driver handles Preferred, when TLS is required by hosted providers, and any driver-specific quirks +- Failed connections caused by SSL/TLS handshake errors now show a structured message that names the cause (server requires encryption, server rejects encryption, untrusted certificate, hostname mismatch, client cert required, cipher mismatch) and recommends a specific SSL Mode to switch to. Covers PostgreSQL, MySQL/MariaDB, SQL Server, Oracle, MongoDB, Redis, Cassandra, and ClickHouse. +- SSL pane warns inline when a driver does not support TLS fallback for Preferred mode (MongoDB, Redis, Cassandra, ScyllaDB, ClickHouse, Oracle, etcd), so the user knows Preferred behaves the same as Required for that engine. +- Welcome screen connection errors (single-click connect, sample database launch) also surface the structured SSL handshake message when applicable +- All driver SSL mapping logic now lives in dedicated `XxxSSLMapping` files (PostgreSQL, MSSQL, Cassandra, MongoDB, Oracle); ClickHouse and Redis keep their existing encapsulated helpers ### Fixed - PostgreSQL connections to AWS RDS, Cloud SQL, Azure, and other hosted Postgres now succeed out of the box instead of failing with "no pg_hba.conf entry for host" (#1298) +- Oracle: SSL/TCPS settings from the SSL pane are now respected; previously every Oracle connection was plain TCP regardless of SSL mode +- Cassandra: SSL settings from the SSL pane are now respected; previously every Cassandra connection was plain TCP because the plugin read from a non-existent "sslMode" field +- MySQL/MariaDB Cloud SQL, Azure Database, and other hosted MySQL servers that require TLS no longer fail with "Connections using insecure transport are prohibited" when SSL mode is Preferred ## [0.42.0] - 2026-05-16 diff --git a/Plugins/CassandraDriverPlugin/CassandraPlugin.swift b/Plugins/CassandraDriverPlugin/CassandraPlugin.swift index ff7e2f22f..5e743949a 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 @@ -143,7 +136,7 @@ private actor CassandraConnectionActor { username: String?, password: String?, keyspace: String?, - sslMode: String, + sslMode: SSLMode, sslCaCertPath: String? ) throws { cluster = cass_cluster_new() @@ -158,34 +151,36 @@ private actor CassandraConnectionActor { cass_cluster_set_credentials(cluster, username, password) } - // SSL/TLS - if sslMode != "Disabled" { + if sslMode != .disabled { guard let ssl = cass_ssl_new() else { cass_cluster_free(cluster) self.cluster = nil throw CassandraPluginError.connectionFailed("Failed to create SSL context") } - if sslMode == "Verify CA" || sslMode == "Verify Identity" { - if sslMode == "Verify Identity" { - let flags = Int32(CASS_SSL_VERIFY_PEER_CERT.rawValue | CASS_SSL_VERIFY_PEER_IDENTITY.rawValue) - cass_ssl_set_verify_flags(ssl, flags) - } else { - cass_ssl_set_verify_flags(ssl, Int32(CASS_SSL_VERIFY_PEER_CERT.rawValue)) - } + cass_ssl_set_verify_flags(ssl, CassandraSSLMapping.verifyFlags(for: sslMode)) - if let caCertPath = sslCaCertPath, !caCertPath.isEmpty, - let certData = FileManager.default.contents(atPath: caCertPath), - let certString = String(data: certData, encoding: .utf8) { - let rc = cass_ssl_add_trusted_cert(ssl, certString) - if rc != CASS_OK { - Self.logger.warning("Failed to add CA certificate, proceeding without verification") - cass_ssl_set_verify_flags(ssl, Int32(CASS_SSL_VERIFY_NONE.rawValue)) - } + if sslMode == .verifyCa || sslMode == .verifyIdentity { + guard let caCertPath = sslCaCertPath, !caCertPath.isEmpty else { + cass_ssl_free(ssl) + cass_cluster_free(cluster) + self.cluster = nil + throw SSLHandshakeError.untrustedCertificate(serverMessage: "Verify CA or Verify Identity requires a CA certificate path") + } + guard let certData = FileManager.default.contents(atPath: caCertPath), + let certString = String(data: certData, encoding: .utf8) else { + cass_ssl_free(ssl) + cass_cluster_free(cluster) + self.cluster = nil + throw SSLHandshakeError.untrustedCertificate(serverMessage: "Could not read CA certificate at \(caCertPath)") + } + let rc = cass_ssl_add_trusted_cert(ssl, certString) + if rc != CASS_OK { + cass_ssl_free(ssl) + cass_cluster_free(cluster) + self.cluster = nil + throw SSLHandshakeError.untrustedCertificate(serverMessage: "CA certificate at \(caCertPath) is not a valid PEM") } - } else { - // "Preferred" / "Required" — encrypt but skip cert verification - cass_ssl_set_verify_flags(ssl, Int32(CASS_SSL_VERIFY_NONE.rawValue)) } cass_cluster_set_ssl(cluster, ssl) @@ -228,6 +223,9 @@ private actor CassandraConnectionActor { cass_session_free(newSession) cass_cluster_free(cluster) self.cluster = nil + if let sslError = Self.classifySSLError(rc: rc, message: errorMessage) { + throw sslError + } throw CassandraPluginError.connectionFailed(errorMessage) } @@ -844,6 +842,26 @@ private actor CassandraConnectionActor { private func escapeIdentifier(_ value: String) -> String { value.replacingOccurrences(of: "\"", with: "\"\"") } + + static func classifySSLError(rc: CassError, message: String) -> SSLHandshakeError? { + switch rc { + case CASS_ERROR_SSL_NO_PEER_CERT, CASS_ERROR_SSL_INVALID_PEER_CERT: + return .untrustedCertificate(serverMessage: message) + case CASS_ERROR_SSL_IDENTITY_MISMATCH: + return .hostnameMismatch(serverMessage: message) + case CASS_ERROR_SSL_INVALID_PRIVATE_KEY, CASS_ERROR_SSL_INVALID_CERT: + return .clientCertRequired(serverMessage: message) + case CASS_ERROR_SSL_PROTOCOL_ERROR: + return .cipherMismatch(serverMessage: message) + default: + break + } + let lower = message.lowercased() + if lower.contains("ssl handshake") || lower.contains("tls handshake") || lower.contains("ssl_connect") { + return .cipherMismatch(serverMessage: message) + } + return nil + } } // MARK: - Raw Result @@ -900,19 +918,18 @@ internal final class CassandraPluginDriver: PluginDatabaseDriver, @unchecked Sen // MARK: - Connection func connect() async throws { - let sslMode = config.additionalFields["sslMode"] ?? "Disabled" - let sslCaCertPath = config.additionalFields["sslCaCertPath"] - let keyspace = config.database.isEmpty ? nil : config.database + let legacyCaPath = config.additionalFields["sslCaCertPath"] + let resolvedCaPath = config.ssl.caCertificatePath.isEmpty ? legacyCaPath : config.ssl.caCertificatePath try await connectionActor.connect( host: config.host, - port: Int(config.port) ?? 9042, + port: Int(config.port) ?? 9_042, username: config.username.isEmpty ? nil : config.username, password: config.password.isEmpty ? nil : config.password, keyspace: keyspace, - sslMode: sslMode, - sslCaCertPath: sslCaCertPath + sslMode: config.ssl.mode, + sslCaCertPath: resolvedCaPath ) if let keyspace { 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/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..d577cfcbf 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") @@ -166,6 +167,9 @@ nonisolated final class FreeTDSConnection: @unchecked Sendable { guard let proc = dbopen(login, serverName) else { let detail = freetdsGetError(for: nil) let msg = detail.isEmpty ? "Check host, port, credentials, and TLS settings" : detail + if let sslError = FreeTDSConnection.classifySSLError(detail) { + throw sslError + } throw MSSQLCoreError.connectionFailed("Failed to connect to \(options.host):\(options.port): \(msg)") } @@ -527,4 +531,24 @@ nonisolated final class FreeTDSConnection: @unchecked Sendable { } return raw } + + static func classifySSLError(_ message: String) -> SSLHandshakeError? { + let lower = message.lowercased() + if lower.contains("encryption is required") || lower.contains("server requires encryption") { + return .serverRejectedPlaintext(serverMessage: message) + } + if lower.contains("encryption not supported") || lower.contains("server does not support encryption") { + return .serverRequiresPlaintext(serverMessage: message) + } + if lower.contains("certificate verify failed") || lower.contains("certificate is not trusted") { + return .untrustedCertificate(serverMessage: message) + } + if lower.contains("does not match host") { + return .hostnameMismatch(serverMessage: message) + } + if lower.contains("ssl handshake") || lower.contains("tls handshake") || lower.contains("openssl error") { + return .cipherMismatch(serverMessage: message) + } + return nil + } } diff --git a/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift b/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift index 198828edc..f82187aa4 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)") @@ -322,6 +300,9 @@ final class MongoDBConnection: @unchecked Sendable { let errorMsg = bsonErrorMessage(&error) mongoc_client_destroy(newClient) logger.error("MongoDB ping failed: \(errorMsg)") + if let sslError = Self.classifySSLError(errorMsg) { + throw sslError + } throw MongoDBError(code: error.code, message: errorMsg) } @@ -1272,6 +1253,26 @@ private extension MongoDBConnection { return nil #endif } + + static func classifySSLError(_ message: String) -> SSLHandshakeError? { + let lower = message.lowercased() + if lower.contains("ssl handshake failed") || lower.contains("tls handshake failed") { + return .cipherMismatch(serverMessage: message) + } + if lower.contains("certificate verify failed") || lower.contains("ssl certificate") { + return .untrustedCertificate(serverMessage: message) + } + if lower.contains("hostname") && lower.contains("verification") { + return .hostnameMismatch(serverMessage: message) + } + if lower.contains("tls required") || lower.contains("ssl required") { + return .serverRejectedPlaintext(serverMessage: message) + } + if lower.contains("client certificate required") || lower.contains("peer did not return a certificate") { + return .clientCertRequired(serverMessage: message) + } + return nil + } } // bsonToDict and bsonToJson take bson_t parameters (a CLibMongoc type), 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/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift b/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift index 04f36644e..132c6b8d8 100644 --- a/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift +++ b/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift @@ -199,133 +199,157 @@ 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") + 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 } - 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) + 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 + } - var readTimeout: UInt32 = 30 - mysql_options(mysql, MYSQL_OPT_READ_TIMEOUT, &readTimeout) + private func attemptConnect(enforceSSL: Bool) throws -> UnsafeMutablePointer { + guard let mysql = mysql_init(nil) else { + throw MariaDBPluginError.initFailed + } - var writeTimeout: UInt32 = 30 - mysql_options(mysql, MYSQL_OPT_WRITE_TIMEOUT, &writeTimeout) + var reconnect: my_bool = 0 + mysql_options(mysql, MYSQL_OPT_RECONNECT, &reconnect) - var protocol_tcp = UInt32(MYSQL_PROTOCOL_TCP.rawValue) - mysql_options(mysql, MYSQL_OPT_PROTOCOL, &protocol_tcp) + var timeout: UInt32 = 10 + mysql_options(mysql, MYSQL_OPT_CONNECT_TIMEOUT, &timeout) - 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 readTimeout: UInt32 = 30 + mysql_options(mysql, MYSQL_OPT_READ_TIMEOUT, &readTimeout) - 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 writeTimeout: UInt32 = 30 + mysql_options(mysql, MYSQL_OPT_WRITE_TIMEOUT, &writeTimeout) - 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 protocol_tcp = UInt32(MYSQL_PROTOCOL_TCP.rawValue) + mysql_options(mysql, MYSQL_OPT_PROTOCOL, &protocol_tcp) - 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 sslEnforce: my_bool = enforceSSL ? 1 : 0 + mysql_options(mysql, MYSQL_OPT_SSL_ENFORCE, &sslEnforce) - mysql_options(mysql, MYSQL_SET_CHARSET_NAME, "utf8mb4") + var sslVerify: my_bool = sslConfig.verifiesCertificate ? 1 : 0 + mysql_options(mysql, MYSQL_OPT_SSL_VERIFY_SERVER_CERT, &sslVerify) - let dbToUse = self.database.isEmpty ? nil : self.database - let passToUse = self.password + 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 result: UnsafeMutablePointer? + mysql_options(mysql, MYSQL_SET_CHARSET_NAME, "utf8mb4") - 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 dbToUse = database.isEmpty ? nil : database + let passToUse = password + + 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..28e4d7403 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 OracleSSLMapping.tls(for: sslConfig) 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 @@ -173,14 +186,42 @@ 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("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/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/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 + } + } +} 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/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)") } diff --git a/Plugins/TableProPluginKit/SSLHandshakeError.swift b/Plugins/TableProPluginKit/SSLHandshakeError.swift new file mode 100644 index 000000000..5c0124e77 --- /dev/null +++ b/Plugins/TableProPluginKit/SSLHandshakeError.swift @@ -0,0 +1,95 @@ +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 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: %@"), 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.. [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/PluginTestSources/PluginSSLClassifiers.swift b/TableProTests/PluginTestSources/PluginSSLClassifiers.swift new file mode 100644 index 000000000..02b2fac7b --- /dev/null +++ b/TableProTests/PluginTestSources/PluginSSLClassifiers.swift @@ -0,0 +1,159 @@ +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 error") { + 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 required") || lower.contains("peer did not return a 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("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/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/PluginSSLClassifierTests.swift b/TableProTests/Plugins/PluginSSLClassifierTests.swift new file mode 100644 index 000000000..82f7ee681 --- /dev/null +++ b/TableProTests/Plugins/PluginSSLClassifierTests.swift @@ -0,0 +1,211 @@ +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("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") + 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/TableProTests/Plugins/SSLHandshakeErrorTests.swift b/TableProTests/Plugins/SSLHandshakeErrorTests.swift new file mode 100644 index 000000000..c6a546935 --- /dev/null +++ b/TableProTests/Plugins/SSLHandshakeErrorTests.swift @@ -0,0 +1,87 @@ +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("formatted() redacts password from libpq-style conninfo") + func testSanitizeKeyValuePassword() { + let error = SSLHandshakeError.untrustedCertificate(serverMessage: "host=db.example.com user=root password=Sup3rS3cret port=5432") + let formatted = SSLHandshakeError.formatted(error) + #expect(!formatted.contains("Sup3rS3cret")) + #expect(formatted.contains("password=[redacted]")) + } + + @Test("formatted() redacts password from URL userinfo segment") + func testSanitizeURLUserInfo() { + let error = SSLHandshakeError.serverRejectedPlaintext(serverMessage: "Failed: postgresql://admin:LeakedPass@db.example.com/app") + let formatted = SSLHandshakeError.formatted(error) + #expect(!formatted.contains("LeakedPass")) + #expect(formatted.contains("://[redacted]@")) + } + + @Test("formatted() leaves non-credential text untouched") + func testSanitizePreservesContent() { + let error = SSLHandshakeError.cipherMismatch(serverMessage: "no shared cipher between client and server") + let formatted = SSLHandshakeError.formatted(error) + #expect(formatted.contains("no shared cipher")) + } + + @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) + } + } +} 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 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 +```