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);
+        }
+    }
 }

Reply via email to