Skip to content

Commit 0b5776d

Browse files
committed
Added ssl_mode to client-v2 and jdbc-v2
1 parent f2ac4b0 commit 0b5776d

8 files changed

Lines changed: 353 additions & 23 deletions

File tree

clickhouse-client/src/main/java/com/clickhouse/client/config/ClickHouseDefaultSslContextProvider.java

Lines changed: 39 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -154,14 +154,38 @@ public SSLContext getJavaSslContext(ClickHouseConfig config) throws SSLException
154154
}
155155

156156
public SSLContext getSslContextFromCerts(String clientCert, String clientKey, String sslRootCert) throws SSLException {
157-
return getSslContextImpl(ClickHouseSslMode.STRICT,
158-
clientCert, clientKey, sslRootCert, null, null, KeyStore.getDefaultType());
157+
return getSslContextFromCerts(ClickHouseSslMode.STRICT, clientCert, clientKey, sslRootCert);
158+
}
159+
160+
/**
161+
* Creates an SSL context from certificates with an explicit SSL mode.
162+
* With {@link ClickHouseSslMode#NONE} the server certificate is not validated, while client
163+
* certificate and key are still used (if provided) so that mTLS keeps working.
164+
*
165+
* @param sslMode ssl mode
166+
* @param clientCert client certificate for mTLS, file path or PEM content; may be null
167+
* @param clientKey client private key for mTLS, file path or PEM content; may be null
168+
* @param sslRootCert CA certificate to validate the server certificate, file path or PEM content; may be null
169+
* @return SSL context
170+
* @throws SSLException when the context cannot be created
171+
*/
172+
public SSLContext getSslContextFromCerts(ClickHouseSslMode sslMode, String clientCert, String clientKey,
173+
String sslRootCert) throws SSLException {
174+
return getSslContextImpl(sslMode, clientCert, clientKey, sslRootCert, null, null, KeyStore.getDefaultType());
159175
}
160176

161177
public SSLContext getSslContextFromKeyStore(String truststorePath, String truststorePassword, String keyStoreType) throws SSLException {
162178
return getSslContextImpl(ClickHouseSslMode.STRICT, null, null, null, truststorePath, truststorePassword, keyStoreType);
163179
}
164180

