diff --git a/libsql-client/pom.xml b/libsql-client/pom.xml index 0431b48..198c831 100644 --- a/libsql-client/pom.xml +++ b/libsql-client/pom.xml @@ -18,6 +18,10 @@ com.github.magnusp.libsql libsql-hrana-codec + + org.apache.httpcomponents.client5 + httpclient5-websocket + org.junit.jupiter junit-jupiter diff --git a/libsql-client/src/main/java/com/github/magnusp/libsql/client/LibsqlWebSocketClient.java b/libsql-client/src/main/java/com/github/magnusp/libsql/client/LibsqlWebSocketClient.java index 94a1acb..660d449 100644 --- a/libsql-client/src/main/java/com/github/magnusp/libsql/client/LibsqlWebSocketClient.java +++ b/libsql-client/src/main/java/com/github/magnusp/libsql/client/LibsqlWebSocketClient.java @@ -11,30 +11,27 @@ import com.github.magnusp.libsql.hrana.codec.WsResponse; import com.github.magnusp.libsql.hrana.codec.WsServerMsg; -import javax.net.ssl.SSLParameters; -import javax.net.ssl.SSLSocket; -import javax.net.ssl.SSLSocketFactory; +import org.apache.hc.client5.http.websocket.api.WebSocket; +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.client5.http.websocket.api.WebSocketListener; +import org.apache.hc.client5.http.websocket.client.CloseableWebSocketClient; +import org.apache.hc.client5.http.websocket.client.WebSocketClients; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.util.Timeout; + import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.net.InetSocketAddress; -import java.net.Socket; import java.net.URI; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.security.SecureRandom; import java.time.Duration; -import java.util.Base64; -import java.util.HashMap; -import java.util.Locale; +import java.util.List; import java.util.Map; import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; -import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicInteger; @@ -52,34 +49,32 @@ *

All request methods block the calling thread until the matching response arrives. The caller * (the JDBC connection) serializes calls, so a single interactive stream id is enough. * - *

Transport note. The WebSocket is spoken directly over a socket rather than - * through {@link java.net.http.WebSocket}. sqld answers the upgrade with a {@code 101} that carries - * {@code content-length: 31} and no body, and the JDK client completes its opening handshake only - * after reading a full response body ({@code OpeningHandshake.send()} uses {@code - * BodyHandlers.ofString()}), so it hangs against every sqld version. The framing below is the RFC - * 6455 client subset Hrana needs: text/binary frames, fragmentation, ping/pong and close. + *

