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..c07132ca7 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,22 @@ package com.clickhouse.client.config; +import com.clickhouse.data.ClickHouseUtils; import org.testng.Assert; import org.testng.annotations.Test; +import java.io.FileNotFoundException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.KeyStore; +import java.security.PrivateKey; + public class ClickHouseDefaultSslContextProviderTest { + static String readTestResource(String name) throws Exception { + try (InputStream in = ClickHouseUtils.getFileInputStream(name)) { + return new String(in.readAllBytes(), StandardCharsets.US_ASCII); + } + } + @Test(groups = { "unit" }) public void testGetAlgorithm() { Assert.assertEquals(ClickHouseDefaultSslContextProvider.getAlgorithm("", null), null); @@ -19,4 +32,44 @@ 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); + Assert.assertEquals(fromContent.getAlgorithm(), fromFile.getAlgorithm()); + Assert.assertEquals(fromContent.getFormat(), fromFile.getFormat()); + Assert.assertEquals(fromContent.getEncoded(), fromFile.getEncoded()); + } + + @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/src/test/java/com/clickhouse/client/HttpTransportTests.java b/client-v2/src/test/java/com/clickhouse/client/HttpTransportTests.java index 0e4483d13..5c6384a60 100644 --- a/client-v2/src/test/java/com/clickhouse/client/HttpTransportTests.java +++ b/client-v2/src/test/java/com/clickhouse/client/HttpTransportTests.java @@ -2186,7 +2186,7 @@ protected Client.Builder newClient() { @DataProvider(name = "testCustomCaCertificateProvider") public static Object[][] testCustomCaCertificateProvider() { return new Object[][]{ - // TODO: decide if we need to support certificates via string {true}, + {true}, {false}}; } 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 cbf668ed7..dc62b65c2 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 @@ -4,6 +4,10 @@ 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; /** @@ -15,6 +19,9 @@ * 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.

    @@ -58,7 +65,9 @@ public static void main(String[] args) { } log.info("Running in standalone mode against {}:{}", host, port); - connectWithCustomRootCertificate("https://" + host + ":" + port, database, user, password, rootCert); + String endpoint = "https://" + host + ":" + port; + connectWithCustomRootCertificate(endpoint, database, user, password, rootCert); + connectWithRootCertificateAsString(endpoint, database, user, password, rootCert); return; } @@ -69,6 +78,8 @@ public static void main(String[] args) { 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); } @@ -100,6 +111,50 @@ static void connectWithCustomRootCertificate(String endpoint, String database, S } } + /** + * 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; 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 c03253650..11a022fb5 100644 --- a/examples/jdbc/src/main/java/com/clickhouse/examples/jdbc/SSLExamples.java +++ b/examples/jdbc/src/main/java/com/clickhouse/examples/jdbc/SSLExamples.java @@ -4,6 +4,10 @@ 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; @@ -20,6 +24,9 @@ * 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.

    @@ -62,7 +69,8 @@ public static void main(String[] args) { log.info("Running in standalone mode against {}", url); try { connectWithCustomRootCertificate(url, user, password, rootCert); - } catch (SQLException e) { + connectWithRootCertificateAsString(url, user, password, rootCert); + } catch (SQLException | IOException e) { log.error("Secure connection with a custom root CA certificate failed", e); } return; @@ -75,6 +83,8 @@ public static void main(String[] args) { 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); @@ -109,6 +119,44 @@ static void connectWithCustomRootCertificate(String url, String user, String pas } } + /** + * 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;