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(Collection endpoints, Map configuration, ExecutorService sharedOperationExecutor, ColumnToMethodMatchingStrategy columnToMethodMatchingStrategy, - Object metricsRegistry, Supplier queryIdGenerator, CredentialsManager cManager) { + Object metricsRegistry, Supplier queryIdGenerator, CredentialsManager cManager, + SSLContext sslContext) { Map parsedConfiguration = new ConcurrentHashMap<>(ClientConfigProperties.parseConfigMap(configuration)); + // A pre-built SSLContext is a live object and cannot travel through the string-based configuration + // map, so it is injected into the parsed (object) configuration directly. + if (sslContext != null) { + parsedConfiguration.put(ClientConfigProperties.SSL_CONTEXT.getKey(), sslContext); + } this.credentialsManager = cManager; this.session = Session.extractFrom(parsedConfiguration); this.configuration = new ConcurrentHashMap<>(parsedConfiguration); @@ -270,6 +278,7 @@ public static class Builder { private ColumnToMethodMatchingStrategy columnToMethodMatchingStrategy; private Object metricRegistry = null; private Supplier queryIdGenerator; + private SSLContext sslContext = null; public Builder() { this.endpoints = new HashSet<>(); @@ -785,6 +794,29 @@ public Builder setSSLMode(SSLMode sslMode) { return this; } + /** + * Supplies a pre-built {@link SSLContext} to be used for secure connections instead of one built + * from the configured trust/key material (trust store, CA certificate, client certificate/key). + * + *

When 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(Map configuration, Object metricsRegi * @return SSLContext */ public SSLContext createSSLContext(Map configuration) { + // A pre-built SSLContext supplied by the application is used as is; the client does not build one + // from the configured trust/key material. Server hostname verification is still governed by the + // SSL mode where the connection socket factory is created (see createHttpClient). + final Object customSSLContext = configuration.get(ClientConfigProperties.SSL_CONTEXT.getKey()); + if (customSSLContext instanceof SSLContext) { + LOG.debug("Using application-supplied SSLContext; trust/key material options are ignored."); + return (SSLContext) customSSLContext; + } + final SSLMode sslMode = ClientConfigProperties.SSL_MODE.getOrDefault(configuration); final String trustStorePath = (String) configuration.get(ClientConfigProperties.SSL_TRUST_STORE.getKey()); final String caCertificate = (String) configuration.get(ClientConfigProperties.CA_CERTIFICATE.getKey()); diff --git a/client-v2/src/test/java/com/clickhouse/client/api/ClientBuilderTest.java b/client-v2/src/test/java/com/clickhouse/client/api/ClientBuilderTest.java index baccdc767..1982c7747 100644 --- a/client-v2/src/test/java/com/clickhouse/client/api/ClientBuilderTest.java +++ b/client-v2/src/test/java/com/clickhouse/client/api/ClientBuilderTest.java @@ -1,12 +1,16 @@ package com.clickhouse.client.api; import com.clickhouse.client.api.enums.SSLMode; +import com.clickhouse.client.api.internal.HttpAPIClientHelper; import org.testng.Assert; import org.testng.annotations.DataProvider; import org.testng.annotations.Test; +import javax.net.ssl.SSLContext; import java.lang.reflect.Field; +import java.util.HashMap; import java.util.List; +import java.util.Map; public class ClientBuilderTest { @@ -86,6 +90,123 @@ public void testSslModeInvalidValueRejected() { .build()); } + @Test + public void testStringSSLContextRejected() { + // ssl_context is an object-only property; a textual value can never be a real context, so the + // builder must reject it instead of silently ignoring it (previously parsed to null and dropped). + Assert.expectThrows(ClientMisconfigurationException.class, () -> new Client.Builder() + .addEndpoint("https://localhost:8443") + .setUsername("default") + .setPassword("") + .setOption(ClientConfigProperties.SSL_CONTEXT.getKey(), "not-a-context") + .build()); + } + + @Test + public void testCustomSSLContextIgnoresTrustAndKeyMaterialConflict() throws Exception { + SSLContext customContext = SSLContext.getInstance("TLS"); + customContext.init(null, null, null); + + // A trust store and a client certificate normally conflict, but when a custom SSLContext is + // supplied that material is ignored, so the conflict must not be reported and the client builds. + try (Client client = new Client.Builder() + .addEndpoint("https://localhost:8443") + .setUsername("default") + .setPassword("") + .setSSLTrustStore("/path/to/truststore.jks") + .setClientCertificate("client.crt") + .setSSLContext(customContext) + .build()) { + Assert.assertSame(extractConfiguration(client).get(ClientConfigProperties.SSL_CONTEXT.getKey()), + customContext, "The application-supplied SSLContext should be used as is"); + } + } + + @Test + public void testTrustStoreAndClientCertificateConflictRejectedWithoutCustomContext() { + // Contrast case: without a custom SSLContext the trust-store/certificate conflict must still be + // rejected exactly as before - the fix only suppresses the check when a context is supplied. + Assert.expectThrows(ClientMisconfigurationException.class, () -> new Client.Builder() + .addEndpoint("https://localhost:8443") + .setUsername("default") + .setPassword("") + .setSSLTrustStore("/path/to/truststore.jks") + .setClientCertificate("client.crt") + .build()); + } + + @Test + public void testSetSSLContextStoredInConfiguration() throws Exception { + SSLContext customContext = SSLContext.getInstance("TLS"); + customContext.init(null, null, null); + + try (Client client = new Client.Builder() + .addEndpoint("https://localhost:8443") + .setUsername("default") + .setPassword("") + .setSSLContext(customContext) + .build()) { + Assert.assertSame(extractConfiguration(client).get(ClientConfigProperties.SSL_CONTEXT.getKey()), + customContext, "The application-supplied SSLContext should be stored in the configuration"); + } + + // Without setSSLContext the key must be absent so the client builds its own context. + try (Client client = new Client.Builder() + .addEndpoint("https://localhost:8443") + .setUsername("default") + .setPassword("") + .build()) { + Assert.assertNull(extractConfiguration(client).get(ClientConfigProperties.SSL_CONTEXT.getKey()), + "No SSLContext should be stored when none is supplied"); + } + } + + @Test + public void testCreateSSLContextReturnsCustomContext() throws Exception { + SSLContext customContext = SSLContext.getInstance("TLS"); + customContext.init(null, null, null); + + try (Client client = new Client.Builder() + .addEndpoint("https://localhost:8443") + .setUsername("default") + .setPassword("") + .setSSLContext(customContext) + .build()) { + HttpAPIClientHelper helper = extractHttpClientHelper(client); + SSLContext resolved = helper.createSSLContext(extractConfiguration(client)); + Assert.assertSame(resolved, customContext, + "createSSLContext must return the application-supplied context as is"); + } + + // When no custom context is configured, createSSLContext builds a context (not the custom one). + try (Client client = new Client.Builder() + .addEndpoint("https://localhost:8443") + .setUsername("default") + .setPassword("") + .build()) { + HttpAPIClientHelper helper = extractHttpClientHelper(client); + Map configWithCustom = new HashMap<>(extractConfiguration(client)); + configWithCustom.put(ClientConfigProperties.SSL_CONTEXT.getKey(), customContext); + Assert.assertSame(helper.createSSLContext(configWithCustom), customContext, + "createSSLContext must honor a custom context supplied via the configuration map"); + Assert.assertNotSame(helper.createSSLContext(extractConfiguration(client)), customContext, + "createSSLContext must build its own context when none is supplied"); + } + } + + private static HttpAPIClientHelper extractHttpClientHelper(Client client) throws Exception { + Field helperField = Client.class.getDeclaredField("httpClientHelper"); + helperField.setAccessible(true); + return (HttpAPIClientHelper) helperField.get(client); + } + + @SuppressWarnings("unchecked") + private static Map extractConfiguration(Client client) throws Exception { + Field configField = Client.class.getDeclaredField("configuration"); + configField.setAccessible(true); + return (Map) configField.get(client); + } + private static String extractFirstEndpointUri(Client client) throws Exception { Field endpointsField = Client.class.getDeclaredField("endpoints"); endpointsField.setAccessible(true); diff --git a/docs/features.md b/docs/features.md index 11a0e4ac4..d4c87f440 100644 --- a/docs/features.md +++ b/docs/features.md @@ -7,6 +7,7 @@ This document lists stable, user-visible behavior in `client-v2` and `jdbc-v2` t - HTTP and HTTPS connectivity: Connects to ClickHouse over HTTP(S), supports endpoint paths, and exposes a basic `ping` health check. - TLS configuration: Supports trust stores, client certificates/keys, SSL certificate authentication, and SNI for HTTPS connections. Trust material (root CA and client certificate/key) can be supplied either as a file path or directly as PEM content. - SSL verification modes: `Client.Builder.setSSLMode(SSLMode)` (or the `ssl_mode` property) controls how strictly the server identity is verified on secure connections: `DISABLED` (SSL not used; plain protocols only), `TRUST` (accept any server certificate and skip hostname verification; a configured trust store or CA certificate is ignored with a warning, while a client certificate/key is still applied for mTLS if configured), `VERIFY_CA` (validate the certificate chain but skip hostname verification), and `STRICT` (full chain and hostname verification, default). +- Custom SSL context: `Client.Builder.setSSLContext(SSLContext)` supplies a fully pre-built `javax.net.ssl.SSLContext`. When set, the client uses it as is instead of building one from the trust/key material (trust store, CA certificate, client certificate/key), so those options are ignored; `ssl_mode` then only controls server hostname verification. This is primarily for material assembled in memory that must never be written to disk. - Authentication modes: Supports username/password credentials, ClickHouse auth headers, bearer tokens, and optional HTTP Basic authentication. - Runtime credential updates: Existing `Client` instances can update username/password or bearer-token credentials for subsequent requests without rebuilding the client. - Proxy support: Can send requests through configured HTTP proxies, including proxy credentials. @@ -44,6 +45,7 @@ Compatibility-sensitive traits: - `Geometry` write inference is dimension-based rather than fully type-specific: point, ring/line string, polygon/multi-line string, and multi-polygon are selected from array depth, so writing `Geometry` cannot currently distinguish `Ring` from `LineString` or `Polygon` from `MultiLineString`. - Session precedence is part of the contract: client session defaults apply to each request, operation settings may override them, and only the client `session_id` is mutable at runtime while other client session properties remain fixed for the lifetime of the client. - SSL mode behavior is compatibility-sensitive: the default is `STRICT`. `ssl_mode` does not enable or disable encryption - the endpoint scheme decides that. `DISABLED` is only valid with a plain `http://` endpoint; combining it with an `https://` endpoint throws `ClientMisconfigurationException`. `TRUST` accepts any server certificate and skips hostname verification; a configured trust store or CA certificate has no effect in this mode and is ignored (a warning is logged), while a client certificate/key is still applied for mTLS. For `VERIFY_CA` and `STRICT`, a trust store and a CA certificate cannot both take effect: when both are configured the trust store is used and the CA certificate is ignored (a warning is logged). A trust store and a client certificate (`sslcert`) still cannot be configured together and throw `ClientMisconfigurationException`. `ssl_mode` values are matched case-insensitively and normalized to the canonical enum name (`DISABLED`, `TRUST`, `VERIFY_CA`, `STRICT`) when the client is built; an unrecognized value throws `ClientMisconfigurationException`. +- Custom SSL context precedence is compatibility-sensitive: when an application-supplied `SSLContext` is set (`Client.Builder.setSSLContext(SSLContext)`), it is used as is and the trust/key material options (trust store, CA certificate, client certificate/key) are ignored. `ssl_mode` still applies but only to server hostname verification (`TRUST`/`VERIFY_CA` skip it, `STRICT` enforces it). Because that material is ignored, the trust-store/client-certificate conflict is not enforced when a custom context is supplied. The supplied context is a live object and is never parsed from or represented as a string: a textual `ssl_context` value (from a string option or URL query parameter) is rejected with `ClientMisconfigurationException` rather than silently ignored. - Certificate-as-content support is compatibility-sensitive: any certificate or key value containing a PEM begin marker (`-----BEGIN`) is treated as inline PEM content, otherwise it is treated as a file path (also searched in the home directory and on the classpath). - JSONEachRow reading depends on the selected parser factory and request format settings: parser materialization determines Java value types, the reader infers minimal schema from the first row, and JSON number server settings are applied only when `QuerySettings` resolves to `ClickHouseFormat.JSONEachRow` and `json_disable_number_quoting` is enabled. - JSONEachRow schema inference is intentionally best-effort: scalar values use Java-to-ClickHouse type mappings, while JSON arrays and objects are identified structurally as `Array` and `Map`. For arrays, maps, and some nested or ambiguous values, the inferred type may not include the most specific element, key, value, or nested ClickHouse type. @@ -54,6 +56,7 @@ Compatibility-sensitive traits: - JDBC driver registration: Registers through the standard JDBC service mechanism and is available through `DriverManager`. - JDBC URL parsing: Accepts `jdbc:clickhouse:` and `jdbc:ch:` URLs with host, port, optional HTTP path, optional database, and query parameters. - SSL URL support: Supports HTTPS connections through URL and property configuration, including default protocol and port handling. The `ssl_mode` property selects the verification strictness (`disabled`, `trust`, `verify_ca`, `strict`); values are case-insensitive and the traditional JDBC value `none` is accepted as an alias for `trust`. Root CA and client certificate/key may be supplied as a file path or as inline PEM content. +- Custom SSL context via properties: A fully pre-built `javax.net.ssl.SSLContext` may be passed as a live object in the connection `Properties` under the `ssl_context` key (added with `Properties.put`, since it is not a string). It is forwarded to the underlying `client-v2` transport and used as is; `ssl_mode` then only controls hostname verification. This supports diskless, in-memory TLS material behind connection pools that only expose `java.util.Properties`. - Driver and client properties: Separates JDBC-specific properties from passthrough client options used by the underlying `client-v2` transport. - DataSource support: Provides a JDBC `DataSource` implementation backed by the same driver configuration model. - Connection lifecycle: Supports connection close, validity checks, ping-based health checks, and network timeout management. @@ -93,4 +96,5 @@ Compatibility-sensitive traits: - Date and timestamp setters with `Calendar` are timezone-sensitive by design. Preserving the current day-shift and instant-preserving behavior is important for compatibility. - `setObject()` temporal behavior is specific and should not drift: `LocalDateTime` and `Instant` are rendered through `fromUnixTimestamp64Nano(...)`, while `Timestamp` and `Date` use quoted textual forms. - JDBC `ssl_mode` handling is compatibility-sensitive: values are case-insensitive, `none` is aliased to `trust` (the no-verification mode), and an unrecognized value throws `SQLException` during connection configuration. The normalized canonical mode name is forwarded to the underlying `client-v2` transport. +- Connection `Properties` values must be strings, with one scoped exception: the `ssl_context` key may carry a live `javax.net.ssl.SSLContext` object. Any other non-string property value still throws `IllegalArgumentException` during connection configuration. A string `ssl_context` (supplied via `setProperty` or a URL query parameter) is rejected with `SQLException`, since a string cannot represent a live context. - INSERT result semantics depend on server-side `async_insert` and `wait_for_async_insert`. The driver does not override these settings, so it follows whatever the server profile or user configuration sets. When `async_insert=1` and `wait_for_async_insert=0`, `Statement.executeUpdate(...)` and `PreparedStatement.executeUpdate(...)` may return `0` (or an under-counted value), and parsing/data errors in the INSERT body may not be reported synchronously as a `SQLException`. Set `async_insert=0` (or `wait_for_async_insert=1`) per connection or statement to restore synchronous row counts and error reporting. diff --git a/examples/client-v2/src/main/java/com/clickhouse/examples/client_v2/SSLExamples.java b/examples/client-v2/src/main/java/com/clickhouse/examples/client_v2/SSLExamples.java index 520b82a03..05a6ec15b 100644 --- a/examples/client-v2/src/main/java/com/clickhouse/examples/client_v2/SSLExamples.java +++ b/examples/client-v2/src/main/java/com/clickhouse/examples/client_v2/SSLExamples.java @@ -5,10 +5,16 @@ import com.clickhouse.client.api.query.GenericRecord; import lombok.extern.slf4j.Slf4j; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManagerFactory; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Paths; +import java.security.KeyStore; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; import java.util.List; /** @@ -26,6 +32,11 @@ *
  • Connecting to a server with a self-signed certificate without any trust material - * {@link SSLMode#TRUST} accepts any server certificate and skips hostname verification. * Use it only for testing or in fully trusted environments.
  • + *
  • Supplying a fully pre-built {@link javax.net.ssl.SSLContext} with + * {@link Client.Builder#setSSLContext(javax.net.ssl.SSLContext)} - useful when the trust/key + * material is assembled entirely in memory (e.g. fetched and decrypted from a secret store) and + * must never be written to disk. The client uses the context as is; {@link SSLMode} then only + * controls hostname verification.
  • * * *

    More 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()) { + + List rows = client.queryAll("SELECT currentUser() AS user, version() AS version"); + log.info("Connected securely (custom SSLContext) as '{}' to ClickHouse {}", + rows.get(0).getString("user"), rows.get(0).getString("version")); + } catch (Exception e) { + log.error("Secure connection with a custom SSLContext failed", e); + } + } + private static String trimToNull(String value) { if (value == null) { return null; diff --git a/examples/jdbc/src/main/java/com/clickhouse/examples/jdbc/SSLExamples.java b/examples/jdbc/src/main/java/com/clickhouse/examples/jdbc/SSLExamples.java index f6a1cef1f..1c11e57f0 100644 --- a/examples/jdbc/src/main/java/com/clickhouse/examples/jdbc/SSLExamples.java +++ b/examples/jdbc/src/main/java/com/clickhouse/examples/jdbc/SSLExamples.java @@ -4,10 +4,17 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManagerFactory; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Paths; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; import java.sql.Connection; import java.sql.DriverManager; import java.sql.ResultSet; @@ -31,6 +38,11 @@ * the {@code ssl_mode=trust} connection property accepts any server certificate and skips * hostname verification ({@code ssl_mode=none} is accepted as an alias). Use it only for * testing or in fully trusted environments. + *
  • Passing a fully pre-built {@link javax.net.ssl.SSLContext} as a live object in the + * connection {@link Properties} under the {@code ssl_context} key - useful when the trust/key + * material is assembled entirely in memory (e.g. fetched and decrypted from a secret store) and + * must never be written to disk, which is common behind connection pools such as HikariCP. The + * driver uses the context as is; {@code ssl_mode} then only controls hostname verification.
  • * * *

    More 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 getClientProperties() { return ImmutableMap.copyOf(clientProperties); } + // A pre-built SSLContext can be passed only as a live object in the connection Properties, so it is + // kept apart from the string-only clientProperties and forwarded to the underlying client directly. + private SSLContext sslContext; + public SSLContext getSslContext() { + return sslContext; + } + private final Map driverProperties; private final String connectionUrl; @@ -347,6 +356,11 @@ private void buildFinalProperties(Map urlProperties, Properties for (Map.Entry entry : providedProperties.entrySet()) { if (entry.getKey() instanceof String && entry.getValue() instanceof String) { props.put((String) entry.getKey(), (String) entry.getValue()); + } else if (ClientConfigProperties.SSL_CONTEXT.getKey().equals(entry.getKey()) + && entry.getValue() instanceof SSLContext) { + // A pre-built SSLContext is the one property allowed to carry a live object: it cannot be + // represented as a string, so it is captured here and forwarded to the client separately. + this.sslContext = (SSLContext) entry.getValue(); } else { throw new IllegalArgumentException("Property key and value should be a string"); } @@ -397,6 +411,16 @@ private void buildFinalProperties(Map urlProperties, Properties } } + // A pre-built SSLContext can only be supplied as a live object via Properties.put(...); such an + // object is captured above and never lands in clientProperties. A textual 'ssl_context' value + // (from a URL query parameter or setProperty) can never be a valid context, so reject it here + // instead of silently forwarding it to the client where it would be ignored. + if (clientProperties.containsKey(ClientConfigProperties.SSL_CONTEXT.getKey())) { + throw new SQLException("Property '" + ClientConfigProperties.SSL_CONTEXT.getKey() + + "' must be a javax.net.ssl.SSLContext object supplied via Properties.put(...); " + + "it cannot be provided as a string or URL query parameter"); + } + // Fill list of client properties information, add not specified properties (doesn't affect client properties) for (ClientConfigProperties clientProp : ClientConfigProperties.values()) { DriverPropertyInfo propertyInfo = propertyInfos.get(clientProp.getKey()); @@ -445,6 +469,9 @@ public Client.Builder applyClientProperties(Client.Builder builder) { builder.addEndpoint(connectionUrl) .setOptions(clientProperties) .typeHintMapping(defaultTypeHintMapping()); + if (sslContext != null) { + builder.setSSLContext(sslContext); + } return builder; } diff --git a/jdbc-v2/src/test/java/com/clickhouse/jdbc/internal/JdbcConfigurationTest.java b/jdbc-v2/src/test/java/com/clickhouse/jdbc/internal/JdbcConfigurationTest.java index e328bc398..9d96f3e45 100644 --- a/jdbc-v2/src/test/java/com/clickhouse/jdbc/internal/JdbcConfigurationTest.java +++ b/jdbc-v2/src/test/java/com/clickhouse/jdbc/internal/JdbcConfigurationTest.java @@ -10,6 +10,8 @@ import org.testng.annotations.DataProvider; import org.testng.annotations.Test; +import javax.net.ssl.SSLContext; +import java.lang.reflect.Field; import java.sql.DriverPropertyInfo; import java.sql.SQLException; import java.util.Arrays; @@ -221,6 +223,66 @@ public void testSSLModeInvalidValue() { () -> new JdbcConfiguration("jdbc:clickhouse://localhost:8123/?ssl_mode=insecure", new Properties())); } + @Test + public void testCustomSSLContextAcceptedViaProperties() throws Exception { + SSLContext customContext = SSLContext.getInstance("TLS"); + customContext.init(null, null, null); + + Properties properties = new Properties(); + properties.put(ClientConfigProperties.SSL_CONTEXT.getKey(), customContext); + // Before the fix this threw IllegalArgumentException ("Property key and value should be a string"). + JdbcConfiguration configuration = new JdbcConfiguration("jdbc:clickhouse://localhost:8123/", properties); + Assert.assertSame(configuration.getSslContext(), customContext); + // The live object must not leak into the string-only client properties. + assertFalse(configuration.getClientProperties().containsKey(ClientConfigProperties.SSL_CONTEXT.getKey())); + } + + @Test + public void testCustomSSLContextForwardedToClient() throws Exception { + SSLContext customContext = SSLContext.getInstance("TLS"); + customContext.init(null, null, null); + + Properties properties = new Properties(); + properties.put(ClientConfigProperties.SSL_CONTEXT.getKey(), customContext); + JdbcConfiguration configuration = new JdbcConfiguration("jdbc:clickhouse://localhost:8123/", properties); + + Client.Builder builder = new Client.Builder(); + configuration.applyClientProperties(builder); + + // applyClientProperties must forward the captured context to the client builder. + Field sslContextField = Client.Builder.class.getDeclaredField("sslContext"); + sslContextField.setAccessible(true); + Assert.assertSame(sslContextField.get(builder), customContext, + "The SSLContext supplied via JDBC Properties must be forwarded to the client builder"); + } + + @Test + public void testNonStringPropertyOtherThanSSLContextStillRejected() { + Properties properties = new Properties(); + properties.put("some_option", new Object()); + // The relaxation is scoped to ssl_context only; any other non-string value is still rejected. + assertThrows(IllegalArgumentException.class, + () -> new JdbcConfiguration("jdbc:clickhouse://localhost:8123/", properties)); + } + + @Test + public void testStringSSLContextViaUrlRejected() { + // ssl_context carries a live object; a textual value in the URL can never be a real context, so it + // must fail fast with a SQLException instead of being silently forwarded and ignored. + assertThrows(SQLException.class, + () -> new JdbcConfiguration("jdbc:clickhouse://localhost:8123/?ssl_context=not-a-context", + new Properties())); + } + + @Test + public void testStringSSLContextViaPropertyRejected() { + Properties properties = new Properties(); + // A String value (setProperty) cannot represent an SSLContext, so it is rejected rather than ignored. + properties.setProperty(ClientConfigProperties.SSL_CONTEXT.getKey(), "not-a-context"); + assertThrows(SQLException.class, + () -> new JdbcConfiguration("jdbc:clickhouse://localhost:8123/", properties)); + } + @DataProvider(name = "typeMappingsPropertyKey") public Object[][] typeMappingsPropertyKey() { return new Object[][] {