From c4b4425ab72fdb9f50096770fbb359d33807206c Mon Sep 17 00:00:00 2001 From: Sergey Chernov Date: Wed, 25 Mar 2026 16:28:39 -0700 Subject: [PATCH] Implemented proper handling of hostnames with . Added DNS resolver --- .../com/clickhouse/client/api/Client.java | 102 +++++++++++++++--- .../clickhouse/client/api/HostResolver.java | 11 ++ .../api/internal/HttpAPIClientHelper.java | 39 +++++-- .../client/api/transport/HttpEndpoint.java | 5 +- .../client/api/ClientBuilderTest.java | 41 +++++++ .../api/transport/HttpEndpointTest.java | 7 ++ 6 files changed, 182 insertions(+), 23 deletions(-) create mode 100644 client-v2/src/main/java/com/clickhouse/client/api/HostResolver.java create mode 100644 client-v2/src/test/java/com/clickhouse/client/api/ClientBuilderTest.java diff --git a/client-v2/src/main/java/com/clickhouse/client/api/Client.java b/client-v2/src/main/java/com/clickhouse/client/api/Client.java index 73ec21155..fb3a9ce1d 100644 --- a/client-v2/src/main/java/com/clickhouse/client/api/Client.java +++ b/client-v2/src/main/java/com/clickhouse/client/api/Client.java @@ -53,8 +53,7 @@ import java.io.InputStream; import java.io.OutputStream; import java.lang.reflect.InvocationTargetException; -import java.net.MalformedURLException; -import java.net.URL; +import java.net.URI; import java.time.Duration; import java.time.ZoneId; import java.time.temporal.ChronoUnit; @@ -144,7 +143,7 @@ public class Client implements AutoCloseable { private Client(Collection endpoints, Map configuration, ExecutorService sharedOperationExecutor, ColumnToMethodMatchingStrategy columnToMethodMatchingStrategy, - Object metricsRegistry, Supplier queryIdGenerator) { + Object metricsRegistry, Supplier queryIdGenerator, HostResolver hostResolver) { this.configuration = ClientConfigProperties.parseConfigMap(configuration); this.readOnlyConfig = Collections.unmodifiableMap(configuration); this.metricsRegistry = metricsRegistry; @@ -191,7 +190,7 @@ private Client(Collection endpoints, Map configuration, this.lz4Factory = LZ4Factory.fastestJavaInstance(); } - this.httpClientHelper = new HttpAPIClientHelper(this.configuration, metricsRegistry, initSslContext, lz4Factory); + this.httpClientHelper = new HttpAPIClientHelper(this.configuration, metricsRegistry, initSslContext, lz4Factory, hostResolver); this.serverVersion = configuration.getOrDefault(ClientConfigProperties.SERVER_VERSION.getKey(), "unknown"); this.dbUser = configuration.getOrDefault(ClientConfigProperties.USER.getKey(), ClientConfigProperties.USER.getDefObjVal()); this.typeHintMapping = (Map>) this.configuration.get(ClientConfigProperties.TYPE_HINT_MAPPING.getKey()); @@ -264,6 +263,7 @@ public static class Builder { private ColumnToMethodMatchingStrategy columnToMethodMatchingStrategy; private Object metricRegistry = null; private Supplier queryIdGenerator; + private HostResolver hostResolver; public Builder() { this.endpoints = new HashSet<>(); @@ -277,6 +277,7 @@ public Builder() { allowBinaryReaderToReuseBuffers(false); columnToMethodMatchingStrategy = DefaultColumnToMethodMatchingStrategy.INSTANCE; + hostResolver = HostResolver.DEFAULT; } /** @@ -293,32 +294,37 @@ public Builder() { */ public Builder addEndpoint(String endpoint) { try { - URL endpointURL = new URL(endpoint); - - String protocolStr = endpointURL.getProtocol(); + URI endpointUri = URI.create(endpoint); + String protocolStr = endpointUri.getScheme(); + if (protocolStr == null) { + throw new IllegalArgumentException("Protocol should be set in endpoint"); + } if (!protocolStr.equalsIgnoreCase("https") && !protocolStr.equalsIgnoreCase("http")) { throw new IllegalArgumentException("Only HTTP and HTTPS protocols are supported"); } boolean secure = protocolStr.equalsIgnoreCase("https"); - String host = endpointURL.getHost(); + ParsedAuthority authority = parseAuthority(endpointUri.getRawAuthority(), endpoint); + String host = authority.host; if (host == null || host.isEmpty()) { throw new IllegalArgumentException("Host cannot be empty in endpoint: " + endpoint); } - int port = endpointURL.getPort(); + int port = authority.port; if (port <= 0) { throw new ValidationUtils.SettingsValidationException("port", "Valid port must be specified"); } - String path = endpointURL.getPath(); + String path = endpointUri.getPath(); if (path == null || path.isEmpty()) { path = "/"; } return addEndpoint(Protocol.HTTP, host, port, secure, path); - } catch (MalformedURLException e) { + } catch (ValidationUtils.SettingsValidationException e) { + throw e; + } catch (IllegalArgumentException e) { throw new IllegalArgumentException("Endpoint should be a valid URL string, but was " + endpoint, e); } } @@ -351,6 +357,78 @@ public Builder addEndpoint(Protocol protocol, String host, int port, boolean sec } + /** + * Sets custom host resolver to resolve endpoint hostnames. + * This is useful in custom DNS environments such as Kubernetes or service mesh deployments. + * By default, {@link java.net.InetAddress#getByName(String)} is used. + * + * @param hostResolver resolver implementation + * @return this builder instance + */ + public Builder setHostResolver(HostResolver hostResolver) { + ValidationUtils.checkNotNull(hostResolver, "hostResolver"); + this.hostResolver = hostResolver; + return this; + } + + private static ParsedAuthority parseAuthority(String rawAuthority, String endpoint) { + if (rawAuthority == null || rawAuthority.trim().isEmpty()) { + throw new IllegalArgumentException("Host cannot be empty in endpoint: " + endpoint); + } + + String authority = rawAuthority; + int userInfoSeparator = authority.lastIndexOf('@'); + if (userInfoSeparator >= 0) { + authority = authority.substring(userInfoSeparator + 1); + } + + if (authority.startsWith("[")) { + int ipv6End = authority.indexOf(']'); + if (ipv6End < 0) { + throw new IllegalArgumentException("Invalid endpoint authority: " + rawAuthority); + } + + String host = authority.substring(0, ipv6End + 1); + if (ipv6End + 1 >= authority.length() || authority.charAt(ipv6End + 1) != ':') { + throw new ValidationUtils.SettingsValidationException("port", "Valid port must be specified"); + } + + String portPart = authority.substring(ipv6End + 2); + return new ParsedAuthority(host, parsePort(portPart)); + } + + int portSeparator = authority.lastIndexOf(':'); + if (portSeparator <= 0 || portSeparator == authority.length() - 1) { + throw new ValidationUtils.SettingsValidationException("port", "Valid port must be specified"); + } + + if (authority.indexOf(':') != portSeparator) { + throw new IllegalArgumentException("Invalid endpoint authority: " + rawAuthority); + } + + String host = authority.substring(0, portSeparator); + String portPart = authority.substring(portSeparator + 1); + return new ParsedAuthority(host, parsePort(portPart)); + } + + private static int parsePort(String portPart) { + try { + return Integer.parseInt(portPart); + } catch (NumberFormatException e) { + throw new ValidationUtils.SettingsValidationException("port", "Valid port must be specified"); + } + } + + private static final class ParsedAuthority { + private final String host; + private final int port; + + private ParsedAuthority(String host, int port) { + this.host = host; + this.port = port; + } + } + /** * Sets a configuration option. This method can be used to set any configuration option. @@ -1152,7 +1230,7 @@ public Client build() { } return new Client(this.endpoints, this.configuration, this.sharedOperationExecutor, - this.columnToMethodMatchingStrategy, this.metricRegistry, this.queryIdGenerator); + this.columnToMethodMatchingStrategy, this.metricRegistry, this.queryIdGenerator, this.hostResolver); } } diff --git a/client-v2/src/main/java/com/clickhouse/client/api/HostResolver.java b/client-v2/src/main/java/com/clickhouse/client/api/HostResolver.java new file mode 100644 index 000000000..48afd8c0b --- /dev/null +++ b/client-v2/src/main/java/com/clickhouse/client/api/HostResolver.java @@ -0,0 +1,11 @@ +package com.clickhouse.client.api; + +import java.net.InetAddress; +import java.net.UnknownHostException; + +@FunctionalInterface +public interface HostResolver { + HostResolver DEFAULT = InetAddress::getByName; + + InetAddress resolve(String host) throws UnknownHostException; +} diff --git a/client-v2/src/main/java/com/clickhouse/client/api/internal/HttpAPIClientHelper.java b/client-v2/src/main/java/com/clickhouse/client/api/internal/HttpAPIClientHelper.java index 76e2dec93..b5068209e 100644 --- a/client-v2/src/main/java/com/clickhouse/client/api/internal/HttpAPIClientHelper.java +++ b/client-v2/src/main/java/com/clickhouse/client/api/internal/HttpAPIClientHelper.java @@ -9,11 +9,13 @@ import com.clickhouse.client.api.ClientMisconfigurationException; import com.clickhouse.client.api.ConnectionInitiationException; import com.clickhouse.client.api.ConnectionReuseStrategy; +import com.clickhouse.client.api.HostResolver; import com.clickhouse.client.api.DataTransferException; import com.clickhouse.client.api.ServerException; import com.clickhouse.client.api.enums.ProxyType; import com.clickhouse.client.api.http.ClickHouseHttpProto; import com.clickhouse.client.api.transport.Endpoint; +import org.apache.hc.client5.http.DnsResolver; import com.clickhouse.data.ClickHouseFormat; import net.jpountz.lz4.LZ4Factory; import org.apache.commons.compress.compressors.CompressorStreamFactory; @@ -22,7 +24,6 @@ import org.apache.hc.client5.http.config.ConnectionConfig; import org.apache.hc.client5.http.config.RequestConfig; import org.apache.hc.client5.http.entity.mime.MultipartEntityBuilder; -import org.apache.hc.client5.http.entity.mime.MultipartPartBuilder; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; import org.apache.hc.client5.http.impl.io.BasicHttpClientConnectionManager; @@ -32,10 +33,9 @@ import org.apache.hc.client5.http.io.HttpClientConnectionManager; import org.apache.hc.client5.http.io.ManagedHttpClientConnection; import org.apache.hc.client5.http.protocol.HttpClientContext; -import org.apache.hc.client5.http.socket.ConnectionSocketFactory; import org.apache.hc.client5.http.socket.LayeredConnectionSocketFactory; -import org.apache.hc.client5.http.socket.PlainConnectionSocketFactory; import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory; +import org.apache.hc.client5.http.ssl.TlsSocketStrategy; import org.apache.hc.core5.http.ClassicHttpResponse; import org.apache.hc.core5.http.ConnectionRequestTimeoutException; import org.apache.hc.core5.http.ContentType; @@ -46,8 +46,10 @@ import org.apache.hc.core5.http.HttpRequest; import org.apache.hc.core5.http.HttpStatus; import org.apache.hc.core5.http.NoHttpResponseException; +import org.apache.hc.core5.http.URIScheme; import org.apache.hc.core5.http.config.CharCodingConfig; import org.apache.hc.core5.http.config.Http1Config; +import org.apache.hc.core5.http.config.Lookup; import org.apache.hc.core5.http.config.RegistryBuilder; import org.apache.hc.core5.http.impl.io.DefaultHttpResponseParserFactory; import org.apache.hc.core5.http.io.SocketConfig; @@ -76,6 +78,7 @@ import java.io.UnsupportedEncodingException; import java.lang.reflect.Method; import java.net.ConnectException; +import java.net.InetAddress; import java.net.InetSocketAddress; import java.net.NoRouteToHostException; import java.net.Socket; @@ -93,7 +96,6 @@ import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.Optional; import java.util.Properties; import java.util.Arrays; import java.util.HashMap; @@ -130,9 +132,11 @@ public class HttpAPIClientHelper { ConnPoolControl poolControl; LZ4Factory lz4Factory; + private final HostResolver hostResolver; - public HttpAPIClientHelper(Map configuration, Object metricsRegistry, boolean initSslContext, LZ4Factory lz4Factory) { + public HttpAPIClientHelper(Map configuration, Object metricsRegistry, boolean initSslContext, LZ4Factory lz4Factory, HostResolver hostResolver) { this.metricsRegistry = metricsRegistry; + this.hostResolver = Objects.requireNonNull(hostResolver, "hostResolver"); this.httpClient = createHttpClient(initSslContext, configuration); this.lz4Factory = lz4Factory; assert this.lz4Factory != null; @@ -205,11 +209,13 @@ private ConnectionConfig createConnectionConfig(Map configuratio } private HttpClientConnectionManager basicConnectionManager(LayeredConnectionSocketFactory sslConnectionSocketFactory, SocketConfig socketConfig, Map configuration) { - RegistryBuilder registryBuilder = RegistryBuilder.create(); - registryBuilder.register("http", PlainConnectionSocketFactory.getSocketFactory()); - registryBuilder.register("https", sslConnectionSocketFactory); + Lookup tlsSocketStrategyLookup = RegistryBuilder.create() + .register(URIScheme.HTTPS.id, (socket, target, port, attachment, context) -> + (SSLSocket) sslConnectionSocketFactory.createLayeredSocket(socket, target, port, context)) + .build(); - BasicHttpClientConnectionManager connManager = new BasicHttpClientConnectionManager(registryBuilder.build()); + BasicHttpClientConnectionManager connManager = BasicHttpClientConnectionManager.create( + null, createDnsResolverAdapter(), tlsSocketStrategyLookup, null); connManager.setConnectionConfig(createConnectionConfig(configuration)); connManager.setSocketConfig(socketConfig); @@ -248,6 +254,7 @@ private HttpClientConnectionManager poolConnectionManager(LayeredConnectionSocke connMgrBuilder.setConnectionFactory(connectionFactory); connMgrBuilder.setSSLSocketFactory(sslConnectionSocketFactory); connMgrBuilder.setDefaultSocketConfig(socketConfig); + connMgrBuilder.setDnsResolver(createDnsResolverAdapter()); PoolingHttpClientConnectionManager phccm = connMgrBuilder.build(); poolControl = phccm; if (metricsRegistry != null) { @@ -266,6 +273,20 @@ private HttpClientConnectionManager poolConnectionManager(LayeredConnectionSocke return phccm; } + private DnsResolver createDnsResolverAdapter() { + return new DnsResolver() { + @Override + public InetAddress[] resolve(String host) throws UnknownHostException { + return new InetAddress[]{hostResolver.resolve(host)}; + } + + @Override + public String resolveCanonicalHostname(String host) throws UnknownHostException { + return hostResolver.resolve(host).getCanonicalHostName(); + } + }; + } + public CloseableHttpClient createHttpClient(boolean initSslContext, Map configuration) { // Top Level builders HttpClientBuilder clientBuilder = HttpClientBuilder.create(); diff --git a/client-v2/src/main/java/com/clickhouse/client/api/transport/HttpEndpoint.java b/client-v2/src/main/java/com/clickhouse/client/api/transport/HttpEndpoint.java index 839b167d6..2e945a55b 100644 --- a/client-v2/src/main/java/com/clickhouse/client/api/transport/HttpEndpoint.java +++ b/client-v2/src/main/java/com/clickhouse/client/api/transport/HttpEndpoint.java @@ -3,7 +3,6 @@ import com.clickhouse.client.api.ClientMisconfigurationException; import java.net.URI; -import java.net.URL; public class HttpEndpoint implements Endpoint { @@ -33,7 +32,9 @@ public HttpEndpoint(String host, int port, boolean secure, String path){ // Use URI constructor to properly handle encoding of path segments // Encode path segments separately to preserve slashes try { - this.uri = new URI(secure ? "https" : "http", null, host, port, this.path, null, null); + String scheme = secure ? "https" : "http"; + String encodedPath = new URI(null, null, this.path, null).getRawPath(); + this.uri = new URI(scheme + "://" + host + ":" + port + encodedPath); } catch (Exception e) { throw new ClientMisconfigurationException("Failed to create endpoint URL", e); } diff --git a/client-v2/src/test/java/com/clickhouse/client/api/ClientBuilderTest.java b/client-v2/src/test/java/com/clickhouse/client/api/ClientBuilderTest.java new file mode 100644 index 000000000..08c72bfcc --- /dev/null +++ b/client-v2/src/test/java/com/clickhouse/client/api/ClientBuilderTest.java @@ -0,0 +1,41 @@ +package com.clickhouse.client.api; + +import org.testng.Assert; +import org.testng.annotations.Test; + +import java.lang.reflect.Field; +import java.util.List; + +public class ClientBuilderTest { + + @Test + public void testAddEndpointToleratesUnderscoreHostname() throws Exception { + try (Client client = new Client.Builder() + .setHostResolver(HostResolver.DEFAULT) + .addEndpoint("http://host_with_underscore:8123") + .setUsername("default") + .setPassword("") + .build()) { + + String firstEndpoint = extractFirstEndpointUri(client); + Assert.assertEquals(firstEndpoint, "http://host_with_underscore:8123/", + "Endpoint URI should preserve original hostname"); + } + } + + @Test + public void testSetHostResolverRejectsNull() { + Assert.assertThrows(IllegalArgumentException.class, + () -> new Client.Builder().setHostResolver(null)); + } + + private static String extractFirstEndpointUri(Client client) throws Exception { + Field endpointsField = Client.class.getDeclaredField("endpoints"); + endpointsField.setAccessible(true); + + @SuppressWarnings("unchecked") + List endpoints = + (List) endpointsField.get(client); + return endpoints.get(0).getURI().toString(); + } +} diff --git a/client-v2/src/test/java/com/clickhouse/client/api/transport/HttpEndpointTest.java b/client-v2/src/test/java/com/clickhouse/client/api/transport/HttpEndpointTest.java index 8870cae00..733fc2ebe 100644 --- a/client-v2/src/test/java/com/clickhouse/client/api/transport/HttpEndpointTest.java +++ b/client-v2/src/test/java/com/clickhouse/client/api/transport/HttpEndpointTest.java @@ -160,4 +160,11 @@ public void testUtf8CharactersInPath() { Assert.assertTrue(cyrillicEndpoint.getURI().toASCIIString().contains("%"), "Cyrillic path should be percent-encoded in ASCII representation"); } + + @Test + public void testUnderscoreHostIsAcceptedInUri() { + HttpEndpoint endpoint = new HttpEndpoint("host_with_underscore", 8123, false, "/"); + Assert.assertEquals(endpoint.getHost(), "host_with_underscore", "Original host should be preserved"); + Assert.assertEquals(endpoint.getURI().toString(), "http://host_with_underscore:8123/"); + } }