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 58d94da9b..7ce51f064 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 @@ -12,6 +12,7 @@ import com.clickhouse.client.api.data_formats.internal.ProcessParser; import com.clickhouse.client.api.enums.Protocol; import com.clickhouse.client.api.enums.ProxyType; +import com.clickhouse.client.api.enums.SSLMode; import com.clickhouse.client.api.http.ClickHouseHttpProto; import com.clickhouse.client.api.insert.InsertResponse; import com.clickhouse.client.api.insert.InsertSettings; @@ -755,6 +756,34 @@ public Builder setClientKey(String path) { return this; } + /** + * Defines how strictly the client verifies a server identity on secure connections. + * + *

Supported modes:

+ * + * + *

The mode applies only when a secure protocol is in use - for the HTTP transport that + * means an {@code https://} endpoint. Setting any mode does not make the client use + * encryption on a plain HTTP endpoint: the endpoint scheme always decides whether the + * connection is encrypted.

+ * + * @param sslMode ssl mode + * @return same instance of the builder + */ + public Builder setSSLMode(SSLMode sslMode) { + this.configuration.put(ClientConfigProperties.SSL_MODE.getKey(), sslMode.name()); + 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. @@ -1140,6 +1169,36 @@ public Client build() { throw new ClientMisconfigurationException("Trust store and certificates cannot be used together"); } + // A trust store and a CA certificate are not rejected here: for VERIFY_CA/STRICT the trust + // store takes precedence and the CA certificate is ignored with a warning (see createSSLContext). + + // Resolve ssl_mode case-insensitively and normalize it to the canonical enum name so that + // downstream parsing is consistent and an unknown value is reported as a misconfiguration + // here instead of failing later with a generic enum-parsing error. + String sslModeValue = configuration.get(ClientConfigProperties.SSL_MODE.getKey()); + if (sslModeValue != null) { + SSLMode sslMode; + try { + sslMode = SSLMode.fromValue(sslModeValue); + } catch (IllegalArgumentException e) { + throw new ClientMisconfigurationException("Invalid value '" + sslModeValue + "' for '" + + ClientConfigProperties.SSL_MODE.getKey() + "'", e); + } + configuration.put(ClientConfigProperties.SSL_MODE.getKey(), sslMode.name()); + + // SSLMode.DISABLED does not turn encryption off - the endpoint scheme decides that. So it + // contradicts a secure (https) endpoint and must be rejected here, before the client is created. + if (sslMode == SSLMode.DISABLED) { + for (Endpoint endpoint : this.endpoints) { + if ("https".equalsIgnoreCase(endpoint.getURI().getScheme())) { + throw new ClientMisconfigurationException("SSL mode '" + SSLMode.DISABLED + + "' cannot be used with a secure (https) endpoint. Use '" + SSLMode.TRUST + + "' to trust all certificates or use plain HTTP."); + } + } + } + } + // Check timezone settings String useTimeZoneValue = this.configuration.get(ClientConfigProperties.USE_TIMEZONE.getKey()); String serverTimeZoneValue = this.configuration.get(ClientConfigProperties.SERVER_TIMEZONE.getKey()); 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 e548a90f9..7008a18ff 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 @@ -1,6 +1,7 @@ package com.clickhouse.client.api; import com.clickhouse.client.api.data_formats.internal.AbstractBinaryFormatReader; +import com.clickhouse.client.api.enums.SSLMode; import com.clickhouse.client.api.internal.ClickHouseLZ4OutputStream; import com.clickhouse.data.ClickHouseDataType; import com.clickhouse.data.ClickHouseFormat; @@ -115,6 +116,8 @@ public enum ClientConfigProperties { SSL_CERTIFICATE("sslcert", String.class), + SSL_MODE("ssl_mode", SSLMode.class, SSLMode.STRICT.name()), + 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/enums/SSLMode.java b/client-v2/src/main/java/com/clickhouse/client/api/enums/SSLMode.java new file mode 100644 index 000000000..f5d64392c --- /dev/null +++ b/client-v2/src/main/java/com/clickhouse/client/api/enums/SSLMode.java @@ -0,0 +1,65 @@ +package com.clickhouse.client.api.enums; + +/** + * Defines how strictly the client verifies a server identity when a secure protocol is used. + * + *

The mode affects only connections that are already using a secure transport (for example, + * an {@code https://} endpoint). It does not enable encryption for plain protocols - an + * {@code http://} endpoint stays unencrypted whatever the mode is.

+ * + *

Modes from the least to the most strict:

+ * + */ +public enum SSLMode { + + /** + * SSL is not used. Connection is not encrypted. Doesn't work with HTTPS. + * Reserved for TCP where protocol doesn't define encryption. + */ + DISABLED, + + /** + * The hostname is not verified and any server certificate is accepted. A configured trust store or + * CA certificate has no effect in this mode and is ignored (a warning is logged). A configured + * client certificate/key is still applied for mTLS. + */ + TRUST, + + /** + * Server certificate chain is validated, but the hostname is not verified. + */ + VERIFY_CA, + + /** + * Full verification: certificate chain is validated and the hostname must match + * the certificate. Default mode for HTTPs. + */ + STRICT; + + /** + * Case-insensitive variant of {@link #valueOf(String)}. + * + * @param value mode name in any case + * @return matching mode + * @throws IllegalArgumentException when the value does not match any mode + */ + public static SSLMode fromValue(String value) { + for (SSLMode mode : values()) { + if (mode.name().equalsIgnoreCase(value)) { + return mode; + } + } + throw new IllegalArgumentException("Unknown SSL mode '" + value + "'"); + } +} 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 5ae4730b7..f3e695c5f 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 @@ -11,9 +11,9 @@ import com.clickhouse.client.api.DataTransferException; import com.clickhouse.client.api.ServerException; import com.clickhouse.client.api.enums.ProxyType; +import com.clickhouse.client.api.enums.SSLMode; import com.clickhouse.client.api.http.ClickHouseHttpProto; import com.clickhouse.client.api.transport.Endpoint; -import com.clickhouse.client.config.ClickHouseDefaultSslContextProvider; import com.clickhouse.data.ClickHouseFormat; import net.jpountz.lz4.LZ4Factory; import org.apache.commons.compress.compressors.CompressorStreamFactory; @@ -85,7 +85,6 @@ import java.net.URLEncoder; import java.net.UnknownHostException; import java.nio.charset.StandardCharsets; -import java.security.NoSuchAlgorithmException; import java.util.Arrays; import java.util.Base64; import java.util.Collection; @@ -131,7 +130,7 @@ public class HttpAPIClientHelper { LZ4Factory lz4Factory; - private final ClickHouseDefaultSslContextProvider sslContextProvider = new ClickHouseDefaultSslContextProvider(); + private final SslContextProvider sslContextProvider = new SslContextProvider(); public HttpAPIClientHelper(Map configuration, Object metricsRegistry, boolean initSslContext, LZ4Factory lz4Factory) { this.metricsRegistry = metricsRegistry; @@ -159,34 +158,46 @@ public HttpAPIClientHelper(Map configuration, Object metricsRegi * @return SSLContext */ public SSLContext createSSLContext(Map configuration) { - SSLContext sslContext; - try { - sslContext = SSLContext.getDefault(); - } catch (NoSuchAlgorithmException e) { - throw new ClientException("Failed to create default SSL context", e); - } + 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()); final String sslCertificate = (String) configuration.get(ClientConfigProperties.SSL_CERTIFICATE.getKey()); final String sslKey = (String) configuration.get(ClientConfigProperties.SSL_KEY.getKey()); - if (trustStorePath != null) { - try { - sslContext = sslContextProvider.getSslContextFromKeyStore( - trustStorePath, - (String) configuration.get(ClientConfigProperties.SSL_KEY_STORE_PASSWORD.getKey()), - (String) configuration.get(ClientConfigProperties.SSL_KEYSTORE_TYPE.getKey()) - ); - } catch (SSLException e) { - throw new ClientMisconfigurationException("Failed to create SSL context from a keystore", e); + + SslContextProvider.Builder builder = sslContextProvider.builder(); + + // The client certificate/key (mTLS) are independent of how the server certificate is verified, + // so they are applied whenever configured, regardless of the SSL mode. + if (sslCertificate != null && !sslCertificate.isEmpty()) { + builder.clientCertificate(sslCertificate, sslKey); + } + + if (sslMode == SSLMode.TRUST) { + // TRUST accepts any server certificate and skips the hostname check (the latter is applied + // where the connection socket factory is created). A configured trust store or CA + // certificate has no effect in this mode and is ignored with a warning. + if (trustStorePath != null || caCertificate != null) { + LOG.warn("SSL mode '{}' trusts any server certificate; the configured {} is ignored.", + SSLMode.TRUST, trustStorePath != null ? "trust store" : "CA certificate"); } - } else if (caCertificate != null || sslCertificate != null|| sslKey != null) { - try { - sslContext = sslContextProvider.getSslContextFromCerts(sslCertificate, sslKey, caCertificate); - } catch (SSLException e) { - throw new ClientMisconfigurationException("Failed to create SSL context from certificates", e); + builder.trustAllCertificates(); + } else if (trustStorePath != null) { + // VERIFY_CA / STRICT: validate against the trust store. A trust store and a CA certificate + // cannot both take effect, so the CA certificate is ignored with a warning. + if (caCertificate != null) { + LOG.warn("Both a trust store and a CA certificate are configured; using the trust store and" + + " ignoring the CA certificate. Import the CA certificate into the trust store instead."); } + builder.trustStore(trustStorePath, + (String) configuration.get(ClientConfigProperties.SSL_KEY_STORE_PASSWORD.getKey()), + (String) configuration.get(ClientConfigProperties.SSL_KEYSTORE_TYPE.getKey())); + } else if (caCertificate != null) { + // VERIFY_CA / STRICT: validate against the CA certificate. + builder.rootCertificate(caCertificate); } - return sslContext; + // else VERIFY_CA / STRICT with no trust material: the JVM default trust store is used. + + return builder.build(); } private static final long CONNECTION_INACTIVITY_CHECK = 5000L; @@ -272,7 +283,11 @@ public CloseableHttpClient createHttpClient(boolean initSslContext, Map true); } else { sslConnectionSocketFactory = new SSLConnectionSocketFactory(sslContext); @@ -880,6 +895,10 @@ public RuntimeException wrapException(String message, Exception cause, String qu return (RuntimeException) cause; } + if (cause instanceof SSLException) { + return new ClickHouseException("SSL Problem", cause, queryId); + } + if (cause instanceof ConnectionRequestTimeoutException || cause instanceof NoHttpResponseException || cause instanceof ConnectTimeoutException || diff --git a/client-v2/src/main/java/com/clickhouse/client/api/internal/SslContextProvider.java b/client-v2/src/main/java/com/clickhouse/client/api/internal/SslContextProvider.java new file mode 100644 index 000000000..65d3dd289 --- /dev/null +++ b/client-v2/src/main/java/com/clickhouse/client/api/internal/SslContextProvider.java @@ -0,0 +1,281 @@ +package com.clickhouse.client.api.internal; + +import com.clickhouse.client.api.ClientMisconfigurationException; +import com.clickhouse.data.ClickHouseUtils; + +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.SecureRandom; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Base64; + +/** + * Builds {@link SSLContext} instances for the {@code client-v2} HTTP transport. + * + *

This is the {@code client-v2} owned counterpart of the deprecated v1 + * {@code ClickHouseDefaultSslContextProvider}. It is kept separate so the v1 provider can evolve + * (or stay frozen) independently of {@code client-v2}.

+ * + *

Contexts are assembled through {@link #builder()}, which sets key material (client + * certificate/key for mTLS) and trust material (trust store, CA certificate, or "trust all") + * independently, mirroring the structure of the v1 {@code getSslContextImpl}.

+ */ +public class SslContextProvider { + + // Defaults copied from the v1 com.clickhouse.client.config.ClickHouseDefaults to avoid depending + // on the deprecated v1 configuration classes. + private static final String SSL_PROTOCOL = "TLS"; + private static final String KEY_ALGORITHM = "RSA"; + private static final String CERTIFICATE_TYPE = "X.509"; + + static final String PEM_HEADER_PREFIX = "---BEGIN "; + static final String PEM_HEADER_SUFFIX = " PRIVATE KEY---"; + static final String PEM_FOOTER_PREFIX = "---END "; + + /** Standard PEM encapsulation boundary (RFC 7468). Present in any PEM content, never in a file path. */ + static final String PEM_BEGIN_MARKER = "-----BEGIN"; + + /** + * Opens a stream over PEM material that may be supplied either as a file path (also searched in the home + * directory and on the classpath) or directly as PEM content. + * + * @param certOrContent file path or PEM content of a certificate or a private key + * @return stream over the PEM content + * @throws IOException when the value is a path and the file cannot be opened + */ + static InputStream getCertificateInputStream(String certOrContent) throws IOException { + if (certOrContent.contains(PEM_BEGIN_MARKER)) { + return new ByteArrayInputStream(certOrContent.getBytes(StandardCharsets.US_ASCII)); + } + return ClickHouseUtils.getFileInputStream(certOrContent); + } + + /** + * An insecure {@link javax.net.ssl.TrustManager}, that don't validate the + * certificate. + */ + static class NonValidatingTrustManager implements X509TrustManager { + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + + @Override + @SuppressWarnings("squid:S4830") + public void checkClientTrusted(X509Certificate[] certs, String authType) { + // ignore + } + + @Override + @SuppressWarnings("squid:S4830") + public void checkServerTrusted(X509Certificate[] certs, String authType) { + // ignore + } + } + + static String getAlgorithm(String header, String defaultAlg) { + int startIndex = header.indexOf(PEM_HEADER_PREFIX); + int endIndex = startIndex < 0 ? startIndex + : header.indexOf(PEM_HEADER_SUFFIX, (startIndex += PEM_HEADER_PREFIX.length())); + return startIndex < endIndex ? header.substring(startIndex, endIndex) : defaultAlg; + } + + public static PrivateKey getPrivateKey(String keyFile) + throws NoSuchAlgorithmException, InvalidKeySpecException, IOException { + String algorithm = KEY_ALGORITHM; + StringBuilder builder = new StringBuilder(); + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(getCertificateInputStream(keyFile)))) { + String line = reader.readLine(); + if (line != null) { + algorithm = getAlgorithm(line, algorithm); + + while ((line = reader.readLine()) != null) { + if (line.contains(PEM_FOOTER_PREFIX)) { + break; + } + + builder.append(line); + } + } + } + byte[] encoded = Base64.getDecoder().decode(builder.toString()); + KeyFactory kf = KeyFactory.getInstance(algorithm); + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(encoded); + return kf.generatePrivate(keySpec); + } + + public KeyStore getKeyStore(String cert, String key) throws NoSuchAlgorithmException, InvalidKeySpecException, + IOException, CertificateException, KeyStoreException { + final KeyStore ks; + try { + ks = KeyStore.getInstance(KeyStore.getDefaultType()); + ks.load(null, null); // needed to initialize the key store + } catch (KeyStoreException e) { + throw new NoSuchAlgorithmException( + String.format("%s KeyStore not available", KeyStore.getDefaultType())); + } + + try (InputStream in = getCertificateInputStream(cert)) { + CertificateFactory factory = CertificateFactory.getInstance(CERTIFICATE_TYPE); + if (key == null || key.isEmpty()) { + int index = 1; + for (Certificate c : factory.generateCertificates(in)) { + ks.setCertificateEntry("cert" + (index++), c); + } + } else { + Certificate[] certChain = factory.generateCertificates(in).toArray(new Certificate[0]); + ks.setKeyEntry("key", getPrivateKey(key), null, certChain); + } + } + return ks; + } + + /** + * Creates a new {@link Builder} for assembling an {@link SSLContext}. Key material (client + * certificate/key for mTLS) and trust material (trust store, CA certificate, or "trust all") are + * configured independently, mirroring the structure of the v1 {@code getSslContextImpl}. + * + * @return new builder + */ + public Builder builder() { + return new Builder(); + } + + /** + * Assembles an {@link SSLContext} from independently configured key and trust material. + * + *

The two are orthogonal:

+ *
    + *
  • {@link #clientCertificate(String, String)} sets the client certificate/key applied for + * mTLS; it is independent of how the server certificate is verified.
  • + *
  • {@link #trustAllCertificates()}, {@link #trustStore(String, String, String)} and + * {@link #rootCertificate(String)} are mutually exclusive trust strategies; the last one set + * wins. When none is set, the JVM default trust store is used.
  • + *
+ */ + public class Builder { + + private String clientCert; + private String clientKey; + private String trustStorePath; + private String trustStorePassword; + private String trustStoreType; + private String rootCertificate; + private boolean trustAll; + + /** + * Sets the client certificate and key applied for mutual TLS. Independent of the trust strategy. + * + * @param clientCert client certificate, file path or PEM content; may be null + * @param clientKey client private key, file path or PEM content; may be null + * @return this builder + */ + public Builder clientCertificate(String clientCert, String clientKey) { + this.clientCert = clientCert; + this.clientKey = clientKey; + return this; + } + + /** + * Trust strategy: accept any server certificate without validating it (no server identity check). + * + * @return this builder + */ + public Builder trustAllCertificates() { + this.trustAll = true; + return this; + } + + /** + * Trust strategy: validate the server certificate against the given trust store. + * + * @param path trust store file path + * @param password trust store password; may be null + * @param type trust store type; when null or empty the JVM default type is used + * @return this builder + */ + public Builder trustStore(String path, String password, String type) { + this.trustStorePath = path; + this.trustStorePassword = password; + this.trustStoreType = type; + return this; + } + + /** + * Trust strategy: validate the server certificate against the given CA certificate. + * + * @param rootCertificate CA certificate, file path or PEM content; may be null + * @return this builder + */ + public Builder rootCertificate(String rootCertificate) { + this.rootCertificate = rootCertificate; + return this; + } + + /** + * Builds the SSL context from the configured key and trust material. + * + * @return SSL context + * @throws ClientMisconfigurationException when the context cannot be created + */ + public SSLContext build() { + try { + KeyManager[] kms = null; + if (clientCert != null && !clientCert.isEmpty()) { + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(getKeyStore(clientCert, clientKey), null); + kms = kmf.getKeyManagers(); + } + + TrustManager[] tms = null; + if (trustAll) { + tms = new TrustManager[]{new NonValidatingTrustManager()}; + } else if (trustStorePath != null && !trustStorePath.isEmpty()) { + String type = trustStoreType == null || trustStoreType.isEmpty() + ? KeyStore.getDefaultType() : trustStoreType; + try (InputStream in = ClickHouseUtils.getFileInputStream(trustStorePath)) { + KeyStore trustStore = KeyStore.getInstance(type); + trustStore.load(in, trustStorePassword == null ? null : trustStorePassword.toCharArray()); + TrustManagerFactory tmf = TrustManagerFactory + .getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(trustStore); + tms = tmf.getTrustManagers(); + } + } else if (rootCertificate != null && !rootCertificate.isEmpty()) { + TrustManagerFactory tmf = TrustManagerFactory + .getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(getKeyStore(rootCertificate, null)); + tms = tmf.getTrustManagers(); + } + + SSLContext ctx = SSLContext.getInstance(SSL_PROTOCOL); + ctx.init(kms, tms, new SecureRandom()); + return ctx; + } catch (GeneralSecurityException | IOException e) { + throw new ClientMisconfigurationException("Failed to create SSL context", e); + } + } + } +} diff --git a/client-v2/src/test/java/com/clickhouse/client/ClientTests.java b/client-v2/src/test/java/com/clickhouse/client/ClientTests.java index eaa675349..43b2d0e80 100644 --- a/client-v2/src/test/java/com/clickhouse/client/ClientTests.java +++ b/client-v2/src/test/java/com/clickhouse/client/ClientTests.java @@ -332,7 +332,7 @@ public void testDefaultSettings() { Assert.assertEquals(config.get(p.getKey()), p.getDefaultValue(), "Default value doesn't match"); } } - Assert.assertEquals(config.size(), 34); // to check everything is set. Increment when new added. + Assert.assertEquals(config.size(), 35); // to check everything is set. Increment when new added. } try (Client client = new Client.Builder() @@ -365,7 +365,7 @@ public void testDefaultSettings() { .setSocketSndbuf(100000) .build()) { Map config = client.getConfiguration(); - Assert.assertEquals(config.size(), 35); // to check everything is set. Increment when new added. + Assert.assertEquals(config.size(), 36); // to check everything is set. Increment when new added. Assert.assertEquals(config.get(ClientConfigProperties.DATABASE.getKey()), "mydb"); Assert.assertEquals(config.get(ClientConfigProperties.MAX_EXECUTION_TIME.getKey()), "10"); Assert.assertEquals(config.get(ClientConfigProperties.COMPRESSION_LZ4_UNCOMPRESSED_BUF_SIZE.getKey()), "300000"); @@ -389,6 +389,7 @@ public void testDefaultSettings() { Assert.assertEquals(config.get(ClientConfigProperties.SOCKET_OPERATION_TIMEOUT.getKey()), "20000"); Assert.assertEquals(config.get(ClientConfigProperties.SOCKET_RCVBUF_OPT.getKey()), "100000"); Assert.assertEquals(config.get(ClientConfigProperties.SOCKET_SNDBUF_OPT.getKey()), "100000"); + Assert.assertEquals(config.get(ClientConfigProperties.SSL_MODE.getKey()), "STRICT"); } } @@ -432,7 +433,7 @@ public void testWithOldDefaults() { Assert.assertEquals(config.get(p.getKey()), p.getDefaultValue(), "Default value doesn't match"); } } - Assert.assertEquals(config.size(), 34); // to check everything is set. Increment when new added. + Assert.assertEquals(config.size(), 35); // to check everything is set. Increment when new added. } } diff --git a/client-v2/src/test/java/com/clickhouse/client/HttpTransportTests.java b/client-v2/src/test/java/com/clickhouse/client/HttpTransportTests.java index 5c6384a60..6d1a27010 100644 --- a/client-v2/src/test/java/com/clickhouse/client/HttpTransportTests.java +++ b/client-v2/src/test/java/com/clickhouse/client/HttpTransportTests.java @@ -1,5 +1,6 @@ package com.clickhouse.client; +import com.clickhouse.client.api.ClickHouseException; import com.clickhouse.client.api.Client; import com.clickhouse.client.api.ClientConfigProperties; import com.clickhouse.client.api.ClientException; @@ -7,6 +8,7 @@ import com.clickhouse.client.api.ClientMisconfigurationException; import com.clickhouse.client.api.ConnectionInitiationException; import com.clickhouse.client.api.ConnectionReuseStrategy; +import com.clickhouse.client.api.DataTransferException; import com.clickhouse.client.api.ServerException; import com.clickhouse.client.api.Session; import com.clickhouse.client.api.command.CommandResponse; @@ -14,6 +16,7 @@ import com.clickhouse.client.api.data_formats.ClickHouseBinaryFormatReader; import com.clickhouse.client.api.enums.Protocol; import com.clickhouse.client.api.enums.ProxyType; +import com.clickhouse.client.api.enums.SSLMode; import com.clickhouse.client.api.http.ClickHouseHttpProto; import com.clickhouse.client.api.insert.InsertResponse; import com.clickhouse.client.api.insert.InsertSettings; @@ -54,9 +57,11 @@ import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; import org.testcontainers.utility.ThrowingFunction; import org.testng.Assert; +import org.testng.SkipException; import org.testng.annotations.DataProvider; import org.testng.annotations.Test; +import javax.net.ssl.SSLHandshakeException; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.StringWriter; @@ -291,6 +296,180 @@ public void testSecureConnection() { } } + @Test(groups = { "integration" }) + public void testSSLModeTrust() { + if (isCloud()) { + throw new SkipException("Test uses a self-signed certificate, not applicable to cloud"); + } + + ClickHouseNode secureServer = getSecureServer(ClickHouseProtocol.HTTP); + + // Default mode (Strict) without any trust material - the self-signed certificate must be rejected + try (Client client = new Client.Builder() + .addEndpoint("https://localhost:" + secureServer.getPort()) + .setUsername("default") + .setPassword(ClickHouseServerForTest.getPassword()) + .build()) { + Assert.expectThrows(Exception.class, () -> client.queryAll("SELECT 1")); + } + + // Trust mode - the same certificate is accepted without any trust material + try (Client client = new Client.Builder() + .addEndpoint("https://localhost:" + secureServer.getPort()) + .setUsername("default") + .setPassword(ClickHouseServerForTest.getPassword()) + .setOption(ClientConfigProperties.SSL_MODE.getKey(), SSLMode.TRUST.name()) + .build()) { + List records = client.queryAll("SELECT timezone()"); + Assert.assertEquals(records.get(0).getString(1), "UTC"); + } catch (Exception e) { + Assert.fail("Trust SSL mode should accept a self-signed certificate", e); + } + + // Trust mode ignores a configured trust store (a warning is logged). A non-existent path proves + // the trust store is never loaded - the connection still succeeds by trusting any certificate. + try (Client client = new Client.Builder() + .addEndpoint("https://localhost:" + secureServer.getPort()) + .setUsername("default") + .setPassword(ClickHouseServerForTest.getPassword()) + .setSSLMode(SSLMode.TRUST) + .setSSLTrustStore("non-existent-trust-store.jks") + .build()) { + List records = client.queryAll("SELECT timezone()"); + Assert.assertEquals(records.get(0).getString(1), "UTC"); + } catch (Exception e) { + Assert.fail("Trust SSL mode should ignore a configured trust store", e); + } + + // Trust mode ignores a configured CA certificate as well (a warning is logged). + try (Client client = new Client.Builder() + .addEndpoint("https://localhost:" + secureServer.getPort()) + .setUsername("default") + .setPassword(ClickHouseServerForTest.getPassword()) + .setSSLMode(SSLMode.TRUST) + .setRootCertificate("non-existent-ca.crt") + .build()) { + List records = client.queryAll("SELECT timezone()"); + Assert.assertEquals(records.get(0).getString(1), "UTC"); + } catch (Exception e) { + Assert.fail("Trust SSL mode should ignore a configured CA certificate", e); + } + } + + @Test(groups = { "integration" }) + public void testSSLModeVerifyCa() { + if (isCloud()) { + throw new SkipException("Test uses a self-signed certificate, not applicable to cloud"); + } + + ClickHouseNode secureServer = getSecureServer(ClickHouseProtocol.HTTP); + // server certificate has CN=localhost, so connecting via 127.0.0.1 fails hostname verification + final String endpointByIp = "https://127.0.0.1:" + secureServer.getPort(); + final String serverCertificate = "containers/clickhouse-server/certs/localhost.crt"; + + // Strict mode (default): certificate chain is trusted, but the hostname does not match + try (Client client = new Client.Builder() + .addEndpoint(endpointByIp) + .setUsername("default") + .setPassword(ClickHouseServerForTest.getPassword()) + .setRootCertificate(serverCertificate) + .build()) { + Assert.expectThrows(Exception.class, () -> client.queryAll("SELECT 1")); + } + + // VerifyCa mode: certificate chain is validated, hostname mismatch is ignored + try (Client client = new Client.Builder() + .addEndpoint(endpointByIp) + .setUsername("default") + .setPassword(ClickHouseServerForTest.getPassword()) + .setRootCertificate(serverCertificate) + .setSSLMode(SSLMode.VERIFY_CA) + .build()) { + List records = client.queryAll("SELECT timezone()"); + Assert.assertEquals(records.get(0).getString(1), "UTC"); + } catch (Exception e) { + Assert.fail("VerifyCa SSL mode should ignore hostname mismatch", e); + } + + // VerifyCa mode still validates the certificate chain - without the CA it must fail + try (Client client = new Client.Builder() + .addEndpoint(endpointByIp) + .setUsername("default") + .setPassword(ClickHouseServerForTest.getPassword()) + .setSSLMode(SSLMode.VERIFY_CA) + .build()) { + Assert.expectThrows(Exception.class, () -> client.queryAll("SELECT 1")); + } + } + + @Test(groups = { "integration" }) + public void testSSLModeDisabled() { + if (isCloud()) { + throw new SkipException("Plain HTTP is not available on cloud"); + } + + ClickHouseNode server = getServer(ClickHouseProtocol.HTTP); + + // Disabled mode with a plain HTTP endpoint - SSL is simply not used + try (Client client = new Client.Builder() + .addEndpoint("http://" + server.getHost() + ":" + server.getPort()) + .setUsername("default") + .setPassword(ClickHouseServerForTest.getPassword()) + .setSSLMode(SSLMode.DISABLED) + .build()) { + List records = client.queryAll("SELECT timezone()"); + Assert.assertEquals(records.get(0).getString(1), "UTC"); + } catch (Exception e) { + Assert.fail("Disabled SSL mode should work with a plain HTTP endpoint", e); + } + + ClickHouseNode secureServer = getSecureServer(ClickHouseProtocol.HTTP); + // Disabled mode contradicts a secure (https) endpoint - the scheme decides encryption, not the mode + Assert.expectThrows(ClientMisconfigurationException.class, () -> new Client.Builder() + .addEndpoint("https://localhost:" + secureServer.getPort()) + .setUsername("default") + .setPassword(ClickHouseServerForTest.getPassword()) + .setSSLMode(SSLMode.DISABLED) + .build()); + } + + @Test(groups = { "integration" }) + public void testSSLModeStrictWithTrustStoreAndCaCertificate() { + if (isCloud()) { + throw new SkipException("Test uses a self-signed certificate, not applicable to cloud"); + } + + ClickHouseNode secureServer = getSecureServer(ClickHouseProtocol.HTTP); + // A trust store and a CA certificate cannot both take effect: the trust store is used and the + // CA certificate is ignored (a warning is logged). The connection still succeeds via the trust store. + try (Client client = new Client.Builder() + .addEndpoint("https://localhost:" + secureServer.getPort()) + .setUsername("default") + .setPassword(ClickHouseServerForTest.getPassword()) + .build()) { + ClientException ex = Assert.expectThrows(ClientException.class, () -> client.queryAll("SELECT timezone()")); + + Assert.assertTrue(ex.getCause() instanceof ClickHouseException); + Assert.assertTrue(ex.getCause().getMessage().startsWith("SSL Problem")); + } + + // A trust store and a CA certificate cannot both take effect: the trust store is used and the + // CA certificate is ignored (a warning is logged). The connection still succeeds via the trust store. + try (Client client = new Client.Builder() + .addEndpoint("https://localhost:" + secureServer.getPort()) + .setUsername("default") + .setPassword(ClickHouseServerForTest.getPassword()) + .setSSLTrustStore("containers/clickhouse-server/certs/KeyStore.jks") + .setSSLTrustStorePassword("iloveclickhouse") + .setRootCertificate("containers/clickhouse-server/certs/localhost.crt") + .build()) { + List records = client.queryAll("SELECT timezone()"); + Assert.assertEquals(records.get(0).getString(1), "UTC"); + } catch (Exception e) { + Assert.fail("Trust store should be used when a CA certificate is also configured", e); + } + } + @Test(groups = { "integration" }, dataProvider = "NoResponseFailureProvider") public void testInsertAndNoHttpResponseFailure(String body, int maxRetries, ThrowingFunction function, boolean shouldFail) { diff --git a/client-v2/src/test/java/com/clickhouse/client/SettingsTests.java b/client-v2/src/test/java/com/clickhouse/client/SettingsTests.java index 81261dc33..113b33090 100644 --- a/client-v2/src/test/java/com/clickhouse/client/SettingsTests.java +++ b/client-v2/src/test/java/com/clickhouse/client/SettingsTests.java @@ -1,13 +1,21 @@ package com.clickhouse.client; import com.clickhouse.client.api.ClientConfigProperties; +import com.clickhouse.client.api.ClientMisconfigurationException; import com.clickhouse.client.api.Session; +import com.clickhouse.client.api.enums.SSLMode; import com.clickhouse.client.api.insert.InsertSettings; import com.clickhouse.client.api.internal.ServerSettings; +import com.clickhouse.client.api.internal.SslContextProvider; import com.clickhouse.client.api.query.QuerySettings; import org.testng.Assert; import org.testng.annotations.Test; +import javax.net.ssl.SSLContext; +import java.io.File; +import java.io.OutputStream; +import java.nio.file.Files; +import java.security.KeyStore; import java.time.temporal.ChronoUnit; import java.util.Arrays; import java.util.Collections; @@ -25,6 +33,65 @@ void testClientSettings() { Assert.assertEquals(listB, source); } + @Test + void testSSLModeFromValue() { + // Every constant resolves from its exact name and is case-insensitive. + for (SSLMode mode : SSLMode.values()) { + Assert.assertEquals(SSLMode.fromValue(mode.name()), mode); + Assert.assertEquals(SSLMode.fromValue(mode.name().toLowerCase()), mode); + Assert.assertEquals(SSLMode.fromValue(mode.name().toUpperCase()), mode); + } + + // VERIFY_CA matches only with the underscore - matching does not normalize separators. + Assert.assertEquals(SSLMode.fromValue("verify_ca"), SSLMode.VERIFY_CA); + Assert.assertEquals(SSLMode.fromValue("Verify_Ca"), SSLMode.VERIFY_CA); + Assert.assertThrows(IllegalArgumentException.class, () -> SSLMode.fromValue("verifyca")); + + // Unknown and null values are rejected. + Assert.assertThrows(IllegalArgumentException.class, () -> SSLMode.fromValue("insecure")); + Assert.assertThrows(IllegalArgumentException.class, () -> SSLMode.fromValue("")); + Assert.assertThrows(IllegalArgumentException.class, () -> SSLMode.fromValue(null)); + } + + @Test + void testSslContextFromKeyStore() throws Exception { + SslContextProvider provider = new SslContextProvider(); + final String type = "PKCS12"; + final String password = "secret"; + File trustStore = createEmptyTrustStore(type, password); + try { + // Happy path: a readable trust store with the right password yields a usable TLS context. + SSLContext ctx = provider.builder().trustStore(trustStore.getAbsolutePath(), password, type).build(); + Assert.assertNotNull(ctx); + Assert.assertEquals(ctx.getProtocol(), "TLS"); + + // Wrong password fails the integrity check. + Assert.assertThrows(ClientMisconfigurationException.class, + () -> provider.builder().trustStore(trustStore.getAbsolutePath(), "wrong", type).build()); + + // Missing file. + Assert.assertThrows(ClientMisconfigurationException.class, + () -> provider.builder().trustStore(trustStore.getAbsolutePath() + ".missing", password, type).build()); + + // Unknown keystore type. + Assert.assertThrows(ClientMisconfigurationException.class, + () -> provider.builder().trustStore(trustStore.getAbsolutePath(), password, "NOT_A_TYPE").build()); + } finally { + Files.deleteIfExists(trustStore.toPath()); + } + } + + private static File createEmptyTrustStore(String type, String password) throws Exception { + KeyStore ks = KeyStore.getInstance(type); + ks.load(null, null); + File file = File.createTempFile("client-v2-truststore", "." + type.toLowerCase()); + file.deleteOnExit(); + try (OutputStream out = Files.newOutputStream(file.toPath())) { + ks.store(out, password.toCharArray()); + } + return file; + } + @Test void testMergeSettings() { { 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 044a37de0..36489ecc5 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,5 +1,6 @@ package com.clickhouse.client.api; +import com.clickhouse.client.api.enums.SSLMode; import org.testng.Assert; import org.testng.annotations.Test; @@ -22,6 +23,44 @@ public void testAddEndpointToleratesUnderscoreHostname() throws Exception { } } + @Test + public void testSslModeDisabledRejectedForHttpsRegardlessOfCase() { + // Value supplied as a raw (non-canonical case) string via setOption must still be recognized + // as DISABLED and rejected with ClientMisconfigurationException for an https endpoint. + for (String value : new String[] { "DISABLED", "disabled", "Disabled" }) { + Assert.expectThrows(ClientMisconfigurationException.class, () -> new Client.Builder() + .addEndpoint("https://localhost:8443") + .setUsername("default") + .setPassword("") + .setOption(ClientConfigProperties.SSL_MODE.getKey(), value) + .build()); + } + } + + @Test + public void testSslModeInvalidValueRejected() { + Assert.expectThrows(ClientMisconfigurationException.class, () -> new Client.Builder() + .addEndpoint("https://localhost:8443") + .setUsername("default") + .setPassword("") + .setOption(ClientConfigProperties.SSL_MODE.getKey(), "insecure") + .build()); + } + + @Test + public void testSslModeNormalizedToCanonicalName() throws Exception { + // A non-canonical case value is accepted and normalized to the canonical enum name. + try (Client client = new Client.Builder() + .addEndpoint("http://localhost:8123") + .setUsername("default") + .setPassword("") + .setOption(ClientConfigProperties.SSL_MODE.getKey(), "trust") + .build()) { + Assert.assertEquals(client.getConfiguration().get(ClientConfigProperties.SSL_MODE.getKey()), + SSLMode.TRUST.name()); + } + } + 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 be63e9f99..29cdfb8e4 100644 --- a/docs/features.md +++ b/docs/features.md @@ -5,7 +5,8 @@ This document lists stable, user-visible behavior in `client-v2` and `jdbc-v2` t ## `client-v2` - 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. +- 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). - 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. @@ -41,13 +42,15 @@ Compatibility-sensitive traits: - `Geometry` handling is shape-sensitive: supported values are 1D through 4D Java arrays representing the nested geometry variants, and unsupported shapes or non-array values are rejected during serialization. - `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`. +- 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). ## `jdbc-v2` - 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. +- 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. - 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. @@ -85,4 +88,5 @@ Compatibility-sensitive traits: - `getString()` formatting for temporal values is stable output: `Date` uses `yyyy-MM-dd`, `DateTime` uses `yyyy-MM-dd HH:mm:ss`, and `DateTime64` preserves fractional precision, all interpreted in server timezone context where applicable. - 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. - 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/README.md b/examples/client-v2/README.md index 0f6bab14e..e807c07c3 100644 --- a/examples/client-v2/README.md +++ b/examples/client-v2/README.md @@ -78,10 +78,15 @@ Notes: ## SSL Examples -`com.clickhouse.examples.client_v2.SSLExamples` shows how to connect securely to a server whose -certificate is signed by a custom (private) CA. Only the CA certificate is passed to the client -with `Client.Builder.setRootCertificate()` - no trust store configuration is required, and the JVM -default trust store stays untouched. +`com.clickhouse.examples.client_v2.SSLExamples` shows how to connect securely to a server: + +- **Custom CA certificate** - the server certificate is signed by a custom (private) CA. Only the + CA certificate is passed to the client with `Client.Builder.setRootCertificate()` (as a file path + or directly as a PEM string) - no trust store configuration is required, and the JVM default + trust store stays untouched. +- **Self-signed certificate without verification** - `Client.Builder.setSSLMode(SSLMode.TRUST)` + accepts any server certificate and skips hostname verification. The connection is encrypted, but + the server identity is not verified - use it only for testing or in fully trusted environments. The example runs in one of two modes. @@ -113,7 +118,8 @@ mvn exec:java -Dexec.mainClass="com.clickhouse.examples.client_v2.SSLExamples" \ -DchRootCert="/path/to/ca.crt" ``` -`-DchRootCert` is required in this mode and must point to the CA certificate in PEM format. +`-DchRootCert` must point to the CA certificate in PEM format. When it is omitted, only the +self-signed (`SSLMode.TRUST`) example runs - useful when you do not have the CA certificate at hand. ### Setting up a Docker dev instance with a self-signed certificate manually 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 dc62b65c2..520b82a03 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 @@ -1,6 +1,7 @@ package com.clickhouse.examples.client_v2; import com.clickhouse.client.api.Client; +import com.clickhouse.client.api.enums.SSLMode; import com.clickhouse.client.api.query.GenericRecord; import lombok.extern.slf4j.Slf4j; @@ -22,6 +23,9 @@ *
  • Passing the CA certificate as a PEM string instead of a file path - useful when the * certificate comes from an environment variable or a secret manager (typical for * Kubernetes/cloud deployments) and you do not want to write it to disk.
  • + *
  • 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.
  • * * *

    More SSL examples (mTLS, trust stores, SNI) will be added to this class later.

    @@ -41,7 +45,8 @@ *
  • {@code chPort} - ClickHouse HTTPS port, default {@code 8443}
  • *
  • {@code chDatabase} - database name, default {@code default}
  • *
  • {@code chUser} and {@code chPassword} - credentials (standalone mode)
  • - *
  • {@code chRootCert} - path to the root CA certificate in PEM format (required in standalone mode)
  • + *
  • {@code chRootCert} - path to the root CA certificate in PEM format. When omitted in + * standalone mode, only the self-signed (TRUST) example runs *
  • {@code chImage} - Docker image for local mode, default {@code clickhouse/clickhouse-server:latest}
  • * */ @@ -58,16 +63,17 @@ public static void main(String[] args) { final String user = System.getProperty("chUser", "default"); final String password = System.getProperty("chPassword", ""); final String rootCert = trimToNull(System.getProperty("chRootCert")); - if (rootCert == null) { - log.error("chRootCert is required when chHost is set. " - + "Pass the path to the CA certificate (PEM) that signed the server certificate."); - return; - } log.info("Running in standalone mode against {}:{}", host, port); String endpoint = "https://" + host + ":" + port; - connectWithCustomRootCertificate(endpoint, database, user, password, rootCert); - connectWithRootCertificateAsString(endpoint, database, user, password, rootCert); + connectToSelfSignedServer(endpoint, database, user, password); + if (rootCert != null) { + connectWithCustomRootCertificate(endpoint, database, user, password, rootCert); + connectWithRootCertificateAsString(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."); + } return; } @@ -76,6 +82,8 @@ public static void main(String[] args) { final String image = System.getProperty("chImage", "clickhouse/clickhouse-server:latest"); log.info("Running in local mode (set -DchHost to verify your own server)"); try (SecureServerSupport server = SecureServerSupport.start(image)) { + connectToSelfSignedServer(server.getEndpoint(), database, + SecureServerSupport.USER, SecureServerSupport.PASSWORD); connectWithCustomRootCertificate(server.getEndpoint(), database, SecureServerSupport.USER, SecureServerSupport.PASSWORD, server.getCaCertPath()); connectWithRootCertificateAsString(server.getEndpoint(), database, @@ -85,6 +93,35 @@ public static void main(String[] args) { } } + /** + * Connects to a ClickHouse server with a self-signed certificate without providing + * any trust material. {@link SSLMode#TRUST} makes the client accept any server + * certificate and skip hostname verification. + * + *

    Warning: the connection is encrypted, but the server identity is NOT verified, + * which makes it susceptible to man-in-the-middle attacks. Use this mode only for testing + * or in fully trusted environments. Prefer {@link Client.Builder#setRootCertificate(String)} + * with the signing CA certificate whenever possible.

    + */ + static void connectToSelfSignedServer(String endpoint, String database, String user, String password) { + log.info("Connecting to {} accepting any server certificate (SSLMode.TRUST)", endpoint); + try (Client client = new Client.Builder() + .addEndpoint(endpoint) + .setUsername(user) + .setPassword(password) + .setDefaultDatabase(database) + // Accept the self-signed certificate and skip hostname verification. + .setSSLMode(SSLMode.TRUST) + .build()) { + + List rows = client.queryAll("SELECT currentUser() AS user, version() AS version"); + log.info("Connected (server certificate not verified) as '{}' to ClickHouse {}", + rows.get(0).getString("user"), rows.get(0).getString("version")); + } catch (Exception e) { + log.error("Connection with SSLMode.TRUST failed", e); + } + } + /** * Connects to a ClickHouse server using a custom root CA certificate. * Use this when the server certificate is signed by a private CA (corporate CA, diff --git a/examples/jdbc/README.md b/examples/jdbc/README.md index 1fedefa5d..4cb796317 100644 --- a/examples/jdbc/README.md +++ b/examples/jdbc/README.md @@ -24,10 +24,16 @@ Addition options can be passed to the application: ## SSL Examples -`com.clickhouse.examples.jdbc.SSLExamples` shows how to connect securely to a server whose -certificate is signed by a custom (private) CA. Only the CA certificate is passed with the -`sslrootcert` connection property - no trust store configuration is required, and the JVM default -trust store stays untouched. +`com.clickhouse.examples.jdbc.SSLExamples` shows how to connect securely to a server: + +- **Custom CA certificate** - the server certificate is signed by a custom (private) CA. Only the + CA certificate is passed with the `sslrootcert` connection property (as a file path or directly + as a PEM string) - no trust store configuration is required, and the JVM default trust store + stays untouched. +- **Self-signed certificate without verification** - the `ssl_mode=trust` connection property + (`ssl_mode=none` is accepted as an alias) accepts any server certificate and skips hostname + verification. The connection is encrypted, but the server identity is not verified - use it only + for testing or in fully trusted environments. The example runs in one of two modes. @@ -57,7 +63,8 @@ mvn exec:java -Dexec.mainClass="com.clickhouse.examples.jdbc.SSLExamples" \ -DchRootCert="/path/to/ca.crt" ``` -`-DchRootCert` is required in this mode and must point to the CA certificate in PEM format. +`-DchRootCert` must point to the CA certificate in PEM format. When it is omitted, only the +self-signed (`ssl_mode=trust`) example runs - useful when you do not have the CA certificate at hand. ### Setting up a Docker dev instance with a self-signed certificate manually 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 11a022fb5..f6a1cef1f 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 @@ -27,6 +27,10 @@ *
  • Passing the CA certificate as a PEM string instead of a file path - useful when the * certificate comes from an environment variable or a secret manager (typical for * Kubernetes/cloud deployments) and you do not want to write it to disk.
  • + *
  • Connecting to a server with a self-signed certificate without any trust material - + * 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.
  • * * *

    More SSL examples (mTLS, trust stores, SNI) will be added to this class later.

    @@ -45,7 +49,8 @@ *
  • {@code chUrl} - ClickHouse JDBC URL, e.g. {@code jdbc:clickhouse://my-host:8443/default}. * When set, standalone mode is used
  • *
  • {@code chUser} and {@code chPassword} - credentials (standalone mode)
  • - *
  • {@code chRootCert} - path to the root CA certificate in PEM format (required in standalone mode)
  • + *
  • {@code chRootCert} - path to the root CA certificate in PEM format. When omitted in + * standalone mode, only the self-signed (trust) example runs
  • *
  • {@code chImage} - Docker image for local mode, default {@code clickhouse/clickhouse-server:latest}
  • * */ @@ -60,18 +65,19 @@ public static void main(String[] args) { final String user = System.getProperty("chUser", "default"); final String password = System.getProperty("chPassword", ""); final String rootCert = trimToNull(System.getProperty("chRootCert")); - if (rootCert == null) { - log.error("chRootCert is required when chUrl is set. " - + "Pass the path to the CA certificate (PEM) that signed the server certificate."); - return; - } log.info("Running in standalone mode against {}", url); try { - connectWithCustomRootCertificate(url, user, password, rootCert); - connectWithRootCertificateAsString(url, user, password, rootCert); + connectToSelfSignedServer(url, user, password); + if (rootCert != null) { + connectWithCustomRootCertificate(url, user, password, rootCert); + connectWithRootCertificateAsString(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."); + } } catch (SQLException | IOException e) { - log.error("Secure connection with a custom root CA certificate failed", e); + log.error("Secure connection failed", e); } return; } @@ -81,6 +87,8 @@ public static void main(String[] args) { final String image = System.getProperty("chImage", "clickhouse/clickhouse-server:latest"); log.info("Running in local mode (set -DchUrl to verify your own server)"); try (SecureServerSupport server = SecureServerSupport.start(image)) { + connectToSelfSignedServer(server.getJdbcUrl(), + SecureServerSupport.USER, SecureServerSupport.PASSWORD); connectWithCustomRootCertificate(server.getJdbcUrl(), SecureServerSupport.USER, SecureServerSupport.PASSWORD, server.getCaCertPath()); connectWithRootCertificateAsString(server.getJdbcUrl(), @@ -93,6 +101,37 @@ public static void main(String[] args) { Runtime.getRuntime().exit(0); } + /** + * Connects to a ClickHouse server with a self-signed certificate without providing + * any trust material. The {@code ssl_mode=trust} connection property makes the driver + * accept any server certificate and skip hostname verification. The traditional JDBC + * value {@code ssl_mode=none} is accepted as an alias. + * + *

    Warning: the connection is encrypted, but the server identity is NOT verified, + * which makes it susceptible to man-in-the-middle attacks. Use this mode only for testing + * or in fully trusted environments. Prefer {@code sslrootcert} with the signing CA + * certificate whenever possible.

    + */ + static void connectToSelfSignedServer(String url, String user, String password) throws SQLException { + log.info("Connecting to {} accepting any server certificate (ssl_mode=trust)", 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 + // Accept the self-signed certificate and skip hostname verification. + properties.setProperty(ClientConfigProperties.SSL_MODE.getKey(), "trust"); // ssl_mode + + 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 (server certificate not verified) as '{}' to ClickHouse {}", + rs.getString("user"), rs.getString("version")); + } + } + } + /** * Connects to a ClickHouse server using a custom root CA certificate. * Use this when the server certificate is signed by a private CA (corporate CA, 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 eca74aa53..99669b981 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 @@ -2,6 +2,7 @@ import com.clickhouse.client.api.Client; import com.clickhouse.client.api.ClientConfigProperties; +import com.clickhouse.client.api.enums.SSLMode; import com.clickhouse.client.api.http.ClickHouseHttpProto; import com.clickhouse.data.ClickHouseDataType; import com.clickhouse.jdbc.Driver; @@ -332,7 +333,8 @@ private Map parseUrl(String url) throws SQLException { * @param urlProperties - properties parsed from URL * @param providedProperties - properties object provided by application */ - private void buildFinalProperties(Map urlProperties, Properties providedProperties) { + private void buildFinalProperties(Map urlProperties, Properties providedProperties) + throws SQLException { // Copy provided properties Map props = new HashMap<>(); @@ -379,6 +381,22 @@ private void buildFinalProperties(Map urlProperties, Properties } } + String sslMode = clientProperties.get(ClientConfigProperties.SSL_MODE.getKey()); + if (sslMode != null) { + if ("none".equalsIgnoreCase(sslMode)) { + // JDBC drivers traditionally use 'none' for the no-verification SSL mode - alias it to 'trust' + clientProperties.put(ClientConfigProperties.SSL_MODE.getKey(), SSLMode.TRUST.name()); + } else { + try { + // values are case-insensitive in JDBC - normalize before passing to the client + clientProperties.put(ClientConfigProperties.SSL_MODE.getKey(), SSLMode.fromValue(sslMode).name()); + } catch (IllegalArgumentException e) { + throw new SQLException("Unknown value '" + sslMode + "' for property '" + + ClientConfigProperties.SSL_MODE.getKey() + "'", e); + } + } + } + // 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()); diff --git a/jdbc-v2/src/test/java/com/clickhouse/jdbc/ConnectionTest.java b/jdbc-v2/src/test/java/com/clickhouse/jdbc/ConnectionTest.java index 0ce66c84c..1b3d5cdae 100644 --- a/jdbc-v2/src/test/java/com/clickhouse/jdbc/ConnectionTest.java +++ b/jdbc-v2/src/test/java/com/clickhouse/jdbc/ConnectionTest.java @@ -13,6 +13,7 @@ import com.github.tomakehurst.wiremock.common.ConsoleNotifier; import com.github.tomakehurst.wiremock.core.WireMockConfiguration; import org.testng.Assert; +import org.testng.SkipException; import org.testng.annotations.DataProvider; import org.testng.annotations.Test; @@ -763,6 +764,78 @@ public void testSecureConnection() throws Exception { } } + @Test(groups = { "integration" }) + public void testSSLModeTrust() throws Exception { + if (isCloud()) { + throw new SkipException("Test uses a self-signed certificate, not applicable to cloud"); + } + ClickHouseNode secureServer = getSecureServer(ClickHouseProtocol.HTTP); + String jdbcUrl = "jdbc:clickhouse:" + secureServer.getBaseUri(); + + Properties properties = new Properties(); + properties.put(ClientConfigProperties.USER.getKey(), "default"); + properties.put(ClientConfigProperties.PASSWORD.getKey(), ClickHouseServerForTest.getPassword()); + + // Default mode (strict) without any trust material - the self-signed certificate must be rejected + Assert.expectThrows(Exception.class, () -> { + try (Connection conn = new ConnectionImpl(jdbcUrl, properties); + Statement stmt = conn.createStatement()) { + stmt.executeQuery("SELECT 1"); + } + }); + + // 'none' is a JDBC alias for the 'trust' mode + for (String mode : new String[] { "none", "trust", "Trust" }) { + Properties trustProperties = new Properties(); + trustProperties.putAll(properties); + trustProperties.put(ClientConfigProperties.SSL_MODE.getKey(), mode); + + try (Connection conn = new ConnectionImpl(jdbcUrl, trustProperties); + Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery("SELECT number FROM system.numbers LIMIT 10")) { + + int count = 0; + while (rs.next()) { count++; } + Assert.assertEquals(count, 10, "Failed for ssl_mode '" + mode + "'"); + } + } + } + + @Test(groups = { "integration" }) + public void testSSLModeVerifyCa() throws Exception { + if (isCloud()) { + throw new SkipException("Test uses a self-signed certificate, not applicable to cloud"); + } + ClickHouseNode secureServer = getSecureServer(ClickHouseProtocol.HTTP); + // server certificate has CN=localhost, so connecting via 127.0.0.1 fails hostname verification + String jdbcUrl = "jdbc:clickhouse://127.0.0.1:" + secureServer.getPort() + "/"; + + Properties properties = new Properties(); + properties.put(ClientConfigProperties.USER.getKey(), "default"); + properties.put(ClientConfigProperties.PASSWORD.getKey(), ClickHouseServerForTest.getPassword()); + properties.put(DriverProperties.SECURE_CONNECTION.getKey(), "true"); + properties.put(ClientConfigProperties.CA_CERTIFICATE.getKey(), "containers/clickhouse-server/certs/localhost.crt"); + + // Default mode (strict): certificate chain is trusted, but the hostname does not match + Assert.expectThrows(Exception.class, () -> { + try (Connection conn = new ConnectionImpl(jdbcUrl, properties); + Statement stmt = conn.createStatement()) { + stmt.executeQuery("SELECT 1"); + } + }); + + // verify_ca: certificate chain is validated, hostname mismatch is ignored + properties.put(ClientConfigProperties.SSL_MODE.getKey(), "verify_ca"); + try (Connection conn = new ConnectionImpl(jdbcUrl, properties); + Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery("SELECT number FROM system.numbers LIMIT 10")) { + + int count = 0; + while (rs.next()) { count++; } + Assert.assertEquals(count, 10); + } + } + @Test(groups = { "integration" }) public void testSelectingDatabase() throws Exception { if (isCloud()) { 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 9e0abe8bc..eba15719c 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 @@ -2,6 +2,7 @@ import com.clickhouse.client.api.Client; import com.clickhouse.client.api.ClientConfigProperties; +import com.clickhouse.client.api.enums.SSLMode; import com.clickhouse.data.ClickHouseDataType; import com.clickhouse.jdbc.DriverProperties; @@ -17,6 +18,8 @@ import java.util.Map; import java.util.Objects; import java.util.Properties; +import java.util.Set; +import java.util.stream.Collectors; import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertFalse; @@ -169,6 +172,54 @@ public void testConfigurationProperties() throws Exception { assertEquals(p.value, "default1"); } + @DataProvider(name = "sslModeValues") + public Object[][] sslModeValues() { + return new Object[][] { + // input value, expected client property value + { "none", SSLMode.TRUST.name() }, // JDBC alias for the no-verification mode + { "NONE", SSLMode.TRUST.name() }, + { "disabled", SSLMode.DISABLED.name() }, + { "Disabled", SSLMode.DISABLED.name() }, + { "trust", SSLMode.TRUST.name() }, + { "Trust", SSLMode.TRUST.name() }, + { "verify_ca", SSLMode.VERIFY_CA.name() }, + { "VERIFY_CA", SSLMode.VERIFY_CA.name() }, + { "strict", SSLMode.STRICT.name() }, + { "Strict", SSLMode.STRICT.name() }, + }; + } + + @Test + public void testSSLModeDatasetCoversAllModes() { + Set covered = Arrays.stream(sslModeValues()) + .map(row -> (String) row[1]) + .collect(Collectors.toSet()); + Set allModes = Arrays.stream(SSLMode.values()) + .map(Enum::name) + .collect(Collectors.toSet()); + assertEquals(covered, allModes, + "SSLMode constants changed - update the 'sslModeValues' dataset and the ssl_mode handling in JdbcConfiguration"); + } + + @Test(dataProvider = "sslModeValues") + public void testSSLModeProperty(String value, String expected) throws Exception { + // passed via Properties + Properties properties = new Properties(); + properties.setProperty(ClientConfigProperties.SSL_MODE.getKey(), value); + JdbcConfiguration configuration = new JdbcConfiguration("jdbc:clickhouse://localhost:8123/", properties); + assertEquals(configuration.getClientProperties().get(ClientConfigProperties.SSL_MODE.getKey()), expected); + + // passed as a URL parameter + configuration = new JdbcConfiguration("jdbc:clickhouse://localhost:8123/?ssl_mode=" + value, new Properties()); + assertEquals(configuration.getClientProperties().get(ClientConfigProperties.SSL_MODE.getKey()), expected); + } + + @Test + public void testSSLModeInvalidValue() { + assertThrows(SQLException.class, + () -> new JdbcConfiguration("jdbc:clickhouse://localhost:8123/?ssl_mode=insecure", new Properties())); + } + @DataProvider(name = "typeMappingsPropertyKey") public Object[][] typeMappingsPropertyKey() { return new Object[][] {