Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,17 @@

### New Features

- **[client-v2, jdbc-v2]** Added support for an application-supplied `javax.net.ssl.SSLContext`. In client-v2,
`Client.Builder.setSSLContext(SSLContext)` hands the client a fully pre-built context that is used as is; in
jdbc-v2 the same context may be passed as a live object in the connection `Properties` under the `ssl_context`
key (added with `Properties.put`, since it is not a string). Trust/key material options cannot be combined with
a custom context and are rejected; `ssl_mode` still applies but only to server hostname verification. This
supports in-memory TLS material that must never be written to disk, including behind connection pools that only
expose `java.util.Properties`.
- 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
- Examples for jdbc-v2 https://github.com/ClickHouse/clickhouse-java/blob/main/examples/jdbc/src/main/java/com/clickhouse/examples/jdbc/SSLExamples.java
(https://github.com/ClickHouse/clickhouse-java/pull/2918, https://github.com/ClickHouse/clickhouse-java/issues/2909)

- **[jdbc-v2, client-v2]** Implemented SSL modes configuration. Now it is possible to set `ssl_mode` to `DISABLED`,
`TRUST`, `VERIFY_CA` and `STRICT`. Note for V1 users: `NONE` is supported only by JDBC driver and mapped to `TRUST`.
Please migrate to the new naming.
Expand Down
64 changes: 58 additions & 6 deletions client-v2/src/main/java/com/clickhouse/client/api/Client.java
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@
import java.util.function.Supplier;
import java.util.stream.Collectors;

import javax.net.ssl.SSLContext;

/**
* <p>Client is the starting point for all interactions with ClickHouse. </p>
*
Expand Down Expand Up @@ -145,10 +147,14 @@
private final Supplier<String> queryIdGenerator;
private final CredentialsManager credentialsManager;

private Client(Collection<Endpoint> endpoints, Map<String,String> configuration,

Check warning on line 150 in client-v2/src/main/java/com/clickhouse/client/api/Client.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Constructor has 8 parameters, which is greater than 7 authorized.

See more on https://sonarcloud.io/project/issues?id=ClickHouse_clickhouse-java&issues=AZ9HqWtfVWej1L9_isBX&open=AZ9HqWtfVWej1L9_isBX&pullRequest=2918
ExecutorService sharedOperationExecutor, ColumnToMethodMatchingStrategy columnToMethodMatchingStrategy,
Object metricsRegistry, Supplier<String> queryIdGenerator, CredentialsManager cManager) {
Object metricsRegistry, Supplier<String> queryIdGenerator, CredentialsManager cManager,
SSLContext sslContext) {
Map<String, Object> parsedConfiguration = new ConcurrentHashMap<>(ClientConfigProperties.parseConfigMap(configuration));
if (sslContext != null) {
parsedConfiguration.put(ClientConfigProperties.SSL_CONTEXT.getKey(), sslContext);
}
this.credentialsManager = cManager;
this.session = Session.extractFrom(parsedConfiguration);
this.configuration = new ConcurrentHashMap<>(parsedConfiguration);
Expand Down Expand Up @@ -270,6 +276,18 @@
private ColumnToMethodMatchingStrategy columnToMethodMatchingStrategy;
private Object metricRegistry = null;
private Supplier<String> queryIdGenerator;
private SSLContext sslContext = null;

// Trust/key material options that feed a context the client would otherwise build; none of them
// may be combined with an application-supplied SSLContext (see build()).
private static final ClientConfigProperties[] SSL_MATERIAL_PROPERTIES = {
ClientConfigProperties.SSL_TRUST_STORE,
ClientConfigProperties.SSL_KEYSTORE_TYPE,
ClientConfigProperties.SSL_KEY_STORE_PASSWORD,
ClientConfigProperties.SSL_KEY,
ClientConfigProperties.CA_CERTIFICATE,
ClientConfigProperties.SSL_CERTIFICATE,
};

public Builder() {
this.endpoints = new HashSet<>();
Expand Down Expand Up @@ -344,6 +362,11 @@
* @param value - configuration option value
*/
public Builder setOption(String key, String value) {
if (key.equals(ClientConfigProperties.SSL_CONTEXT.getKey())) {
throw new ClientMisconfigurationException("'" + ClientConfigProperties.SSL_CONTEXT.getKey()
+ "' cannot be set as a string; supply a javax.net.ssl.SSLContext object via "
+ "Client.Builder.setSSLContext(...)");
}
this.configuration.put(key, value);
if (key.equals(ClientConfigProperties.PRODUCT_NAME.getKey())) {
setClientName(value);
Expand Down Expand Up @@ -785,6 +808,20 @@
return this;
}

/**
* Supplies a pre-built {@link SSLContext}. When set, it is used as is instead of a context built
* from the configured trust/key material (which then cannot be set alongside it). {@link SSLMode}
* still applies, but only to server hostname verification: {@link SSLMode#STRICT} (default) enforces
* it while {@link SSLMode#TRUST} and {@link SSLMode#VERIFY_CA} skip it.
*
* @param sslContext a fully configured SSL context; {@code null} clears any previously set context
* @return same instance of the builder
*/
public Builder setSSLContext(SSLContext sslContext) {
this.sslContext = sslContext;
return this;
}

/**
* Configure client to use server timezone for date/datetime columns. Default is true.
* If this options is selected then server timezone should be set as well.
Expand Down Expand Up @@ -1157,7 +1194,7 @@
return this;
}

public Client build() {

Check warning on line 1197 in client-v2/src/main/java/com/clickhouse/client/api/Client.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

A "Brain Method" was detected. Refactor it to reduce at least one of the following metrics: LOC from 70 to 64, Complexity from 16 to 14, Nesting Level from 4 to 2, Number of Variables from 11 to 6.

See more on https://sonarcloud.io/project/issues?id=ClickHouse_clickhouse-java&issues=AZ9xhWCIZYIwyl1GQ12b&open=AZ9xhWCIZYIwyl1GQ12b&pullRequest=2918
// check if endpoint are empty. so can not initiate client
if (this.endpoints.isEmpty()) {
throw new IllegalArgumentException("At least one endpoint is required");
Expand All @@ -1165,14 +1202,28 @@

CredentialsManager cManager = new CredentialsManager(this.configuration);

if (configuration.containsKey(ClientConfigProperties.SSL_TRUST_STORE.getKey()) &&
// A textual 'ssl_context' can never be a live context (also rejected in setOption).
if (configuration.containsKey(ClientConfigProperties.SSL_CONTEXT.getKey())) {
Comment thread
polyglotAI-bot marked this conversation as resolved.
throw new ClientMisconfigurationException("'" + ClientConfigProperties.SSL_CONTEXT.getKey()
+ "' cannot be set as a string; supply a javax.net.ssl.SSLContext object via "
+ "Client.Builder.setSSLContext(...)");
}

if (this.sslContext != null) {
// A custom SSLContext replaces any context the client would build, so trust/key material
// cannot be set alongside it. SSL_MODE (hostname verification) is still allowed.
for (ClientConfigProperties material : SSL_MATERIAL_PROPERTIES) {
if (configuration.containsKey(material.getKey())) {
throw new ClientMisconfigurationException("'" + material.getKey() + "' cannot be combined"
+ " with a custom SSLContext; the supplied context is used as is. Only 'ssl_mode'"
+ " (hostname verification) may be set alongside it.");
}
}
} else if (configuration.containsKey(ClientConfigProperties.SSL_TRUST_STORE.getKey()) &&
configuration.containsKey(ClientConfigProperties.SSL_CERTIFICATE.getKey())) {
throw new ClientMisconfigurationException("Trust store and certificates cannot be used together");
}

// A trust store and a CA certificate are not rejected here: for VERIFY_CA/STRICT the trust
// store takes precedence and the CA certificate is ignored with a warning (see createSSLContext).

// Resolve ssl_mode case-insensitively and normalize it to the canonical enum name so that
// downstream parsing is consistent and an unknown value is reported as a misconfiguration
// here instead of failing later with a generic enum-parsing error.
Expand Down Expand Up @@ -1229,7 +1280,8 @@
}

return new Client(this.endpoints, this.configuration, this.sharedOperationExecutor,
this.columnToMethodMatchingStrategy, this.metricRegistry, this.queryIdGenerator, cManager);
Comment thread
polyglotAI-bot marked this conversation as resolved.
this.columnToMethodMatchingStrategy, this.metricRegistry, this.queryIdGenerator, cManager,
this.sslContext);
}
}

Expand Down Expand Up @@ -1403,7 +1455,7 @@

// Check response
if (httpResponse.getCode() == HttpStatus.SC_SERVICE_UNAVAILABLE) {
LOG.warn("Failed to get response. Server returned {}. Retrying. (Duration: {})", httpResponse.getCode(), durationSince(startTime));

Check failure on line 1458 in client-v2/src/main/java/com/clickhouse/client/api/Client.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "Failed to get response. Server returned {}. Retrying. (Duration: {})" 3 times.

See more on https://sonarcloud.io/project/issues?id=ClickHouse_clickhouse-java&issues=AZ9xhWCIZYIwyl1GQ12a&open=AZ9xhWCIZYIwyl1GQ12a&pullRequest=2918
selectedEndpoint = getNextAliveNode();
continue;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.net.ssl.SSLContext;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
Expand Down Expand Up @@ -118,6 +120,8 @@ public enum ClientConfigProperties {

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

SSL_CONTEXT("ssl_context", SSLContext.class),

Comment thread
polyglotAI-bot marked this conversation as resolved.
RETRY_ON_FAILURE("retry", Integer.class, "3"),

INPUT_OUTPUT_FORMAT("format", ClickHouseFormat.class),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ public HttpAPIClientHelper(Map<String, Object> configuration, Object metricsRegi
}

/**
* Creates or returns default SSL context.
* Creates an SSL context from the configured trust/key material.
*
* @return SSLContext
*/
Expand Down Expand Up @@ -280,7 +280,21 @@ private HttpClientConnectionManager poolConnectionManager(LayeredConnectionSocke
public CloseableHttpClient createHttpClient(boolean initSslContext, Map<String, Object> configuration) {
// Top Level builders
HttpClientBuilder clientBuilder = HttpClientBuilder.create();
SSLContext sslContext = initSslContext ? createSSLContext(configuration) : null;
// An application-supplied SSLContext is used as is; otherwise one is built from the configured
// trust/key material. Server hostname verification below still applies via the SSL mode.
SSLContext sslContext = null;
if (initSslContext) {
Object customSSLContext = configuration.get(ClientConfigProperties.SSL_CONTEXT.getKey());
if (customSSLContext == null) {
sslContext = createSSLContext(configuration);
} else if (customSSLContext instanceof SSLContext) {
sslContext = (SSLContext) customSSLContext;
} else {
throw new ClientMisconfigurationException("'" + ClientConfigProperties.SSL_CONTEXT.getKey()
+ "' must be a javax.net.ssl.SSLContext instance but was "
+ customSSLContext.getClass().getName() + "; supply it via Client.Builder.setSSLContext(...)");
}
}
LayeredConnectionSocketFactory sslConnectionSocketFactory;
if (sslContext != null) {
String socketSNI = (String)configuration.get(ClientConfigProperties.SSL_SOCKET_SNI.getKey());
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
package com.clickhouse.client.api;

import com.clickhouse.client.api.enums.SSLMode;
import com.clickhouse.client.api.internal.HttpAPIClientHelper;
import org.testng.Assert;
import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;

import javax.net.ssl.SSLContext;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class ClientBuilderTest {

Expand Down Expand Up @@ -86,6 +90,144 @@ public void testSslModeInvalidValueRejected() {
.build());
}

@Test
public void testStringSSLContextRejectedBySetOption() {
Assert.expectThrows(ClientMisconfigurationException.class,
() -> new Client.Builder().setOption(ClientConfigProperties.SSL_CONTEXT.getKey(), "not-a-context"));
}

@DataProvider(name = "sslMaterialWithCustomContext_DP")
public static Object[][] sslMaterialWithCustomContext_DP() {
return new Object[][] {
{ ClientConfigProperties.SSL_TRUST_STORE.getKey(), "/path/to/truststore.jks" },
{ ClientConfigProperties.SSL_KEYSTORE_TYPE.getKey(), "JKS" },
{ ClientConfigProperties.SSL_KEY_STORE_PASSWORD.getKey(), "secret" },
{ ClientConfigProperties.SSL_KEY.getKey(), "/path/to/client.key" },
{ ClientConfigProperties.CA_CERTIFICATE.getKey(), "/path/to/ca.crt" },
{ ClientConfigProperties.SSL_CERTIFICATE.getKey(), "/path/to/client.crt" },
};
}

@Test(dataProvider = "sslMaterialWithCustomContext_DP")
public void testCustomSSLContextRejectsOtherSslMaterial(String materialKey, String materialValue) throws Exception {
SSLContext customContext = SSLContext.getInstance("TLS");
customContext.init(null, null, null);

Assert.expectThrows(ClientMisconfigurationException.class, () -> new Client.Builder()
.addEndpoint("https://localhost:8443")
.setUsername("default")
.setPassword("")
.setOption(materialKey, materialValue)
.setSSLContext(customContext)
.build());
}

@Test
public void testCustomSSLContextAllowsSslMode() throws Exception {
SSLContext customContext = SSLContext.getInstance("TLS");
customContext.init(null, null, null);

try (Client client = new Client.Builder()
.addEndpoint("https://localhost:8443")
.setUsername("default")
.setPassword("")
.setSSLMode(SSLMode.VERIFY_CA)
.setSSLContext(customContext)
.build()) {
Assert.assertSame(extractConfiguration(client).get(ClientConfigProperties.SSL_CONTEXT.getKey()),
Comment thread
polyglotAI-bot marked this conversation as resolved.
customContext);
Assert.assertEquals(client.getConfiguration().get(ClientConfigProperties.SSL_MODE.getKey()),
SSLMode.VERIFY_CA.name());
}
}

@Test
public void testTrustStoreAndClientCertificateConflictRejectedWithoutCustomContext() {
// Contrast: without a custom SSLContext the trust-store/certificate conflict is still rejected.
Assert.expectThrows(ClientMisconfigurationException.class, () -> new Client.Builder()
.addEndpoint("https://localhost:8443")
.setUsername("default")
.setPassword("")
.setSSLTrustStore("/path/to/truststore.jks")
.setClientCertificate("client.crt")
.build());
}

@Test
public void testSetSSLContextStoredInConfiguration() throws Exception {
SSLContext customContext = SSLContext.getInstance("TLS");
customContext.init(null, null, null);

try (Client client = new Client.Builder()
.addEndpoint("https://localhost:8443")
.setUsername("default")
.setPassword("")
.setSSLContext(customContext)
.build()) {
Assert.assertSame(extractConfiguration(client).get(ClientConfigProperties.SSL_CONTEXT.getKey()),
customContext, "The application-supplied SSLContext should be stored in the configuration");
}

// Without setSSLContext the key must be absent so the client builds its own context.
try (Client client = new Client.Builder()
.addEndpoint("https://localhost:8443")
.setUsername("default")
.setPassword("")
.build()) {
Assert.assertNull(extractConfiguration(client).get(ClientConfigProperties.SSL_CONTEXT.getKey()),
"No SSLContext should be stored when none is supplied");
}
}

@Test
public void testCreateSSLContextIgnoresCustomContext() throws Exception {
SSLContext customContext = SSLContext.getInstance("TLS");
customContext.init(null, null, null);

try (Client client = new Client.Builder()
.addEndpoint("https://localhost:8443")
.setUsername("default")
.setPassword("")
.build()) {
HttpAPIClientHelper helper = extractHttpClientHelper(client);
Map<String, Object> configWithCustom = new HashMap<>(extractConfiguration(client));
configWithCustom.put(ClientConfigProperties.SSL_CONTEXT.getKey(), customContext);
// createSSLContext only builds from trust/key material; selecting a custom context is
// createHttpClient's responsibility, so this must not return the supplied context.
Assert.assertNotSame(helper.createSSLContext(configWithCustom), customContext);
}
}

@Test
public void testCreateHttpClientRejectsNonSSLContextValue() throws Exception {
try (Client client = new Client.Builder()
.addEndpoint("https://localhost:8443")
.setUsername("default")
.setPassword("")
.build()) {
HttpAPIClientHelper helper = extractHttpClientHelper(client);
Map<String, Object> configWithBadContext = new HashMap<>(extractConfiguration(client));
configWithBadContext.put(ClientConfigProperties.SSL_CONTEXT.getKey(), "not-a-context");
// A non-SSLContext value under 'ssl_context' is a misconfiguration; createHttpClient must
// reject it instead of silently building a context from trust/key material.
Assert.expectThrows(ClientMisconfigurationException.class,
() -> helper.createHttpClient(true, configWithBadContext));
}
}

private static HttpAPIClientHelper extractHttpClientHelper(Client client) throws Exception {
Field helperField = Client.class.getDeclaredField("httpClientHelper");
helperField.setAccessible(true);
return (HttpAPIClientHelper) helperField.get(client);
}

@SuppressWarnings("unchecked")
private static Map<String, Object> extractConfiguration(Client client) throws Exception {
Field configField = Client.class.getDeclaredField("configuration");
configField.setAccessible(true);
return (Map<String, Object>) configField.get(client);
}

private static String extractFirstEndpointUri(Client client) throws Exception {
Field endpointsField = Client.class.getDeclaredField("endpoints");
endpointsField.setAccessible(true);
Expand Down
Loading
Loading