2424
2525package io .questdb .client .cutlass .qwp .client ;
2626
27+ import io .questdb .client .ClientTlsConfiguration ;
2728import io .questdb .client .cutlass .http .client .HttpClientException ;
2829import io .questdb .client .cutlass .http .client .WebSocketClient ;
2930import io .questdb .client .cutlass .http .client .WebSocketClientFactory ;
3536import org .slf4j .Logger ;
3637import org .slf4j .LoggerFactory ;
3738
39+ import java .nio .charset .StandardCharsets ;
40+ import java .util .Base64 ;
41+
3842/**
3943 * QWP egress (query results) client.
4044 * <p>
@@ -113,6 +117,11 @@ public class QwpQueryClient implements QuietCloseable {
113117 // hit the timeout branch in under a second instead of spending five.
114118 @ SuppressWarnings ("FieldMayBeFinal" )
115119 private volatile long shutdownJoinMs = 5_000 ;
120+ private boolean tlsEnabled ;
121+ // Only meaningful when tlsEnabled. Default is full validation against the JVM's trust store.
122+ private int tlsValidationMode = ClientTlsConfiguration .TLS_VALIDATION_MODE_FULL ;
123+ private char [] trustStorePassword ;
124+ private String trustStorePath ;
116125 private WebSocketClient webSocketClient ;
117126
118127 private QwpQueryClient (CharSequence host , int port ) {
@@ -126,13 +135,23 @@ private QwpQueryClient(CharSequence host, int port) {
126135 * <p>
127136 * Supported schemas:
128137 * <ul>
129- * <li>{@code ws::} -- plain WebSocket (matches QWP egress today; TLS not yet supported).</li>
138+ * <li>{@code ws::} -- plain WebSocket.</li>
139+ * <li>{@code wss::} -- WebSocket over TLS. See {@code tls_verify} / {@code tls_roots} /
140+ * {@code tls_roots_password} for trust configuration.</li>
130141 * </ul>
131142 * Supported keys:
132143 * <ul>
133144 * <li>{@code addr=host[:port]} -- required. Default port is {@value #DEFAULT_WS_PORT}.</li>
134145 * <li>{@code path=/read/v1} -- egress endpoint. Default {@value #DEFAULT_ENDPOINT_PATH}.</li>
135- * <li>{@code auth=<value>} -- sent as the HTTP {@code Authorization} header during the upgrade handshake.</li>
146+ * <li>{@code auth=<value>} -- sent verbatim as the HTTP {@code Authorization} header during the upgrade handshake.
147+ * Mutually exclusive with {@code username}/{@code password} and {@code token}.</li>
148+ * <li>{@code username=<name>;password=<secret>} -- HTTP Basic authentication. Server verifies the credentials
149+ * against the same user store the Postgres wire protocol uses, so a user created via
150+ * {@code CREATE USER ... WITH PASSWORD ...} can log in unchanged.
151+ * Both keys must be present together; mutually exclusive with {@code auth} and {@code token}.</li>
152+ * <li>{@code token=<access_token>} -- HTTP Bearer authentication with an OIDC access token (sent as
153+ * {@code Authorization: Bearer <token>}). Mutually exclusive with {@code auth} and
154+ * {@code username}/{@code password}.</li>
136155 * <li>{@code client_id=<id>} -- sent as the {@code X-QWP-Client-Id} header.</li>
137156 * <li>{@code buffer_pool_size=N} -- depth of the I/O thread's batch buffer pool. Default 4.</li>
138157 * <li>{@code compression=zstd|raw|auto} -- compression codec the client
@@ -141,6 +160,12 @@ private QwpQueryClient(CharSequence host, int port) {
141160 * when it supports it and falls back to raw otherwise.</li>
142161 * <li>{@code compression_level=N} -- zstd level hint, clamped server-side
143162 * to [1, 9]. Default 3. Ignored when {@code compression=raw}.</li>
163+ * <li>{@code tls_verify=on|unsafe_off} -- TLS certificate validation. Default is {@code on}.
164+ * Only allowed with the {@code wss::} schema. {@code unsafe_off} disables hostname and
165+ * certificate chain validation; use only for testing.</li>
166+ * <li>{@code tls_roots=<path>} -- path to a custom trust store (PKCS12 or JKS). Must be
167+ * paired with {@code tls_roots_password}. Only allowed with {@code wss::}.</li>
168+ * <li>{@code tls_roots_password=<secret>} -- password for the custom trust store.</li>
144169 * </ul>
145170 * Examples:
146171 * <pre>
@@ -157,25 +182,34 @@ public static QwpQueryClient fromConfig(CharSequence configurationString) {
157182 if (pos < 0 ) {
158183 throw new IllegalArgumentException ("invalid configuration string: " + sink );
159184 }
160- if (Chars .equals ("wss" , sink )) {
161- throw new IllegalArgumentException ("wss:: (TLS) is not supported by QwpQueryClient yet" );
162- }
163- if (!Chars .equals ("ws" , sink )) {
185+ boolean tls ;
186+ if (Chars .equals ("ws" , sink )) {
187+ tls = false ;
188+ } else if (Chars .equals ("wss" , sink )) {
189+ tls = true ;
190+ } else {
164191 throw new IllegalArgumentException (
165- "unsupported schema [schema=" + sink + ", supported-schemas=[ws]]" );
192+ "unsupported schema [schema=" + sink + ", supported-schemas=[ws, wss ]]" );
166193 }
167194
168195 String addrHost = null ;
169196 int addrPort = DEFAULT_WS_PORT ;
170197 String path = DEFAULT_ENDPOINT_PATH ;
171198 String auth = null ;
199+ String username = null ;
200+ String password = null ;
201+ String token = null ;
172202 String cid = null ;
173203 int poolSize = DEFAULT_IO_BUFFER_POOL_SIZE ;
174204 // Default matches the field initializer in QwpQueryClient: raw wire,
175205 // zstd opt-in.
176206 String compression = "raw" ;
177207 int compressionLevel = 3 ;
178208 int maxBatchRows = 0 ; // 0 = omit header, server uses its default
209+ // TLS validation mode: null means "unset in config". Explicit values kick in only when tls is true.
210+ Integer tlsValidation = null ;
211+ String tlsRoots = null ;
212+ String tlsRootsPassword = null ;
179213
180214 while (ConfStringParser .hasNext (configurationString , pos )) {
181215 pos = ConfStringParser .nextKey (configurationString , pos , sink );
@@ -209,6 +243,15 @@ public static QwpQueryClient fromConfig(CharSequence configurationString) {
209243 case "auth" :
210244 auth = value ;
211245 break ;
246+ case "username" :
247+ username = value ;
248+ break ;
249+ case "password" :
250+ password = value ;
251+ break ;
252+ case "token" :
253+ token = value ;
254+ break ;
212255 case "client_id" :
213256 cid = value ;
214257 break ;
@@ -250,18 +293,62 @@ public static QwpQueryClient fromConfig(CharSequence configurationString) {
250293 "max_batch_rows must be in [1, " + MAX_BATCH_ROWS_UPPER_BOUND + "]" );
251294 }
252295 break ;
296+ case "tls_verify" :
297+ if ("on" .equals (value )) {
298+ tlsValidation = ClientTlsConfiguration .TLS_VALIDATION_MODE_FULL ;
299+ } else if ("unsafe_off" .equals (value )) {
300+ tlsValidation = ClientTlsConfiguration .TLS_VALIDATION_MODE_NONE ;
301+ } else {
302+ throw new IllegalArgumentException (
303+ "invalid tls_verify: " + value + " (expected on or unsafe_off)" );
304+ }
305+ break ;
306+ case "tls_roots" :
307+ tlsRoots = value ;
308+ break ;
309+ case "tls_roots_password" :
310+ tlsRootsPassword = value ;
311+ break ;
253312 default :
254313 throw new IllegalArgumentException ("unknown configuration key: " + key );
255314 }
256315 }
257316 if (addrHost == null ) {
258317 throw new IllegalArgumentException ("missing required key: addr" );
259318 }
319+ boolean hasBasic = username != null || password != null ;
320+ if (hasBasic && (username == null || password == null )) {
321+ throw new IllegalArgumentException ("both username and password must be provided together" );
322+ }
323+ int authModesSet = (auth != null ? 1 : 0 ) + (hasBasic ? 1 : 0 ) + (token != null ? 1 : 0 );
324+ if (authModesSet > 1 ) {
325+ throw new IllegalArgumentException (
326+ "auth, username/password, and token are mutually exclusive" );
327+ }
328+ if (!tls && (tlsValidation != null || tlsRoots != null || tlsRootsPassword != null )) {
329+ throw new IllegalArgumentException (
330+ "tls_verify/tls_roots/tls_roots_password require the wss:: schema" );
331+ }
332+ if ((tlsRoots == null ) != (tlsRootsPassword == null )) {
333+ throw new IllegalArgumentException (
334+ "tls_roots and tls_roots_password must be provided together" );
335+ }
260336 QwpQueryClient client = new QwpQueryClient (addrHost , addrPort )
261337 .withEndpointPath (path )
262338 .withBufferPoolSize (poolSize )
263339 .withCompression (compression , compressionLevel );
340+ if (tls ) {
341+ if (tlsRoots != null ) {
342+ client .withTrustStore (tlsRoots , tlsRootsPassword .toCharArray ());
343+ } else if (tlsValidation != null && tlsValidation == ClientTlsConfiguration .TLS_VALIDATION_MODE_NONE ) {
344+ client .withInsecureTls ();
345+ } else {
346+ client .withTls ();
347+ }
348+ }
264349 if (auth != null ) client .withAuthorization (auth );
350+ if (hasBasic ) client .withBasicAuth (username , password );
351+ if (token != null ) client .withBearerToken (token );
265352 if (cid != null ) client .withClientId (cid );
266353 if (maxBatchRows > 0 ) client .withMaxBatchRows (maxBatchRows );
267354 return client ;
@@ -355,7 +442,12 @@ public void connect() {
355442 if (connected ) {
356443 return ;
357444 }
358- webSocketClient = WebSocketClientFactory .newPlainTextInstance ();
445+ if (tlsEnabled ) {
446+ webSocketClient = WebSocketClientFactory .newTlsInstance (
447+ new ClientTlsConfiguration (trustStorePath , trustStorePassword , tlsValidationMode ));
448+ } else {
449+ webSocketClient = WebSocketClientFactory .newPlainTextInstance ();
450+ }
359451 webSocketClient .setQwpMaxVersion (QWP_MAX_VERSION );
360452 webSocketClient .setQwpClientId (clientId != null ? clientId : defaultClientId ());
361453 webSocketClient .setQwpAcceptEncoding (buildAcceptEncodingHeader ());
@@ -472,6 +564,37 @@ public QwpQueryClient withAuthorization(String authorizationHeader) {
472564 return this ;
473565 }
474566
567+ /**
568+ * Configures HTTP Basic authentication for the WebSocket upgrade request.
569+ * The server verifies the credentials against the same user store the
570+ * Postgres wire protocol uses, so a user created via
571+ * {@code CREATE USER ... WITH PASSWORD ...} can authenticate here unchanged.
572+ * Must be called before {@link #connect}.
573+ */
574+ public QwpQueryClient withBasicAuth (String username , String password ) {
575+ if (username == null || password == null ) {
576+ throw new IllegalArgumentException ("username and password must not be null" );
577+ }
578+ String credentials = username + ":" + password ;
579+ this .authorizationHeader = "Basic " + Base64 .getEncoder ()
580+ .encodeToString (credentials .getBytes (StandardCharsets .UTF_8 ));
581+ return this ;
582+ }
583+
584+ /**
585+ * Configures HTTP Bearer authentication with an OIDC access token for the
586+ * WebSocket upgrade request. The server verifies the token via the
587+ * configured OIDC provider and resolves the principal (and any groups)
588+ * from the token's claims. Must be called before {@link #connect}.
589+ */
590+ public QwpQueryClient withBearerToken (String token ) {
591+ if (token == null ) {
592+ throw new IllegalArgumentException ("token must not be null" );
593+ }
594+ this .authorizationHeader = "Bearer " + token ;
595+ return this ;
596+ }
597+
475598 /**
476599 * Overrides the default I/O buffer pool depth (4). Larger pools let the
477600 * I/O thread decode further ahead of the consumer at the cost of memory;
@@ -534,6 +657,19 @@ public QwpQueryClient withInitialCredit(long bytes) {
534657 return this ;
535658 }
536659
660+ /**
661+ * Enables TLS with certificate validation disabled. Intended for testing only --
662+ * production code should use {@link #withTls} or {@link #withTrustStore}.
663+ * Must be called before {@link #connect}.
664+ */
665+ public QwpQueryClient withInsecureTls () {
666+ this .tlsEnabled = true ;
667+ this .tlsValidationMode = ClientTlsConfiguration .TLS_VALIDATION_MODE_NONE ;
668+ this .trustStorePath = null ;
669+ this .trustStorePassword = null ;
670+ return this ;
671+ }
672+
537673 /**
538674 * Asks the server to cap each {@code RESULT_BATCH} at {@code rows} rows.
539675 * Useful for latency-sensitive streaming consumers that want to start
@@ -554,6 +690,36 @@ public QwpQueryClient withMaxBatchRows(int rows) {
554690 return this ;
555691 }
556692
693+ /**
694+ * Enables TLS with full certificate validation against the JVM's default trust store.
695+ * Must be called before {@link #connect}.
696+ */
697+ public QwpQueryClient withTls () {
698+ this .tlsEnabled = true ;
699+ this .tlsValidationMode = ClientTlsConfiguration .TLS_VALIDATION_MODE_FULL ;
700+ this .trustStorePath = null ;
701+ this .trustStorePassword = null ;
702+ return this ;
703+ }
704+
705+ /**
706+ * Enables TLS with full certificate validation against the given custom trust store.
707+ * Must be called before {@link #connect}.
708+ *
709+ * @param trustStorePath filesystem path to a PKCS12 or JKS trust store
710+ * @param trustStorePassword password for the trust store
711+ */
712+ public QwpQueryClient withTrustStore (String trustStorePath , char [] trustStorePassword ) {
713+ if (trustStorePath == null || trustStorePassword == null ) {
714+ throw new IllegalArgumentException ("trustStorePath and trustStorePassword must not be null" );
715+ }
716+ this .tlsEnabled = true ;
717+ this .tlsValidationMode = ClientTlsConfiguration .TLS_VALIDATION_MODE_FULL ;
718+ this .trustStorePath = trustStorePath ;
719+ this .trustStorePassword = trustStorePassword ;
720+ return this ;
721+ }
722+
557723 private static String defaultClientId () {
558724 return "questdb-java-egress/1.0.0" ;
559725 }
0 commit comments