Skip to content

Commit a65e60f

Browse files
committed
implemented SNI mapping in both clients
1 parent df12360 commit a65e60f

File tree

12 files changed

+626
-8
lines changed

12 files changed

+626
-8
lines changed

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -451,7 +451,15 @@ public enum ClickHouseClientOption implements ClickHouseOption {
451451
*/
452452
CONNECTION_TTL("connection_ttl", 0L,
453453
"Connection time to live in milliseconds. 0 or negative number means no limit."),
454-
MEASURE_REQUEST_TIME("debug_measure_request_time", false, "Whether to measure request time. If true, the time will be logged in debug mode.");
454+
MEASURE_REQUEST_TIME("debug_measure_request_time", false, "Whether to measure request time. If true, the time will be logged in debug mode."),
455+
456+
/**
457+
* Comma separated key-value pairs of IP address/host to SNI mapping.
458+
* Special mapping {@code _default_} - for default SNI when no match found. Without default mapping only matched targets will have SNI parameter.
459+
*/
460+
SSL_SNI_MAPPING("ssl_sni_map", "", "Comma separated key-value pairs of IP address/host to SNI mapping. Special mapping _default_ - for default SNI when no match found. Without default mapping only matched targets will have SNI parameter.")
461+
462+
;
455463

456464
private final String key;
457465
private final Serializable defaultValue;

clickhouse-http-client/src/main/java/com/clickhouse/client/http/ApacheHttpConnectionImpl.java

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import com.clickhouse.client.config.ClickHouseProxyType;
1212
import com.clickhouse.client.config.ClickHouseSslMode;
1313
import com.clickhouse.client.http.config.ClickHouseHttpOption;
14+
import com.clickhouse.config.ClickHouseOption;
1415
import com.clickhouse.data.ClickHouseChecker;
1516
import com.clickhouse.data.ClickHouseExternalTable;
1617
import com.clickhouse.data.ClickHouseFormat;
@@ -54,8 +55,11 @@
5455
import org.apache.hc.core5.util.Timeout;
5556
import org.apache.hc.core5.util.VersionInfo;
5657

58+
import javax.net.ssl.SNIHostName;
5759
import javax.net.ssl.SSLContext;
5860
import javax.net.ssl.SSLException;
61+
import javax.net.ssl.SSLParameters;
62+
import javax.net.ssl.SSLSocket;
5963
import java.io.BufferedReader;
6064
import java.io.ByteArrayInputStream;
6165
import java.io.ByteArrayOutputStream;
@@ -66,10 +70,9 @@
6670
import java.io.UncheckedIOException;
6771
import java.net.ConnectException;
6872
import java.net.HttpURLConnection;
73+
import java.net.InetAddress;
6974
import java.net.InetSocketAddress;
7075
import java.net.Socket;
71-
import java.net.SocketOption;
72-
import java.net.SocketOptions;
7376
import java.nio.charset.StandardCharsets;
7477
import java.util.Collections;
7578
import java.util.List;
@@ -394,6 +397,8 @@ public static SocketFactory create(ClickHouseConfig config) {
394397

395398
static class SSLSocketFactory extends SSLConnectionSocketFactory {
396399
private final ClickHouseConfig config;
400+
private final Map<String, String> sniMapping;
401+
private final String defaultSNI;
397402

398403
private SSLSocketFactory(ClickHouseConfig config) throws SSLException {
399404
super(ClickHouseSslContextProvider.getProvider().getSslContext(SSLContext.class, config)
@@ -402,13 +407,41 @@ private SSLSocketFactory(ClickHouseConfig config) throws SSLException {
402407
? new DefaultHostnameVerifier()
403408
: (hostname, session) -> true); // NOSONAR
404409
this.config = config;
410+
String sniMappingStr = config.getStrOption(ClickHouseClientOption.SSL_SNI_MAPPING);
411+
sniMapping = ClickHouseOption.toKeyValuePairs(sniMappingStr);
412+
defaultSNI = sniMapping.get("_default_");
405413
}
406414

407415
@Override
408416
public Socket createSocket(HttpContext context) throws IOException {
409417
return AbstractSocketClient.setSocketOptions(config, new Socket());
410418
}
411419

420+
@Override
421+
protected void prepareSocket(SSLSocket socket, HttpContext context) throws IOException {
422+
super.prepareSocket(socket, context);
423+
424+
if (!sniMapping.isEmpty()) {
425+
InetAddress remote = socket.getInetAddress();
426+
if (remote != null) { // actually should be not null here
427+
String sni = sniMapping.get(remote.getHostAddress());
428+
if (sni == null) {
429+
sni = sniMapping.get(remote.getHostName());
430+
if (sni == null) {
431+
sni = defaultSNI;
432+
}
433+
}
434+
if (sni != null && !sni.isEmpty()) {
435+
SSLParameters sslParams = socket.getSSLParameters();
436+
sslParams.setServerNames(Collections.singletonList(new SNIHostName(sni)));
437+
socket.setSSLParameters(sslParams);
438+
}
439+
} else {
440+
log.warn("Failed to apply SNI - remote address is null");
441+
}
442+
}
443+
}
444+
412445
public static SSLSocketFactory create(ClickHouseConfig config) throws SSLException {
413446
return new SSLSocketFactory(config);
414447
}
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
package com.clickhouse.client.http;
2+
3+
import com.clickhouse.client.AbstractSocketClient;
4+
import com.clickhouse.client.ClickHouseClient;
5+
import com.clickhouse.client.ClickHouseConfig;
6+
import com.clickhouse.client.ClickHouseNode;
7+
import com.clickhouse.client.ClickHouseNodeSelector;
8+
import com.clickhouse.client.ClickHouseProtocol;
9+
import com.clickhouse.client.ClickHouseResponse;
10+
import com.clickhouse.client.ClickHouseSocketFactory;
11+
import com.clickhouse.client.ClickHouseSslContextProvider;
12+
import com.clickhouse.client.config.ClickHouseClientOption;
13+
import com.clickhouse.client.config.ClickHouseDefaults;
14+
import com.clickhouse.client.config.ClickHouseSslMode;
15+
import com.clickhouse.config.ClickHouseOption;
16+
import com.clickhouse.data.ClickHouseFormat;
17+
import com.clickhouse.data.ClickHouseRecord;
18+
import com.clickhouse.data.ClickHouseUtils;
19+
import org.apache.hc.client5.http.socket.PlainConnectionSocketFactory;
20+
import org.apache.hc.client5.http.ssl.DefaultHostnameVerifier;
21+
import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory;
22+
import org.apache.hc.core5.http.protocol.HttpContext;
23+
import org.apache.hc.core5.ssl.SSLContexts;
24+
import org.slf4j.Logger;
25+
import org.slf4j.LoggerFactory;
26+
import org.testcontainers.containers.GenericContainer;
27+
import org.testcontainers.containers.wait.strategy.Wait;
28+
import org.testcontainers.utility.MountableFile;
29+
import org.testng.Assert;
30+
import org.testng.annotations.AfterClass;
31+
import org.testng.annotations.BeforeClass;
32+
import org.testng.annotations.DataProvider;
33+
import org.testng.annotations.Test;
34+
import wiremock.Run;
35+
36+
import javax.net.ssl.SNIHostName;
37+
import javax.net.ssl.SSLContext;
38+
import javax.net.ssl.SSLException;
39+
import javax.net.ssl.SSLParameters;
40+
import javax.net.ssl.SSLSession;
41+
import javax.net.ssl.SSLSocket;
42+
import javax.net.ssl.SSLSocketFactory;
43+
import javax.net.ssl.TrustManager;
44+
import javax.net.ssl.X509TrustManager;
45+
import java.io.IOException;
46+
import java.net.Socket;
47+
import java.security.cert.X509Certificate;
48+
import java.util.Collections;
49+
import java.util.Map;
50+
51+
/**
52+
* This tests is only for development and manual testing
53+
*/
54+
@Test(groups = {"integration"})
55+
public class NetworkTests {
56+
private static final String NGINX_IMAGE = "nginx:alpine";
57+
private static final int NGINX_SSL_PORT = 8443;
58+
private static final String NODE1_HOST = "node1.test";
59+
private static final String NODE2_HOST = "node2.test";
60+
61+
private GenericContainer<?> nginxContainer;
62+
63+
static {
64+
System.setProperty("org.slf4j.simpleLogger.defaultLogLevel", "DEBUG");
65+
}
66+
67+
@BeforeClass
68+
public void setUp() throws Exception {
69+
70+
// Create Nginx container with custom configuration
71+
nginxContainer = new GenericContainer<>(NGINX_IMAGE)
72+
.withCopyFileToContainer(
73+
MountableFile.forClasspathResource("nginx.conf"),
74+
"/etc/nginx/nginx.conf"
75+
)
76+
.withCopyFileToContainer(
77+
MountableFile.forClasspathResource("certs"),
78+
"/etc/nginx/certs/"
79+
)
80+
.withExposedPorts(NGINX_SSL_PORT)
81+
.waitingFor(Wait.forListeningPort());
82+
83+
nginxContainer.start();
84+
}
85+
86+
@AfterClass
87+
public void tearDown() {
88+
if (nginxContainer != null) {
89+
nginxContainer.stop();
90+
}
91+
}
92+
93+
private String getNginxHost() {
94+
return nginxContainer.getHost();
95+
}
96+
97+
private int getNginxPort() {
98+
return nginxContainer.getMappedPort(NGINX_SSL_PORT);
99+
}
100+
101+
@Test
102+
public void testSNI() {
103+
// Test will be implemented here
104+
String host = getNginxHost();
105+
int port = getNginxPort();
106+
System.out.println("Nginx container running at: " + host + ":" + port);
107+
}
108+
109+
@Test(dataProvider = "testSNINodesDP")
110+
void testSNINodes(String host) throws Exception {
111+
int port = nginxContainer.getMappedPort(NGINX_SSL_PORT);
112+
SSLSocket socket = createSniSocket(host, "localhost", port);
113+
socket.startHandshake();
114+
115+
SSLSession session = socket.getSession();
116+
X509Certificate cert = (X509Certificate) session.getPeerCertificates()[0];
117+
Assert.assertTrue(cert.getSubjectX500Principal().getName().contains("CN=" + host));
118+
}
119+
120+
@DataProvider
121+
static Object[][] testSNINodesDP() {
122+
return new Object[][]{
123+
{NODE1_HOST},
124+
{NODE2_HOST}
125+
};
126+
}
127+
128+
private SSLSocket createSniSocket(String sniHost, String serverHost, int port) throws Exception {
129+
SSLContext context = SSLContext.getInstance("TLS");
130+
context.init(null, new TrustManager[]{new X509TrustManager() {
131+
public void checkClientTrusted(X509Certificate[] chain, String authType) {
132+
}
133+
134+
public void checkServerTrusted(X509Certificate[] chain, String authType) {
135+
}
136+
137+
public X509Certificate[] getAcceptedIssuers() {
138+
return new X509Certificate[0];
139+
}
140+
}}, null);
141+
142+
SSLSocketFactory factory = context.getSocketFactory();
143+
SSLSocket socket = (SSLSocket) factory.createSocket(serverHost, port);
144+
145+
SSLParameters sslParams = socket.getSSLParameters();
146+
sslParams.setServerNames(Collections.singletonList(new SNIHostName(sniHost)));
147+
socket.setSSLParameters(sslParams);
148+
149+
return socket;
150+
}
151+
152+
@Test(groups = {"integration"})
153+
void testClientConfiguration() throws Exception {
154+
ClickHouseNode gateway = ClickHouseNode.of("https://" + getNginxHost() + ":" + getNginxPort() + "/?sslmode=none");
155+
try (ClickHouseClient client = ClickHouseClient.builder()
156+
.option(ClickHouseDefaults.USER, "default")
157+
.option(ClickHouseDefaults.PASSWORD, "")
158+
.option(ClickHouseClientOption.SSL_MODE, ClickHouseSslMode.NONE)
159+
.option(ClickHouseClientOption.COMPRESS, false) /// we emulate servers so need plain text
160+
.option(ClickHouseClientOption.SSL_SNI_MAPPING, "127.0.1.1=nodeX.test,localhost=node2.test")
161+
.nodeSelector(ClickHouseNodeSelector.of(ClickHouseProtocol.HTTP))
162+
.build()) {
163+
164+
165+
166+
try (ClickHouseResponse response = client.read(gateway)
167+
.format(ClickHouseFormat.TabSeparated)
168+
.query("SELECT hostname()").executeAndWait()) {
169+
// ClickHouseRecord record = response.firstRecord();
170+
String serverHostname = response.firstRecord().getValue(0).asString();
171+
Assert.assertEquals(serverHostname, "node2.test");
172+
};
173+
}
174+
}
175+
176+
177+
@Test(groups = {"integration"})
178+
void testCustomSSLConnectionFactory() throws Exception {
179+
ClickHouseNode gateway = ClickHouseNode.of("https://" + getNginxHost() + ":" + getNginxPort() + "/?sslmode=none");
180+
try (ClickHouseClient client = ClickHouseClient.builder()
181+
.option(ClickHouseDefaults.USER, "default")
182+
.option(ClickHouseDefaults.PASSWORD, "")
183+
.option(ClickHouseClientOption.CUSTOM_SOCKET_FACTORY_OPTIONS, "default_sni=node1.sni")
184+
.option(ClickHouseClientOption.CUSTOM_SOCKET_FACTORY, SNIAwareSSLSocketFactory.class.getName())
185+
.option(ClickHouseClientOption.SSL_MODE, ClickHouseSslMode.NONE)
186+
.option(ClickHouseClientOption.COMPRESS, false) /// we emulate servers so need plain text
187+
188+
.nodeSelector(ClickHouseNodeSelector.of(ClickHouseProtocol.HTTP))
189+
.build()) {
190+
191+
192+
193+
try (ClickHouseResponse response = client.read(gateway)
194+
.format(ClickHouseFormat.TabSeparated)
195+
.query("SELECT hostname()").executeAndWait()) {
196+
// ClickHouseRecord record = response.firstRecord();
197+
String serverHostname = response.firstRecord().getValue(0).asString();
198+
Assert.assertEquals(serverHostname, "node2.test");
199+
};
200+
}
201+
}
202+
203+
public static class SNIAwareSSLSocketFactory implements ClickHouseSocketFactory {
204+
205+
@Override
206+
public <T> T create(ClickHouseConfig config, Class<T> clazz) throws IOException, UnsupportedOperationException {
207+
if (config == null || clazz == null) {
208+
throw new IllegalArgumentException("Non-null configuration and class are required");
209+
} else if (SSLConnectionSocketFactory.class.equals(clazz)) {
210+
return clazz.cast(new CustomSSLSocketFactory(config));
211+
} else if (PlainConnectionSocketFactory.class.equals(clazz)) {
212+
return clazz.cast(new CustomPlainSocketFactory(config));
213+
}
214+
215+
throw new UnsupportedOperationException(ClickHouseUtils.format("Class %s is not supported", clazz));
216+
}
217+
218+
@Override
219+
public boolean supports(Class<?> clazz) {
220+
return PlainConnectionSocketFactory.class.equals(clazz) || SSLConnectionSocketFactory.class.equals(clazz);
221+
}
222+
}
223+
224+
static class CustomPlainSocketFactory extends PlainConnectionSocketFactory {
225+
private final ClickHouseConfig config;
226+
227+
public CustomPlainSocketFactory(ClickHouseConfig config) {
228+
this.config = config;
229+
}
230+
231+
@Override
232+
public Socket createSocket(final HttpContext context) throws IOException {
233+
// Use AbstractSockerClient.setSockerOptions to propagate socket configuration
234+
return AbstractSocketClient.setSocketOptions(config, new Socket());
235+
}
236+
}
237+
238+
static class CustomSSLSocketFactory extends SSLConnectionSocketFactory {
239+
private static final Logger LOG = LoggerFactory.getLogger(CustomSSLSocketFactory.class);
240+
private final ClickHouseConfig config;
241+
242+
private final SNIHostName defaultSNIHostName;
243+
244+
public CustomSSLSocketFactory(ClickHouseConfig config) throws SSLException {
245+
super(ClickHouseSslContextProvider.getProvider().getSslContext(SSLContext.class, config)
246+
.orElse(SSLContexts.createDefault()),
247+
config.getSslMode() == ClickHouseSslMode.STRICT
248+
? new DefaultHostnameVerifier()
249+
: (hostname, session) -> true); // NOSONAR
250+
this.config = config;
251+
String configStr = config.getStrOption(ClickHouseClientOption.CUSTOM_SOCKET_FACTORY_OPTIONS);
252+
if (configStr != null) {
253+
Map<String, String> configMap = ClickHouseOption.toKeyValuePairs(configStr);
254+
defaultSNIHostName = new SNIHostName(configMap.get("default_sni"));
255+
} else {
256+
throw new RuntimeException("Missing configuration for the factory");
257+
}
258+
}
259+
260+
@Override
261+
protected void prepareSocket(SSLSocket socket, HttpContext context) throws IOException {
262+
LOG.debug("Preparing socket: {}", socket);
263+
LOG.debug("Remote address: {}", socket.getInetAddress());
264+
SSLParameters sslParams = socket.getSSLParameters();
265+
sslParams.setServerNames(Collections.singletonList(defaultSNIHostName));
266+
socket.setSSLParameters(sslParams);
267+
}
268+
}
269+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
-----BEGIN CERTIFICATE-----
2+
MIIDIjCCAgqgAwIBAgIUPgsC1txpblBP8ybZlyOE/iwm3GAwDQYJKoZIhvcNAQEL
3+
BQAwFTETMBEGA1UEAwwKbm9kZTEudGVzdDAeFw0yNTA2MDYyMTI4NDJaFw0yNjA2
4+
MDYyMTI4NDJaMBUxEzARBgNVBAMMCm5vZGUxLnRlc3QwggEiMA0GCSqGSIb3DQEB
5+
AQUAA4IBDwAwggEKAoIBAQC16EcQY0+5s2sF4WDFcQgExmuIDn3v8gxqklv4BTXV
6+
1B4MqOFhwKytLiAXHL8wyILZJXfCzSQb+75gnm7nDd/5yCEbuhOGjHZcKWLx0ff7
7+
MBunje4b3t4LzKMtYmrPGSS540LLpsh+1VhJaHyDQRbK3Uu896K3wm5YC8FArj83
8+
XAV7UmEOjS4JeTWmFgfXcH04e6tSfZ9TrhyxMWaviFVHk9Lvvj8ajrJQ6nyFNOYL
9+
hTxSU3hrEixJ/Y8h7PPEfUFjVBixCjh93XbrGFu6bstL0V6MeAFV7u+oE6uaJpzw
10+
23qgKq8q93niq4MLhv+sfUX5j5mHF4OookY4uxNztVhtAgMBAAGjajBoMB0GA1Ud
11+
DgQWBBSFVEPrB2BgUiQ2VuyBbpcASR+HujAfBgNVHSMEGDAWgBSFVEPrB2BgUiQ2
12+
VuyBbpcASR+HujAPBgNVHRMBAf8EBTADAQH/MBUGA1UdEQQOMAyCCm5vZGUxLnRl
13+
c3QwDQYJKoZIhvcNAQELBQADggEBACDcZV0fYLyWHcY2lRRp+XfCC9pku2QCLKtb
14+
UYF2sAzMm/ND5U6MFu5BB9EcveFVDDe00DgdkSTC/7LgljhZvTLFxaqmcHO6bz9N
15+
woh1sljzlxDWymPCFg812lTt+KF6dvRswVYVY8ZkF9mrf0oVTYFNeX29G65/GlWQ
16+
ykN8VvjtnMZpphJYTaMNzOsAvEWLxSppVYYIhNqjwIifJjiWGptqtmeycQrInJp0
17+
5KgecVqEffxm1NcZyTlcoDvcm2ayQblpq17rcQ6H5Y1LF9vVffA1UwAgLPQmSNO2
18+
5hbfGo2ySmRzBv5goYdhR4/KVj+IXL0ASgzp6lHh+UhSLFeTFWY=
19+
-----END CERTIFICATE-----

0 commit comments

Comments
 (0)