Currently covered:
+ *More SSL examples (mTLS, trust stores, SNI) will be added to this class later.
+ * + *The example runs in one of two modes:
+ *Supported startup properties:
+ *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("custom_ca_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/custom_ca_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 = "Currently covered:
+ *More SSL examples (mTLS, trust stores, SNI) will be added to this class later.
+ * + *The example runs in one of two modes:
+ *Supported startup properties:
+ *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 = "