diff --git a/clickhouse-client/src/main/java/com/clickhouse/client/config/ClickHouseDefaultSslContextProvider.java b/clickhouse-client/src/main/java/com/clickhouse/client/config/ClickHouseDefaultSslContextProvider.java index 5bb432e6f..cf114cb26 100644 --- a/clickhouse-client/src/main/java/com/clickhouse/client/config/ClickHouseDefaultSslContextProvider.java +++ b/clickhouse-client/src/main/java/com/clickhouse/client/config/ClickHouseDefaultSslContextProvider.java @@ -1,6 +1,22 @@ package com.clickhouse.client.config; -import java.io.*; +import com.clickhouse.client.ClickHouseConfig; +import com.clickhouse.client.ClickHouseSslContextProvider; +import com.clickhouse.data.ClickHouseUtils; + +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLException; +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.KeyFactory; import java.security.KeyManagementException; import java.security.KeyStore; @@ -18,24 +34,30 @@ import java.util.Base64; import java.util.Optional; -import javax.net.ssl.KeyManager; -import javax.net.ssl.KeyManagerFactory; -import javax.net.ssl.SSLContext; -import javax.net.ssl.SSLException; -import javax.net.ssl.TrustManager; -import javax.net.ssl.TrustManagerFactory; -import javax.net.ssl.X509TrustManager; - -import com.clickhouse.client.ClickHouseConfig; -import com.clickhouse.client.ClickHouseSslContextProvider; -import com.clickhouse.data.ClickHouseUtils; - @Deprecated public class ClickHouseDefaultSslContextProvider implements ClickHouseSslContextProvider { 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. @@ -71,7 +93,7 @@ public static PrivateKey getPrivateKey(String keyFile) String algorithm = (String) ClickHouseDefaults.SSL_KEY_ALGORITHM.getEffectiveDefaultValue(); StringBuilder builder = new StringBuilder(); try (BufferedReader reader = new BufferedReader( - new InputStreamReader(ClickHouseUtils.getFileInputStream(keyFile)))) { + new InputStreamReader(getCertificateInputStream(keyFile)))) { String line = reader.readLine(); if (line != null) { algorithm = getAlgorithm(line, algorithm); @@ -102,7 +124,7 @@ public KeyStore getKeyStore(String cert, String key) throws NoSuchAlgorithmExcep ClickHouseUtils.format("%s KeyStore not available", KeyStore.getDefaultType())); } - try (InputStream in = ClickHouseUtils.getFileInputStream(cert)) { + try (InputStream in = getCertificateInputStream(cert)) { CertificateFactory factory = CertificateFactory .getInstance((String) ClickHouseDefaults.SSL_CERTIFICATE_TYPE.getEffectiveDefaultValue()); if (key == null || key.isEmpty()) { diff --git a/clickhouse-client/src/test/java/com/clickhouse/client/config/ClickHouseDefaultSslContextProviderTest.java b/clickhouse-client/src/test/java/com/clickhouse/client/config/ClickHouseDefaultSslContextProviderTest.java index 2fe066a48..a0baec5d9 100644 --- a/clickhouse-client/src/test/java/com/clickhouse/client/config/ClickHouseDefaultSslContextProviderTest.java +++ b/clickhouse-client/src/test/java/com/clickhouse/client/config/ClickHouseDefaultSslContextProviderTest.java @@ -1,9 +1,30 @@ package com.clickhouse.client.config; +import java.io.ByteArrayOutputStream; +import java.io.FileNotFoundException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.KeyStore; +import java.security.PrivateKey; + +import com.clickhouse.data.ClickHouseUtils; + import org.testng.Assert; import org.testng.annotations.Test; public class ClickHouseDefaultSslContextProviderTest { + static String readTestResource(String name) throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (InputStream in = ClickHouseUtils.getFileInputStream(name)) { + byte[] buffer = new byte[2048]; + int read; + while ((read = in.read(buffer)) != -1) { + out.write(buffer, 0, read); + } + } + return new String(out.toByteArray(), StandardCharsets.US_ASCII); + } + @Test(groups = { "unit" }) public void testGetAlgorithm() { Assert.assertEquals(ClickHouseDefaultSslContextProvider.getAlgorithm("", null), null); @@ -19,4 +40,41 @@ public void testGetPrivateKey() throws Exception { // openssl genpkey -out pkey4test.pem -algorithm RSA -pkeyopt rsa_keygen_bits:2048 Assert.assertNotNull(ClickHouseDefaultSslContextProvider.getPrivateKey("pkey4test.pem")); } + + @Test(groups = { "unit" }) + public void testGetCertificateInputStream() throws Exception { + String pemContent = readTestResource("client.crt"); + try (InputStream in = ClickHouseDefaultSslContextProvider.getCertificateInputStream(pemContent)) { + byte[] buffer = new byte[pemContent.length()]; + int read = in.read(buffer); + Assert.assertEquals(new String(buffer, 0, read, StandardCharsets.US_ASCII), pemContent); + } + + try (InputStream in = ClickHouseDefaultSslContextProvider.getCertificateInputStream("client.crt")) { + Assert.assertTrue(in.read() != -1); + } + + Assert.assertThrows(FileNotFoundException.class, + () -> ClickHouseDefaultSslContextProvider.getCertificateInputStream("non-existent.crt")); + } + + @Test(groups = { "unit" }) + public void testGetPrivateKeyFromPemContent() throws Exception { + PrivateKey fromFile = ClickHouseDefaultSslContextProvider.getPrivateKey("pkey4test.pem"); + PrivateKey fromContent = ClickHouseDefaultSslContextProvider + .getPrivateKey(readTestResource("pkey4test.pem")); + Assert.assertEquals(fromContent, fromFile); + } + + @Test(groups = { "unit" }) + public void testGetKeyStoreFromPemContent() throws Exception { + ClickHouseDefaultSslContextProvider provider = new ClickHouseDefaultSslContextProvider(); + + KeyStore trustStore = provider.getKeyStore(readTestResource("client.crt"), null); + Assert.assertNotNull(trustStore.getCertificate("cert1")); + + KeyStore keyStore = provider.getKeyStore(readTestResource("some_user.crt"), + readTestResource("some_user.key")); + Assert.assertNotNull(keyStore.getKey("key", null)); + } } \ No newline at end of file diff --git a/client-v2/pom.xml b/client-v2/pom.xml index 0c6409cdf..3ec4c8012 100644 --- a/client-v2/pom.xml +++ b/client-v2/pom.xml @@ -147,6 +147,18 @@ 1.5.7-6 test + + org.bouncycastle + bcprov-jdk18on + 1.84 + test + + + org.bouncycastle + bcpkix-jdk18on + 1.84 + test + 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 344305084..5ae4730b7 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 @@ -1,6 +1,5 @@ package com.clickhouse.client.api.internal; -import com.clickhouse.client.ClickHouseSslContextProvider; import com.clickhouse.client.api.ClickHouseException; import com.clickhouse.client.api.Client; import com.clickhouse.client.api.ClientConfigProperties; @@ -14,6 +13,7 @@ import com.clickhouse.client.api.enums.ProxyType; 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; @@ -131,6 +131,8 @@ public class HttpAPIClientHelper { LZ4Factory lz4Factory; + private final ClickHouseDefaultSslContextProvider sslContextProvider = new ClickHouseDefaultSslContextProvider(); + public HttpAPIClientHelper(Map configuration, Object metricsRegistry, boolean initSslContext, LZ4Factory lz4Factory) { this.metricsRegistry = metricsRegistry; this.httpClient = createHttpClient(initSslContext, configuration); @@ -163,8 +165,10 @@ public SSLContext createSSLContext(Map configuration) { } catch (NoSuchAlgorithmException e) { throw new ClientException("Failed to create default SSL context", e); } - ClickHouseSslContextProvider sslContextProvider = ClickHouseSslContextProvider.getProvider(); - String trustStorePath = (String) configuration.get(ClientConfigProperties.SSL_TRUST_STORE.getKey()); + 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( @@ -175,16 +179,9 @@ public SSLContext createSSLContext(Map configuration) { } catch (SSLException e) { throw new ClientMisconfigurationException("Failed to create SSL context from a keystore", e); } - } else if (configuration.get(ClientConfigProperties.CA_CERTIFICATE.getKey()) != null || - configuration.get(ClientConfigProperties.SSL_CERTIFICATE.getKey()) != null || - configuration.get(ClientConfigProperties.SSL_KEY.getKey()) != null) { - + } else if (caCertificate != null || sslCertificate != null|| sslKey != null) { try { - sslContext = sslContextProvider.getSslContextFromCerts( - (String) configuration.get(ClientConfigProperties.SSL_CERTIFICATE.getKey()), - (String) configuration.get(ClientConfigProperties.SSL_KEY.getKey()), - (String) configuration.get(ClientConfigProperties.CA_CERTIFICATE.getKey()) - ); + sslContext = sslContextProvider.getSslContextFromCerts(sslCertificate, sslKey, caCertificate); } catch (SSLException e) { throw new ClientMisconfigurationException("Failed to create SSL context from certificates", e); } 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 fddd05f4e..5c6384a60 100644 --- a/client-v2/src/test/java/com/clickhouse/client/HttpTransportTests.java +++ b/client-v2/src/test/java/com/clickhouse/client/HttpTransportTests.java @@ -38,6 +38,20 @@ import org.apache.hc.core5.http.HttpHeaders; import org.apache.hc.core5.http.HttpStatus; import org.apache.hc.core5.net.URIBuilder; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; +import org.bouncycastle.asn1.x509.KeyUsage; +import org.bouncycastle.cert.X509v3CertificateBuilder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.openssl.jcajce.JcaPEMWriter; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; import org.testcontainers.utility.ThrowingFunction; import org.testng.Assert; import org.testng.annotations.DataProvider; @@ -45,14 +59,29 @@ import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.io.StringWriter; +import java.math.BigInteger; import java.net.InetAddress; import java.net.Socket; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.SecureRandom; +import java.security.Security; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.time.Duration; import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Arrays; import java.util.Base64; +import java.util.Date; import java.util.EnumSet; import java.util.HashMap; import java.util.HashSet; @@ -2145,4 +2174,183 @@ protected Client.Builder newClient() { .setDefaultDatabase(ClickHouseServerForTest.getDatabase()) .serverSetting(ServerSettings.WAIT_END_OF_QUERY, "1"); } + + private static final String BC_PROVIDER = BouncyCastleProvider.PROVIDER_NAME; + + static { + if (Security.getProvider(BC_PROVIDER) == null) { + Security.addProvider(new BouncyCastleProvider()); + } + } + + @DataProvider(name = "testCustomCaCertificateProvider") + public static Object[][] testCustomCaCertificateProvider() { + return new Object[][]{ + {true}, + {false}}; + } + + /** + * End-to-end verification that a client configured with only a custom root CA certificate + * ({@link Client.Builder#setRootCertificate(String)}, no trust store involved) can establish a validated TLS + * connection to a server whose certificate is signed by that CA, and successfully perform operations over it. + * The certificate is passed either as a file or as a string with PEM content. As a control, a client without + * the root CA must fail to validate the same server, proving the provided CA is what makes the trusted + * connection possible. + */ + @Test(groups = {"integration"}, dataProvider = "testCustomCaCertificateProvider") + public void testCustomCaCertificate(boolean certAsString) throws Exception { + if (isCloud()) { + return; // test uses a local WireMock HTTPS server instead of a ClickHouse instance + } + + // Generate a private CA and a server certificate (CN/SAN=localhost) signed by it. + KeyPair caKeyPair = generateRsaKeyPair(); + X500Name caSubject = new X500Name("CN=ClickHouse Java Test CA"); + X509Certificate caCertificate = generateCertificate(caSubject, caSubject, caKeyPair.getPublic(), + caKeyPair.getPrivate(), caKeyPair.getPublic(), true, null); + + KeyPair serverKeyPair = generateRsaKeyPair(); + X500Name serverSubject = new X500Name("CN=localhost"); + GeneralNames serverSans = new GeneralNames(new GeneralName[]{ + new GeneralName(GeneralName.dNSName, "localhost"), + new GeneralName(GeneralName.iPAddress, "127.0.0.1") + }); + X509Certificate serverCertificate = generateCertificate(serverSubject, caSubject, serverKeyPair.getPublic(), + caKeyPair.getPrivate(), caKeyPair.getPublic(), false, serverSans); + + // Server side: PKCS12 keystore with the server key and its certificate chain for WireMock HTTPS. + char[] keystorePassword = "changeit".toCharArray(); + KeyStore serverKeyStore = KeyStore.getInstance("PKCS12"); + serverKeyStore.load(null, null); + serverKeyStore.setKeyEntry("server", serverKeyPair.getPrivate(), keystorePassword, + new Certificate[]{serverCertificate, caCertificate}); + Path keyStoreFile = Files.createTempFile("ch-test-server-keystore-", ".p12"); + try (java.io.OutputStream out = Files.newOutputStream(keyStoreFile)) { + serverKeyStore.store(out, keystorePassword); + } + + // Client side: only the root CA certificate, either as PEM content or as a file. + String caCertificatePem = toPem(caCertificate); + Path caCertFile = Files.createTempFile("ch-test-ca-", ".crt"); + Files.write(caCertFile, caCertificatePem.getBytes(StandardCharsets.US_ASCII)); + String rootCertificateValue = certAsString ? caCertificatePem : caCertFile.toAbsolutePath().toString(); + + WireMockServer httpsServer = new WireMockServer(WireMockConfiguration.options() + .dynamicPort() + .dynamicHttpsPort() + .keystorePath(keyStoreFile.toAbsolutePath().toString()) + .keystorePassword(new String(keystorePassword)) + .keyManagerPassword(new String(keystorePassword)) + .keystoreType("PKCS12") + .notifier(new ConsoleNotifier(false))); + httpsServer.start(); + + try { + // ClickHouse-style success response for any query/command. + httpsServer.addStubMapping(WireMock.post(WireMock.anyUrl()) + .willReturn(WireMock.aResponse() + .withStatus(HttpStatus.SC_OK) + .withHeader(ClickHouseHttpProto.HEADER_QUERY_ID, UUID.randomUUID().toString()) + .withBody("1\n")) + .build()); + + int httpsPort = httpsServer.httpsPort(); + String endpoint = "https://localhost:" + httpsPort; + + // Trusting client: configured with the root CA certificate only. + try (Client client = new Client.Builder() + .addEndpoint(endpoint) + .setUsername("default") + .setPassword("") + .setRootCertificate(rootCertificateValue) + .compressClientRequest(false) + .compressServerResponse(false) + .build()) { + + // Ping executes "SELECT 1" - succeeds only if the TLS handshake validated the server certificate. + Assert.assertTrue(client.ping(), + "Client should validate the server certificate using the root CA passed as " + + (certAsString ? "a string" : "a file")); + + // A real operation over the validated connection: read the result back. + try (QueryResponse response = + client.query("SELECT 1 FORMAT TabSeparated").get(10, TimeUnit.SECONDS)) { + Assert.assertNotNull(response.getQueryId()); + ByteArrayOutputStream content = new ByteArrayOutputStream(); + byte[] buffer = new byte[128]; + int read; + while ((read = response.getInputStream().read(buffer)) != -1) { + content.write(buffer, 0, read); + } + Assert.assertEquals(content.toString("UTF-8"), "1\n"); + } + } + + // The operations actually reached the HTTPS mock server. + httpsServer.verify(WireMock.moreThanOrExactly(1), WireMock.postRequestedFor(WireMock.anyUrl())); + + // Control client: without the root CA the default trust store rejects the private chain. + try (Client client = new Client.Builder() + .addEndpoint(endpoint) + .setUsername("default") + .setPassword("") + .compressClientRequest(false) + .compressServerResponse(false) + .build()) { + Assert.assertFalse(client.ping(), + "Client should not trust the server when the root CA is not provided"); + } + } finally { + httpsServer.stop(); + Files.deleteIfExists(keyStoreFile); + Files.deleteIfExists(caCertFile); + } + } + + private static KeyPair generateRsaKeyPair() throws Exception { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048, new SecureRandom()); + return keyPairGenerator.generateKeyPair(); + } + + private static X509Certificate generateCertificate(X500Name subject, X500Name issuer, PublicKey subjectPublicKey, + PrivateKey issuerPrivateKey, PublicKey issuerPublicKey, + boolean isCa, GeneralNames subjectAlternativeNames) + throws Exception { + Date notBefore = new Date(System.currentTimeMillis() - 60_000L); + Date notAfter = new Date(System.currentTimeMillis() + Duration.ofDays(1).toMillis()); + BigInteger serial = new BigInteger(160, new SecureRandom()).abs().add(BigInteger.ONE); + + X509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder( + issuer, serial, notBefore, notAfter, subject, subjectPublicKey); + certBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(isCa)); + certBuilder.addExtension(Extension.keyUsage, true, new KeyUsage(isCa + ? KeyUsage.keyCertSign | KeyUsage.cRLSign + : KeyUsage.digitalSignature | KeyUsage.keyEncipherment)); + + JcaX509ExtensionUtils extensionUtils = new JcaX509ExtensionUtils(); + certBuilder.addExtension(Extension.subjectKeyIdentifier, false, + extensionUtils.createSubjectKeyIdentifier(subjectPublicKey)); + certBuilder.addExtension(Extension.authorityKeyIdentifier, false, + extensionUtils.createAuthorityKeyIdentifier(issuerPublicKey)); + if (subjectAlternativeNames != null) { + certBuilder.addExtension(Extension.subjectAlternativeName, false, subjectAlternativeNames); + } + + ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA") + .setProvider(BC_PROVIDER) + .build(issuerPrivateKey); + return new JcaX509CertificateConverter() + .setProvider(BC_PROVIDER) + .getCertificate(certBuilder.build(signer)); + } + + private static String toPem(X509Certificate certificate) throws Exception { + StringWriter stringWriter = new StringWriter(); + try (JcaPEMWriter pemWriter = new JcaPEMWriter(stringWriter)) { + pemWriter.writeObject(certificate); + } + return stringWriter.toString(); + } } diff --git a/examples/client-v2/README.md b/examples/client-v2/README.md index c80941e5b..b1a688b96 100644 --- a/examples/client-v2/README.md +++ b/examples/client-v2/README.md @@ -74,4 +74,98 @@ mvn exec:java -Dexec.mainClass="com.clickhouse.examples.client_v2.RuntimeCredent Notes: - First argument is server location (endpoint). - Example uses admin credentials to `CREATE USER` and `DROP USER`. -- Optional database can be overridden with `-DchDatabase=`. \ No newline at end of file +- Optional database can be overridden with `-DchDatabase=`. + +## 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. + +The example runs in one of two modes. + +### Local mode (dockerized server, default) + +Verifies the whole scenario end to end: the example generates a private CA and a server +certificate, starts a local ClickHouse server in Docker configured with them, and connects using +only the generated CA certificate. Requires a running Docker daemon. + +```shell +mvn exec:java -Dexec.mainClass="com.clickhouse.examples.client_v2.SSLExamples" +``` + +Optional: +- `-DchImage` - Docker image to use (default: `clickhouse/clickhouse-server:latest`) + +### Standalone mode (your own server) + +Once the local scenario works, verify your own instance by passing its address and the CA +certificate that signed its server certificate: + +```shell +mvn exec:java -Dexec.mainClass="com.clickhouse.examples.client_v2.SSLExamples" \ + -DchHost="clickhouse.example.com" \ + -DchPort="8443" \ + -DchUser="default" \ + -DchPassword="secret" \ + -DchDatabase="default" \ + -DchRootCert="/path/to/ca.crt" +``` + +`-DchRootCert` is required in this mode and must point to the CA certificate in PEM format. + +### Setting up a Docker dev instance with a self-signed certificate manually + +The local mode does all of this automatically, but the same setup can be created by hand. + +1. Generate a private CA and a server certificate signed by it (`CN`/SAN must match the hostname +you will connect to, `localhost` in this example): + +```shell +# Private CA +openssl req -x509 -newkey rsa:2048 -days 365 -nodes \ + -keyout ca.key -out ca.crt -subj "/CN=ExamplePrivateCA" + +# Server key and certificate signing request +openssl req -newkey rsa:2048 -nodes \ + -keyout server.key -out server.csr -subj "/CN=localhost" + +# Server certificate signed by the CA, with SANs for localhost +openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial \ + -days 365 -out server.crt \ + -extfile <(printf "subjectAltName=DNS:localhost,IP:127.0.0.1") +``` + +2. Create a `config.d` overlay enabling the HTTPS interface, e.g. `zzz_ssl.xml`: + +```xml + + 8443 + + + /etc/clickhouse-server/certs/server.crt + /etc/clickhouse-server/certs/server.key + none + true + sslv2,sslv3 + true + + + +``` + +3. Start the server with the certificates and the configuration mounted: + +```shell +docker run -d --name clickhouse-ssl -p 8443:8443 \ + -v "$PWD/server.crt:/etc/clickhouse-server/certs/server.crt:ro" \ + -v "$PWD/server.key:/etc/clickhouse-server/certs/server.key:ro" \ + -v "$PWD/zzz_ssl.xml:/etc/clickhouse-server/config.d/zzz_ssl.xml:ro" \ + clickhouse/clickhouse-server:latest +``` + +4. Run the example in standalone mode with `-DchHost=localhost -DchRootCert="$PWD/ca.crt"`. + +The full description of the server-side TLS configuration is in the official documentation: +[Configuring SSL-TLS](https://clickhouse.com/docs/en/guides/sre/configuring-ssl). \ No newline at end of file diff --git a/examples/client-v2/pom.xml b/examples/client-v2/pom.xml index 360e0722e..b3111c640 100644 --- a/examples/client-v2/pom.xml +++ b/examples/client-v2/pom.xml @@ -128,6 +128,23 @@ compile + + + org.testcontainers + testcontainers + 2.0.2 + + + org.bouncycastle + bcprov-jdk18on + 1.84 + + + org.bouncycastle + bcpkix-jdk18on + 1.84 + + 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 new file mode 100644 index 000000000..5d47235f4 --- /dev/null +++ b/examples/client-v2/src/main/java/com/clickhouse/examples/client_v2/SSLExamples.java @@ -0,0 +1,169 @@ +package com.clickhouse.examples.client_v2; + +import com.clickhouse.client.api.Client; +import com.clickhouse.client.api.query.GenericRecord; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.List; + +/** + * Examples showing how to configure secure (TLS/HTTPS) connections with Client-v2. + * + *

Currently covered:

+ *
    + *
  • Connecting to a server whose certificate is signed by a custom (private) CA - + * the CA certificate is passed with {@link Client.Builder#setRootCertificate(String)}. + * No trust store configuration is needed: the certificate is added to a trust store + * used only by this client, so the JVM default trust store stays untouched.
  • + *
  • 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.
  • + *
+ * + *

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

+ * + *

The example runs in one of two modes:

+ *
    + *
  • Local mode (default, when {@code chHost} is not set) - starts a local ClickHouse + * server in Docker with a freshly generated self-signed certificate and verifies the whole + * scenario end to end. Requires a running Docker daemon.
  • + *
  • Standalone mode (when {@code chHost} is set) - connects to your own server using + * the provided CA certificate. Use it to verify your own instance once the local scenario works.
  • + *
+ * + *

Supported startup properties:

+ *
    + *
  • {@code chHost} - ClickHouse host. When set, standalone mode is used
  • + *
  • {@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 chImage} - Docker image for local mode, default {@code clickhouse/clickhouse-server:latest}
  • + *
+ */ +@Slf4j +public class SSLExamples { + + public static void main(String[] args) { + final String host = trimToNull(System.getProperty("chHost")); + final String database = System.getProperty("chDatabase", "default"); + + if (host != null) { + // Standalone mode: verify a user-provided instance. + final String port = System.getProperty("chPort", "8443"); + 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); + return; + } + + // Local mode: start a dockerized ClickHouse with a self-signed certificate and + // verify the whole scenario end to end. + 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)) { + connectWithCustomRootCertificate(server.getEndpoint(), database, + SecureServerSupport.USER, SecureServerSupport.PASSWORD, server.getCaCertPath()); + connectWithRootCertificateAsString(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); + Runtime.getRuntime().exit(-1); + } + // Explicit exit: testcontainers keeps non-daemon threads alive after the scenario is done. + Runtime.getRuntime().exit(0); + } + + /** + * 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, + * self-managed Kubernetes CA, etc.) that is not present in the JVM default trust store. + */ + static void connectWithCustomRootCertificate(String endpoint, String database, String user, String password, + String rootCert) { + log.info("Connecting to {} using root CA certificate from {}", endpoint, rootCert); + try (Client client = new Client.Builder() + .addEndpoint(endpoint) + .setUsername(user) + .setPassword(password) + .setDefaultDatabase(database) + // Only the CA certificate is required. The server certificate chain will be + // validated against it, and hostname verification stays enabled. + .setRootCertificate(rootCert) + .build()) { + + List rows = client.queryAll("SELECT currentUser() AS user, version() AS version"); + log.info("Connected securely as '{}' to ClickHouse {}", rows.get(0).getString("user"), + rows.get(0).getString("version")); + } catch (Exception e) { + log.error("Secure connection with a custom root CA certificate failed", e); + } + } + + /** + * Same as {@link #connectWithCustomRootCertificate}, but the CA certificate is passed as PEM + * content instead of a file path. {@link Client.Builder#setRootCertificate(String)} accepts both: + * any value containing a {@code -----BEGIN ...-----} block is treated as PEM content. + * + *

This is handy when the certificate is delivered through an environment variable or + * a secret manager (e.g. a Kubernetes secret projected into {@code CLICKHOUSE_CA_CERT}), + * so the application never has to write it to disk:

+ * + *
{@code
+     * String caPem = System.getenv("CLICKHOUSE_CA_CERT");
+     * Client client = new Client.Builder().setRootCertificate(caPem)...
+     * }
+ */ + static void connectWithRootCertificateAsString(String endpoint, String database, String user, String password, + String rootCertPath) { + final String rootCertPem; + try { + // In a real application the PEM content would typically come from an env variable + // or a secret manager; here we simply read the file generated for this example. + rootCertPem = new String(Files.readAllBytes(Paths.get(rootCertPath)), StandardCharsets.US_ASCII); + } catch (IOException e) { + log.error("Failed to read the CA certificate from {}", rootCertPath, e); + return; + } + + log.info("Connecting to {} using root CA certificate passed as a PEM string", endpoint); + try (Client client = new Client.Builder() + .addEndpoint(endpoint) + .setUsername(user) + .setPassword(password) + .setDefaultDatabase(database) + // PEM content, not a path - detected by the "-----BEGIN" marker. + .setRootCertificate(rootCertPem) + .build()) { + + List rows = client.queryAll("SELECT currentUser() AS user, version() AS version"); + log.info("Connected securely (CA cert as string) as '{}' to ClickHouse {}", + rows.get(0).getString("user"), rows.get(0).getString("version")); + } catch (Exception e) { + log.error("Secure connection with a CA certificate passed as a string failed", e); + } + } + + private static String trimToNull(String value) { + if (value == null) { + return null; + } + + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } +} diff --git a/examples/client-v2/src/main/java/com/clickhouse/examples/client_v2/SecureServerSupport.java b/examples/client-v2/src/main/java/com/clickhouse/examples/client_v2/SecureServerSupport.java new file mode 100644 index 000000000..a8d4eea65 --- /dev/null +++ b/examples/client-v2/src/main/java/com/clickhouse/examples/client_v2/SecureServerSupport.java @@ -0,0 +1,271 @@ +package com.clickhouse.examples.client_v2; + +import lombok.extern.slf4j.Slf4j; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; +import org.bouncycastle.asn1.x509.KeyUsage; +import org.bouncycastle.cert.X509v3CertificateBuilder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.openssl.jcajce.JcaPEMWriter; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.testcontainers.containers.BindMode; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; + +import java.io.File; +import java.io.IOException; +import java.io.Writer; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.SecureRandom; +import java.security.Security; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.util.Date; +import java.util.stream.Stream; + +/** + * Support class for SSL examples: starts a local ClickHouse server in Docker configured with a + * freshly generated self-signed certificate (a private CA signs the server certificate). + * + *

All TLS material is generated at runtime and removed when the server is closed, so the + * examples are fully self-contained. The same setup can be reproduced manually with + * {@code openssl} - see the project README. The server-side TLS configuration is described in the + * official documentation: Configuring SSL-TLS.

+ */ +@Slf4j +public class SecureServerSupport implements AutoCloseable { + + /** Credentials of the user created in the local container. */ + public static final String USER = "ssl_demo"; + public static final String PASSWORD = "ssl_demo_password"; + + private static final int HTTP_PORT = 8123; + private static final int HTTPS_PORT = 8443; + private static final long CERTIFICATE_DAYS_VALID = 365; + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + private static final String BC_PROVIDER = BouncyCastleProvider.PROVIDER_NAME; + + static { + if (Security.getProvider(BC_PROVIDER) == null) { + Security.addProvider(new BouncyCastleProvider()); + } + } + + private final GenericContainer container; + private final Path certDir; + private final Path confDir; + + private SecureServerSupport(GenericContainer container, Path certDir, Path confDir) { + this.container = container; + this.certDir = certDir; + this.confDir = confDir; + } + + /** + * Generates a private CA and a server certificate, writes the ClickHouse SSL configuration and + * starts a ClickHouse container with HTTPS enabled. + */ + public static SecureServerSupport start(String image) throws Exception { + Path certDir = Files.createTempDirectory("ch-ssl-example-certs-"); + Path confDir = Files.createTempDirectory("ch-ssl-example-config-"); + Path sslConfig = confDir.resolve("zzz_ssl.xml"); + + log.info("Generating an ephemeral private CA and a server certificate in {}", certDir); + generatePrivateCaAndServerCertificate(certDir); + writeClickHouseSslConfig(sslConfig); + // The TLS material must be readable by the 'clickhouse' user inside the container, + // while temp directories are created accessible to the current user only. + makeReadableByContainer(certDir); + makeReadableByContainer(confDir); + + log.info("Starting ClickHouse container from image: {}", image); + GenericContainer container = new GenericContainer<>(image) + .withExposedPorts(HTTP_PORT, HTTPS_PORT) + .withEnv("CLICKHOUSE_USER", USER) + .withEnv("CLICKHOUSE_PASSWORD", PASSWORD) + .withFileSystemBind(certDir.toAbsolutePath().toString(), + "/etc/clickhouse-server/certs", BindMode.READ_ONLY) + .withFileSystemBind(sslConfig.toAbsolutePath().toString(), + "/etc/clickhouse-server/config.d/zzz_ssl.xml", BindMode.READ_ONLY) + .waitingFor(Wait.forHttp("/ping") + .forPort(HTTP_PORT) + .forStatusCode(200) + .withStartupTimeout(Duration.ofMinutes(3))); + try { + container.start(); + } catch (Exception e) { + log.error("ClickHouse container failed to start. Container logs:\n{}", safeGetLogs(container)); + deleteRecursively(certDir); + deleteRecursively(confDir); + throw e; + } + log.info("ClickHouse container is ready on https://localhost:{}", container.getMappedPort(HTTPS_PORT)); + return new SecureServerSupport(container, certDir, confDir); + } + + /** HTTPS endpoint of the started container. */ + public String getEndpoint() { + return "https://localhost:" + container.getMappedPort(HTTPS_PORT); + } + + /** Path to the CA certificate (PEM) that signed the server certificate. */ + public String getCaCertPath() { + return certDir.resolve("ca.crt").toAbsolutePath().toString(); + } + + @Override + public void close() { + log.info("Stopping ClickHouse container and deleting temporary TLS artifacts"); + container.stop(); + deleteRecursively(certDir); + deleteRecursively(confDir); + } + + private static void generatePrivateCaAndServerCertificate(Path outputDir) throws Exception { + KeyPair caKeys = generateRsaKeyPair(); + X500Name caSubject = new X500Name("CN=ExamplePrivateCA"); + X509Certificate caCertificate = generateCertificate(caSubject, caSubject, + caKeys.getPublic(), caKeys.getPrivate(), caKeys.getPublic(), true, null); + + KeyPair serverKeys = generateRsaKeyPair(); + X500Name serverSubject = new X500Name("CN=localhost"); + GeneralNames serverSans = new GeneralNames(new GeneralName[]{ + new GeneralName(GeneralName.dNSName, "localhost"), + new GeneralName(GeneralName.iPAddress, "127.0.0.1") + }); + X509Certificate serverCertificate = generateCertificate(serverSubject, caSubject, + serverKeys.getPublic(), caKeys.getPrivate(), caKeys.getPublic(), false, serverSans); + + writePemObject(outputDir.resolve("ca.crt"), caCertificate); + writePemObject(outputDir.resolve("server.crt"), serverCertificate); + writePemObject(outputDir.resolve("server.key"), serverKeys.getPrivate()); + } + + private static KeyPair generateRsaKeyPair() throws Exception { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048, SECURE_RANDOM); + return keyPairGenerator.generateKeyPair(); + } + + private static X509Certificate generateCertificate( + X500Name subject, + X500Name issuer, + PublicKey subjectPublicKey, + PrivateKey issuerPrivateKey, + PublicKey issuerPublicKey, + boolean isCa, + GeneralNames subjectAlternativeNames) throws Exception { + Date notBefore = new Date(System.currentTimeMillis() - 60_000L); + Date notAfter = new Date(System.currentTimeMillis() + Duration.ofDays(CERTIFICATE_DAYS_VALID).toMillis()); + BigInteger serial = new BigInteger(160, SECURE_RANDOM).abs().add(BigInteger.ONE); + + X509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder( + issuer, serial, notBefore, notAfter, subject, subjectPublicKey); + certBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(isCa)); + certBuilder.addExtension(Extension.keyUsage, true, new KeyUsage(isCa + ? KeyUsage.keyCertSign | KeyUsage.cRLSign + : KeyUsage.digitalSignature | KeyUsage.keyEncipherment)); + + JcaX509ExtensionUtils extensionUtils = new JcaX509ExtensionUtils(); + certBuilder.addExtension(Extension.subjectKeyIdentifier, false, + extensionUtils.createSubjectKeyIdentifier(subjectPublicKey)); + certBuilder.addExtension(Extension.authorityKeyIdentifier, false, + extensionUtils.createAuthorityKeyIdentifier(issuerPublicKey)); + if (subjectAlternativeNames != null) { + certBuilder.addExtension(Extension.subjectAlternativeName, false, subjectAlternativeNames); + } + + ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA") + .setProvider(BC_PROVIDER) + .build(issuerPrivateKey); + X509Certificate certificate = new JcaX509CertificateConverter() + .setProvider(BC_PROVIDER) + .getCertificate(certBuilder.build(signer)); + certificate.checkValidity(new Date()); + certificate.verify(issuerPublicKey); + return certificate; + } + + private static void writePemObject(Path targetPath, Object value) throws IOException { + try (Writer fileWriter = Files.newBufferedWriter(targetPath, StandardCharsets.US_ASCII); + JcaPEMWriter pemWriter = new JcaPEMWriter(fileWriter)) { + pemWriter.writeObject(value); + } + } + + /** + * Writes a config.d overlay enabling the HTTPS interface. The full description of the + * server-side options is in the official documentation: + * https://clickhouse.com/docs/en/guides/sre/configuring-ssl + */ + private static void writeClickHouseSslConfig(Path configPath) throws IOException { + String config = "\n" + + " " + HTTPS_PORT + "\n" + + " \n" + + " \n" + + " /etc/clickhouse-server/certs/server.crt\n" + + " /etc/clickhouse-server/certs/server.key\n" + + " none\n" + + " true\n" + + " sslv2,sslv3\n" + + " true\n" + + " \n" + + " \n" + + "\n"; + Files.write(configPath, config.getBytes(StandardCharsets.UTF_8)); + } + + private static void makeReadableByContainer(Path dir) throws IOException { + try (Stream targets = Files.walk(dir)) { + for (Path target : targets.toArray(Path[]::new)) { + File file = target.toFile(); + if (!file.setReadable(true, false)) { + log.warn("Failed to make {} world-readable", target); + } + if (Files.isDirectory(target) && !file.setExecutable(true, false)) { + log.warn("Failed to make {} world-executable", target); + } + } + } + } + + private static String safeGetLogs(GenericContainer container) { + try { + return container.getLogs(); + } catch (Exception e) { + return ""; + } + } + + private static void deleteRecursively(Path path) { + if (path == null || !Files.exists(path)) { + return; + } + try (Stream targets = Files.walk(path)) { + targets.sorted((left, right) -> right.getNameCount() - left.getNameCount()) + .forEach(target -> { + try { + Files.deleteIfExists(target); + } catch (IOException ignored) { + // Best effort cleanup for temporary example files. + } + }); + } catch (IOException ignored) { + // Best effort cleanup for temporary example files. + } + } +} diff --git a/examples/jdbc/README.md b/examples/jdbc/README.md index be21334ce..51d63327a 100644 --- a/examples/jdbc/README.md +++ b/examples/jdbc/README.md @@ -20,4 +20,97 @@ To run simplified example: Addition options can be passed to the application: - `-DchUrl` - ClickHouse JDBC URL. Default is `jdbc:clickhouse://localhost:8123/default` -- `-Dclickhouse.jdbc.v2=true` - Use JDBC V2 implementation \ No newline at end of file +- `-Dclickhouse.jdbc.v2=true` - Use JDBC V2 implementation + +## 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. + +The example runs in one of two modes. + +### Local mode (dockerized server, default) + +Verifies the whole scenario end to end: the example generates a private CA and a server +certificate, starts a local ClickHouse server in Docker configured with them, and connects using +only the generated CA certificate. Requires a running Docker daemon. + +```shell +mvn exec:java -Dexec.mainClass="com.clickhouse.examples.jdbc.SSLExamples" +``` + +Optional: +- `-DchImage` - Docker image to use (default: `clickhouse/clickhouse-server:latest`) + +### Standalone mode (your own server) + +Once the local scenario works, verify your own instance by passing its JDBC URL and the CA +certificate that signed its server certificate: + +```shell +mvn exec:java -Dexec.mainClass="com.clickhouse.examples.jdbc.SSLExamples" \ + -DchUrl="jdbc:clickhouse://clickhouse.example.com:8443/default" \ + -DchUser="default" \ + -DchPassword="secret" \ + -DchRootCert="/path/to/ca.crt" +``` + +`-DchRootCert` is required in this mode and must point to the CA certificate in PEM format. + +### Setting up a Docker dev instance with a self-signed certificate manually + +The local mode does all of this automatically, but the same setup can be created by hand. + +1. Generate a private CA and a server certificate signed by it (`CN`/SAN must match the hostname +you will connect to, `localhost` in this example): + +```shell +# Private CA +openssl req -x509 -newkey rsa:2048 -days 365 -nodes \ + -keyout ca.key -out ca.crt -subj "/CN=ExamplePrivateCA" + +# Server key and certificate signing request +openssl req -newkey rsa:2048 -nodes \ + -keyout server.key -out server.csr -subj "/CN=localhost" + +# Server certificate signed by the CA, with SANs for localhost +openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial \ + -days 365 -out server.crt \ + -extfile <(printf "subjectAltName=DNS:localhost,IP:127.0.0.1") +``` + +2. Create a `config.d` overlay enabling the HTTPS interface, e.g. `zzz_ssl.xml`: + +```xml + + 8443 + + + /etc/clickhouse-server/certs/server.crt + /etc/clickhouse-server/certs/server.key + none + true + sslv2,sslv3 + true + + + +``` + +3. Start the server with the certificates and the configuration mounted: + +```shell +docker run -d --name clickhouse-ssl -p 8443:8443 \ + -v "$PWD/server.crt:/etc/clickhouse-server/certs/server.crt:ro" \ + -v "$PWD/server.key:/etc/clickhouse-server/certs/server.key:ro" \ + -v "$PWD/zzz_ssl.xml:/etc/clickhouse-server/config.d/zzz_ssl.xml:ro" \ + clickhouse/clickhouse-server:latest +``` + +4. Run the example in standalone mode with +`-DchUrl="jdbc:clickhouse://localhost:8443/default" -DchRootCert="$PWD/ca.crt"`. + +The full description of the server-side TLS configuration is in the official documentation: +[Configuring SSL-TLS](https://clickhouse.com/docs/en/guides/sre/configuring-ssl). \ No newline at end of file diff --git a/examples/jdbc/pom.xml b/examples/jdbc/pom.xml index c21b09fba..0bfdce4d0 100644 --- a/examples/jdbc/pom.xml +++ b/examples/jdbc/pom.xml @@ -91,6 +91,23 @@ ${slf4j.version} runtime + + + + org.testcontainers + testcontainers + 2.0.2 + + + org.bouncycastle + bcprov-jdk18on + 1.84 + + + org.bouncycastle + bcpkix-jdk18on + 1.84 + 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 new file mode 100644 index 000000000..11a022fb5 --- /dev/null +++ b/examples/jdbc/src/main/java/com/clickhouse/examples/jdbc/SSLExamples.java @@ -0,0 +1,168 @@ +package com.clickhouse.examples.jdbc; + +import com.clickhouse.client.api.ClientConfigProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.Properties; + +/** + * Examples showing how to configure secure (TLS/HTTPS) connections with the JDBC driver. + * + *

Currently covered:

+ *
    + *
  • Connecting to a server whose certificate is signed by a custom (private) CA - + * the CA certificate is passed with the {@code sslrootcert} connection property. + * No trust store configuration is needed: the certificate is added to a trust store + * used only by this connection, so the JVM default trust store stays untouched.
  • + *
  • 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.
  • + *
+ * + *

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

+ * + *

The example runs in one of two modes:

+ *
    + *
  • Local mode (default, when {@code chUrl} is not set) - starts a local ClickHouse + * server in Docker with a freshly generated self-signed certificate and verifies the whole + * scenario end to end. Requires a running Docker daemon.
  • + *
  • Standalone mode (when {@code chUrl} is set) - connects to your own server using + * the provided CA certificate. Use it to verify your own instance once the local scenario works.
  • + *
+ * + *

Supported startup properties:

+ *
    + *
  • {@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 chImage} - Docker image for local mode, default {@code clickhouse/clickhouse-server:latest}
  • + *
+ */ +public class SSLExamples { + private static final Logger log = LoggerFactory.getLogger(SSLExamples.class); + + public static void main(String[] args) { + final String url = trimToNull(System.getProperty("chUrl")); + + if (url != null) { + // Standalone mode: verify a user-provided instance. + 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); + } catch (SQLException | IOException e) { + log.error("Secure connection with a custom root CA certificate failed", e); + } + return; + } + + // Local mode: start a dockerized ClickHouse with a self-signed certificate and + // verify the whole scenario end to end. + 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)) { + connectWithCustomRootCertificate(server.getJdbcUrl(), + SecureServerSupport.USER, SecureServerSupport.PASSWORD, server.getCaCertPath()); + connectWithRootCertificateAsString(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); + } + // Explicit exit: testcontainers keeps non-daemon threads alive after the scenario is done. + Runtime.getRuntime().exit(0); + } + + /** + * 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, + * self-managed Kubernetes CA, etc.) that is not present in the JVM default trust store. + */ + static void connectWithCustomRootCertificate(String url, String user, String password, String rootCert) + throws SQLException { + log.info("Connecting to {} using root CA certificate from {}", url, rootCert); + + 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 + // Only the CA certificate is required. The server certificate chain will be + // validated against it, and hostname verification stays enabled. + properties.setProperty(ClientConfigProperties.CA_CERTIFICATE.getKey(), rootCert); // sslrootcert + + 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 as '{}' to ClickHouse {}", rs.getString("user"), rs.getString("version")); + } + } + } + + /** + * Same as {@link #connectWithCustomRootCertificate}, but the CA certificate is passed as PEM + * content instead of a file path. The {@code sslrootcert} property accepts both: any value + * containing a {@code -----BEGIN ...-----} block is treated as PEM content. + * + *

This is handy when the certificate is delivered through an environment variable or + * a secret manager (e.g. a Kubernetes secret projected into {@code CLICKHOUSE_CA_CERT}), + * so the application never has to write it to disk:

+ * + *
{@code
+     * properties.setProperty("sslrootcert", System.getenv("CLICKHOUSE_CA_CERT"));
+     * }
+ */ + static void connectWithRootCertificateAsString(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 simply read the file generated for this example. + String rootCertPem = new String(Files.readAllBytes(Paths.get(rootCertPath)), StandardCharsets.US_ASCII); + + log.info("Connecting to {} using root CA certificate passed as a PEM string", 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 + // PEM content, not a path - detected by the "-----BEGIN" marker. + properties.setProperty(ClientConfigProperties.CA_CERTIFICATE.getKey(), rootCertPem); // sslrootcert + + 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 (CA cert as string) as '{}' to ClickHouse {}", + rs.getString("user"), rs.getString("version")); + } + } + } + + private static String trimToNull(String value) { + if (value == null) { + return null; + } + + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } +} diff --git a/examples/jdbc/src/main/java/com/clickhouse/examples/jdbc/SecureServerSupport.java b/examples/jdbc/src/main/java/com/clickhouse/examples/jdbc/SecureServerSupport.java new file mode 100644 index 000000000..331141f9f --- /dev/null +++ b/examples/jdbc/src/main/java/com/clickhouse/examples/jdbc/SecureServerSupport.java @@ -0,0 +1,272 @@ +package com.clickhouse.examples.jdbc; + +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; +import org.bouncycastle.asn1.x509.KeyUsage; +import org.bouncycastle.cert.X509v3CertificateBuilder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.openssl.jcajce.JcaPEMWriter; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.containers.BindMode; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; + +import java.io.File; +import java.io.IOException; +import java.io.Writer; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.SecureRandom; +import java.security.Security; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.util.Date; +import java.util.stream.Stream; + +/** + * Support class for SSL examples: starts a local ClickHouse server in Docker configured with a + * freshly generated self-signed certificate (a private CA signs the server certificate). + * + *

All TLS material is generated at runtime and removed when the server is closed, so the + * examples are fully self-contained. The same setup can be reproduced manually with + * {@code openssl} - see the project README. The server-side TLS configuration is described in the + * official documentation: Configuring SSL-TLS.

+ */ +public class SecureServerSupport implements AutoCloseable { + private static final Logger log = LoggerFactory.getLogger(SecureServerSupport.class); + + /** Credentials of the user created in the local container. */ + public static final String USER = "ssl_demo"; + public static final String PASSWORD = "ssl_demo_password"; + + private static final int HTTP_PORT = 8123; + private static final int HTTPS_PORT = 8443; + private static final long CERTIFICATE_DAYS_VALID = 365; + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + private static final String BC_PROVIDER = BouncyCastleProvider.PROVIDER_NAME; + + static { + if (Security.getProvider(BC_PROVIDER) == null) { + Security.addProvider(new BouncyCastleProvider()); + } + } + + private final GenericContainer container; + private final Path certDir; + private final Path confDir; + + private SecureServerSupport(GenericContainer container, Path certDir, Path confDir) { + this.container = container; + this.certDir = certDir; + this.confDir = confDir; + } + + /** + * Generates a private CA and a server certificate, writes the ClickHouse SSL configuration and + * starts a ClickHouse container with HTTPS enabled. + */ + public static SecureServerSupport start(String image) throws Exception { + Path certDir = Files.createTempDirectory("ch-ssl-example-certs-"); + Path confDir = Files.createTempDirectory("ch-ssl-example-config-"); + Path sslConfig = confDir.resolve("zzz_ssl.xml"); + + log.info("Generating an ephemeral private CA and a server certificate in {}", certDir); + generatePrivateCaAndServerCertificate(certDir); + writeClickHouseSslConfig(sslConfig); + // The TLS material must be readable by the 'clickhouse' user inside the container, + // while temp directories are created accessible to the current user only. + makeReadableByContainer(certDir); + makeReadableByContainer(confDir); + + log.info("Starting ClickHouse container from image: {}", image); + GenericContainer container = new GenericContainer<>(image) + .withExposedPorts(HTTP_PORT, HTTPS_PORT) + .withEnv("CLICKHOUSE_USER", USER) + .withEnv("CLICKHOUSE_PASSWORD", PASSWORD) + .withFileSystemBind(certDir.toAbsolutePath().toString(), + "/etc/clickhouse-server/certs", BindMode.READ_ONLY) + .withFileSystemBind(sslConfig.toAbsolutePath().toString(), + "/etc/clickhouse-server/config.d/zzz_ssl.xml", BindMode.READ_ONLY) + .waitingFor(Wait.forHttp("/ping") + .forPort(HTTP_PORT) + .forStatusCode(200) + .withStartupTimeout(Duration.ofMinutes(3))); + try { + container.start(); + } catch (Exception e) { + log.error("ClickHouse container failed to start. Container logs:\n{}", safeGetLogs(container)); + deleteRecursively(certDir); + deleteRecursively(confDir); + throw e; + } + log.info("ClickHouse container is ready on https://localhost:{}", container.getMappedPort(HTTPS_PORT)); + return new SecureServerSupport(container, certDir, confDir); + } + + /** JDBC URL of the started container (HTTPS). */ + public String getJdbcUrl() { + return "jdbc:clickhouse://localhost:" + container.getMappedPort(HTTPS_PORT) + "/default?ssl=true"; + } + + /** Path to the CA certificate (PEM) that signed the server certificate. */ + public String getCaCertPath() { + return certDir.resolve("ca.crt").toAbsolutePath().toString(); + } + + @Override + public void close() { + log.info("Stopping ClickHouse container and deleting temporary TLS artifacts"); + container.stop(); + deleteRecursively(certDir); + deleteRecursively(confDir); + } + + private static void generatePrivateCaAndServerCertificate(Path outputDir) throws Exception { + KeyPair caKeys = generateRsaKeyPair(); + X500Name caSubject = new X500Name("CN=ExamplePrivateCA"); + X509Certificate caCertificate = generateCertificate(caSubject, caSubject, + caKeys.getPublic(), caKeys.getPrivate(), caKeys.getPublic(), true, null); + + KeyPair serverKeys = generateRsaKeyPair(); + X500Name serverSubject = new X500Name("CN=localhost"); + GeneralNames serverSans = new GeneralNames(new GeneralName[]{ + new GeneralName(GeneralName.dNSName, "localhost"), + new GeneralName(GeneralName.iPAddress, "127.0.0.1") + }); + X509Certificate serverCertificate = generateCertificate(serverSubject, caSubject, + serverKeys.getPublic(), caKeys.getPrivate(), caKeys.getPublic(), false, serverSans); + + writePemObject(outputDir.resolve("ca.crt"), caCertificate); + writePemObject(outputDir.resolve("server.crt"), serverCertificate); + writePemObject(outputDir.resolve("server.key"), serverKeys.getPrivate()); + } + + private static KeyPair generateRsaKeyPair() throws Exception { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048, SECURE_RANDOM); + return keyPairGenerator.generateKeyPair(); + } + + private static X509Certificate generateCertificate( + X500Name subject, + X500Name issuer, + PublicKey subjectPublicKey, + PrivateKey issuerPrivateKey, + PublicKey issuerPublicKey, + boolean isCa, + GeneralNames subjectAlternativeNames) throws Exception { + Date notBefore = new Date(System.currentTimeMillis() - 60_000L); + Date notAfter = new Date(System.currentTimeMillis() + Duration.ofDays(CERTIFICATE_DAYS_VALID).toMillis()); + BigInteger serial = new BigInteger(160, SECURE_RANDOM).abs().add(BigInteger.ONE); + + X509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder( + issuer, serial, notBefore, notAfter, subject, subjectPublicKey); + certBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(isCa)); + certBuilder.addExtension(Extension.keyUsage, true, new KeyUsage(isCa + ? KeyUsage.keyCertSign | KeyUsage.cRLSign + : KeyUsage.digitalSignature | KeyUsage.keyEncipherment)); + + JcaX509ExtensionUtils extensionUtils = new JcaX509ExtensionUtils(); + certBuilder.addExtension(Extension.subjectKeyIdentifier, false, + extensionUtils.createSubjectKeyIdentifier(subjectPublicKey)); + certBuilder.addExtension(Extension.authorityKeyIdentifier, false, + extensionUtils.createAuthorityKeyIdentifier(issuerPublicKey)); + if (subjectAlternativeNames != null) { + certBuilder.addExtension(Extension.subjectAlternativeName, false, subjectAlternativeNames); + } + + ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA") + .setProvider(BC_PROVIDER) + .build(issuerPrivateKey); + X509Certificate certificate = new JcaX509CertificateConverter() + .setProvider(BC_PROVIDER) + .getCertificate(certBuilder.build(signer)); + certificate.checkValidity(new Date()); + certificate.verify(issuerPublicKey); + return certificate; + } + + private static void writePemObject(Path targetPath, Object value) throws IOException { + try (Writer fileWriter = Files.newBufferedWriter(targetPath, StandardCharsets.US_ASCII); + JcaPEMWriter pemWriter = new JcaPEMWriter(fileWriter)) { + pemWriter.writeObject(value); + } + } + + /** + * Writes a config.d overlay enabling the HTTPS interface. The full description of the + * server-side options is in the official documentation: + * https://clickhouse.com/docs/en/guides/sre/configuring-ssl + */ + private static void writeClickHouseSslConfig(Path configPath) throws IOException { + String config = "\n" + + " " + HTTPS_PORT + "\n" + + " \n" + + " \n" + + " /etc/clickhouse-server/certs/server.crt\n" + + " /etc/clickhouse-server/certs/server.key\n" + + " none\n" + + " true\n" + + " sslv2,sslv3\n" + + " true\n" + + " \n" + + " \n" + + "\n"; + Files.write(configPath, config.getBytes(StandardCharsets.UTF_8)); + } + + private static void makeReadableByContainer(Path dir) throws IOException { + try (Stream targets = Files.walk(dir)) { + for (Path target : targets.toArray(Path[]::new)) { + File file = target.toFile(); + if (!file.setReadable(true, false)) { + log.warn("Failed to make {} world-readable", target); + } + if (Files.isDirectory(target) && !file.setExecutable(true, false)) { + log.warn("Failed to make {} world-executable", target); + } + } + } + } + + private static String safeGetLogs(GenericContainer container) { + try { + return container.getLogs(); + } catch (Exception e) { + return ""; + } + } + + private static void deleteRecursively(Path path) { + if (path == null || !Files.exists(path)) { + return; + } + try (Stream targets = Files.walk(path)) { + targets.sorted((left, right) -> right.getNameCount() - left.getNameCount()) + .forEach(target -> { + try { + Files.deleteIfExists(target); + } catch (IOException ignored) { + // Best effort cleanup for temporary example files. + } + }); + } catch (IOException ignored) { + // Best effort cleanup for temporary example files. + } + } +}