This is an automated email from the ASF dual-hosted git repository. Croway pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/camel-spring-boot.git
commit 8aa45d0444316dc72735c6897287b51a5b43e84f Author: croway <[email protected]> AuthorDate: Thu Oct 1 17:31:27 2026 +0200 CAMEL-25197: camel-cli-connector-starter - prod profile guard, connect deadline From the review of the Camel Quarkus client (apache/camel-quarkus#9264), applied to Spring Boot: - The websocket transport refuses to start when the Spring prod profile is active. Camel only refuses the Camel prod profile (camel.main.profile), which Spring profiles do not set, so a packaged application with camel.cli.transport=websocket left in application.properties started it. The docs now set it in a development profile. - connect() always completes: Tomcat times out each step of the handshake (connect, TLS, upgrade request and response) after 10 s, and the returned stage now also has an overall deadline, for Jakarta WebSocket implementations that ignore Tomcat's options. A connection opened after the deadline is closed. - Tests: a tool that accepts the connection but never answers the upgrade, and a 1 MB message sent by the tool in a single unfragmented frame (Tomcat, the tool of the other tests, fragments what it sends). Co-Authored-By: Claude Opus 5.5 <[email protected]> --- .../modules/ROOT/pages/starters/cli-connector.adoc | 4 +- .../src/main/doc/usage.adoc | 4 +- .../cli/connector/SpringCliWebSocketClient.java | 35 +++++++---- .../cli/connector/SpringLocalCliConnector.java | 15 +++++ ...t.java => CliConnectorSpringLifecycleTest.java} | 24 +++++++- .../connector/SpringCliWebSocketClientTest.java | 71 ++++++++++++++++++++++ 6 files changed, 138 insertions(+), 15 deletions(-) diff --git a/docs/spring-boot/modules/ROOT/pages/starters/cli-connector.adoc b/docs/spring-boot/modules/ROOT/pages/starters/cli-connector.adoc index 8214a543820..d10f8f761a5 100644 --- a/docs/spring-boot/modules/ROOT/pages/starters/cli-connector.adoc +++ b/docs/spring-boot/modules/ROOT/pages/starters/cli-connector.adoc @@ -30,7 +30,7 @@ The `stop` action of the tooling (for example `camel stop`) closes the Spring ap === WebSocket transport -With `camel.cli.transport = websocket`, the application connects to the tool at `camel.cli.websocket.url`. See the CLI Connector documentation of Camel for the protocol and the security rules: *the tool gets full control of the application*, the transport is for development only and is refused with the `prod` profile. +With `camel.cli.transport = websocket`, the application connects to the tool at `camel.cli.websocket.url`. See the CLI Connector documentation of Camel for the protocol and the security rules: *the tool gets full control of the application*, so the transport is for development only. Set it in a development profile, for example in `application-dev.properties`: [source,properties] ---- @@ -38,6 +38,8 @@ camel.cli.transport = websocket camel.cli.websocket.url = ws://127.0.0.1:8000/connect ---- +The transport refuses to start when the Spring `prod` profile is active, as with the Camel `prod` profile (`camel.main.profile = prod`). + The WebSocket client of Spring is used when the application has `spring-websocket` and a Jakarta WebSocket client, for example with `spring-boot-starter-websocket`. Otherwise, the JDK client is used: the starter does not add any dependency for it. The log, and the `transport` field of the `hello` frame sent to the tool, say which client is used (`spring` or `jdk`). Set `camel.cli.websocket.client = jdk` to always use the JDK client. For a `wss://` URL, the Spring client can trust the tool with an SSL bundle: diff --git a/dsl-starter/camel-cli-connector-starter/src/main/doc/usage.adoc b/dsl-starter/camel-cli-connector-starter/src/main/doc/usage.adoc index 149fc023ed1..d5f69619870 100644 --- a/dsl-starter/camel-cli-connector-starter/src/main/doc/usage.adoc +++ b/dsl-starter/camel-cli-connector-starter/src/main/doc/usage.adoc @@ -9,7 +9,7 @@ The `stop` action of the tooling (for example `camel stop`) closes the Spring ap === WebSocket transport -With `camel.cli.transport = websocket`, the application connects to the tool at `camel.cli.websocket.url`. See the CLI Connector documentation of Camel for the protocol and the security rules: *the tool gets full control of the application*, the transport is for development only and is refused with the `prod` profile. +With `camel.cli.transport = websocket`, the application connects to the tool at `camel.cli.websocket.url`. See the CLI Connector documentation of Camel for the protocol and the security rules: *the tool gets full control of the application*, so the transport is for development only. Set it in a development profile, for example in `application-dev.properties`: [source,properties] ---- @@ -17,6 +17,8 @@ camel.cli.transport = websocket camel.cli.websocket.url = ws://127.0.0.1:8000/connect ---- +The transport refuses to start when the Spring `prod` profile is active, as with the Camel `prod` profile (`camel.main.profile = prod`). + The WebSocket client of Spring is used when the application has `spring-websocket` and a Jakarta WebSocket client, for example with `spring-boot-starter-websocket`. Otherwise, the JDK client is used: the starter does not add any dependency for it. The log, and the `transport` field of the `hello` frame sent to the tool, say which client is used (`spring` or `jdk`). Set `camel.cli.websocket.client = jdk` to always use the JDK client. For a `wss://` URL, the Spring client can trust the tool with an SSL bundle: diff --git a/dsl-starter/camel-cli-connector-starter/src/main/java/org/apache/camel/springboot/cli/connector/SpringCliWebSocketClient.java b/dsl-starter/camel-cli-connector-starter/src/main/java/org/apache/camel/springboot/cli/connector/SpringCliWebSocketClient.java index 781a61aca9b..4cfec9ba45c 100644 --- a/dsl-starter/camel-cli-connector-starter/src/main/java/org/apache/camel/springboot/cli/connector/SpringCliWebSocketClient.java +++ b/dsl-starter/camel-cli-connector-starter/src/main/java/org/apache/camel/springboot/cli/connector/SpringCliWebSocketClient.java @@ -24,6 +24,7 @@ import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.CompletionStage; +import java.util.concurrent.TimeUnit; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -60,11 +61,14 @@ public class SpringCliWebSocketClient implements CliWebSocketClient { static final String IO_TIMEOUT_PROPERTY = "org.apache.tomcat.websocket.IO_TIMEOUT_MS"; static final String BLOCKING_SEND_TIMEOUT_PROPERTY = "org.apache.tomcat.websocket.BLOCKING_SEND_TIMEOUT"; private static final long TIMEOUT = 10000; + // each step of the Tomcat handshake has its own timeout: the connection must be open within a few of them + private static final int CONNECT_TIMEOUTS = 3; // Jakarta WebSocket has no status code for a rejected upgrade: Tomcat puts it in the message, as [401] private static final Pattern HTTP_STATUS = Pattern.compile("\\[([1-5][0-9]{2})]"); private final SSLContext sslContext; + private final long timeout; private volatile StandardWebSocketClient client; public SpringCliWebSocketClient() { @@ -75,7 +79,12 @@ public class SpringCliWebSocketClient implements CliWebSocketClient { * @param sslContext for wss:// urls, or null for the default one */ public SpringCliWebSocketClient(SSLContext sslContext) { + this(sslContext, TIMEOUT); + } + + SpringCliWebSocketClient(SSLContext sslContext, long timeout) { this.sslContext = sslContext; + this.timeout = timeout; } @Override @@ -92,18 +101,22 @@ public class SpringCliWebSocketClient implements CliWebSocketClient { WebSocketHttpHeaders handshakeHeaders = new WebSocketHttpHeaders(); headers.forEach(handshakeHeaders::add); Handler handler = new Handler(listener); + CompletableFuture<Channel> answer = new CompletableFuture<>(); try { - return client().execute(handler, handshakeHeaders, url) - .handle((session, e) -> { - if (e != null) { - throw new CompletionException(translate(e)); - } - return handler.channel; - }); + client().execute(handler, handshakeHeaders, url).whenComplete((session, e) -> { + if (e != null) { + answer.completeExceptionally(translate(e)); + } else if (!answer.complete(handler.channel)) { + // opened after the connect timed out: nobody uses it + SpringChannel.abort(session); + } + }); } catch (RuntimeException e) { // no Jakarta WebSocket implementation on the classpath - return CompletableFuture.failedFuture(e); + answer.completeExceptionally(e); } + // the transport only reconnects once this completes: never wait forever, whatever the Jakarta implementation + return answer.orTimeout(timeout * CONNECT_TIMEOUTS, TimeUnit.MILLISECONDS); } /** @@ -119,10 +132,10 @@ public class SpringCliWebSocketClient implements CliWebSocketClient { answer = new StandardWebSocketClient(ContainerProvider.getWebSocketContainer()); answer.setSslContext(sslContext); Map<String, Object> properties = new HashMap<>(); - // the JDK client times out a connect after 10 seconds too - properties.put(IO_TIMEOUT_PROPERTY, Long.toString(TIMEOUT)); + // connecting, the TLS handshake, and each read and write of the HTTP upgrade: as the JDK client + properties.put(IO_TIMEOUT_PROPERTY, Long.toString(timeout)); // pings and close frames are blocking sends - properties.put(BLOCKING_SEND_TIMEOUT_PROPERTY, TIMEOUT); + properties.put(BLOCKING_SEND_TIMEOUT_PROPERTY, timeout); answer.setUserProperties(properties); answer.setTaskExecutor(new SimpleAsyncTaskExecutor("CliConnectorWebSocketConnect-")); client = answer; diff --git a/dsl-starter/camel-cli-connector-starter/src/main/java/org/apache/camel/springboot/cli/connector/SpringLocalCliConnector.java b/dsl-starter/camel-cli-connector-starter/src/main/java/org/apache/camel/springboot/cli/connector/SpringLocalCliConnector.java index 23c63db2843..6488693fa08 100644 --- a/dsl-starter/camel-cli-connector-starter/src/main/java/org/apache/camel/springboot/cli/connector/SpringLocalCliConnector.java +++ b/dsl-starter/camel-cli-connector-starter/src/main/java/org/apache/camel/springboot/cli/connector/SpringLocalCliConnector.java @@ -16,9 +16,11 @@ */ package org.apache.camel.springboot.cli.connector; +import org.apache.camel.cli.connector.CliConnectorTransport; import org.apache.camel.cli.connector.LocalCliConnector; import org.apache.camel.spi.CliConnectorFactory; import org.springframework.context.support.AbstractApplicationContext; +import org.springframework.core.env.Profiles; public class SpringLocalCliConnector extends LocalCliConnector { @@ -30,6 +32,19 @@ public class SpringLocalCliConnector extends LocalCliConnector { this.applicationContext = applicationContext; } + @Override + protected CliConnectorTransport createTransport(String name) { + // Camel refuses it with the Camel prod profile (camel.main.profile), which Spring profiles do not set + if ("websocket".equalsIgnoreCase(name) + && applicationContext.getEnvironment().acceptsProfiles(Profiles.of("prod"))) { + throw new IllegalStateException( + "The Camel CLI connector websocket transport gives the connected tool full control of this" + + " application and cannot be used with the Spring prod profile." + + " Remove camel.cli.transport=websocket, or use another profile."); + } + return super.createTransport(name); + } + @Override public void sigterm() { try { diff --git a/dsl-starter/camel-cli-connector-starter/src/test/java/org/apache/camel/springboot/cli/connector/CliConnectorStopActionTest.java b/dsl-starter/camel-cli-connector-starter/src/test/java/org/apache/camel/springboot/cli/connector/CliConnectorSpringLifecycleTest.java similarity index 67% rename from dsl-starter/camel-cli-connector-starter/src/test/java/org/apache/camel/springboot/cli/connector/CliConnectorStopActionTest.java rename to dsl-starter/camel-cli-connector-starter/src/test/java/org/apache/camel/springboot/cli/connector/CliConnectorSpringLifecycleTest.java index a35aa132bb7..755aa26b56f 100644 --- a/dsl-starter/camel-cli-connector-starter/src/test/java/org/apache/camel/springboot/cli/connector/CliConnectorStopActionTest.java +++ b/dsl-starter/camel-cli-connector-starter/src/test/java/org/apache/camel/springboot/cli/connector/CliConnectorSpringLifecycleTest.java @@ -28,12 +28,13 @@ import org.springframework.boot.builder.SpringApplicationBuilder; import org.springframework.context.ConfigurableApplicationContext; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.awaitility.Awaitility.await; /** - * The stop action closes the Spring application context, so the application exits. + * How the connector stops a Spring Boot application, and refuses to run where it should not. */ -class CliConnectorStopActionTest { +class CliConnectorSpringLifecycleTest { private final ToolServer tool = new ToolServer(); private ConfigurableApplicationContext context; @@ -65,4 +66,23 @@ class CliConnectorStopActionTest { assertThat(tool.awaitResult("r1").getBoolean("ok")).isTrue(); await().atMost(20, TimeUnit.SECONDS).untilAsserted(() -> assertThat(context.isActive()).isFalse()); } + + @Test + void refusesTheWebSocketTransportWithTheSpringProdProfile() throws Exception { + tool.start(); + SpringApplicationBuilder app = new SpringApplicationBuilder( + CamelAutoConfiguration.class, CliConnectorAutoConfiguration.class, + CliConnectorWebSocketTestSupport.Routes.class) + .web(WebApplicationType.NONE) + .profiles("prod") + .properties( + "camel.cli.transport=websocket", + "camel.cli.websocket.url=" + tool.url()); + + assertThatThrownBy(app::run).hasRootCauseMessage( + "The Camel CLI connector websocket transport gives the connected tool full control of this application" + + " and cannot be used with the Spring prod profile." + + " Remove camel.cli.transport=websocket, or use another profile."); + assertThat(tool.sessions).isEmpty(); + } } diff --git a/dsl-starter/camel-cli-connector-starter/src/test/java/org/apache/camel/springboot/cli/connector/SpringCliWebSocketClientTest.java b/dsl-starter/camel-cli-connector-starter/src/test/java/org/apache/camel/springboot/cli/connector/SpringCliWebSocketClientTest.java index b12937ce123..b9e6f59a47c 100644 --- a/dsl-starter/camel-cli-connector-starter/src/test/java/org/apache/camel/springboot/cli/connector/SpringCliWebSocketClientTest.java +++ b/dsl-starter/camel-cli-connector-starter/src/test/java/org/apache/camel/springboot/cli/connector/SpringCliWebSocketClientTest.java @@ -16,9 +16,20 @@ */ package org.apache.camel.springboot.cli.connector; +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; import java.net.URI; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Base64; import java.util.Map; import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; @@ -72,6 +83,36 @@ class SpringCliWebSocketClientTest { await().atMost(10, TimeUnit.SECONDS).until(tool.sessions::isEmpty); } + @Test + void failsWhenTheToolNeverAnswersTheUpgrade() throws Exception { + // the TCP connection is accepted (a paused tool, a port-forward with nothing behind it), the upgrade not answered + SpringCliWebSocketClient fast = new SpringCliWebSocketClient(null, 500); + try (ServerSocket server = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) { + CompletableFuture<?> connect = fast.connect( + URI.create("ws://127.0.0.1:" + server.getLocalPort() + "/connect"), Map.of(), listener) + .toCompletableFuture(); + + await().atMost(10, TimeUnit.SECONDS).until(connect::isDone); + assertThat(connect).isCompletedExceptionally(); + } + } + + @Test + void receivesALargeMessageSentInASingleFrame() throws Exception { + // Tomcat (the tool server of the other tests) fragments what it sends: some tools send one frame per message + String text = "x".repeat(1024 * 1024); + try (ServerSocket server = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) { + Thread tool = new Thread(() -> sendInOneFrame(server, text)); + tool.setDaemon(true); + tool.start(); + + client.connect(URI.create("ws://127.0.0.1:" + server.getLocalPort() + "/connect"), Map.of(), listener) + .toCompletableFuture().get(10, TimeUnit.SECONDS); + + assertThat(listener.texts.poll(10, TimeUnit.SECONDS)).isEqualTo(text); + } + } + @Test void failsTheConnectionOnAMessageLargerThanTheLimit() throws Exception { tool.greeting = "x".repeat(CliWebSocketClient.MAX_MESSAGE_SIZE + 1); @@ -145,4 +186,34 @@ class SpringCliWebSocketClientTest { this.error = error; } } + + /** + * Accepts one WebSocket connection, and sends the text in a single unfragmented frame. + */ + private static void sendInOneFrame(ServerSocket server, String text) { + try { + Socket socket = server.accept(); + BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.ISO_8859_1)); + String key = null; + for (String line = in.readLine(); line != null && !line.isEmpty(); line = in.readLine()) { + if (line.regionMatches(true, 0, "Sec-WebSocket-Key:", 0, 18)) { + key = line.substring(18).trim(); + } + } + String accept = Base64.getEncoder().encodeToString(MessageDigest.getInstance("SHA-1") + .digest((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").getBytes(StandardCharsets.ISO_8859_1))); + OutputStream out = socket.getOutputStream(); + out.write(("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" + + "Sec-WebSocket-Accept: " + accept + "\r\n\r\n").getBytes(StandardCharsets.ISO_8859_1)); + byte[] payload = text.getBytes(StandardCharsets.UTF_8); + // FIN + text, 64-bit length, not masked (server to client) + out.write(0x81); + out.write(127); + out.write(ByteBuffer.allocate(8).putLong(payload.length).array()); + out.write(payload); + out.flush(); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } }
