diff --git a/client-v2/src/main/java/com/clickhouse/client/api/Client.java b/client-v2/src/main/java/com/clickhouse/client/api/Client.java index 3bf94c529..98e82ca26 100644 --- a/client-v2/src/main/java/com/clickhouse/client/api/Client.java +++ b/client-v2/src/main/java/com/clickhouse/client/api/Client.java @@ -80,6 +80,8 @@ import java.util.function.Supplier; import java.util.stream.Collectors; +import javax.net.ssl.SSLContext; + /** *
Client is the starting point for all interactions with ClickHouse.
* @@ -147,8 +149,14 @@ public class Client implements AutoCloseable { private Client(CollectionWhen a context is set, the client uses it as is - it is the application's responsibility to + * configure it correctly. Trust- and key-material options ({@link Builder#setSSLTrustStore(String)}, + * {@link Builder#setRootCertificate(String)}, {@link Builder#setClientCertificate(String)}, ...) + * are then ignored because they only feed the context the client would otherwise build. + * {@link SSLMode} still applies, but only to server hostname verification: {@link SSLMode#TRUST} + * and {@link SSLMode#VERIFY_CA} skip the hostname check while {@link SSLMode#STRICT} (default) + * enforces it.
+ * + *This is primarily useful when certificates and keys are held in memory (for example, loaded + * from a secret store) and must never be written to disk.
+ * + * @param sslContext a fully configured SSL context; {@code null} clears any previously set context + * @return same instance of the builder + */ + public Builder setSSLContext(SSLContext sslContext) { + this.sslContext = sslContext; + return this; + } + /** * Configure client to use server timezone for date/datetime columns. Default is true. * If this options is selected then server timezone should be set as well. @@ -1165,7 +1197,21 @@ public Client build() { CredentialsManager cManager = new CredentialsManager(this.configuration); - if (configuration.containsKey(ClientConfigProperties.SSL_TRUST_STORE.getKey()) && + // A pre-built SSLContext is a live object and can only be supplied programmatically via + // setSSLContext(SSLContext). A textual 'ssl_context' value (e.g. from setOption(...), a JDBC + // property, or a URL query parameter) can never represent a real context, so it is rejected + // here instead of being silently ignored when the SSL context is created. + if (configuration.containsKey(ClientConfigProperties.SSL_CONTEXT.getKey())) { + throw new ClientMisconfigurationException("'" + ClientConfigProperties.SSL_CONTEXT.getKey() + + "' cannot be set as a string; supply a javax.net.ssl.SSLContext object via " + + "Client.Builder.setSSLContext(...)"); + } + + // Trust- and key-material options only feed a context the client would otherwise build. When + // the application supplies its own SSLContext they are ignored (see createSSLContext), so this + // conflict cannot arise and must not be reported. + if (this.sslContext == null && + configuration.containsKey(ClientConfigProperties.SSL_TRUST_STORE.getKey()) && configuration.containsKey(ClientConfigProperties.SSL_CERTIFICATE.getKey())) { throw new ClientMisconfigurationException("Trust store and certificates cannot be used together"); } @@ -1229,7 +1275,8 @@ public Client build() { } return new Client(this.endpoints, this.configuration, this.sharedOperationExecutor, - this.columnToMethodMatchingStrategy, this.metricRegistry, this.queryIdGenerator, cManager); + this.columnToMethodMatchingStrategy, this.metricRegistry, this.queryIdGenerator, cManager, + this.sslContext); } } diff --git a/client-v2/src/main/java/com/clickhouse/client/api/ClientConfigProperties.java b/client-v2/src/main/java/com/clickhouse/client/api/ClientConfigProperties.java index 9a38232ba..5a6f04e65 100644 --- a/client-v2/src/main/java/com/clickhouse/client/api/ClientConfigProperties.java +++ b/client-v2/src/main/java/com/clickhouse/client/api/ClientConfigProperties.java @@ -8,6 +8,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import javax.net.ssl.SSLContext; + import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -118,6 +120,15 @@ public enum ClientConfigProperties { SSL_MODE("ssl_mode", SSLMode.class, SSLMode.STRICT.name()), + /** + * A pre-built {@link javax.net.ssl.SSLContext} supplied by the application. When set, the client uses + * it as is instead of building one from the configured trust/key material, and {@link #SSL_MODE} then + * only controls server hostname verification. The value is a live object, so it can only be provided + * programmatically (for example via {@code Client.Builder#setSSLContext}); it is never parsed from a + * string and has no textual representation in a configuration map. + */ + SSL_CONTEXT("ssl_context", SSLContext.class), + RETRY_ON_FAILURE("retry", Integer.class, "3"), INPUT_OUTPUT_FORMAT("format", ClickHouseFormat.class), diff --git a/client-v2/src/main/java/com/clickhouse/client/api/internal/HttpAPIClientHelper.java b/client-v2/src/main/java/com/clickhouse/client/api/internal/HttpAPIClientHelper.java index ab4b0153c..fb1f80426 100644 --- a/client-v2/src/main/java/com/clickhouse/client/api/internal/HttpAPIClientHelper.java +++ b/client-v2/src/main/java/com/clickhouse/client/api/internal/HttpAPIClientHelper.java @@ -159,6 +159,15 @@ public HttpAPIClientHelper(MapMore SSL examples (mTLS, trust stores, SNI) will be added to this class later.
@@ -70,6 +81,7 @@ public static void main(String[] args) { if (rootCert != null) { connectWithCustomRootCertificate(endpoint, database, user, password, rootCert); connectWithRootCertificateAsString(endpoint, database, user, password, rootCert); + connectWithCustomSSLContext(endpoint, database, user, password, rootCert); } else { log.info("chRootCert is not set - skipping the custom CA certificate examples. " + "Pass the path to the CA certificate (PEM) that signed the server certificate to run them."); @@ -88,6 +100,8 @@ public static void main(String[] args) { SecureServerSupport.USER, SecureServerSupport.PASSWORD, server.getCaCertPath()); connectWithRootCertificateAsString(server.getEndpoint(), database, SecureServerSupport.USER, SecureServerSupport.PASSWORD, server.getCaCertPath()); + connectWithCustomSSLContext(server.getEndpoint(), database, + SecureServerSupport.USER, SecureServerSupport.PASSWORD, server.getCaCertPath()); } catch (Exception e) { log.error("Failed to run the SSL example against a local Docker server", e); } @@ -192,6 +206,60 @@ static void connectWithRootCertificateAsString(String endpoint, String database, } } + /** + * Connects using a fully pre-built {@link SSLContext} supplied with + * {@link Client.Builder#setSSLContext(SSLContext)}. + * + *This mirrors an enterprise use-case where certificates and keys are held only in memory + * (for example fetched and decrypted from a secret store) and must never be written to disk. + * The whole {@link SSLContext} is assembled by the application and handed to the client, which + * uses it as is - the CA certificate, trust store and client certificate/key builder options are + * then ignored. {@link SSLMode} still applies to server hostname verification only; here the + * default {@link SSLMode#STRICT} keeps full verification because the in-memory trust material + * validates the whole certificate chain.
+ */ + static void connectWithCustomSSLContext(String endpoint, String database, String user, String password, + String rootCertPath) { + final SSLContext sslContext; + try { + // Build the trust material entirely in memory. In a real application the PEM bytes would come + // from a secret manager; here we read the file generated for this example to keep it runnable. + byte[] caPem = Files.readAllBytes(Paths.get(rootCertPath)); + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + X509Certificate caCert = (X509Certificate) cf.generateCertificate(new ByteArrayInputStream(caPem)); + + KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); + trustStore.load(null, null); + trustStore.setCertificateEntry("ca", caCert); + + TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(trustStore); + + sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, tmf.getTrustManagers(), null); + } catch (Exception e) { + log.error("Failed to build the in-memory SSLContext from {}", rootCertPath, e); + return; + } + + log.info("Connecting to {} using an application-supplied in-memory SSLContext", endpoint); + try (Client client = new Client.Builder() + .addEndpoint(endpoint) + .setUsername(user) + .setPassword(password) + .setDefaultDatabase(database) + // The client uses this context as is; trust/key builder options would be ignored. + .setSSLContext(sslContext) + .build()) { + + ListMore SSL examples (mTLS, trust stores, SNI) will be added to this class later.
@@ -72,6 +84,7 @@ public static void main(String[] args) { if (rootCert != null) { connectWithCustomRootCertificate(url, user, password, rootCert); connectWithRootCertificateAsString(url, user, password, rootCert); + connectWithCustomSSLContext(url, user, password, rootCert); } else { log.info("chRootCert is not set - skipping the custom CA certificate examples. " + "Pass the path to the CA certificate (PEM) that signed the server certificate to run them."); @@ -93,6 +106,8 @@ public static void main(String[] args) { SecureServerSupport.USER, SecureServerSupport.PASSWORD, server.getCaCertPath()); connectWithRootCertificateAsString(server.getJdbcUrl(), SecureServerSupport.USER, SecureServerSupport.PASSWORD, server.getCaCertPath()); + connectWithCustomSSLContext(server.getJdbcUrl(), + SecureServerSupport.USER, SecureServerSupport.PASSWORD, server.getCaCertPath()); } catch (Exception e) { log.error("Failed to run the SSL example against a local Docker server", e); Runtime.getRuntime().exit(-1); @@ -196,6 +211,64 @@ static void connectWithRootCertificateAsString(String url, String user, String p } } + /** + * Connects by passing a fully pre-built {@link SSLContext} as a live object in the connection + * {@link Properties} under the {@code ssl_context} key. + * + *This mirrors an enterprise use-case where certificates and keys are held only in memory + * (for example fetched and decrypted from a secret store) and must never be written to disk, and + * where the application is confined to the {@link java.sql.DriverManager}/{@link Properties} API + * behind a connection pool such as HikariCP. Because an {@link SSLContext} cannot be represented + * as a string, it is added with {@link Properties#put(Object, Object)} (not + * {@link Properties#setProperty(String, String)}). The driver uses the context as is - the + * {@code sslrootcert}/{@code sslcert} properties would be ignored - and {@code ssl_mode} + * (default {@code strict}) then only controls hostname verification.
+ */ + static void connectWithCustomSSLContext(String url, String user, String password, String rootCertPath) + throws SQLException, IOException { + // In a real application the PEM content would typically come from an env variable or a secret + // manager; here we read the file generated for this example to keep it runnable. + byte[] caPem = Files.readAllBytes(Paths.get(rootCertPath)); + + final SSLContext sslContext; + try { + // Assemble the trust material and the SSLContext entirely in memory. + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + X509Certificate caCert = (X509Certificate) cf.generateCertificate(new ByteArrayInputStream(caPem)); + + KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); + trustStore.load(null, null); + trustStore.setCertificateEntry("ca", caCert); + + TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(trustStore); + + sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, tmf.getTrustManagers(), null); + } catch (GeneralSecurityException | IOException e) { + log.error("Failed to build the in-memory SSLContext from {}", rootCertPath, e); + return; + } + + log.info("Connecting to {} using an application-supplied in-memory SSLContext", url); + + Properties properties = new Properties(); + properties.setProperty(ClientConfigProperties.USER.getKey(), user); // user + properties.setProperty(ClientConfigProperties.PASSWORD.getKey(), password); // password + properties.setProperty("ssl", "true"); // enable TLS even if the URL has no https scheme + // The SSLContext is a live object, so it is added with put(...) rather than setProperty(...). + properties.put(ClientConfigProperties.SSL_CONTEXT.getKey(), sslContext); // ssl_context + + try (Connection connection = DriverManager.getConnection(url, properties); + Statement stmt = connection.createStatement(); + ResultSet rs = stmt.executeQuery("SELECT currentUser() AS user, version() AS version")) { + if (rs.next()) { + log.info("Connected securely (custom SSLContext) as '{}' to ClickHouse {}", + rs.getString("user"), rs.getString("version")); + } + } + } + private static String trimToNull(String value) { if (value == null) { return null; diff --git a/jdbc-v2/src/main/java/com/clickhouse/jdbc/internal/JdbcConfiguration.java b/jdbc-v2/src/main/java/com/clickhouse/jdbc/internal/JdbcConfiguration.java index 99669b981..2e4a85903 100644 --- a/jdbc-v2/src/main/java/com/clickhouse/jdbc/internal/JdbcConfiguration.java +++ b/jdbc-v2/src/main/java/com/clickhouse/jdbc/internal/JdbcConfiguration.java @@ -31,6 +31,8 @@ import java.util.regex.Pattern; import java.util.stream.Collectors; +import javax.net.ssl.SSLContext; + public class JdbcConfiguration { public static final Logger LOG = LoggerFactory.getLogger(JdbcConfiguration.class); @@ -74,6 +76,13 @@ public Map