Transport. Apache HttpClient's {@code httpclient5-websocket} module speaks RFC + * 6455. The JDK's {@link java.net.http.WebSocket} cannot be used against sqld: sqld answers the + * upgrade with a {@code 101} that carries {@code content-length: 31} and no body, and the JDK client + * completes its opening handshake only after reading a full response body. HttpClient treats a + * {@code 101} as informational and upgrades without reading one. */ public class LibsqlWebSocketClient implements LibsqlClient { private static final String SUBPROTOCOL_JSON = "hrana3"; private static final String SUBPROTOCOL_PROTOBUF = "hrana3-protobuf"; - private static final String WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; - - private static final int OP_CONTINUATION = 0x0; - private static final int OP_TEXT = 0x1; - private static final int OP_BINARY = 0x2; - private static final int OP_CLOSE = 0x8; - private static final int OP_PING = 0x9; - private static final int OP_PONG = 0xA; private static final int CLOSE_NORMAL = 1000; - private static final int MAX_FRAME_BYTES = 1024 * 1024 * 1024; + /** + * One message must be buffered whole before it can be decoded, so the bound matches the payload + * size a Hrana response may reach. + */ + private static final int MAX_MESSAGE_BYTES = 1024 * 1024 * 1024; + + private static final Duration CLOSE_TIMEOUT = Duration.ofSeconds(1); private final LibsqlClientConfig config; private final HranaWsCodec codec; private final String subprotocol; private final Duration timeout; - private final Transport transport; + private final CloseableWebSocketClient client; + private WebSocket socket; private final ConcurrentHashMap> pending = new ConcurrentHashMap<>(); @@ -103,13 +98,16 @@ public LibsqlWebSocketClient(LibsqlClientConfig config, HranaWsCodec codec) { ? config.connectTimeout() : LibsqlClientConfig.DEFAULT_CONNECT_TIMEOUT; this.subprotocol = codec.isBinary() ? SUBPROTOCOL_PROTOBUF : SUBPROTOCOL_JSON; - this.transport = new Transport(URI.create(config.url()), timeout, subprotocol); + CloseableWebSocketClient started = WebSocketClients.createDefault(); + started.start(); + this.client = started; try { + this.socket = connect(); send(new WsClientMsg.Hello(authToken())); awaitHello(); } catch (RuntimeException e) { - transport.abort(); + shutdown(); throw e; } } @@ -119,6 +117,34 @@ private String authToken() { return token == null || token.isBlank() ? null : token; } + private WebSocket connect() { + WebSocketClientConfig webSocketConfig = WebSocketClientConfig.custom() + .setSubprotocols(List.of(subprotocol)) + .setConnectTimeout(Timeout.of(timeout)) + .setCloseWaitTimeout(Timeout.of(CLOSE_TIMEOUT)) + .setMaxFrameSize(MAX_MESSAGE_BYTES) + .setMaxMessageSize(MAX_MESSAGE_BYTES) + // The transport this replaces negotiated no extension, so keep the wire bytes equal. + .enablePerMessageDeflate(false) + .build(); + try { + return client.connect( + URI.create(config.url()), new HranaListener(), webSocketConfig) + .get(timeout.toMillis(), TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new LibsqlException("Interrupted while opening the Hrana WebSocket", e); + } catch (TimeoutException e) { + throw new LibsqlException( + "Timed out after " + timeout.toMillis() + " ms opening the Hrana WebSocket", e); + } catch (ExecutionException e) { + Throwable cause = e.getCause() != null ? e.getCause() : e; + throw new LibsqlException( + "Failed to open Hrana WebSocket to " + config.url() + ": " + cause.getMessage(), + cause); + } + } + @Override public StmtResult executeOneShot(Stmt stmt) { int streamId = nextStreamId.getAndIncrement(); @@ -207,7 +233,7 @@ public synchronized void close() { // Closing a connection is best effort: the peer may already be gone and the server // reaps the stream when it notices. } finally { - transport.close(CLOSE_NORMAL, "closing"); + shutdown(); } } @@ -241,7 +267,24 @@ private void send(WsClientMsg message) { } catch (IOException e) { throw new LibsqlException("Failed to serialize Hrana message: " + e.getMessage(), e); } - transport.send(payload, codec.isBinary()); + boolean queued = codec.isBinary() + ? socket.sendBinary(ByteBuffer.wrap(payload), true) + : socket.sendText(new String(payload, StandardCharsets.UTF_8), true); + if (!queued) { + throw new LibsqlException("Hrana WebSocket is closed", "WS_CLOSED", 0); + } + } + + /** + * Stops the IO reactor, the executor, and the connection pool in one step. The socket close + * frame is queued first, but sqld never echoes it, so the teardown does not wait for the + * handshake. + */ + private void shutdown() { + if (socket != null) { + socket.close(CLOSE_NORMAL, "closing"); + } + client.close(CloseMode.IMMEDIATE); } private void awaitHello() { @@ -302,7 +345,7 @@ private void dispatch(byte[] payload) { } catch (IOException e) { failAll(new LibsqlException( "Failed to decode Hrana WebSocket message: " + e.getMessage(), e)); - transport.abort(); + socket.close(CLOSE_NORMAL, "protocol violation"); return; } @@ -334,7 +377,7 @@ private void fail(int requestId, LibsqlException error) { private void protocolViolation(String detail) { failAll(new LibsqlException("Hrana WebSocket protocol violation: " + detail, "WS_PROTOCOL", 0)); - transport.abort(); + socket.close(CLOSE_NORMAL, "protocol violation"); } private void failAll(LibsqlException error) { @@ -350,352 +393,56 @@ private void failAll(LibsqlException error) { } /** - * A minimal RFC 6455 client connection: the opening handshake plus frame encoding and decoding. + * Reassembles frames into whole messages and routes them to {@link #dispatch(byte[])}. * - *

A reader thread routes complete text/binary messages to {@link #dispatch(String)} and - * fails the pending futures when the connection ends. It is a virtual thread, so an idle - * connection never keeps the JVM alive. + *

The buffers the client passes in are only valid during the callback, so every chunk is + * copied before it is buffered. A message either arrives whole or in fragments ending with + * {@code last}, and the fragments of one message never interleave with another. */ - private final class Transport { - - private Socket socket; - private InputStream in; - private OutputStream out; - private final Object writeLock = new Object(); - - private volatile boolean open = true; - - private Transport(URI uri, Duration timeout, String subprotocol) { - Socket connected = null; - try { - connected = connect(uri, timeout); - this.socket = connected; - this.in = connected.getInputStream(); - this.out = connected.getOutputStream(); - handshake(uri, in, out, timeout, subprotocol); - } catch (IOException e) { - closeQuietly(connected); - throw new LibsqlException( - "Failed to open Hrana WebSocket to " + uri + ": " + e.getMessage(), e); - } - Thread.ofVirtual().name("hrana-ws-reader").start(this::readLoop); - } - - private void send(byte[] payload, boolean binary) { - sendFrame(binary ? OP_BINARY : OP_TEXT, payload); - } + private final class HranaListener implements WebSocketListener { - private void close(int statusCode, String reason) { - if (open) { - try { - sendFrame(OP_CLOSE, closePayload(statusCode, reason)); - } catch (LibsqlException e) { - // The peer is already gone; closing the socket below is enough. - } - } - open = false; - failAll(new LibsqlException( - "Hrana WebSocket closed with code " + statusCode, "WS_CLOSED", 0)); - closeQuietly(socket); - } - - private void abort() { - open = false; - closeQuietly(socket); - } + private final StringBuilder text = new StringBuilder(); + private ByteArrayOutputStream binary; - private void sendFrame(int opcode, byte[] payload) { - synchronized (writeLock) { - if (!open) { - throw new LibsqlException("Hrana WebSocket is closed", "WS_CLOSED", 0); - } - try { - out.write(0x80 | opcode); - int length = payload.length; - if (length <= 125) { - out.write(0x80 | length); - } else if (length <= 0xFFFF) { - out.write(0x80 | 126); - out.write(length >>> 8); - out.write(length); - } else { - out.write(0x80 | 127); - for (int shift = 56; shift >= 0; shift -= 8) { - out.write((int) ((long) length >>> shift) & 0xFF); - } - } - byte[] mask = new byte[4]; - ThreadLocalRandom.current().nextBytes(mask); - out.write(mask); - for (int i = 0; i < length; i++) { - out.write(payload[i] ^ mask[i & 3]); - } - out.flush(); - } catch (IOException e) { - open = false; - throw new LibsqlException( - "Failed to send Hrana message: " + e.getMessage(), e); - } + @Override + public void onText(CharBuffer data, boolean last) { + text.append(data); + if (!last) { + return; } + String message = text.toString(); + text.setLength(0); + dispatch(message.getBytes(StandardCharsets.UTF_8)); } - private void readLoop() { - ByteArrayOutputStream fragments = new ByteArrayOutputStream(); - int fragmentedOpcode = -1; - try { - while (open) { - int first = in.read(); - if (first < 0) { - break; - } - int second = readByte(in); - boolean fin = (first & 0x80) != 0; - int opcode = first & 0x0F; - if ((second & 0x80) != 0) { - protocolViolation("server sent a masked frame"); - return; - } - long length = second & 0x7F; - if (length == 126) { - length = (readByte(in) << 8) | readByte(in); - } else if (length == 127) { - length = 0; - for (int i = 0; i < 8; i++) { - length = (length << 8) | readByte(in); - } - } - if (length < 0 || length > MAX_FRAME_BYTES) { - protocolViolation("frame is too large: " + length + " bytes"); - return; - } - byte[] payload = in.readNBytes((int) length); - if (payload.length != length) { - break; - } - - switch (opcode) { - case OP_TEXT, OP_BINARY -> { - if (fragmentedOpcode != -1) { - protocolViolation("new message before the fragmented one finished"); - return; - } - if (fin) { - deliver(opcode, payload); - } else { - fragmentedOpcode = opcode; - fragments.write(payload); - } - } - case OP_CONTINUATION -> { - if (fragmentedOpcode == -1) { - protocolViolation("continuation without a started message"); - return; - } - fragments.write(payload); - if (fin) { - byte[] complete = fragments.toByteArray(); - int messageOpcode = fragmentedOpcode; - fragments.reset(); - fragmentedOpcode = -1; - deliver(messageOpcode, complete); - } - } - case OP_PING -> sendFrame(OP_PONG, payload); - case OP_PONG -> { - // Unsolicited pongs are allowed and need no answer. - } - case OP_CLOSE -> { - handlePeerClose(payload); - return; - } - default -> { - protocolViolation("unknown frame opcode 0x" + Integer.toHexString(opcode)); - return; - } - } - } - if (open) { - connectionLost("connection closed by the server without a close frame"); - } - } catch (IOException e) { - if (open) { - connectionLost("Hrana WebSocket connection failed: " + e.getMessage()); - } - } finally { - open = false; - closeQuietly(socket); + @Override + public void onBinary(ByteBuffer data, boolean last) { + if (binary == null) { + binary = new ByteArrayOutputStream(); } - } - - private void deliver(int opcode, byte[] payload) { - boolean expectsBinary = codec.isBinary(); - if ((opcode == OP_BINARY) != expectsBinary) { - protocolViolation("frame type does not match the negotiated encoding"); + byte[] chunk = new byte[data.remaining()]; + data.get(chunk); + binary.write(chunk, 0, chunk.length); + if (!last) { return; } - dispatch(payload); + byte[] message = binary.toByteArray(); + binary = null; + dispatch(message); } - private void handlePeerClose(byte[] payload) { - int statusCode = payload.length >= 2 - ? ((payload[0] & 0xFF) << 8) | (payload[1] & 0xFF) - : CLOSE_NORMAL; - String reason = payload.length > 2 - ? new String(payload, 2, payload.length - 2, StandardCharsets.UTF_8) - : ""; - try { - // Echo the close frame while the connection is still writable. - sendFrame(OP_CLOSE, closePayload(statusCode, "")); - } catch (LibsqlException e) { - // The peer closed; the socket is closed by the reader's finally block. - } - open = false; - connectionLost(reason.isBlank() - ? "Hrana WebSocket closed with code " + statusCode - : "Hrana WebSocket closed with code " + statusCode + ": " + reason); + @Override + public void onError(Throwable cause) { + failAll(new LibsqlException( + "Hrana WebSocket connection failed: " + cause.getMessage(), cause)); } - private void connectionLost(String detail) { + @Override + public void onClose(int statusCode, String reason) { + String detail = reason == null || reason.isBlank() + ? "Hrana WebSocket closed with code " + statusCode + : "Hrana WebSocket closed with code " + statusCode + ": " + reason; failAll(new LibsqlException(detail, "WS_CLOSED", 0)); } } - - private static Socket connect(URI uri, Duration timeout) throws IOException { - boolean tls = "wss".equalsIgnoreCase(uri.getScheme()); - String host = uri.getHost(); - if (host == null) { - throw new IOException("WebSocket URL has no host: " + uri); - } - int port = uri.getPort() > 0 ? uri.getPort() : (tls ? 443 : 80); - - Socket socket = new Socket(); - try { - int connectTimeout = (int) Math.min(Integer.MAX_VALUE, Math.max(0, timeout.toMillis())); - socket.connect(new InetSocketAddress(host, port), connectTimeout); - socket.setTcpNoDelay(true); - if (!tls) { - return socket; - } - SSLSocketFactory factory = (SSLSocketFactory) SSLSocketFactory.getDefault(); - SSLSocket ssl = (SSLSocket) factory.createSocket(socket, host, port, true); - SSLParameters parameters = ssl.getSSLParameters(); - parameters.setEndpointIdentificationAlgorithm("HTTPS"); - ssl.setSSLParameters(parameters); - ssl.startHandshake(); - return ssl; - } catch (IOException e) { - closeQuietly(socket); - throw e; - } - } - - private static void handshake(URI uri, InputStream in, OutputStream out, Duration timeout, - String subprotocol) throws IOException { - String key = createKey(); - String expectedAccept = acceptKey(key); - String path = uri.getRawPath() == null || uri.getRawPath().isEmpty() ? "/" : uri.getRawPath(); - if (uri.getRawQuery() != null && !uri.getRawQuery().isEmpty()) { - path = path + "?" + uri.getRawQuery(); - } - String host = uri.getHost(); - int port = uri.getPort(); - String hostHeader = port > 0 ? host + ":" + port : host; - - StringBuilder request = new StringBuilder(256); - request.append("GET ").append(path).append(" HTTP/1.1\r\n") - .append("Host: ").append(hostHeader).append("\r\n") - .append("Upgrade: websocket\r\n") - .append("Connection: Upgrade\r\n") - .append("Sec-WebSocket-Key: ").append(key).append("\r\n") - .append("Sec-WebSocket-Version: 13\r\n") - .append("Sec-WebSocket-Protocol: ").append(subprotocol).append("\r\n") - .append("\r\n"); - out.write(request.toString().getBytes(StandardCharsets.US_ASCII)); - out.flush(); - - String statusLine = readLine(in); - if (statusLine == null || !statusLine.contains(" 101 ")) { - throw new IOException("WebSocket upgrade rejected: " + statusLine); - } - Map headers = new HashMap<>(); - String line; - while ((line = readLine(in)) != null && !line.isEmpty()) { - int colon = line.indexOf(':'); - if (colon > 0) { - headers.put(line.substring(0, colon).trim().toLowerCase(Locale.ROOT), - line.substring(colon + 1).trim()); - } - } - if (!"websocket".equalsIgnoreCase(headers.get("upgrade"))) { - throw new IOException("WebSocket upgrade response missing Upgrade: websocket"); - } - if (!expectedAccept.equals(headers.get("sec-websocket-accept"))) { - throw new IOException("WebSocket upgrade response has an invalid Sec-WebSocket-Accept"); - } - String negotiated = headers.get("sec-websocket-protocol"); - if (!subprotocol.equals(negotiated)) { - throw new IOException("Server did not negotiate subprotocol " + subprotocol - + " (got " + negotiated + ")"); - } - } - - private static String createKey() { - byte[] nonce = new byte[16]; - new SecureRandom().nextBytes(nonce); - return Base64.getEncoder().encodeToString(nonce); - } - - private static String acceptKey(String key) { - try { - MessageDigest sha1 = MessageDigest.getInstance("SHA-1"); - byte[] digest = sha1.digest((key + WEBSOCKET_GUID).getBytes(StandardCharsets.US_ASCII)); - return Base64.getEncoder().encodeToString(digest); - } catch (NoSuchAlgorithmException e) { - throw new IllegalStateException("SHA-1 is required by the WebSocket protocol", e); - } - } - - private static byte[] closePayload(int statusCode, String reason) { - byte[] reasonBytes = reason == null ? new byte[0] : reason.getBytes(StandardCharsets.UTF_8); - byte[] payload = new byte[2 + reasonBytes.length]; - payload[0] = (byte) (statusCode >>> 8); - payload[1] = (byte) statusCode; - System.arraycopy(reasonBytes, 0, payload, 2, reasonBytes.length); - return payload; - } - - private static String readLine(InputStream in) throws IOException { - StringBuilder line = new StringBuilder(); - int b; - while ((b = in.read()) >= 0) { - if (b == '\n') { - int length = line.length(); - if (length > 0 && line.charAt(length - 1) == '\r') { - line.setLength(length - 1); - } - return line.toString(); - } - line.append((char) b); - } - return line.isEmpty() ? null : line.toString(); - } - - private static int readByte(InputStream in) throws IOException { - int b = in.read(); - if (b < 0) { - throw new IOException("Unexpected end of stream"); - } - return b; - } - - private static void closeQuietly(Socket socket) { - if (socket == null) { - return; - } - try { - socket.close(); - } catch (IOException ignored) { - // Nothing useful can be done with a failure to close. - } - } } diff --git a/libsql-itests/pom.xml b/libsql-itests/pom.xml index 737baad..6391406 100644 --- a/libsql-itests/pom.xml +++ b/libsql-itests/pom.xml @@ -60,6 +60,15 @@ org.apache.maven.plugins maven-failsafe-plugin + + + + + ${project.build.testOutputDirectory}/tls/truststore.p12 + + changeit + + diff --git a/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/SqldContainerBase.java b/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/SqldContainerBase.java index 6025e8e..6042057 100644 --- a/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/SqldContainerBase.java +++ b/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/SqldContainerBase.java @@ -1,10 +1,12 @@ package com.github.magnusp.libsql.itest; -import org.junit.jupiter.api.BeforeAll; import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.Network; import org.testcontainers.containers.wait.strategy.Wait; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; import java.time.Duration; @@ -12,7 +14,11 @@ public abstract class SqldContainerBase { static final String LIBSQL_IMAGE = requireLibsqlImage(); + private static final String NGINX_IMAGE = "nginx:1.27-alpine"; private static final int PORT = 8080; + private static final int TLS_PORT = 8443; + + private static final Network NETWORK = Network.newNetwork(); private static String requireLibsqlImage() { String image = System.getProperty("libsql.image"); @@ -26,6 +32,8 @@ private static String requireLibsqlImage() { @Container public static final GenericContainer LIBSQL_CONTAINER = new GenericContainer<>(LIBSQL_IMAGE) + .withNetwork(NETWORK) + .withNetworkAliases("sqld") .withExposedPorts(PORT) .withEnv("SQLD_NODE", "primary") .withEnv("SQLD_DISABLE_INTELLIGENT_THROTTLING", "true") @@ -35,18 +43,35 @@ private static String requireLibsqlImage() { .forStatusCode(405) .withStartupTimeout(Duration.ofSeconds(60))); - @BeforeAll - public static void startContainer() { + /** + * A TLS terminator in front of sqld, so the {@code wss://} tests exercise a real handshake. + * Started on demand: only the WebSocket tests need it. + */ + private static final GenericContainer TLS_PROXY = new GenericContainer<>(DockerImageName.parse(NGINX_IMAGE)) + .withNetwork(NETWORK) + .withExposedPorts(TLS_PORT) + .withCopyFileToContainer(MountableFile.forClasspathResource("tls/nginx.conf"), "/etc/nginx/nginx.conf") + .withCopyFileToContainer( + MountableFile.forClasspathResource("tls/server.crt.pem"), "/etc/nginx/tls/server.crt.pem") + .withCopyFileToContainer( + MountableFile.forClasspathResource("tls/server.key.pem"), "/etc/nginx/tls/server.key.pem") + .waitingFor(Wait.forListeningPort().withStartupTimeout(Duration.ofSeconds(60))); + + static String getWebSocketUrl() { + return "ws://" + LIBSQL_CONTAINER.getHost() + ":" + LIBSQL_CONTAINER.getMappedPort(PORT); + } + + static synchronized String getSecureWebSocketUrl() { if (!LIBSQL_CONTAINER.isRunning()) { LIBSQL_CONTAINER.start(); } + if (!TLS_PROXY.isRunning()) { + TLS_PROXY.start(); + } + return "wss://" + TLS_PROXY.getHost() + ":" + TLS_PROXY.getMappedPort(TLS_PORT); } - public static String getHttpUrl() { + static String getHttpUrl() { return "http://" + LIBSQL_CONTAINER.getHost() + ":" + LIBSQL_CONTAINER.getMappedPort(PORT); } - - public static String getWebSocketUrl() { - return "ws://" + LIBSQL_CONTAINER.getHost() + ":" + LIBSQL_CONTAINER.getMappedPort(PORT); - } } diff --git a/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/WebSocketProtobufTransportIT.java b/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/WebSocketProtobufTransportIT.java index 1d82ff3..2689554 100644 --- a/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/WebSocketProtobufTransportIT.java +++ b/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/WebSocketProtobufTransportIT.java @@ -1,30 +1,38 @@ package com.github.magnusp.libsql.itest; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; import java.sql.Connection; import java.sql.DriverManager; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; +import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; /** * Drives the JDBC driver over Hrana 3 Protobuf on a WebSocket ({@code hrana3-protobuf} - * subprotocol). The observable JDBC behavior must match the JSON transport. + * subprotocol). The observable JDBC behavior must match the JSON transport, over {@code ws://} and + * {@code wss://} alike. */ public class WebSocketProtobufTransportIT extends SqldContainerBase { - private static Connection connect() throws SQLException { - return DriverManager.getConnection( - "jdbc:libsql:" + getWebSocketUrl() + "?encoding=protobuf"); + static Stream schemes() { + return Stream.of("ws", "wss"); } - @Test - void selectReturnsTypedValuesIncluding64BitIntegerAndBlob() throws SQLException { - try (Connection connection = connect(); + private static Connection connect(String scheme) throws SQLException { + String url = "wss".equals(scheme) ? getSecureWebSocketUrl() : getWebSocketUrl(); + return DriverManager.getConnection("jdbc:libsql:" + url + "?encoding=protobuf"); + } + + @ParameterizedTest + @MethodSource("schemes") + void selectReturnsTypedValuesIncluding64BitIntegerAndBlob(String scheme) throws SQLException { + try (Connection connection = connect(scheme); Statement statement = connection.createStatement(); ResultSet resultSet = statement.executeQuery( "SELECT 9007199254740993 AS big, -9223372036854775808 AS min," @@ -40,28 +48,30 @@ void selectReturnsTypedValuesIncluding64BitIntegerAndBlob() throws SQLException } } - @Test - void transactionCommitsAndRollsBackOnOneStream() throws SQLException { - try (Connection connection = connect()) { + @ParameterizedTest + @MethodSource("schemes") + void transactionCommitsAndRollsBackOnOneStream(String scheme) throws SQLException { + try (Connection connection = connect(scheme)) { try (Statement statement = connection.createStatement()) { - statement.execute("CREATE TABLE ws_proto_tx (id INTEGER PRIMARY KEY, name TEXT)"); + statement.execute( + "CREATE TABLE ws_proto_tx_" + scheme + " (id INTEGER PRIMARY KEY, name TEXT)"); } connection.setAutoCommit(false); try (Statement statement = connection.createStatement()) { - statement.execute("INSERT INTO ws_proto_tx VALUES (1, 'committed')"); + statement.execute("INSERT INTO ws_proto_tx_" + scheme + " VALUES (1, 'committed')"); } connection.commit(); try (Statement statement = connection.createStatement()) { - statement.execute("INSERT INTO ws_proto_tx VALUES (2, 'rolled back')"); + statement.execute("INSERT INTO ws_proto_tx_" + scheme + " VALUES (2, 'rolled back')"); } connection.rollback(); connection.setAutoCommit(true); try (Statement statement = connection.createStatement(); ResultSet resultSet = statement.executeQuery( - "SELECT id, name FROM ws_proto_tx ORDER BY id")) { + "SELECT id, name FROM ws_proto_tx_" + scheme + " ORDER BY id")) { assertThat(resultSet.next()).isTrue(); assertThat(resultSet.getInt(1)).isEqualTo(1); assertThat(resultSet.getString(2)).isEqualTo("committed"); @@ -70,9 +80,10 @@ void transactionCommitsAndRollsBackOnOneStream() throws SQLException { } } - @Test - void failingStatementMapsToSqlExceptionAndLeavesTheConnectionUsable() throws SQLException { - try (Connection connection = connect(); Statement statement = connection.createStatement()) { + @ParameterizedTest + @MethodSource("schemes") + void failingStatementMapsToSqlExceptionAndLeavesTheConnectionUsable(String scheme) throws SQLException { + try (Connection connection = connect(scheme); Statement statement = connection.createStatement()) { assertThatThrownBy(() -> statement.executeQuery("SELECT * FROM ws_proto_missing_table")) .isInstanceOf(SQLException.class) .hasMessageContaining("no such table"); diff --git a/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/WebSocketTransportIT.java b/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/WebSocketTransportIT.java index bd60e25..d4bedb9 100644 --- a/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/WebSocketTransportIT.java +++ b/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/WebSocketTransportIT.java @@ -4,6 +4,8 @@ import com.github.magnusp.libsql.jdbc.LibsqlUrlParser; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; import java.sql.Connection; import java.sql.DatabaseMetaData; @@ -12,6 +14,7 @@ import java.sql.SQLException; import java.sql.Statement; import java.util.Properties; +import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -19,16 +22,26 @@ /** * Drives the JDBC driver over Hrana 3 JSON on a WebSocket ({@code hrana3} subprotocol), which is * the same server and the same protocol structures as the HTTP transport but a different envelope. + * Every case runs over plain {@code ws://} and over {@code wss://} through a TLS terminator. */ public class WebSocketTransportIT extends SqldContainerBase { - private static Connection connect() throws SQLException { - return DriverManager.getConnection("jdbc:libsql:" + getWebSocketUrl()); + static Stream schemes() { + return Stream.of("ws", "wss"); } - @Test - void selectReturnsTypedValues() throws SQLException { - try (Connection connection = connect(); + private static String url(String scheme) { + return "wss".equals(scheme) ? getSecureWebSocketUrl() : getWebSocketUrl(); + } + + private static Connection connect(String scheme) throws SQLException { + return DriverManager.getConnection("jdbc:libsql:" + url(scheme)); + } + + @ParameterizedTest + @MethodSource("schemes") + void selectReturnsTypedValues(String scheme) throws SQLException { + try (Connection connection = connect(scheme); Statement statement = connection.createStatement(); ResultSet resultSet = statement.executeQuery( "SELECT 1 AS i, 1.5 AS f, 'hello' AS t, x'010203' AS b, NULL AS n")) { @@ -42,27 +55,30 @@ void selectReturnsTypedValues() throws SQLException { } } - @Test - void transactionCommitsAndRollsBackOnOneStream() throws SQLException { - try (Connection connection = connect()) { + @ParameterizedTest + @MethodSource("schemes") + void transactionCommitsAndRollsBackOnOneStream(String scheme) throws SQLException { + try (Connection connection = connect(scheme)) { try (Statement statement = connection.createStatement()) { - statement.execute("CREATE TABLE ws_tx (id INTEGER PRIMARY KEY, name TEXT)"); + statement.execute( + "CREATE TABLE ws_tx_" + scheme + " (id INTEGER PRIMARY KEY, name TEXT)"); } connection.setAutoCommit(false); try (Statement statement = connection.createStatement()) { - statement.execute("INSERT INTO ws_tx VALUES (1, 'committed')"); + statement.execute("INSERT INTO ws_tx_" + scheme + " VALUES (1, 'committed')"); } connection.commit(); try (Statement statement = connection.createStatement()) { - statement.execute("INSERT INTO ws_tx VALUES (2, 'rolled back')"); + statement.execute("INSERT INTO ws_tx_" + scheme + " VALUES (2, 'rolled back')"); } connection.rollback(); connection.setAutoCommit(true); try (Statement statement = connection.createStatement(); - ResultSet resultSet = statement.executeQuery("SELECT id, name FROM ws_tx ORDER BY id")) { + ResultSet resultSet = statement.executeQuery( + "SELECT id, name FROM ws_tx_" + scheme + " ORDER BY id")) { assertThat(resultSet.next()).isTrue(); assertThat(resultSet.getInt(1)).isEqualTo(1); assertThat(resultSet.getString(2)).isEqualTo("committed"); @@ -71,9 +87,10 @@ void transactionCommitsAndRollsBackOnOneStream() throws SQLException { } } - @Test - void failingStatementMapsToSqlExceptionAndLeavesTheConnectionUsable() throws SQLException { - try (Connection connection = connect(); Statement statement = connection.createStatement()) { + @ParameterizedTest + @MethodSource("schemes") + void failingStatementMapsToSqlExceptionAndLeavesTheConnectionUsable(String scheme) throws SQLException { + try (Connection connection = connect(scheme); Statement statement = connection.createStatement()) { assertThatThrownBy(() -> statement.executeQuery("SELECT * FROM ws_missing_table")) .isInstanceOf(SQLException.class) .hasMessageContaining("no such table"); @@ -85,22 +102,25 @@ void failingStatementMapsToSqlExceptionAndLeavesTheConnectionUsable() throws SQL } } - @Test - void metadataAndBatchWorkOverWebSocket() throws SQLException { - try (Connection connection = connect()) { + @ParameterizedTest + @MethodSource("schemes") + void metadataAndBatchWorkOverWebSocket(String scheme) throws SQLException { + try (Connection connection = connect(scheme)) { DatabaseMetaData metaData = connection.getMetaData(); assertThat(metaData.getDatabaseProductName()).isEqualTo("SQLite (libSQL)"); - assertThat(metaData.getURL()).isEqualTo(getWebSocketUrl()); + assertThat(metaData.getURL()).isEqualTo(url(scheme)); try (Statement statement = connection.createStatement()) { - statement.execute("CREATE TABLE ws_batch (id INTEGER PRIMARY KEY, name TEXT)"); - statement.addBatch("INSERT INTO ws_batch VALUES (1, 'a')"); - statement.addBatch("INSERT INTO ws_batch VALUES (2, 'b')"); + statement.execute( + "CREATE TABLE ws_batch_" + scheme + " (id INTEGER PRIMARY KEY, name TEXT)"); + statement.addBatch("INSERT INTO ws_batch_" + scheme + " VALUES (1, 'a')"); + statement.addBatch("INSERT INTO ws_batch_" + scheme + " VALUES (2, 'b')"); assertThat(statement.executeBatch()).containsExactly(1, 1); } try (Statement statement = connection.createStatement(); - ResultSet resultSet = statement.executeQuery("SELECT COUNT(*) FROM ws_batch")) { + ResultSet resultSet = statement.executeQuery( + "SELECT COUNT(*) FROM ws_batch_" + scheme)) { assertThat(resultSet.next()).isTrue(); assertThat(resultSet.getInt(1)).isEqualTo(2); } diff --git a/libsql-itests/src/test/resources/tls/README.md b/libsql-itests/src/test/resources/tls/README.md new file mode 100644 index 0000000..f3cd231 --- /dev/null +++ b/libsql-itests/src/test/resources/tls/README.md @@ -0,0 +1,19 @@ +# TLS fixtures for the `wss://` integration tests + +`server.crt.pem`, `server.key.pem`, and `truststore.p12` belong to a self-signed +certificate for `localhost` and `127.0.0.1` that exists only so the test suite can +put a TLS terminator in front of sqld. The key is public and worthless. + +Regenerate them when they expire (2036-10-21) with: + +```bash +openssl req -x509 -newkey rsa:2048 -sha256 -days 3650 -nodes \ + -keyout server.key.pem -out server.crt.pem \ + -subj "/CN=localhost" -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" +openssl pkcs12 -export -in server.crt.pem -inkey server.key.pem \ + -name libsql-test-tls -out truststore.p12 -passout pass:changeit +``` + +`libsql-itests/pom.xml` points the failsafe JVM at `truststore.p12` through +`javax.net.ssl.trustStore`, so the driver trusts this certificate and nothing else +during the integration tests. diff --git a/libsql-itests/src/test/resources/tls/nginx.conf b/libsql-itests/src/test/resources/tls/nginx.conf new file mode 100644 index 0000000..4f2b4e1 --- /dev/null +++ b/libsql-itests/src/test/resources/tls/nginx.conf @@ -0,0 +1,28 @@ +worker_processes 1; + +events { + worker_connections 128; +} + +http { + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + + server { + listen 8443 ssl; + ssl_certificate /etc/nginx/tls/server.crt.pem; + ssl_certificate_key /etc/nginx/tls/server.key.pem; + + location / { + proxy_pass http://sqld:8080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } + } +} diff --git a/libsql-itests/src/test/resources/tls/server.crt.pem b/libsql-itests/src/test/resources/tls/server.crt.pem new file mode 100644 index 0000000..c183ecc --- /dev/null +++ b/libsql-itests/src/test/resources/tls/server.crt.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDJTCCAg2gAwIBAgIUAWaPySVWG8RjRqRpm4F4ksenO8cwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MTAyNDA3MzYzOVoXDTM2MTAy +MTA3MzYzOVowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEA06oL0ySDw5sHes8jRkiFF17/MeLMC96wrmm2p524z8uq +XOAAIQyVvboobvID6JvLnGUMFnrGJf9aTjsPYora4vvWNyz7ShcgsLVNrv1yMugv ++As+gr8UcKFR19Kf4AViMt8u+MsxwQl+QACy1+MqctxBzlFJfNa3hUTgp+5aowJC +DDtplhBICbBiBNtgPxEvQGp+yZEGxLrETA6JspsBgzszyuFMtwa9hiTmHdFsCHIl +JFo1qim2Y1kXWYQ4iQ+BBQf7cOMXazm9xz17HcXBWXRR5Azzmul1+tCMFiUZXAua +xLn8nqzUDiScYIuViKxY+kRo7rRKEJ6/cbdKy8IAhQIDAQABo28wbTAdBgNVHQ4E +FgQUYhIs3fdvgMr2UkvOB9jKCAgP2LEwHwYDVR0jBBgwFoAUYhIs3fdvgMr2UkvO +B9jKCAgP2LEwDwYDVR0TAQH/BAUwAwEB/zAaBgNVHREEEzARgglsb2NhbGhvc3SH +BH8AAAEwDQYJKoZIhvcNAQELBQADggEBAKROksqonPMSnQ1HL3jLnxZmpgjdagZ1 +E2O+kpPillH84+HU1zrV8bhdif59GRC58cxVyep6V8NUS49pScyaB1B8mv+MLDGq +dukuX6qTNIOxXyFmvKDH6xqQEBAjrws/VeK94tjpmoHLUtC4/+LACwrxK6js9RnE +S40m5Qc17f05mE2Rhi8NpHJc6w9g/vd54BQsvMpxqT+oTaayFjos/Vc+VqcGZAzI +nCp8cpazDbaUQSm/RKSuwZFPEi5Yj3xte0Jv9V1MhOrrqXX42jt18oME3Ql2xPlE +RhsfmolCB3DmLQB100X6UZLJlT1pd6q17qsUzzDNTNfHCduI59s0eXE= +-----END CERTIFICATE----- diff --git a/libsql-itests/src/test/resources/tls/server.key.pem b/libsql-itests/src/test/resources/tls/server.key.pem new file mode 100644 index 0000000..1c404b6 --- /dev/null +++ b/libsql-itests/src/test/resources/tls/server.key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDTqgvTJIPDmwd6 +zyNGSIUXXv8x4swL3rCuabannbjPy6pc4AAhDJW9uihu8gPom8ucZQwWesYl/1pO +Ow9iitri+9Y3LPtKFyCwtU2u/XIy6C/4Cz6CvxRwoVHX0p/gBWIy3y74yzHBCX5A +ALLX4ypy3EHOUUl81reFROCn7lqjAkIMO2mWEEgJsGIE22A/ES9Aan7JkQbEusRM +DomymwGDOzPK4Uy3Br2GJOYd0WwIciUkWjWqKbZjWRdZhDiJD4EFB/tw4xdrOb3H +PXsdxcFZdFHkDPOa6XX60IwWJRlcC5rEufyerNQOJJxgi5WIrFj6RGjutEoQnr9x +t0rLwgCFAgMBAAECggEAPkKnLsjvX0LDXPsAD8T2LS062KQzTAyJoQwkd8J7odfx +OVeEgjQu+bt/+D6FKs9lAZBxkU006ullWVyjBmP8xca5OMqo8h7CdWgnPSF9DX9h +2M8J6LCnJQg2yErCz0UWIQuoR/ORI4yUz+PEt58xFc83Jiaf/LI2EmPzx0Hj9E2F +EjgFIKthb8KU86B2zMMjgoajT/QY7CQ8YTMkPvsiqaAAUWk/6TFUVD+4piKnQQ9W +Lct8iZI7CuKn7Pd51g0BvTscTSojfjeMMRwZKeMx/JRoleLFT6K+2Vk4QWZOCUMD +XFzGKxs7Sol4piM6THAFQ/08cbgNB6zsjxw4aPlRswKBgQD2Mfwh/tuMlllbymIx +8XBp6wL0qfTjDK3UaNs/B9lOFFdXkxhqGQUl3TyghIpY6BqAz2YX5h9UIsY1o+Zx +tR1DvuzpF/+VcKzffdDvBYaCtXPjPjLszLuib/5G7yjQQhBjceJSQ73j8soizexA +tSSJvHerlTy7OcGw4br5eh/lhwKBgQDcGAKPZEIYzG1AdQ7Z3iHYoHq8z50wtcH3 +T80811hMO6DOE3834WeAMzpyvQrmKjwLIOUURtKKgGZv2qKG2+3wg4vh7qvsXwYv +vho+E5TC3P44DQzkWIGub/0fV+Ml2y/89fm6QKyMGauIKmy0BseF4lDQaA+/O/nZ +0lBsr5IskwKBgQDrgQch8exMwKm8D439HmsdNsz+A/IYm1FRRfisg7KQaQ7f2/rX +yxVMkiRjnEXHJEft/fc4V06Sqi0WtLFFBi53VpTDqMw5RK5pn64UZyGzuSWhNCd8 +s5+tlBoV+sL87KVGq3MGpHxqKB7KSld70ougIiQLg1ez9Vzd+iBkczk90wKBgCZD +vl4zrhIlqHDmaRiTuAqzi+M7rTaEwx4+L45+WgmW7FQnq5yJgbcWMPk1yKlPenTs +2Sb3US9nN2gG+O69WdbQ6Ab1uco0kxzFzkYwnVJrnX8dNbqVcKZlLfq/CuzP+L3f +/fTVvan15I6d4ZxC+XlbaXVyQhqbUMr/95+NvrolAoGAQxz92susMxl8ImTgABxJ +lkctHT9g75RRBN8m1egwrjrSyoTHU/KGvHhjtXJKH3btFZDxICmCgykdnFN0PD8Q +QrRvDqO6wFlIM1R83c8acr3ws4Ff/2c1eEgOBakpD/UrWyrufXRDe0p1aKPTy+zb +ClDi/Q5BnPXM+FkDXttytK4= +-----END PRIVATE KEY----- diff --git a/libsql-itests/src/test/resources/tls/truststore.p12 b/libsql-itests/src/test/resources/tls/truststore.p12 new file mode 100644 index 0000000..b84eb31 Binary files /dev/null and b/libsql-itests/src/test/resources/tls/truststore.p12 differ diff --git a/pom.xml b/pom.xml index 00219a1..1a50741 100644 --- a/pom.xml +++ b/pom.xml @@ -46,6 +46,8 @@ 2.0.5 7.1.0 4.36.2 + + 5.7-beta1 ghcr.io/tursodatabase/libsql-server:v0.24.33 @@ -76,6 +78,13 @@ ${protobuf.version} + + + org.apache.httpcomponents.client5 + httpclient5-websocket + ${httpclient5.websocket.version} + + com.fasterxml.jackson.core