diff --git a/README.md b/README.md
index 01aa3370..af6d922a 100644
--- a/README.md
+++ b/README.md
@@ -364,6 +364,19 @@ try (QuestDB db = QuestDB.connect("wss::addr=localhost:9000;username=admin;passw
}
```
+**Custom PEM certificate authority:**
+
+```java
+try (QuestDB db = QuestDB.connect(
+ "wss::addr=localhost:9000;tls_roots=/path/to/ca.pem;")) {
+ // ... use db ...
+}
+```
+
+`tls_roots` accepts a PEM certificate or bundle directly. For an existing JKS
+or PKCS#12 trust store, also set `tls_roots_password`; the password switches the
+file interpretation from PEM to a Java trust store.
+
**Disable certificate validation (not for production):**
```java
@@ -412,8 +425,8 @@ schema::key1=value1;key2=value2;
| `password` / `pass` | | Basic-auth password |
| `token` | | Bearer token (sent as an `Authorization` header on the WS upgrade) |
| `tls_verify` | `on` | TLS certificate validation (`on` or `unsafe_off`) |
-| `tls_roots` | | Path to a custom truststore |
-| `tls_roots_password` | | Truststore password |
+| `tls_roots` | | Path to a PEM certificate/bundle, or a JKS/PKCS#12 trust store |
+| `tls_roots_password` | | Optional JKS/PKCS#12 password; omit when `tls_roots` is PEM |
| `connect_timeout` | _(OS)_ | TCP connect + TLS handshake timeout, in milliseconds |
| `auth_timeout_ms` | `15000` | Authentication/upgrade request timeout, in milliseconds |
diff --git a/core/src/main/java/io/questdb/client/ClientTlsConfiguration.java b/core/src/main/java/io/questdb/client/ClientTlsConfiguration.java
index 93394893..dc982ae4 100644
--- a/core/src/main/java/io/questdb/client/ClientTlsConfiguration.java
+++ b/core/src/main/java/io/questdb/client/ClientTlsConfiguration.java
@@ -33,6 +33,9 @@ public class ClientTlsConfiguration {
private final String trustStorePath;
public ClientTlsConfiguration(String trustStorePath, char[] trustStorePassword, int tlsValidationMode) {
+ if (trustStorePath != null && tlsValidationMode == TLS_VALIDATION_MODE_NONE) {
+ throw new IllegalArgumentException("custom trust store cannot be combined with disabled TLS validation");
+ }
this.trustStorePath = trustStorePath;
this.trustStorePassword = trustStorePassword;
this.tlsValidationMode = tlsValidationMode;
diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java
index aa500d44..5106da96 100644
--- a/core/src/main/java/io/questdb/client/Sender.java
+++ b/core/src/main/java/io/questdb/client/Sender.java
@@ -934,6 +934,7 @@ enum Transport {
*/
final class LineSenderBuilder {
private static final int AUTO_FLUSH_DISABLED = 0;
+ private static final String TLS_ROOTS_INSECURE_CONFIG_ERROR = "tls_roots cannot be combined with tls_verify=unsafe_off; remove tls_verify to use custom roots, or remove tls_roots to disable certificate validation";
// close() drain timeout. Default applied at build() time. 0 or -1
// means "fast close" (skip the drain entirely); any positive value
// bounds the wait for ackedFsn to catch up to publishedFsn. Uses
@@ -1415,7 +1416,7 @@ public Sender build() {
}
ClientTlsConfiguration tlsConfig = null;
if (tlsEnabled) {
- assert (trustStorePath == null) == (trustStorePassword == null); //either both null or both non-null
+ assert trustStorePassword == null || trustStorePath != null;
tlsConfig = new ClientTlsConfiguration(trustStorePath, trustStorePassword, tlsValidationMode == TlsValidationMode.DEFAULT ? ClientTlsConfiguration.TLS_VALIDATION_MODE_FULL : ClientTlsConfiguration.TLS_VALIDATION_MODE_NONE);
}
return AbstractLineHttpSender.createLineSender(hosts, ports, httpPath, httpClientConfiguration, tlsConfig, actualAutoFlushRows, httpToken,
@@ -1437,7 +1438,7 @@ public Sender build() {
ClientTlsConfiguration wsTlsConfig = null;
if (tlsEnabled) {
- assert (trustStorePath == null) == (trustStorePassword == null);
+ assert trustStorePassword == null || trustStorePath != null;
wsTlsConfig = new ClientTlsConfiguration(
trustStorePath,
trustStorePassword,
@@ -3573,13 +3574,12 @@ private LineSenderBuilder fromConfig(CharSequence configurationString) {
if (hosts.size() == 0) {
throw new LineSenderException("addr is missing");
}
- if (trustStorePath != null) {
- if (trustStorePassword == null) {
- throw new LineSenderException("tls_roots was configured, but tls_roots_password is missing");
- }
- } else if (trustStorePassword != null) {
+ if (trustStorePath == null && trustStorePassword != null) {
throw new LineSenderException("tls_roots_password was configured, but tls_roots is missing");
}
+ if (trustStorePath != null && tlsValidationMode == TlsValidationMode.INSECURE) {
+ throw new LineSenderException(TLS_ROOTS_INSECURE_CONFIG_ERROR);
+ }
if (protocol == PROTOCOL_HTTP || protocol == PROTOCOL_WEBSOCKET) {
if (user != null) {
httpUsernamePassword(user, password);
@@ -3836,8 +3836,11 @@ static void validateWsConfig(ConfigView view, boolean tls) {
if (!tls && (tlsVerify != null || tlsRoots != null || tlsRootsPassword != null)) {
throw new IllegalArgumentException("tls_verify/tls_roots/tls_roots_password require the wss:: schema");
}
- if ((tlsRoots == null) != (tlsRootsPassword == null)) {
- throw new IllegalArgumentException("tls_roots and tls_roots_password must be provided together");
+ if (tlsRoots == null && tlsRootsPassword != null) {
+ throw new IllegalArgumentException("tls_roots_password requires tls_roots");
+ }
+ if (tlsRoots != null && "unsafe_off".equals(tlsVerify)) {
+ throw new IllegalArgumentException(TLS_ROOTS_INSECURE_CONFIG_ERROR);
}
}
@@ -3975,6 +3978,9 @@ private void validateParameters() {
if (!tlsEnabled && tlsValidationMode != TlsValidationMode.DEFAULT) {
throw new LineSenderException("TLS validation disabled, but TLS was not enabled");
}
+ if (trustStorePath != null && tlsValidationMode == TlsValidationMode.INSECURE) {
+ throw new LineSenderException("custom trust store cannot be combined with disabled TLS validation");
+ }
if (keyId != null && bufferCapacity < MIN_BUFFER_SIZE) {
throw new LineSenderException("Requested buffer too small ")
.put("[minimalCapacity=").put(MIN_BUFFER_SIZE)
@@ -4133,17 +4139,42 @@ private void websocket() {
public class AdvancedTlsSettings {
/**
- * Configure a custom truststore. This is only needed when using {@link #enableTls()} when your default
- * truststore does not contain certificate chain used by a server. Most users should not need it.
+ * Configure a PEM file containing one or more custom root certificates.
+ * This is only needed when using {@link #enableTls()} and the default
+ * trust store does not contain the certificate chain used by a server.
+ * Most users should not need it.
*
- * The path can be either a path on a local filesystem. Or you can prefix it with "classpath:" to instruct
- * the Sender to load a trust store from a classpath.
+ * The path can be on the local filesystem, or it can use the
+ * {@code classpath:} prefix.
+ *
+ * @param pemRootsPath a path to a PEM certificate file or bundle
+ * @return an instance of LineSenderBuilder for further configuration
+ */
+ public LineSenderBuilder customTrustStore(String pemRootsPath) {
+ return setCustomTrustStore(pemRootsPath, null);
+ }
+
+ /**
+ * Configure a password-protected JKS or PKCS#12 trust store. This is
+ * only needed when using {@link #enableTls()} and the default trust
+ * store does not contain the certificate chain used by a server.
+ * Most users should not need it.
+ *
+ * The path can be on the local filesystem, or it can use the
+ * {@code classpath:} prefix.
*
* @param trustStorePath a path to a trust store.
- * @param trustStorePassword a password to for the truststore
+ * @param trustStorePassword the trust store password
* @return an instance of LineSenderBuilder for further configuration
*/
public LineSenderBuilder customTrustStore(String trustStorePath, char[] trustStorePassword) {
+ if (trustStorePassword == null) {
+ throw new LineSenderException("trust store password cannot be null");
+ }
+ return setCustomTrustStore(trustStorePath, trustStorePassword);
+ }
+
+ private LineSenderBuilder setCustomTrustStore(String trustStorePath, char[] trustStorePassword) {
if (LineSenderBuilder.this.trustStorePath != null) {
throw new LineSenderException("custom trust store was already configured ")
.put("[path=").put(LineSenderBuilder.this.trustStorePath).put("]");
@@ -4151,8 +4182,8 @@ public LineSenderBuilder customTrustStore(String trustStorePath, char[] trustSto
if (Chars.isBlank(trustStorePath)) {
throw new LineSenderException("trust store path cannot be empty nor null");
}
- if (trustStorePassword == null) {
- throw new LineSenderException("trust store password cannot be null");
+ if (tlsValidationMode == TlsValidationMode.INSECURE) {
+ throw new LineSenderException("custom trust store cannot be configured when TLS validation is disabled");
}
LineSenderBuilder.this.trustStorePath = trustStorePath;
@@ -4165,12 +4196,16 @@ public LineSenderBuilder customTrustStore(String trustStorePath, char[] trustSto
* This is suitable when testing self-signed certificate. It's inherently insecure and should
* never be used in a production.
*
- * If you cannot use trusted certificate then you should prefer {@link #customTrustStore(String, char[])}
- * over disabling validation.
+ * If you cannot use a certificate in the default trust store then
+ * you should prefer {@link #customTrustStore(String)} or
+ * {@link #customTrustStore(String, char[])} over disabling validation.
*
* @return an instance of LineSenderBuilder for further configuration
*/
public LineSenderBuilder disableCertificateValidation() {
+ if (LineSenderBuilder.this.trustStorePath != null) {
+ throw new LineSenderException("TLS validation cannot be disabled when a custom trust store is configured");
+ }
LineSenderBuilder.this.tlsValidationMode = TlsValidationMode.INSECURE;
return LineSenderBuilder.this;
}
diff --git a/core/src/main/java/io/questdb/client/cutlass/line/tcp/DelegatingTlsChannel.java b/core/src/main/java/io/questdb/client/cutlass/line/tcp/DelegatingTlsChannel.java
index 659b1574..b0f96aa2 100644
--- a/core/src/main/java/io/questdb/client/cutlass/line/tcp/DelegatingTlsChannel.java
+++ b/core/src/main/java/io/questdb/client/cutlass/line/tcp/DelegatingTlsChannel.java
@@ -27,6 +27,7 @@
import io.questdb.client.Sender;
import io.questdb.client.cutlass.line.LineChannel;
import io.questdb.client.cutlass.line.LineSenderException;
+import io.questdb.client.network.TlsTrustStore;
import io.questdb.client.std.MemoryTag;
import io.questdb.client.std.Misc;
import io.questdb.client.std.Unsafe;
@@ -34,39 +35,16 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLEngine;
import javax.net.ssl.SSLEngineResult;
import javax.net.ssl.SSLException;
-import javax.net.ssl.SSLParameters;
-import javax.net.ssl.TrustManager;
-import javax.net.ssl.TrustManagerFactory;
-import javax.net.ssl.X509TrustManager;
-import java.io.FileInputStream;
-import java.io.FileNotFoundException;
-import java.io.InputStream;
import java.lang.reflect.Field;
import java.nio.Buffer;
import java.nio.ByteBuffer;
-import java.security.KeyStore;
-import java.security.SecureRandom;
-import java.security.cert.X509Certificate;
public final class DelegatingTlsChannel implements LineChannel {
private static final long ADDRESS_FIELD_OFFSET;
private static final int AFTER_HANDSHAKE = 1;
- private static final TrustManager[] BLIND_TRUST_MANAGERS = new TrustManager[]{new X509TrustManager() {
- public void checkClientTrusted(X509Certificate[] certs, String t) {
- }
-
- public void checkServerTrusted(X509Certificate[] certs, String t) {
- }
-
- public X509Certificate[] getAcceptedIssuers() {
- return null;
- }
- }};
-
private static final long CAPACITY_FIELD_OFFSET;
private static final int CLOSED = 3;
private static final int CLOSING = 2;
@@ -202,44 +180,14 @@ private static long allocateMemoryAndResetBuffer(ByteBuffer buffer, int capacity
}
private static SSLEngine createSslEngine(String trustStorePath, char[] trustStorePassword, Sender.TlsValidationMode validationMode, String peerHost) {
- assert trustStorePath == null || validationMode == Sender.TlsValidationMode.DEFAULT;
try {
- SSLContext sslContext;
- if (trustStorePath != null) {
- sslContext = SSLContext.getInstance("TLS");
- TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
- KeyStore jks = KeyStore.getInstance("JKS");
- try (InputStream trustStoreStream = openTruststoreStream(trustStorePath)) {
- jks.load(trustStoreStream, trustStorePassword);
- }
- tmf.init(jks);
- TrustManager[] trustManagers = tmf.getTrustManagers();
- sslContext.init(null, trustManagers, new SecureRandom());
- } else if (validationMode == Sender.TlsValidationMode.INSECURE) {
- sslContext = SSLContext.getInstance("TLS");
- sslContext.init(null, BLIND_TRUST_MANAGERS, new SecureRandom());
- } else {
- sslContext = SSLContext.getDefault();
- }
-
- // SSLEngine needs to know hostname during TLS handshake to validate a server certificate was issued
- // for the server we are connecting to. For details see the comment below.
- // Hostname validation does not use port at all hence we can get away with a dummy value -1
- SSLEngine sslEngine = sslContext.createSSLEngine(peerHost, -1);
- if (validationMode != Sender.TlsValidationMode.INSECURE) {
- SSLParameters sslParameters = sslEngine.getSSLParameters();
- // The https validation algorithm? That looks confusing! After all we are not using any
- // https here at so what does it mean?
- // It's actually simple: It just instructs the SSLEngine to perform the same hostname validation
- // as it does during HTTPS connections. SSLEngine does not do hostname validation by default. Without
- // this option SSLEngine would happily accept any certificate as long as it's signed by a trusted CA.
- // This option will make sure certificates are accepted only if they were issued for the
- // server we are connecting to.
- sslParameters.setEndpointIdentificationAlgorithm("https");
- sslEngine.setSSLParameters(sslParameters);
- }
- sslEngine.setUseClientMode(true);
- return sslEngine;
+ return TlsTrustStore.createSslEngine(
+ trustStorePath,
+ trustStorePassword,
+ validationMode == Sender.TlsValidationMode.INSECURE,
+ peerHost,
+ DelegatingTlsChannel.class
+ );
} catch (Throwable t) {
if (t instanceof LineSenderException) {
throw (LineSenderException) t;
@@ -256,20 +204,6 @@ private static long expandBuffer(ByteBuffer buffer, long oldAddress) {
return newAddress;
}
- private static InputStream openTruststoreStream(String trustStorePath) throws FileNotFoundException {
- InputStream trustStoreStream;
- if (trustStorePath.startsWith("classpath:")) {
- String adjustedPath = trustStorePath.substring("classpath:".length());
- trustStoreStream = DelegatingTlsChannel.class.getResourceAsStream(adjustedPath);
- if (trustStoreStream == null) {
- throw new LineSenderException("configured trust store is unavailable ")
- .put("[path=").put(trustStorePath).put("]");
- }
- return trustStoreStream;
- }
- return new FileInputStream(trustStorePath);
- }
-
private static void resetBufferToPointer(ByteBuffer buffer, long ptr, int len) {
assert buffer.isDirect();
Unsafe.getUnsafe().putLong(buffer, ADDRESS_FIELD_OFFSET, ptr);
@@ -437,4 +371,4 @@ private void writeToUpstreamAndClear() {
LIMIT_FIELD_OFFSET = Unsafe.getUnsafe().objectFieldOffset(limitField);
CAPACITY_FIELD_OFFSET = Unsafe.getUnsafe().objectFieldOffset(capacityField);
}
-}
\ No newline at end of file
+}
diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java
index f6587631..1bc478dc 100644
--- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java
+++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java
@@ -140,6 +140,7 @@ public class QwpQueryClient implements QuietCloseable {
private static final long DEFAULT_FAILOVER_MAX_BACKOFF_MS = 1_000L;
private static final long DEFAULT_FAILOVER_MAX_DURATION_MS = 30_000L;
private static final int DEFAULT_IO_BUFFER_POOL_SIZE = 4;
+ private static final String TLS_ROOTS_INSECURE_CONFIG_ERROR = "tls_roots cannot be combined with tls_verify=unsafe_off; remove tls_verify to use custom roots, or remove tls_roots to disable certificate validation";
/**
* How long {@link #connect()} waits to read the {@code SERVER_INFO} frame
* from each endpoint before giving up and moving to the next. 5 seconds is
@@ -345,9 +346,10 @@ private QwpQueryClient(String host, int port) {
*
+ * A {@code null} trust store password selects a PEM certificate bundle. A
+ * non-null password retains the historical Java KeyStore behaviour.
+ */
+public final class TlsTrustStore {
+
+ private static final TrustManager[] BLIND_TRUST_MANAGERS = new TrustManager[]{new X509TrustManager() {
+ public void checkClientTrusted(X509Certificate[] certs, String t) {
+ }
+
+ public void checkServerTrusted(X509Certificate[] certs, String t) {
+ }
+
+ public X509Certificate[] getAcceptedIssuers() {
+ return null;
+ }
+ }};
+
+ private TlsTrustStore() {
+ }
+
+ public static SSLEngine createSslEngine(
+ String trustStorePath,
+ char[] trustStorePassword,
+ boolean insecure,
+ String peerHost,
+ Class> resourceAnchor
+ ) throws CertificateException, IOException, KeyManagementException, KeyStoreException, NoSuchAlgorithmException {
+ if (trustStorePath != null && insecure) {
+ throw new IllegalArgumentException("custom trust store cannot be combined with disabled TLS validation");
+ }
+ SSLContext sslContext;
+ if (trustStorePath != null) {
+ sslContext = SSLContext.getInstance("TLS");
+ sslContext.init(null, load(trustStorePath, trustStorePassword, resourceAnchor), new SecureRandom());
+ } else if (insecure) {
+ sslContext = SSLContext.getInstance("TLS");
+ sslContext.init(null, BLIND_TRUST_MANAGERS, new SecureRandom());
+ } else {
+ sslContext = SSLContext.getDefault();
+ }
+
+ // SSLEngine needs to know hostname during TLS handshake to validate a server certificate was issued
+ // for the server we are connecting to. For details see the comment below.
+ // Hostname validation does not use port at all hence we can get away with a dummy value -1
+ SSLEngine sslEngine = sslContext.createSSLEngine(peerHost, -1);
+ if (!insecure) {
+ SSLParameters sslParameters = sslEngine.getSSLParameters();
+ // The https validation algorithm? That looks confusing! After all we are not using any
+ // https here at so what does it mean?
+ // It's actually simple: It just instructs the SSLEngine to perform the same hostname validation
+ // as it does during HTTPS connections. SSLEngine does not do hostname validation by default. Without
+ // this option SSLEngine would happily accept any certificate as long as it's signed by a trusted CA.
+ // This option will make sure certificates are accepted only if they were issued for the
+ // server we are connecting to.
+ sslParameters.setEndpointIdentificationAlgorithm("https");
+ sslEngine.setSSLParameters(sslParameters);
+ }
+ sslEngine.setUseClientMode(true);
+ return sslEngine;
+ }
+
+ public static TrustManager[] load(
+ String trustStorePath,
+ char[] trustStorePassword,
+ Class> resourceAnchor
+ ) throws CertificateException, IOException, KeyStoreException, NoSuchAlgorithmException {
+ KeyStore trustStore = trustStorePassword == null
+ ? loadPem(trustStorePath, resourceAnchor)
+ : loadJavaKeyStore(trustStorePath, trustStorePassword, resourceAnchor);
+ TrustManagerFactory trustManagerFactory =
+ TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
+ trustManagerFactory.init(trustStore);
+ return trustManagerFactory.getTrustManagers();
+ }
+
+ private static KeyStore loadJavaKeyStore(
+ String trustStorePath,
+ char[] trustStorePassword,
+ Class> resourceAnchor
+ ) throws CertificateException, IOException, KeyStoreException, NoSuchAlgorithmException {
+ try (BufferedInputStream trustStoreStream =
+ new BufferedInputStream(open(trustStorePath, resourceAnchor))) {
+ trustStoreStream.mark(1);
+ int firstByte = trustStoreStream.read();
+ trustStoreStream.reset();
+ String keyStoreType = firstByte == 0x30 ? "PKCS12" : "JKS";
+ KeyStore trustStore = KeyStore.getInstance(keyStoreType);
+ trustStore.load(trustStoreStream, trustStorePassword);
+ return trustStore;
+ }
+ }
+
+ private static KeyStore loadPem(
+ String trustStorePath,
+ Class> resourceAnchor
+ ) throws CertificateException, IOException, KeyStoreException, NoSuchAlgorithmException {
+ Collection extends Certificate> certificates;
+ CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
+ try (InputStream trustStoreStream = open(trustStorePath, resourceAnchor)) {
+ try {
+ certificates = certificateFactory.generateCertificates(trustStoreStream);
+ } catch (CertificateException e) {
+ throw new CertificateException(
+ "could not read PEM TLS roots [path=" + trustStorePath + "]; "
+ + "if this is a JKS or PKCS#12 trust store, set tls_roots_password",
+ e
+ );
+ }
+ }
+ if (certificates.isEmpty()) {
+ throw new CertificateException(
+ "no X.509 certificates found in PEM TLS roots [path=" + trustStorePath + "]; "
+ + "if this is a JKS or PKCS#12 trust store, set tls_roots_password"
+ );
+ }
+
+ KeyStore trustStore = KeyStore.getInstance("JKS");
+ trustStore.load(null, null);
+ int certificateIndex = 0;
+ for (Certificate certificate : certificates) {
+ trustStore.setCertificateEntry("pem-certificate-" + certificateIndex++, certificate);
+ }
+ return trustStore;
+ }
+
+ private static InputStream open(String trustStorePath, Class> resourceAnchor) throws FileNotFoundException {
+ if (trustStorePath.startsWith("classpath:")) {
+ String adjustedPath = trustStorePath.substring("classpath:".length());
+ InputStream trustStoreStream = resourceAnchor.getResourceAsStream(adjustedPath);
+ if (trustStoreStream == null) {
+ throw new LineSenderException("configured trust store is unavailable ")
+ .put("[path=").put(trustStorePath).put("]");
+ }
+ return trustStoreStream;
+ }
+ return new FileInputStream(trustStorePath);
+ }
+}
diff --git a/core/src/test/java/io/questdb/client/test/QuestDBBuilderTest.java b/core/src/test/java/io/questdb/client/test/QuestDBBuilderTest.java
index 6b7cacf8..6cf5eb09 100644
--- a/core/src/test/java/io/questdb/client/test/QuestDBBuilderTest.java
+++ b/core/src/test/java/io/questdb/client/test/QuestDBBuilderTest.java
@@ -114,6 +114,14 @@ public void testMalformedIngressConfigRejectedAtBuildWithMinZero() {
"ws::addr=127.0.0.1:1;sf_durability=flush;sender_pool_min=0;query_pool_min=0;", "not yet supported");
}
+ @Test
+ public void testTlsRootsWithUnsafeOffRejectedAtBuildWithMinZero() {
+ assertBuildRejected(
+ "wss::addr=127.0.0.1:1;tls_roots=/ca.pem;tls_verify=unsafe_off;sender_pool_min=0;query_pool_min=0;",
+ "tls_roots cannot be combined with tls_verify=unsafe_off"
+ );
+ }
+
@Test
public void testMalformedPoolValueRejectedAtBuild() {
// A non-numeric pool value is rejected at build()'s pool-key resolution,
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/line/LineSenderBuilderTest.java b/core/src/test/java/io/questdb/client/test/cutlass/line/LineSenderBuilderTest.java
index 30a66f98..76a0eb50 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/line/LineSenderBuilderTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/line/LineSenderBuilderTest.java
@@ -44,6 +44,7 @@
public class LineSenderBuilderTest {
private static final String AUTH_TOKEN_KEY1 = "UvuVb1USHGRRT08gEnwN2zGZrvM4MsLQ5brgF6SVkAw=";
private static final String LOCALHOST = "localhost";
+ private static final String TLS_ROOTS_INSECURE_ERROR = "tls_roots cannot be combined with tls_verify=unsafe_off; remove tls_verify to use custom roots, or remove tls_roots to disable certificate validation";
private static final char[] TRUSTSTORE_PASSWORD = "questdb".toCharArray();
private static final String TRUSTSTORE_PATH = "/keystore/server.keystore";
@@ -184,8 +185,12 @@ public void testConfStringValidation() throws Exception {
assertConfStrError("badschema::addr=bar;", "invalid schema [schema=badschema, supported-schemas=[http, https, tcp, tcps, ws, wss, udp]]");
assertConfStrError("http::addr=localhost:-1;", "invalid port [port=-1]");
assertConfStrError("http::auto_flush=on;", "addr is missing");
- assertConfStrError("http::addr=localhost;tls_roots=/some/path;", "tls_roots was configured, but tls_roots_password is missing");
+ assertConfStrError("http::addr=localhost;tls_roots=/some/path;", "custom trust store configured, but TLS was not enabled");
+ assertConfStrOk("https::addr=localhost;tls_roots=/some/path;protocol_version=2;");
assertConfStrError("http::addr=localhost;tls_roots_password=hunter123;", "tls_roots_password was configured, but tls_roots is missing");
+ assertConfStrError("tcps::addr=localhost;tls_roots=/ca.pem;tls_verify=unsafe_off;", TLS_ROOTS_INSECURE_ERROR);
+ assertConfStrError("https::addr=localhost;tls_verify=unsafe_off;tls_roots=/ca.pem;protocol_version=2;", TLS_ROOTS_INSECURE_ERROR);
+ assertConfStrError("tcps::addr=localhost;tls_roots=/ca.p12;tls_roots_password=secret;tls_verify=unsafe_off;", TLS_ROOTS_INSECURE_ERROR);
assertConfStrError("tcp::addr=localhost;user=foo;", "token cannot be empty nor null");
assertConfStrError("tcp::addr=localhost;username=foo;", "token cannot be empty nor null");
assertConfStrError("tcp::addr=localhost;token=foo;", "TCP token is configured, but user is missing");
@@ -306,8 +311,46 @@ public void testCustomTruststorePasswordCannotBeNull() {
() -> Sender.builder(Sender.Transport.TCP).advancedTls().customTrustStore(TRUSTSTORE_PATH, null));
}
+ @Test
+ public void testCustomTruststoreAndDisabledValidationCannotBeCombinedViaRetainedSettings() {
+ Sender.LineSenderBuilder customRootsBuilder = Sender.builder(Sender.Transport.TCP).enableTls();
+ Sender.LineSenderBuilder.AdvancedTlsSettings customRootsSettings = customRootsBuilder.advancedTls();
+ customRootsSettings.customTrustStore(TRUSTSTORE_PATH, TRUSTSTORE_PASSWORD);
+ assertThrows(
+ "TLS validation cannot be disabled when a custom trust store is configured",
+ customRootsSettings::disableCertificateValidation
+ );
+
+ Sender.LineSenderBuilder insecureBuilder = Sender.builder(Sender.Transport.TCP).enableTls();
+ Sender.LineSenderBuilder.AdvancedTlsSettings insecureSettings = insecureBuilder.advancedTls();
+ insecureSettings.disableCertificateValidation();
+ assertThrows(
+ "custom trust store cannot be configured when TLS validation is disabled",
+ () -> insecureSettings.customTrustStore(TRUSTSTORE_PATH, TRUSTSTORE_PASSWORD)
+ );
+ }
+
+ @Test
+ public void testCustomPemRootsDoNotRequirePassword() throws Exception {
+ assertMemoryLeak(() -> {
+ try (Sender ignored = Sender.builder(Sender.Transport.HTTP)
+ .enableTls()
+ .advancedTls().customTrustStore("/path/to/ca.pem")
+ .address(LOCALHOST)
+ .protocolVersion(2)
+ .build()) {
+ // Building the lazy HTTP sender proves the fluent TLS
+ // configuration accepts a passwordless PEM roots path.
+ }
+ });
+ }
+
@Test
public void testCustomTruststorePathCannotBeBlank() {
+ assertThrows("trust store path cannot be empty nor null",
+ () -> Sender.builder(Sender.Transport.TCP).advancedTls().customTrustStore(""));
+ assertThrows("trust store path cannot be empty nor null",
+ () -> Sender.builder(Sender.Transport.TCP).advancedTls().customTrustStore((String) null));
assertThrows("trust store path cannot be empty nor null",
() -> Sender.builder(Sender.Transport.TCP).advancedTls().customTrustStore("", TRUSTSTORE_PASSWORD));
assertThrows("trust store path cannot be empty nor null",
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/LineSenderBuilderWebSocketTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/LineSenderBuilderWebSocketTest.java
index fbbc5313..42dbaa51 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/LineSenderBuilderWebSocketTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/LineSenderBuilderWebSocketTest.java
@@ -1029,6 +1029,26 @@ public void testWsConfigString_tlsKeysOnNonTlsSchema_fails() {
assertBadConfig("ws::addr=localhost:9000;tls_roots=/ca.p12;", "require the wss:: schema");
}
+ @Test
+ public void testWssConfigString_tlsRootsPasswordWithoutRoots_fails() {
+ assertBadConfig(
+ "wss::addr=localhost:9000;tls_roots_password=secret;",
+ "tls_roots_password requires tls_roots"
+ );
+ }
+
+ @Test
+ public void testWssConfigString_tlsRootsWithUnsafeOff_fails() {
+ assertBadConfig(
+ "wss::addr=localhost:9000;tls_roots=/ca.pem;tls_verify=unsafe_off;",
+ "tls_roots cannot be combined with tls_verify=unsafe_off"
+ );
+ assertBadConfig(
+ "wss::addr=localhost:9000;tls_verify=unsafe_off;tls_roots=/ca.p12;tls_roots_password=secret;",
+ "tls_roots cannot be combined with tls_verify=unsafe_off"
+ );
+ }
+
@Test
public void testWsConfigString_withToken() throws Exception {
assertMemoryLeak(() -> {
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientFromConfigTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientFromConfigTest.java
index 42d668fb..daf55bb9 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientFromConfigTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientFromConfigTest.java
@@ -40,6 +40,8 @@
*/
public class QwpQueryClientFromConfigTest {
+ private static final String TLS_ROOTS_INSECURE_ERROR = "tls_roots cannot be combined with tls_verify=unsafe_off; remove tls_verify to use custom roots, or remove tls_roots to disable certificate validation";
+
@Test
public void testAddrAcceptsHostWithoutPort() {
// Host-only accepted; port defaults to the public DEFAULT_WS_PORT constant.
@@ -974,7 +976,7 @@ public void testTlsRootsOnWsRejected() {
public void testTlsRootsPasswordWithoutPathRejected() {
assertReject(
"wss::addr=db:9000;tls_roots_password=secret;",
- "tls_roots and tls_roots_password must be provided together"
+ "tls_roots_password requires tls_roots"
);
}
@@ -984,13 +986,22 @@ public void testTlsRootsWithPasswordAccepted() {
}
@Test
- public void testTlsRootsWithoutPasswordRejected() {
+ public void testTlsRootsWithUnsafeOffRejected() {
+ assertReject(
+ "wss::addr=db:9000;tls_roots=/etc/qdb/ca.pem;tls_verify=unsafe_off;",
+ TLS_ROOTS_INSECURE_ERROR
+ );
assertReject(
- "wss::addr=db:9000;tls_roots=/etc/qdb/ca.p12;",
- "tls_roots and tls_roots_password must be provided together"
+ "wss::addr=db:9000;tls_verify=unsafe_off;tls_roots=/etc/qdb/ca.p12;tls_roots_password=secret;",
+ TLS_ROOTS_INSECURE_ERROR
);
}
+ @Test
+ public void testPemTlsRootsWithoutPasswordAccepted() {
+ assertParses("wss::addr=db:9000;tls_roots=/etc/qdb/ca.pem;");
+ }
+
@Test
public void testTlsVerifyInvalidRejected() {
assertReject(
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientPostConnectGuardTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientPostConnectGuardTest.java
index d4ad155e..a220084a 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientPostConnectGuardTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientPostConnectGuardTest.java
@@ -80,6 +80,7 @@ public void testAllSettersRejectAfterConnect() throws Exception {
// withTls
assertRejects(QwpQueryClient::withTls, "withTls");
// withTrustStore
+ assertRejects(c -> c.withTrustStore("/tmp/ca.pem"), "withTrustStore");
assertRejects(c -> c.withTrustStore("/tmp/ts.p12", "pw".toCharArray()), "withTrustStore");
}
diff --git a/core/src/test/java/io/questdb/client/test/impl/QwpQueryClientConfigHonoredTest.java b/core/src/test/java/io/questdb/client/test/impl/QwpQueryClientConfigHonoredTest.java
index 00313004..10b1e3c0 100644
--- a/core/src/test/java/io/questdb/client/test/impl/QwpQueryClientConfigHonoredTest.java
+++ b/core/src/test/java/io/questdb/client/test/impl/QwpQueryClientConfigHonoredTest.java
@@ -80,10 +80,13 @@ public void testEveryEgressKeyIsHonored() throws Exception {
markHonored("username", "password", "token");
// COMMON TLS keys applied by egress (require the wss schema). tls_verify
- // drives the validation mode; tls_roots/tls_roots_password set the trust
- // store. All three read back from the snapshot.
+ // drives the validation mode. A passwordless roots path is PEM;
+ // supplying a password retains the JKS/PKCS12 trust-store path.
Assert.assertEquals(ClientTlsConfiguration.TLS_VALIDATION_MODE_NONE,
snapshot("wss::addr=h:9000;tls_verify=unsafe_off;").get("tls_verify"));
+ Map