diff --git a/docs/secure_connection.md b/docs/secure_connection.md
new file mode 100644
index 000000000..e79135e53
--- /dev/null
+++ b/docs/secure_connection.md
@@ -0,0 +1,228 @@
+# Secure Connections Guide
+
+This guide explains how to configure the ClickHouse Java client (Client-v2) for secure (TLS/HTTPS) connections. It covers the most common deployment scenarios and describes what to expect from the client in each one.
+
+## How TLS Validation Works
+
+A secure connection does two independent things:
+
+| Check | What it verifies | Why it matters |
+|---|---|---|
+| **Certificate validation** | The server's certificate is trusted (signed by a known CA or explicitly trusted) | Prevents connecting to a server impersonating ClickHouse |
+| **Hostname verification** | The certificate's subject matches the hostname the client is connecting to | Prevents connecting to the right CA's certificate on the wrong server |
+
+Both checks are needed for a fully secure connection. Skipping either one leaves a gap even if the other check passes.
+
+> **Note:** When troubleshooting a TLS failure, the first step is to determine which of the two checks is failing. The error message from the client will usually indicate whether the problem is with the certificate chain or the hostname.
+
+---
+
+## Use Cases
+
+### 1. Public CA
+
+Common for managed cloud services and public-facing deployments. The server certificate is issued by a well-known authority such as Let's Encrypt, DigiCert, or Amazon, so no custom certificates need to be distributed — the JVM truststore already trusts these issuers.
+
+| Chain validation | Hostname verification | Direct certificate trust |
+|---|---|---|
+| ✓ Against JVM truststore | ✓ Enabled | — |
+
+```java
+Client client = new Client.Builder()
+ .addEndpoint("https://clickhouse.example.com:8443")
+ .setUsername("default")
+ .setPassword("secret")
+ .build();
+```
+
+---
+
+### 2. Private CA with Hostname Verification
+
+Mainly used in regulated environments and zero-trust networks where the organization runs its own certificate authority and strict server identity verification is required. Provide the CA certificate so the client can validate the chain, and the hostname in the connection URL must match the certificate exactly.
+
+| Chain validation | Hostname verification | Direct certificate trust |
+|---|---|---|
+| ✓ Against provided CA | ✓ Enabled | — |
+
+#### Option A — CA certificate as a PEM file
+
+```java
+Client client = new Client.Builder()
+ .addEndpoint("https://clickhouse.internal:8443")
+ .setUsername("default")
+ .setPassword("secret")
+ .setRootCertificate("/path/to/company-ca.crt")
+ .build();
+```
+
+#### Option B — CA certificate via a Java truststore
+
+```java
+Client client = new Client.Builder()
+ .addEndpoint("https://clickhouse.internal:8443")
+ .setUsername("default")
+ .setPassword("secret")
+ .setSSLTrustStore("/path/to/truststore.jks")
+ .setSSLTrustStorePassword("changeit")
+ .setSSLTrustStoreType("JKS")
+ .build();
+```
+
+#### Connecting via IP, proxy, or load balancer
+
+When the connection target does not match the certificate subject (e.g. an IP address or proxy hostname), use `sslSocketSNI` to tell the client which hostname to expect on the certificate.
+
+```java
+Client client = new Client.Builder()
+ .addEndpoint("https://10.0.0.1:8443")
+ .setUsername("default")
+ .setPassword("secret")
+ .setRootCertificate("/path/to/company-ca.crt")
+ .sslSocketSNI("clickhouse.internal")
+ .build();
+```
+
+---
+
+### 3. Private CA without Hostname Verification
+
+Common in corporate and on-premise deployments where the hostname used to reach the server does not match the certificate subject or SANs. The certificate chain is still validated against the private CA, but the hostname check is skipped.
+
+| Chain validation | Hostname verification | Direct certificate trust |
+|---|---|---|
+| ✓ Against provided CA | ✗ Disabled | — |
+
+#### Why hostname verification fails in this scenario
+
+A server certificate is issued for a specific hostname (e.g. `clickhouse.internal`). When you connect to the server, the client checks that the hostname in the URL matches the certificate. This check fails whenever the connection goes through an intermediary that has its own address:
+
+- **IP address** — the certificate is issued for a hostname, not an IP, so connecting via `https://10.0.0.5:8443` fails even if the cert is otherwise valid.
+- **Load balancer or reverse proxy** — the connection URL targets the load balancer hostname, which is different from the hostname on the ClickHouse certificate.
+- **Cloud private endpoints** — AWS PrivateLink, GCP Private Service Connect, and Azure Private Link each expose a private DNS name within your network (e.g. `xxxxxxxxxx.us-west-2.vpce.aws.clickhouse.cloud`). This address routes traffic privately to ClickHouse, but the ClickHouse server certificate is issued for the service's own hostname, not the private endpoint address. See the ClickHouse Cloud documentation for details: [AWS PrivateLink](https://clickhouse.com/docs/en/manage/security/aws-privatelink), [GCP Private Service Connect](https://clickhouse.com/docs/manage/security/gcp-private-service-connect), [Azure Private Link](https://clickhouse.com/docs/en/cloud/security/azure-privatelink).
+
+#### What sslSocketSNI does
+
+SNI (Server Name Indication) is a standard TLS extension that the client sends during the handshake to tell the server which hostname it intends to reach. Servers that host multiple services behind a single IP use SNI to select the correct certificate to present.
+
+Calling `.sslSocketSNI("expected-hostname")` on the client builder does two things:
+
+1. **Sets the SNI value** in the TLS handshake to the provided hostname, so the server presents the certificate issued for that name rather than a default fallback.
+2. **Disables hostname verification** in the client, so the check between the URL hostname and the certificate subject is skipped.
+
+The result is that the certificate chain is still validated (the server must present a cert signed by your CA), but the mismatch between the connection URL and the certificate subject no longer causes a failure.
+
+#### Option A — CA certificate as a PEM file
+
+```java
+// Connecting via a private endpoint or IP; the cert is issued for "clickhouse.internal"
+Client client = new Client.Builder()
+ .addEndpoint("https://10.0.0.5:8443")
+ .setUsername("default")
+ .setPassword("secret")
+ .setRootCertificate("/path/to/company-ca.crt")
+ .sslSocketSNI("clickhouse.internal")
+ .build();
+```
+
+#### Option B — CA certificate via a Java truststore
+
+```java
+// Connecting via a private endpoint or IP; the cert is issued for "clickhouse.internal"
+Client client = new Client.Builder()
+ .addEndpoint("https://10.0.0.5:8443")
+ .setUsername("default")
+ .setPassword("secret")
+ .setSSLTrustStore("/path/to/truststore.jks")
+ .setSSLTrustStorePassword("changeit")
+ .setSSLTrustStoreType("JKS")
+ .sslSocketSNI("clickhouse.internal")
+ .build();
+```
+
+---
+
+### 4. Self-Signed Certificate
+
+Common in local development, Docker Compose setups, and small internal instances. The server certificate is self-generated and not signed by any CA. Instead of trusting an issuer, you provide the server's own certificate directly so the client can recognize it.
+
+#### With hostname verification enabled
+
+Common when the hostname in the connection URL matches the certificate's subject or Subject Alternative Names (SANs).
+
+| Chain validation | Hostname verification | Direct certificate trust |
+|---|---|---|
+| — | ✓ Enabled | ✓ Exact certificate match |
+
+```java
+Client client = new Client.Builder()
+ .addEndpoint("https://clickhouse.local:8443")
+ .setUsername("default")
+ .setPassword("secret")
+ .setRootCertificate("/path/to/server.crt")
+ .build();
+```
+
+#### With hostname verification disabled
+
+Common when the certificate was generated for `localhost` but you connect via an IP or container name. Providing the expected certificate name via `sslSocketSNI` disables automatic hostname matching.
+
+| Chain validation | Hostname verification | Direct certificate trust |
+|---|---|---|
+| — | ✗ Disabled | ✓ Exact certificate match |
+
+```java
+Client client = new Client.Builder()
+ .addEndpoint("https://localhost:8443")
+ .setUsername("default")
+ .setPassword("secret")
+ .setRootCertificate("/path/to/server.crt")
+ .sslSocketSNI("localhost")
+ .build();
+```
+
+---
+
+## Configuration API Summary
+
+| Goal | Builder Method |
+|---|---|
+| Enable TLS | `.addEndpoint("https://host:port")` |
+| Trust a CA certificate (PEM) | `.setRootCertificate("/path/to/ca.crt")` |
+| Trust a Java truststore | `.setSSLTrustStore("/path/to/truststore.jks")` |
+| Truststore password | `.setSSLTrustStorePassword("password")` |
+| Truststore type | `.setSSLTrustStoreType("JKS")` |
+| SNI override / disable hostname verification | `.sslSocketSNI("expected-hostname")` |
+| mTLS client certificate | `.setClientCertificate("/path/to/client.crt")` |
+| mTLS client key | `.setClientKey("/path/to/client.key")` |
+| Enable SSL certificate authentication | `.useSSLAuthentication(true)` |
+
+---
+
+## Development Environments
+
+It is strongly recommended to keep development setups as close to production as possible:
+
+- Use TLS-enabled endpoints even in development.
+- Use certificates signed by a development CA or another explicitly trusted issuer.
+- Keep hostname verification enabled whenever the environment supports stable hostnames.
+
+This approach catches integration issues earlier and reduces deployment-time surprises.
+
+If you need a more relaxed setup for quick local experimentation, treat it as an exception rather than the default. CI pipelines and integration tests should still validate certificates and hosts to match real-world usage.
+
+---
+
+## Troubleshooting
+
+| Symptom | Likely cause | What to check |
+|---|---|---|
+| `PKIX path building failed` | CA not trusted by the JVM | Add the CA cert via `.setRootCertificate()` or `.setSSLTrustStore()` |
+| `unable to find valid certification path` | CA chain is incomplete | Check whether intermediate CA certificates are needed and include them |
+| `Certificate expired` | Server cert has passed its expiry date | Renew the server certificate |
+| `No subject alternative names` | Certificate has no SANs for the requested hostname | Use a certificate that includes the correct hostname as a SAN |
+| Hostname mismatch | URL hostname does not match the certificate subject or SANs | Verify the hostname; use `.sslSocketSNI()` when connecting via IP or proxy |
+| Hostname mismatch despite a valid cert | Connection goes through a proxy or load balancer | Use `.sslSocketSNI("expected-hostname")` to set the certificate's hostname |
+| Connection times out | TLS not enabled on the server | Confirm the server is listening on the HTTPS port |
+| Works in browser, fails in client | JVM truststore does not include the CA | Add the CA via `.setRootCertificate()` or `.setSSLTrustStore()` |
+| Works locally, fails in production | TLS validation was skipped in dev | Review the builder configuration and ensure cert and hostname checks are in place |
diff --git a/examples/client-v2/README.md b/examples/client-v2/README.md
index 8c6918228..60c931fb1 100644
--- a/examples/client-v2/README.md
+++ b/examples/client-v2/README.md
@@ -22,4 +22,37 @@ Addition options can be passed to the application:
- `-DchEndpoint` - Endpoint to connect in the format of URL (default: http://localhost:8123/)
- `-DchUser` - ClickHouse user name (default: default)
- `-DchPassword` - ClickHouse user password (default: empty)
-- `-DchDatabase` - ClickHouse database name (default: default)
\ No newline at end of file
+- `-DchDatabase` - ClickHouse database name (default: default)
+
+## Secure Connection Example (Private CA + Host Verification)
+
+Run the SSL example:
+
+```shell
+mvn exec:java -Dexec.mainClass="com.clickhouse.examples.client_v2.ssl.SecureConnectionMain"
+```
+
+Behavior:
+- If `--host` is set, the example uses an external ClickHouse server.
+- If `--host` is not set, the example starts a local ClickHouse test container.
+- If `--root-ca` is set without `--host`, local mode uses the provided CA to sign a local server certificate. This is useful to test your own CA material.
+
+### Local mode (auto-generated CA and server certificates)
+
+```shell
+mvn exec:java -Dexec.mainClass="com.clickhouse.examples.client_v2.ssl.SecureConnectionMain"
+```
+
+### Local mode with user-provided CA certificate and key
+
+```shell
+mvn exec:java -Dexec.mainClass="com.clickhouse.examples.client_v2.ssl.SecureConnectionMain" -Dexec.args="--root-ca /path/to/ca.crt --root-ca-key /path/to/ca.key"
+```
+
+`--root-ca-key` is optional only when `--root-ca` PEM already includes an unencrypted private key.
+
+### External mode (no docker container started)
+
+```shell
+mvn exec:java -Dexec.mainClass="com.clickhouse.examples.client_v2.ssl.SecureConnectionMain" -Dexec.args="--host clickhouse.example.com --port 8443 --user default --password secret --root-ca /path/to/ca.crt --database default"
+```
\ No newline at end of file
diff --git a/examples/client-v2/pom.xml b/examples/client-v2/pom.xml
index 360e0722e..e462b1b6a 100644
--- a/examples/client-v2/pom.xml
+++ b/examples/client-v2/pom.xml
@@ -97,6 +97,27 @@
1.11.0
+
+ info.picocli
+ picocli
+ 4.7.7
+
+
+ org.testcontainers
+ testcontainers
+ 2.0.2
+
+
+ org.bouncycastle
+ bcprov-jdk18on
+ 1.81
+
+
+ org.bouncycastle
+ bcpkix-jdk18on
+ 1.81
+
+
diff --git a/examples/client-v2/src/main/java/com/clickhouse/examples/client_v2/ssl/SecureConnectionMain.java b/examples/client-v2/src/main/java/com/clickhouse/examples/client_v2/ssl/SecureConnectionMain.java
new file mode 100644
index 000000000..9ed1f0eb4
--- /dev/null
+++ b/examples/client-v2/src/main/java/com/clickhouse/examples/client_v2/ssl/SecureConnectionMain.java
@@ -0,0 +1,472 @@
+package com.clickhouse.examples.client_v2.ssl;
+
+import com.clickhouse.client.api.Client;
+import com.clickhouse.client.api.query.GenericRecord;
+import com.clickhouse.client.api.query.Records;
+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.asn1.pkcs.PrivateKeyInfo;
+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.PEMKeyPair;
+import org.bouncycastle.openssl.PEMParser;
+import org.bouncycastle.openssl.jcajce.JcaPEMWriter;
+import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter;
+import org.bouncycastle.pkcs.PKCS8EncryptedPrivateKeyInfo;
+import org.bouncycastle.operator.ContentSigner;
+import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
+import picocli.CommandLine;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Option;
+import org.testcontainers.containers.BindMode;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.wait.strategy.Wait;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.Reader;
+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.time.Duration;
+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.util.Date;
+import java.util.UUID;
+import java.util.stream.Stream;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.Callable;
+
+@Command(
+ name = "secure-connection",
+ mixinStandardHelpOptions = true,
+ description = "Private CA + hostname verification SSL example for Client-v2."
+)
+public class SecureConnectionMain implements Callable {
+ 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());
+ }
+ }
+
+ @Option(names = "--host",
+ description = "ClickHouse host. If set, external server mode is used.")
+ private String host;
+
+ @Option(names = "--port",
+ defaultValue = "8443",
+ description = "ClickHouse HTTPS port. Default: ${DEFAULT-VALUE}.")
+ private int port;
+
+ @Option(names = "--user",
+ defaultValue = "default",
+ description = "ClickHouse username. Default: ${DEFAULT-VALUE}.")
+ private String user;
+
+ @Option(names = "--password",
+ defaultValue = "",
+ description = "ClickHouse password. Default: empty.")
+ private String password;
+
+ @Option(names = "--root-ca",
+ description = "Path to root CA certificate PEM. Required for external mode; optional for local mode.")
+ private Path rootCa;
+
+ @Option(names = "--root-ca-key",
+ description = "Path to root CA private key PEM. Used in local mode when --root-ca is provided.")
+ private Path rootCaKey;
+
+ @Option(names = "--clickhouse-image",
+ defaultValue = "clickhouse/clickhouse-server:latest",
+ description = "Docker image for local ClickHouse container. Default: ${DEFAULT-VALUE}.")
+ private String clickHouseImage;
+
+ @Option(names = "--database",
+ defaultValue = "default",
+ description = "Database used for write/read check. Default: ${DEFAULT-VALUE}.")
+ private String database;
+
+ @Override
+ public Integer call() throws Exception {
+ if (host != null && rootCa == null) {
+ throw new CommandLine.ParameterException(
+ new CommandLine(this),
+ "--root-ca is required when --host is set.");
+ }
+
+ if (rootCa != null && !Files.exists(rootCa)) {
+ throw new CommandLine.ParameterException(
+ new CommandLine(this),
+ "Root CA file does not exist: " + rootCa);
+ }
+ if (rootCaKey != null && !Files.exists(rootCaKey)) {
+ throw new CommandLine.ParameterException(
+ new CommandLine(this),
+ "Root CA key file does not exist: " + rootCaKey);
+ }
+ if (host != null) {
+ printInfo("Running in external-server mode");
+ runScenario(
+ host,
+ port,
+ user,
+ password,
+ rootCa,
+ "external");
+ return CommandLine.ExitCode.OK;
+ }
+
+ printInfo("Running in local-container mode");
+ runLocalContainerScenario();
+ return CommandLine.ExitCode.OK;
+ }
+
+ private void runLocalContainerScenario() throws Exception {
+ Path certDir = Files.createTempDirectory("ch-private-ca-example-certs-");
+ Path confDir = Files.createTempDirectory("ch-private-ca-example-config-");
+ Path sslConfig = confDir.resolve("zzz_ssl.xml");
+ Path caCert = certDir.resolve("ca.crt");
+ Path serverCert = certDir.resolve("server.crt");
+ Path serverKey = certDir.resolve("server.key");
+
+ // Build server TLS material on the fly so this example is fully self-contained.
+ if (rootCa != null) {
+ printInfo("Using provided root CA to sign local server certificate: " + rootCa.toAbsolutePath());
+ generateServerCertificateFromProvidedCa(certDir, rootCa, rootCaKey);
+ } else {
+ printInfo("Generating ephemeral root CA and local server certificate");
+ generatePrivateCaAndServerCertificate(certDir);
+ }
+ writeClickHouseSslConfig(sslConfig, serverCert, serverKey);
+ printInfo("Generated TLS files at " + certDir.toAbsolutePath());
+
+ printInfo("Starting ClickHouse container from image: " + clickHouseImage);
+ GenericContainer> container = new GenericContainer<>(clickHouseImage)
+ .withExposedPorts(HTTP_PORT, HTTPS_PORT)
+ .withEnv("CLICKHOUSE_USER", "ssl_demo")
+ .withEnv("CLICKHOUSE_PASSWORD", "ssl_demo_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();
+ int mappedHttpsPort = container.getMappedPort(HTTPS_PORT);
+ printInfo("ClickHouse container is ready on https://localhost:" + mappedHttpsPort);
+ runScenario("localhost", mappedHttpsPort, "ssl_demo", "ssl_demo_password", caCert, "container");
+ } finally {
+ printInfo("Stopping ClickHouse container and deleting temporary TLS artifacts");
+ container.stop();
+ deleteIfExists(certDir);
+ deleteIfExists(confDir);
+ }
+ }
+
+ private void runScenario(
+ String host,
+ int httpsPort,
+ String user,
+ String password,
+ Path rootCaCert,
+ String mode) throws Exception {
+ String endpoint = "https://" + host + ":" + httpsPort;
+ String tableName = "ssl_private_ca_demo_" + UUID.randomUUID().toString().replace("-", "");
+ printInfo("Preparing secure client for " + endpoint + " (mode=" + mode + ")");
+ printInfo("Using root CA certificate: " + rootCaCert.toAbsolutePath());
+
+ try (Client client = new Client.Builder()
+ .addEndpoint(endpoint)
+ .setUsername(user)
+ .setPassword(password)
+ .setDefaultDatabase(database)
+ .setRootCertificate(rootCaCert.toAbsolutePath().toString())
+ .setOption("sslmode", "strict")
+ .build()) {
+ boolean tableCreated = false;
+ // Ping verifies both TLS handshake and basic connectivity.
+ printInfo("Pinging ClickHouse endpoint");
+ if (!client.ping()) {
+ throw new IllegalStateException("Unable to ping ClickHouse over HTTPS at " + endpoint);
+ }
+ printInfo("Ping succeeded");
+
+ printInfo("Creating demo table: " + tableName);
+ client.query("CREATE TABLE " + tableName
+ + " (id UInt32, message String) ENGINE=MergeTree ORDER BY id")
+ .get(10, TimeUnit.SECONDS);
+ tableCreated = true;
+
+ printInfo("Inserting a demo row");
+ client.query("INSERT INTO " + tableName + " VALUES (1, 'private-ca-host-verification-ok')")
+ .get(10, TimeUnit.SECONDS);
+
+ String loadedMessage = null;
+ printInfo("Reading inserted row back");
+ try (Records records = client.queryRecords("SELECT message FROM " + tableName + " WHERE id = 1")
+ .get(10, TimeUnit.SECONDS)) {
+ for (GenericRecord record : records) {
+ loadedMessage = record.getString("message");
+ }
+ }
+
+ if (!"private-ca-host-verification-ok".equals(loadedMessage)) {
+ throw new IllegalStateException("Unexpected query result: " + loadedMessage);
+ }
+
+ System.out.printf(
+ "Mode=%s; endpoint=%s; TLS chain and host verification succeeded; read value='%s'%n",
+ mode,
+ endpoint,
+ loadedMessage);
+ if (tableCreated) {
+ printInfo("Dropping demo table: " + tableName);
+ client.query("DROP TABLE IF EXISTS " + tableName).get(10, TimeUnit.SECONDS);
+ }
+ } catch (Exception e) {
+ throw new IllegalStateException("Secure private-CA scenario failed", e);
+ }
+ }
+
+ private static void generatePrivateCaAndServerCertificate(Path outputDir)
+ throws Exception {
+ printInfo("Generating a temporary RSA key pair for root CA");
+ KeyPair caKeys = generateRsaKeyPair();
+ X500Name caSubject = new X500Name("CN=ExamplePrivateCA");
+ X509Certificate caCertificate = generateCertificate(
+ caSubject,
+ caSubject,
+ caKeys.getPublic(),
+ caKeys.getPrivate(),
+ caKeys.getPublic(),
+ true,
+ null);
+
+ printInfo("Generating a temporary RSA key pair for server certificate");
+ 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 void generateServerCertificateFromProvidedCa(Path outputDir, Path caCertPath, Path caKeyPath)
+ throws Exception {
+ printInfo("Loading provided CA certificate from " + caCertPath.toAbsolutePath());
+ X509Certificate caCertificate = readCertificateFromPem(caCertPath);
+ PrivateKey caPrivateKey = (caKeyPath == null)
+ ? readPrivateKeyFromPem(caCertPath)
+ : readPrivateKeyFromPem(caKeyPath);
+
+ if (caPrivateKey == null) {
+ throw new IllegalStateException(
+ "Unable to read CA private key. Provide --root-ca-key or include unencrypted private key in --root-ca PEM.");
+ }
+
+ printInfo("Generating server certificate signed by provided CA");
+ 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,
+ new X500Name(caCertificate.getSubjectX500Principal().getName()),
+ serverKeys.getPublic(),
+ caPrivateKey,
+ caCertificate.getPublicKey(),
+ 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();
+ if (BigInteger.ZERO.equals(serial)) {
+ serial = 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);
+ }
+ }
+
+ private static X509Certificate readCertificateFromPem(Path pemPath) throws Exception {
+ try (Reader reader = Files.newBufferedReader(pemPath, StandardCharsets.US_ASCII);
+ PEMParser parser = new PEMParser(reader)) {
+ Object pemObject;
+ while ((pemObject = parser.readObject()) != null) {
+ if (pemObject instanceof org.bouncycastle.cert.X509CertificateHolder) {
+ return new JcaX509CertificateConverter()
+ .setProvider(BC_PROVIDER)
+ .getCertificate((org.bouncycastle.cert.X509CertificateHolder) pemObject);
+ }
+ }
+ }
+ throw new IllegalStateException("No certificate found in PEM file: " + pemPath);
+ }
+
+ private static PrivateKey readPrivateKeyFromPem(Path pemPath) throws Exception {
+ JcaPEMKeyConverter keyConverter = new JcaPEMKeyConverter().setProvider(BC_PROVIDER);
+ try (Reader reader = Files.newBufferedReader(pemPath, StandardCharsets.US_ASCII);
+ PEMParser parser = new PEMParser(reader)) {
+ Object pemObject;
+ while ((pemObject = parser.readObject()) != null) {
+ if (pemObject instanceof PEMKeyPair) {
+ return keyConverter.getKeyPair((PEMKeyPair) pemObject).getPrivate();
+ }
+ if (pemObject instanceof PrivateKeyInfo) {
+ return keyConverter.getPrivateKey((PrivateKeyInfo) pemObject);
+ }
+ if (pemObject instanceof PKCS8EncryptedPrivateKeyInfo) {
+ throw new IllegalStateException("Encrypted private keys are not supported in this example: " + pemPath);
+ }
+ }
+ }
+ return null;
+ }
+
+ private static void printInfo(String message) {
+ System.out.println("[secure-connection] " + message);
+ }
+
+ private static void writeClickHouseSslConfig(Path configPath, Path certificatePath, Path privateKeyPath)
+ throws IOException {
+ String config = "\n"
+ + " " + HTTPS_PORT + "\n"
+ + " \n"
+ + " \n"
+ + " " + certificatePath.toAbsolutePath() + "\n"
+ + " " + privateKeyPath.toAbsolutePath() + "\n"
+ + " none\n"
+ + " true\n"
+ + " sslv2,sslv3\n"
+ + " true\n"
+ + " \n"
+ + " \n"
+ + "\n";
+ try (OutputStream out = Files.newOutputStream(configPath)) {
+ out.write(config.getBytes(StandardCharsets.UTF_8));
+ }
+ }
+
+ private static void deleteIfExists(Path path) {
+ if (path == null) {
+ return;
+ }
+
+ try {
+ if (!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.
+ }
+ }
+
+ public static void main(String... args) {
+ System.exit(new CommandLine(new SecureConnectionMain()).execute(args));
+ }
+}