181+
private KeyManager[] getKeyManagers(String clientCert, String clientKey)
182+
throws NoSuchAlgorithmException, InvalidKeySpecException, IOException, CertificateException,
183+
KeyStoreException, UnrecoverableKeyException {
184+
KeyManagerFactory factory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
185+
factory.init(getKeyStore(clientCert, clientKey), null);
186+
return factory.getKeyManagers();
187+
}
188+
165189
private SSLContext getSslContextImpl(ClickHouseSslMode sslMode, String clientCert, String clientKey, String sslRootCert, String truststorePath, String truststorePassword, String keyStoreType) throws SSLException {
166190
SSLContext ctx;
167191
try {
@@ -172,34 +196,29 @@ private SSLContext getSslContextImpl(ClickHouseSslMode sslMode, String clientCer
172196

173197
if (sslMode == ClickHouseSslMode.NONE) {
174198
tms = new TrustManager[]{new NonValidatingTrustManager()};
175-
kms = new KeyManager[0];
199+
// client certificate and key are independent from server verification - keep mTLS working
200+
kms = clientCert != null && !clientCert.isEmpty() ? getKeyManagers(clientCert, clientKey)
201+
: new KeyManager[0];
176202
sr = new SecureRandom();
177203
} else if (sslMode == ClickHouseSslMode.STRICT) {
178-
if (truststorePath != null && !truststorePath.isEmpty()) {
204+
if (clientCert != null && !clientCert.isEmpty()) {
205+
kms = getKeyManagers(clientCert, clientKey);
206+
}
179207

208+
if (truststorePath != null && !truststorePath.isEmpty()) {
180209
try (InputStream in = ClickHouseUtils.getFileInputStream(truststorePath)) {
181210
KeyStore myTrustStore = KeyStore.getInstance(keyStoreType);
182-
myTrustStore.load(in, truststorePassword.toCharArray());
211+
myTrustStore.load(in, truststorePassword == null ? null : truststorePassword.toCharArray());
183212
TrustManagerFactory factory = TrustManagerFactory
184213
.getInstance(TrustManagerFactory.getDefaultAlgorithm());
185214
factory.init(myTrustStore);
186215
tms = factory.getTrustManagers();
187-
188-
}
189-
} else {
190-
if (clientCert != null && !clientCert.isEmpty()) {
191-
KeyManagerFactory factory = KeyManagerFactory
192-
.getInstance(KeyManagerFactory.getDefaultAlgorithm());
193-
factory.init(getKeyStore(clientCert, clientKey), null);
194-
kms = factory.getKeyManagers();
195-
}
196-
197-
if (sslRootCert != null && !sslRootCert.isEmpty()) {
198-
TrustManagerFactory factory = TrustManagerFactory
199-
.getInstance(TrustManagerFactory.getDefaultAlgorithm());
200-
factory.init(getKeyStore(sslRootCert, null));
201-
tms = factory.getTrustManagers();
202216
}
217+
} else if (sslRootCert != null && !sslRootCert.isEmpty()) {
218+
TrustManagerFactory factory = TrustManagerFactory
219+
.getInstance(TrustManagerFactory.getDefaultAlgorithm());
220+
factory.init(getKeyStore(sslRootCert, null));
221+
tms = factory.getTrustManagers();
203222
}
204223

205224
sr = new SecureRandom();

client-v2/src/main/java/com/clickhouse/client/api/Client.java

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import com.clickhouse.client.api.data_formats.internal.ProcessParser;
1313
import com.clickhouse.client.api.enums.Protocol;
1414
import com.clickhouse.client.api.enums.ProxyType;
15+
import com.clickhouse.client.api.enums.SSLMode;
1516
import com.clickhouse.client.api.http.ClickHouseHttpProto;
1617
import com.clickhouse.client.api.insert.InsertResponse;
1718
import com.clickhouse.client.api.insert.InsertSettings;
@@ -755,6 +756,33 @@ public Builder setClientKey(String path) {
755756
return this;
756757
}
757758

759+
/**
760+
* Defines how strictly the client verifies a server identity on secure connections.
761+
*
762+
* <p>Supported modes:</p>
763+
* <ul>
764+
* <li>{@link SSLMode#Disabled} - SSL is not used; only meaningful with plain protocols</li>
765+
* <li>{@link SSLMode#Trust} - encrypt, but accept any server certificate and skip
766+
* hostname verification</li>
767+
* <li>{@link SSLMode#VerifyCa} - validate the server certificate chain, but skip
768+
* hostname verification</li>
769+
* <li>{@link SSLMode#Strict} - full verification of the certificate chain and the
770+
* hostname (default)</li>
771+
* </ul>
772+
*
773+
* <p>The mode applies only when a secure protocol is in use - for the HTTP transport that
774+
* means an {@code https://} endpoint. Setting any mode does <b>not</b> make the client use
775+
* encryption on a plain HTTP endpoint: the endpoint scheme always decides whether the
776+
* connection is encrypted.</p>
777+
*
778+
* @param sslMode ssl mode
779+
* @return same instance of the builder
780+
*/
781+
public Builder setSSLMode(SSLMode sslMode) {
782+
this.configuration.put(ClientConfigProperties.SSL_MODE.getKey(), sslMode.name());
783+
return this;
784+
}
785+
758786
/**
759787
* Configure client to use server timezone for date/datetime columns. Default is true.
760788
* If this options is selected then server timezone should be set as well.

client-v2/src/main/java/com/clickhouse/client/api/ClientConfigProperties.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.clickhouse.client.api;
22

33
import com.clickhouse.client.api.data_formats.internal.AbstractBinaryFormatReader;
4+
import com.clickhouse.client.api.enums.SSLMode;
45
import com.clickhouse.client.api.internal.ClickHouseLZ4OutputStream;
56
import com.clickhouse.data.ClickHouseDataType;
67
import com.clickhouse.data.ClickHouseFormat;
@@ -115,6 +116,8 @@ public enum ClientConfigProperties {
115116

116117
SSL_CERTIFICATE("sslcert", String.class),
117118

119+
SSL_MODE("ssl_mode", SSLMode.class, SSLMode.Strict.name()),
120+
118121
RETRY_ON_FAILURE("retry", Integer.class, "3"),
119122

120123
INPUT_OUTPUT_FORMAT("format", ClickHouseFormat.class),

client-v2/src/main/java/com/clickhouse/client/api/internal/HttpAPIClientHelper.java

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
import com.clickhouse.client.api.DataTransferException;
1212
import com.clickhouse.client.api.ServerException;
1313
import com.clickhouse.client.api.enums.ProxyType;
14+
import com.clickhouse.client.api.enums.SSLMode;
15+
import com.clickhouse.client.config.ClickHouseSslMode;
1416
import com.clickhouse.client.api.http.ClickHouseHttpProto;
1517
import com.clickhouse.client.api.transport.Endpoint;
1618
import com.clickhouse.client.config.ClickHouseDefaultSslContextProvider;
@@ -165,11 +167,26 @@ public SSLContext createSSLContext(Map<String, Object> configuration) {
165167
} catch (NoSuchAlgorithmException e) {
166168
throw new ClientException("Failed to create default SSL context", e);
167169
}
170+
final SSLMode sslMode = ClientConfigProperties.SSL_MODE.getOrDefault(configuration);
168171
final String trustStorePath = (String) configuration.get(ClientConfigProperties.SSL_TRUST_STORE.getKey());
169172
final String caCertificate = (String) configuration.get(ClientConfigProperties.CA_CERTIFICATE.getKey());
170173
final String sslCertificate = (String) configuration.get(ClientConfigProperties.SSL_CERTIFICATE.getKey());
171174
final String sslKey = (String) configuration.get(ClientConfigProperties.SSL_KEY.getKey());
172-
if (trustStorePath != null) {
175+
176+
if (sslMode == SSLMode.Trust) {
177+
// Server certificate is not validated. Trust material (trust store or CA certificate)
178+
// is not needed, but client certificate and key are still applied for mTLS.
179+
try {
180+
sslContext = sslContextProvider.getSslContextFromCerts(ClickHouseSslMode.NONE,
181+
sslCertificate, sslKey, null);
182+
} catch (SSLException e) {
183+
throw new ClientMisconfigurationException("Failed to create SSL context for the Trust SSL mode", e);
184+
}
185+
} else if (trustStorePath != null) {
186+
if (caCertificate != null) {
187+
throw new ClientMisconfigurationException("CA certificate cannot be used together with a trust store."
188+
+ " The CA certificate should be imported into the trust store instead.");
189+
}
173190
try {
174191
sslContext = sslContextProvider.getSslContextFromKeyStore(
175192
trustStorePath,
@@ -272,7 +289,11 @@ public CloseableHttpClient createHttpClient(boolean initSslContext, Map<String,
272289
LayeredConnectionSocketFactory sslConnectionSocketFactory;
273290
if (sslContext != null) {
274291
String socketSNI = (String)configuration.get(ClientConfigProperties.SSL_SOCKET_SNI.getKey());
275-
if (socketSNI != null && !socketSNI.trim().isEmpty()) {
292+
SSLMode sslMode = ClientConfigProperties.SSL_MODE.getOrDefault(configuration);
293+
// Trust and VerifyCa skip hostname verification. The same applies when a custom SNI is
294+
// set because the connection hostname will not match the certificate.
295+
boolean trustAllHostnames = sslMode == SSLMode.Trust || sslMode == SSLMode.VerifyCa;
296+
if (socketSNI != null && !socketSNI.trim().isEmpty() || trustAllHostnames) {
276297
sslConnectionSocketFactory = new CustomSSLConnectionFactory(socketSNI, sslContext, (hostname, session) -> true);
277298
} else {
278299
sslConnectionSocketFactory = new SSLConnectionSocketFactory(sslContext);

client-v2/src/test/java/com/clickhouse/client/HttpTransportTests.java

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import com.clickhouse.client.api.data_formats.ClickHouseBinaryFormatReader;
1515
import com.clickhouse.client.api.enums.Protocol;
1616
import com.clickhouse.client.api.enums.ProxyType;
17+
import com.clickhouse.client.api.enums.SSLMode;
1718
import com.clickhouse.client.api.http.ClickHouseHttpProto;
1819
import com.clickhouse.client.api.insert.InsertResponse;
1920
import com.clickhouse.client.api.insert.InsertSettings;
@@ -291,6 +292,123 @@ public void testSecureConnection() {
291292
}
292293
}
293294

295+
@Test(groups = { "integration" })
296+
public void testSSLModeTrust() {
297+
if (isCloud()) {
298+
return; // test uses self-signed cert
299+
}
300+
301+
ClickHouseNode secureServer = getSecureServer(ClickHouseProtocol.HTTP);
302+
303+
// Default mode (Strict) without any trust material - the self-signed certificate must be rejected
304+
try (Client client = new Client.Builder()
305+
.addEndpoint("https://localhost:" + secureServer.getPort())
306+
.setUsername("default")
307+
.setPassword(ClickHouseServerForTest.getPassword())
308+
.build()) {
309+
Assert.expectThrows(Exception.class, () -> client.queryAll("SELECT 1"));
310+
}
311+
312+
// Trust mode - the same certificate is accepted without any trust material
313+
try (Client client = new Client.Builder()
314+
.addEndpoint("https://localhost:" + secureServer.getPort())
315+
.setUsername("default")
316+
.setPassword(ClickHouseServerForTest.getPassword())
317+
.setOption(ClientConfigProperties.SSL_MODE.getKey(), SSLMode.Trust.name())
318+
.build()) {
319+
List<GenericRecord> records = client.queryAll("SELECT timezone()");
320+
Assert.assertEquals(records.get(0).getString(1), "UTC");
321+
} catch (Exception e) {
322+
Assert.fail("Trust SSL mode should accept a self-signed certificate", e);
323+
}
324+
}
325+
326+
@Test(groups = { "integration" })
327+
public void testSSLModeVerifyCa() {
328+
if (isCloud()) {
329+
return; // test uses self-signed cert
330+
}
331+
332+
ClickHouseNode secureServer = getSecureServer(ClickHouseProtocol.HTTP);
333+
// server certificate has CN=localhost, so connecting via 127.0.0.1 fails hostname verification
334+
final String endpointByIp = "https://127.0.0.1:" + secureServer.getPort();
335+
final String serverCertificate = "containers/clickhouse-server/certs/localhost.crt";
336+
337+
// Strict mode (default): certificate chain is trusted, but the hostname does not match
338+
try (Client client = new Client.Builder()
339+
.addEndpoint(endpointByIp)
340+
.setUsername("default")
341+
.setPassword(ClickHouseServerForTest.getPassword())
342+
.setRootCertificate(serverCertificate)
343+
.build()) {
344+
Assert.expectThrows(Exception.class, () -> client.queryAll("SELECT 1"));
345+
}
346+
347+
// VerifyCa mode: certificate chain is validated, hostname mismatch is ignored
348+
try (Client client = new Client.Builder()
349+
.addEndpoint(endpointByIp)
350+
.setUsername("default")
351+
.setPassword(ClickHouseServerForTest.getPassword())
352+
.setRootCertificate(serverCertificate)
353+
.setSSLMode(SSLMode.VerifyCa)
354+
.build()) {
355+
List<GenericRecord> records = client.queryAll("SELECT timezone()");
356+
Assert.assertEquals(records.get(0).getString(1), "UTC");
357+
} catch (Exception e) {
358+
Assert.fail("VerifyCa SSL mode should ignore hostname mismatch", e);
359+
}
360+
361+
// VerifyCa mode still validates the certificate chain - without the CA it must fail
362+
try (Client client = new Client.Builder()
363+
.addEndpoint(endpointByIp)
364+
.setUsername("default")
365+
.setPassword(ClickHouseServerForTest.getPassword())
366+
.setSSLMode(SSLMode.VerifyCa)
367+
.build()) {
368+
Assert.expectThrows(Exception.class, () -> client.queryAll("SELECT 1"));
369+
}
370+
}
371+
372+
@Test(groups = { "integration" })
373+
public void testSSLModeDisabled() {
374+
if (isCloud()) {
375+
return; // plain HTTP is not available in cloud
376+
}
377+
378+
ClickHouseNode server = getServer(ClickHouseProtocol.HTTP);
379+
380+
// Disabled mode with a plain HTTP endpoint - SSL is simply not used
381+
try (Client client = new Client.Builder()
382+
.addEndpoint("http://" + server.getHost() + ":" + server.getPort())
383+
.setUsername("default")
384+
.setPassword(ClickHouseServerForTest.getPassword())
385+
.setSSLMode(SSLMode.Disabled)
386+
.build()) {
387+
List<GenericRecord> records = client.queryAll("SELECT timezone()");
388+
Assert.assertEquals(records.get(0).getString(1), "UTC");
389+
} catch (Exception e) {
390+
Assert.fail("Disabled SSL mode should work with a plain HTTP endpoint", e);
391+
}
392+
}
393+
394+
@Test(groups = { "integration" })
395+
public void testSSLModeStrictWithTrustStoreAndCaCertificate() {
396+
if (isCloud()) {
397+
return;
398+
}
399+
400+
ClickHouseNode secureServer = getSecureServer(ClickHouseProtocol.HTTP);
401+
402+
// CA certificate cannot be combined with a trust store - it should be in the trust store already
403+
Assert.expectThrows(ClientMisconfigurationException.class, () -> new Client.Builder()
404+
.addEndpoint("https://localhost:" + secureServer.getPort())
405+
.setUsername("default")
406+
.setPassword(ClickHouseServerForTest.getPassword())
407+
.setSSLTrustStore("containers/clickhouse-server/certs/KeyStore.jks")
408+
.setRootCertificate("containers/clickhouse-server/certs/localhost.crt")
409+
.build());
410+
}
411+
294412
@Test(groups = { "integration" }, dataProvider = "NoResponseFailureProvider")
295413
public void testInsertAndNoHttpResponseFailure(String body, int maxRetries, ThrowingFunction<Client, Void> function,
296414
boolean shouldFail) {

jdbc-v2/src/main/java/com/clickhouse/jdbc/internal/JdbcConfiguration.java

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.clickhouse.client.api.Client;
44
import com.clickhouse.client.api.ClientConfigProperties;
5+
import com.clickhouse.client.api.enums.SSLMode;
56
import com.clickhouse.client.api.http.ClickHouseHttpProto;
67
import com.clickhouse.data.ClickHouseDataType;
78
import com.clickhouse.jdbc.Driver;
@@ -332,7 +333,8 @@ private Map<String, String> parseUrl(String url) throws SQLException {
332333
* @param urlProperties - properties parsed from URL
333334
* @param providedProperties - properties object provided by application
334335
*/
335-
private void buildFinalProperties(Map<String, String> urlProperties, Properties providedProperties) {
336+
private void buildFinalProperties(Map<String, String> urlProperties, Properties providedProperties)
337+
throws SQLException {
336338

337339
// Copy provided properties
338340
Map<String, String> props = new HashMap<>();
@@ -379,6 +381,22 @@ private void buildFinalProperties(Map<String, String> urlProperties, Properties
379381
}
380382
}
381383

384+
String sslMode = clientProperties.get(ClientConfigProperties.SSL_MODE.getKey());
385+
if (sslMode != null) {
386+
if ("none".equalsIgnoreCase(sslMode)) {
387+
// JDBC drivers traditionally use 'none' for the no-verification SSL mode - alias it to 'trust'
388+
clientProperties.put(ClientConfigProperties.SSL_MODE.getKey(), SSLMode.Trust.name());
389+
} else {
390+
try {
391+
// values are case-insensitive in JDBC - normalize before passing to the client
392+
clientProperties.put(ClientConfigProperties.SSL_MODE.getKey(), SSLMode.fromValue(sslMode).name());
393+
} catch (IllegalArgumentException e) {
394+
throw new SQLException("Unknown value '" + sslMode + "' for property '"
395+
+ ClientConfigProperties.SSL_MODE.getKey() + "'", e);
396+
}
397+
}
398+
}
399+
382400
// Fill list of client properties information, add not specified properties (doesn't affect client properties)
383401
for (ClientConfigProperties clientProp : ClientConfigProperties.values()) {
384402
DriverPropertyInfo propertyInfo = propertyInfos.get(clientProp.getKey());

0 commit comments

Comments
 (0)