Skip to content

Commit 565c59b

Browse files
authored
Merge pull request #2918 from ClickHouse/polyglot/jdbc-v2-custom-ssl-context
Support application-supplied SSLContext in client-v2 and jdbc-v2
2 parents 39fed41 + 7409993 commit 565c59b

10 files changed

Lines changed: 482 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,17 @@
8989

9090
### New Features
9191

92+
- **[client-v2, jdbc-v2]** Added support for an application-supplied `javax.net.ssl.SSLContext`. In client-v2,
93+
`Client.Builder.setSSLContext(SSLContext)` hands the client a fully pre-built context that is used as is; in
94+
jdbc-v2 the same context may be passed as a live object in the connection `Properties` under the `ssl_context`
95+
key (added with `Properties.put`, since it is not a string). Trust/key material options cannot be combined with
96+
a custom context and are rejected; `ssl_mode` still applies but only to server hostname verification. This
97+
supports in-memory TLS material that must never be written to disk, including behind connection pools that only
98+
expose `java.util.Properties`.
99+
- Examples for client-v2 https://github.com/ClickHouse/clickhouse-java/blob/main/examples/client-v2/src/main/java/com/clickhouse/examples/client_v2/SSLExamples.java
100+
- Examples for jdbc-v2 https://github.com/ClickHouse/clickhouse-java/blob/main/examples/jdbc/src/main/java/com/clickhouse/examples/jdbc/SSLExamples.java
101+
(https://github.com/ClickHouse/clickhouse-java/pull/2918, https://github.com/ClickHouse/clickhouse-java/issues/2909)
102+
92103
- **[jdbc-v2, client-v2]** Implemented SSL modes configuration. Now it is possible to set `ssl_mode` to `DISABLED`,
93104
`TRUST`, `VERIFY_CA` and `STRICT`. Note for V1 users: `NONE` is supported only by JDBC driver and mapped to `TRUST`.
94105
Please migrate to the new naming.

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

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@
8181
import java.util.function.Supplier;
8282
import java.util.stream.Collectors;
8383

84+
import javax.net.ssl.SSLContext;
85+
8486
/**
8587
* <p>Client is the starting point for all interactions with ClickHouse. </p>
8688
*
@@ -148,8 +150,12 @@ public class Client implements AutoCloseable {
148150

149151
private Client(Collection<Endpoint> endpoints, Map<String,String> configuration,
150152
ExecutorService sharedOperationExecutor, ColumnToMethodMatchingStrategy columnToMethodMatchingStrategy,
151-
Object metricsRegistry, Supplier<String> queryIdGenerator, CredentialsManager cManager) {
153+
Object metricsRegistry, Supplier<String> queryIdGenerator, CredentialsManager cManager,
154+
SSLContext sslContext) {
152155
Map<String, Object> parsedConfiguration = new ConcurrentHashMap<>(ClientConfigProperties.parseConfigMap(configuration));
156+
if (sslContext != null) {
157+
parsedConfiguration.put(ClientConfigProperties.SSL_CONTEXT.getKey(), sslContext);
158+
}
153159
this.credentialsManager = cManager;
154160
this.session = Session.extractFrom(parsedConfiguration);
155161
this.configuration = new ConcurrentHashMap<>(parsedConfiguration);
@@ -270,6 +276,18 @@ public static class Builder {
270276
private ColumnToMethodMatchingStrategy columnToMethodMatchingStrategy;
271277
private Object metricRegistry = null;
272278
private Supplier<String> queryIdGenerator;
279+
private SSLContext sslContext = null;
280+
281+
// Trust/key material options that feed a context the client would otherwise build; none of them
282+
// may be combined with an application-supplied SSLContext (see build()).
283+
private static final ClientConfigProperties[] SSL_MATERIAL_PROPERTIES = {
284+
ClientConfigProperties.SSL_TRUST_STORE,
285+
ClientConfigProperties.SSL_KEYSTORE_TYPE,
286+
ClientConfigProperties.SSL_KEY_STORE_PASSWORD,
287+
ClientConfigProperties.SSL_KEY,
288+
ClientConfigProperties.CA_CERTIFICATE,
289+
ClientConfigProperties.SSL_CERTIFICATE,
290+
};
273291

274292
public Builder() {
275293
this.endpoints = new LinkedHashSet<>();
@@ -344,6 +362,11 @@ private Builder addEndpoint(Endpoint endpoint) {
344362
* @param value - configuration option value
345363
*/
346364
public Builder setOption(String key, String value) {
365+
if (key.equals(ClientConfigProperties.SSL_CONTEXT.getKey())) {
366+
throw new ClientMisconfigurationException("'" + ClientConfigProperties.SSL_CONTEXT.getKey()
367+
+ "' cannot be set as a string; supply a javax.net.ssl.SSLContext object via "
368+
+ "Client.Builder.setSSLContext(...)");
369+
}
347370
this.configuration.put(key, value);
348371
if (key.equals(ClientConfigProperties.PRODUCT_NAME.getKey())) {
349372
setClientName(value);
@@ -785,6 +808,20 @@ public Builder setSSLMode(SSLMode sslMode) {
785808
return this;
786809
}
787810

811+
/**
812+
* Supplies a pre-built {@link SSLContext}. When set, it is used as is instead of a context built
813+
* from the configured trust/key material (which then cannot be set alongside it). {@link SSLMode}
814+
* still applies, but only to server hostname verification: {@link SSLMode#STRICT} (default) enforces
815+
* it while {@link SSLMode#TRUST} and {@link SSLMode#VERIFY_CA} skip it.
816+
*
817+
* @param sslContext a fully configured SSL context; {@code null} clears any previously set context
818+
* @return same instance of the builder
819+
*/
820+
public Builder setSSLContext(SSLContext sslContext) {
821+
this.sslContext = sslContext;
822+
return this;
823+
}
824+
788825
/**
789826
* Configure client to use server timezone for date/datetime columns. Default is true.
790827
* If this options is selected then server timezone should be set as well.
@@ -1165,14 +1202,28 @@ public Client build() {
11651202

11661203
CredentialsManager cManager = new CredentialsManager(this.configuration);
11671204

1168-
if (configuration.containsKey(ClientConfigProperties.SSL_TRUST_STORE.getKey()) &&
1205+
// A textual 'ssl_context' can never be a live context (also rejected in setOption).
1206+
if (configuration.containsKey(ClientConfigProperties.SSL_CONTEXT.getKey())) {
1207+
throw new ClientMisconfigurationException("'" + ClientConfigProperties.SSL_CONTEXT.getKey()
1208+
+ "' cannot be set as a string; supply a javax.net.ssl.SSLContext object via "
1209+
+ "Client.Builder.setSSLContext(...)");
1210+
}
1211+
1212+
if (this.sslContext != null) {
1213+
// A custom SSLContext replaces any context the client would build, so trust/key material
1214+
// cannot be set alongside it. SSL_MODE (hostname verification) is still allowed.
1215+
for (ClientConfigProperties material : SSL_MATERIAL_PROPERTIES) {
1216+
if (configuration.containsKey(material.getKey())) {
1217+
throw new ClientMisconfigurationException("'" + material.getKey() + "' cannot be combined"
1218+
+ " with a custom SSLContext; the supplied context is used as is. Only 'ssl_mode'"
1219+
+ " (hostname verification) may be set alongside it.");
1220+
}
1221+
}
1222+
} else if (configuration.containsKey(ClientConfigProperties.SSL_TRUST_STORE.getKey()) &&
11691223
configuration.containsKey(ClientConfigProperties.SSL_CERTIFICATE.getKey())) {
11701224
throw new ClientMisconfigurationException("Trust store and certificates cannot be used together");
11711225
}
11721226

1173-
// A trust store and a CA certificate are not rejected here: for VERIFY_CA/STRICT the trust
1174-
// store takes precedence and the CA certificate is ignored with a warning (see createSSLContext).
1175-
11761227
// Resolve ssl_mode case-insensitively and normalize it to the canonical enum name so that
11771228
// downstream parsing is consistent and an unknown value is reported as a misconfiguration
11781229
// here instead of failing later with a generic enum-parsing error.
@@ -1229,7 +1280,8 @@ public Client build() {
12291280
}
12301281

12311282
return new Client(this.endpoints, this.configuration, this.sharedOperationExecutor,
1232-
this.columnToMethodMatchingStrategy, this.metricRegistry, this.queryIdGenerator, cManager);
1283+
this.columnToMethodMatchingStrategy, this.metricRegistry, this.queryIdGenerator, cManager,
1284+
this.sslContext);
12331285
}
12341286
}
12351287

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
import org.slf4j.Logger;
99
import org.slf4j.LoggerFactory;
1010

11+
import javax.net.ssl.SSLContext;
12+
1113
import java.util.ArrayList;
1214
import java.util.Arrays;
1315
import java.util.Collection;
@@ -118,6 +120,8 @@ public enum ClientConfigProperties {
118120

119121
SSL_MODE("ssl_mode", SSLMode.class, SSLMode.STRICT.name()),
120122

123+
SSL_CONTEXT("ssl_context", SSLContext.class),
124+
121125
RETRY_ON_FAILURE("retry", Integer.class, "3"),
122126

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

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

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ public HttpAPIClientHelper(Map<String, Object> configuration, Object metricsRegi
154154
}
155155

156156
/**
157-
* Creates or returns default SSL context.
157+
* Creates an SSL context from the configured trust/key material.
158158
*
159159
* @return SSLContext
160160
*/
@@ -280,7 +280,21 @@ private HttpClientConnectionManager poolConnectionManager(LayeredConnectionSocke
280280
public CloseableHttpClient createHttpClient(boolean initSslContext, Map<String, Object> configuration) {
281281
// Top Level builders
282282
HttpClientBuilder clientBuilder = HttpClientBuilder.create();
283-
SSLContext sslContext = initSslContext ? createSSLContext(configuration) : null;
283+
// An application-supplied SSLContext is used as is; otherwise one is built from the configured
284+
// trust/key material. Server hostname verification below still applies via the SSL mode.
285+
SSLContext sslContext = null;
286+
if (initSslContext) {
287+
Object customSSLContext = configuration.get(ClientConfigProperties.SSL_CONTEXT.getKey());
288+
if (customSSLContext == null) {
289+
sslContext = createSSLContext(configuration);
290+
} else if (customSSLContext instanceof SSLContext) {
291+
sslContext = (SSLContext) customSSLContext;
292+
} else {
293+
throw new ClientMisconfigurationException("'" + ClientConfigProperties.SSL_CONTEXT.getKey()
294+
+ "' must be a javax.net.ssl.SSLContext instance but was "
295+
+ customSSLContext.getClass().getName() + "; supply it via Client.Builder.setSSLContext(...)");
296+
}
297+
}
284298
LayeredConnectionSocketFactory sslConnectionSocketFactory;
285299
if (sslContext != null) {
286300
String socketSNI = (String)configuration.get(ClientConfigProperties.SSL_SOCKET_SNI.getKey());

client-v2/src/test/java/com/clickhouse/client/api/ClientBuilderTest.java

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

33
import com.clickhouse.client.api.enums.SSLMode;
4+
import com.clickhouse.client.api.internal.HttpAPIClientHelper;
45
import org.testng.Assert;
56
import org.testng.annotations.DataProvider;
67
import org.testng.annotations.Test;
78

9+
import javax.net.ssl.SSLContext;
810
import java.lang.reflect.Field;
11+
import java.util.HashMap;
912
import java.util.List;
13+
import java.util.Map;
1014

1115
public class ClientBuilderTest {
1216

@@ -86,6 +90,144 @@ public void testSslModeInvalidValueRejected() {
8690
.build());
8791
}
8892

93+
@Test
94+
public void testStringSSLContextRejectedBySetOption() {
95+
Assert.expectThrows(ClientMisconfigurationException.class,
96+
() -> new Client.Builder().setOption(ClientConfigProperties.SSL_CONTEXT.getKey(), "not-a-context"));
97+
}
98+
99+
@DataProvider(name = "sslMaterialWithCustomContext_DP")
100+
public static Object[][] sslMaterialWithCustomContext_DP() {
101+
return new Object[][] {
102+
{ ClientConfigProperties.SSL_TRUST_STORE.getKey(), "/path/to/truststore.jks" },
103+
{ ClientConfigProperties.SSL_KEYSTORE_TYPE.getKey(), "JKS" },
104+
{ ClientConfigProperties.SSL_KEY_STORE_PASSWORD.getKey(), "secret" },
105+
{ ClientConfigProperties.SSL_KEY.getKey(), "/path/to/client.key" },
106+
{ ClientConfigProperties.CA_CERTIFICATE.getKey(), "/path/to/ca.crt" },
107+
{ ClientConfigProperties.SSL_CERTIFICATE.getKey(), "/path/to/client.crt" },
108+
};
109+
}
110+
111+
@Test(dataProvider = "sslMaterialWithCustomContext_DP")
112+
public void testCustomSSLContextRejectsOtherSslMaterial(String materialKey, String materialValue) throws Exception {
113+
SSLContext customContext = SSLContext.getInstance("TLS");
114+
customContext.init(null, null, null);
115+
116+
Assert.expectThrows(ClientMisconfigurationException.class, () -> new Client.Builder()
117+
.addEndpoint("https://localhost:8443")
118+
.setUsername("default")
119+
.setPassword("")
120+
.setOption(materialKey, materialValue)
121+
.setSSLContext(customContext)
122+
.build());
123+
}
124+
125+
@Test
126+
public void testCustomSSLContextAllowsSslMode() throws Exception {
127+
SSLContext customContext = SSLContext.getInstance("TLS");
128+
customContext.init(null, null, null);
129+
130+
try (Client client = new Client.Builder()
131+
.addEndpoint("https://localhost:8443")
132+
.setUsername("default")
133+
.setPassword("")
134+
.setSSLMode(SSLMode.VERIFY_CA)
135+
.setSSLContext(customContext)
136+
.build()) {
137+
Assert.assertSame(extractConfiguration(client).get(ClientConfigProperties.SSL_CONTEXT.getKey()),
138+
customContext);
139+
Assert.assertEquals(client.getConfiguration().get(ClientConfigProperties.SSL_MODE.getKey()),
140+
SSLMode.VERIFY_CA.name());
141+
}
142+
}
143+
144+
@Test
145+
public void testTrustStoreAndClientCertificateConflictRejectedWithoutCustomContext() {
146+
// Contrast: without a custom SSLContext the trust-store/certificate conflict is still rejected.
147+
Assert.expectThrows(ClientMisconfigurationException.class, () -> new Client.Builder()
148+
.addEndpoint("https://localhost:8443")
149+
.setUsername("default")
150+
.setPassword("")
151+
.setSSLTrustStore("/path/to/truststore.jks")
152+
.setClientCertificate("client.crt")
153+
.build());
154+
}
155+
156+
@Test
157+
public void testSetSSLContextStoredInConfiguration() throws Exception {
158+
SSLContext customContext = SSLContext.getInstance("TLS");
159+
customContext.init(null, null, null);
160+
161+
try (Client client = new Client.Builder()
162+
.addEndpoint("https://localhost:8443")
163+
.setUsername("default")
164+
.setPassword("")
165+
.setSSLContext(customContext)
166+
.build()) {
167+
Assert.assertSame(extractConfiguration(client).get(ClientConfigProperties.SSL_CONTEXT.getKey()),
168+
customContext, "The application-supplied SSLContext should be stored in the configuration");
169+
}
170+
171+
// Without setSSLContext the key must be absent so the client builds its own context.
172+
try (Client client = new Client.Builder()
173+
.addEndpoint("https://localhost:8443")
174+
.setUsername("default")
175+
.setPassword("")
176+
.build()) {
177+
Assert.assertNull(extractConfiguration(client).get(ClientConfigProperties.SSL_CONTEXT.getKey()),
178+
"No SSLContext should be stored when none is supplied");
179+
}
180+
}
181+
182+
@Test
183+
public void testCreateSSLContextIgnoresCustomContext() throws Exception {
184+
SSLContext customContext = SSLContext.getInstance("TLS");
185+
customContext.init(null, null, null);
186+
187+
try (Client client = new Client.Builder()
188+
.addEndpoint("https://localhost:8443")
189+
.setUsername("default")
190+
.setPassword("")
191+
.build()) {
192+
HttpAPIClientHelper helper = extractHttpClientHelper(client);
193+
Map<String, Object> configWithCustom = new HashMap<>(extractConfiguration(client));
194+
configWithCustom.put(ClientConfigProperties.SSL_CONTEXT.getKey(), customContext);
195+
// createSSLContext only builds from trust/key material; selecting a custom context is
196+
// createHttpClient's responsibility, so this must not return the supplied context.
197+
Assert.assertNotSame(helper.createSSLContext(configWithCustom), customContext);
198+
}
199+
}
200+
201+
@Test
202+
public void testCreateHttpClientRejectsNonSSLContextValue() throws Exception {
203+
try (Client client = new Client.Builder()
204+
.addEndpoint("https://localhost:8443")
205+
.setUsername("default")
206+
.setPassword("")
207+
.build()) {
208+
HttpAPIClientHelper helper = extractHttpClientHelper(client);
209+
Map<String, Object> configWithBadContext = new HashMap<>(extractConfiguration(client));
210+
configWithBadContext.put(ClientConfigProperties.SSL_CONTEXT.getKey(), "not-a-context");
211+
// A non-SSLContext value under 'ssl_context' is a misconfiguration; createHttpClient must
212+
// reject it instead of silently building a context from trust/key material.
213+
Assert.expectThrows(ClientMisconfigurationException.class,
214+
() -> helper.createHttpClient(true, configWithBadContext));
215+
}
216+
}
217+
218+
private static HttpAPIClientHelper extractHttpClientHelper(Client client) throws Exception {
219+
Field helperField = Client.class.getDeclaredField("httpClientHelper");
220+
helperField.setAccessible(true);
221+
return (HttpAPIClientHelper) helperField.get(client);
222+
}
223+
224+
@SuppressWarnings("unchecked")
225+
private static Map<String, Object> extractConfiguration(Client client) throws Exception {
226+
Field configField = Client.class.getDeclaredField("configuration");
227+
configField.setAccessible(true);
228+
return (Map<String, Object>) configField.get(client);
229+
}
230+
89231
private static String extractFirstEndpointUri(Client client) throws Exception {
90232
Field endpointsField = Client.class.getDeclaredField("endpoints");
91233
endpointsField.setAccessible(true);

0 commit comments

Comments
 (0)