atiaomar1978-hub commented on code in PR #25317: URL: https://github.com/apache/camel/pull/25317#discussion_r3714423977
########## dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiWebServer.java: ########## @@ -0,0 +1,198 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.tui; + +import java.io.IOException; +import java.lang.System.Logger; +import java.lang.System.Logger.Level; +import java.net.BindException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import dev.tamboui.backend.aesh.AeshBackend; +import io.netty.bootstrap.ServerBootstrap; +import io.netty.channel.Channel; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.ChannelPipeline; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.MultiThreadIoEventLoopGroup; +import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.channel.group.ChannelGroup; +import io.netty.channel.group.DefaultChannelGroup; +import io.netty.channel.nio.NioIoHandler; +import io.netty.channel.socket.SocketChannel; +import io.netty.channel.socket.nio.NioServerSocketChannel; +import io.netty.handler.codec.http.DefaultFullHttpResponse; +import io.netty.handler.codec.http.FullHttpRequest; +import io.netty.handler.codec.http.HttpHeaderNames; +import io.netty.handler.codec.http.HttpObjectAggregator; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.netty.handler.codec.http.HttpServerCodec; +import io.netty.handler.codec.http.HttpVersion; +import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler; +import io.netty.handler.stream.ChunkedWriteHandler; +import io.netty.util.concurrent.ImmediateEventExecutor; +import org.aesh.terminal.Connection; +import org.aesh.terminal.http.netty.HttpRequestHandler; +import org.aesh.terminal.http.netty.TtyWebSocketFrameHandler; +import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; + +/** + * Serves the Camel TUI dashboard to a web browser over WebSocket, using Aesh's HTTP/WebSocket terminal bridge. + * <p> + * Each incoming connection gets its own {@link CamelMonitor} instance (running the same live-monitoring logic as a + * local terminal session) driven by an {@link AeshBackend} wrapping that connection. + * <p> + * Binds to 127.0.0.1 only for security. + */ +class TuiWebServer { + + private static final Logger LOG = System.getLogger(TuiWebServer.class.getName()); + private final int port; + private final CamelJBangMain main; + private final ClassLoader classLoader; + private final String name; + private final long refreshInterval; + private final String theme; + private final ChannelGroup channels = new DefaultChannelGroup(ImmediateEventExecutor.INSTANCE); + private final EventLoopGroup bossGroup = new MultiThreadIoEventLoopGroup(1, NioIoHandler.newFactory()); + private final EventLoopGroup workerGroup = new MultiThreadIoEventLoopGroup(NioIoHandler.newFactory()); + private final ExecutorService sessionExecutor = Executors.newFixedThreadPool( + Math.max(4, Runtime.getRuntime().availableProcessors() * 2), r -> { + Thread t = new Thread(r, "tui-web-session"); + t.setDaemon(true); + return t; + }); + private Channel serverChannel; + private boolean stopped; + + TuiWebServer(int port, CamelJBangMain main, ClassLoader classLoader, String name, long refreshInterval, + String theme) { + this.port = port; + this.main = main; + this.classLoader = classLoader; + this.name = name; + this.refreshInterval = refreshInterval; + this.theme = theme; + } + + void start() throws IOException { + try { + ServerBootstrap bootstrap = new ServerBootstrap(); + serverChannel = bootstrap.group(bossGroup, workerGroup) + .channel(NioServerSocketChannel.class) + .childHandler(new WebServerInitializer()) + .bind("127.0.0.1", port) + .sync() + .channel(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + stop(); + throw new IOException("Interrupted while starting web terminal server", e); + } catch (Exception e) { + stop(); + Throwable cause = e.getCause(); + if (cause instanceof BindException bindException) { + throw bindException; + } + if (e instanceof BindException bindException) { + throw bindException; + } + throw new IOException("Failed to start web terminal server", e); + } + } + + synchronized void stop() { + if (stopped) { + return; + } + stopped = true; + if (serverChannel != null) { + serverChannel.close().syncUninterruptibly(); + } + channels.close().syncUninterruptibly(); + bossGroup.shutdownGracefully(0, 5, TimeUnit.SECONDS).syncUninterruptibly(); + workerGroup.shutdownGracefully(0, 5, TimeUnit.SECONDS).syncUninterruptibly(); + sessionExecutor.shutdownNow(); Review Comment: **[Medium — BugBot]** `stop()` calls `sessionExecutor.shutdownNow()` immediately after closing Netty channels, without waiting for in-flight `accept()` tasks to finish. When the local terminal exits while browser tabs are connected, `CamelMonitor.call()` `finally` blocks can race with event-loop teardown and be interrupted mid-cleanup. **Suggestion:** Close channels first, then `shutdown()` + `awaitTermination` on `sessionExecutor` (with a timeout) before shutting down Netty groups; only then `shutdownNow()` as a last resort. ########## dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiWebServer.java: ########## @@ -0,0 +1,198 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.tui; + +import java.io.IOException; +import java.lang.System.Logger; +import java.lang.System.Logger.Level; +import java.net.BindException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import dev.tamboui.backend.aesh.AeshBackend; +import io.netty.bootstrap.ServerBootstrap; +import io.netty.channel.Channel; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.ChannelPipeline; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.MultiThreadIoEventLoopGroup; +import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.channel.group.ChannelGroup; +import io.netty.channel.group.DefaultChannelGroup; +import io.netty.channel.nio.NioIoHandler; +import io.netty.channel.socket.SocketChannel; +import io.netty.channel.socket.nio.NioServerSocketChannel; +import io.netty.handler.codec.http.DefaultFullHttpResponse; +import io.netty.handler.codec.http.FullHttpRequest; +import io.netty.handler.codec.http.HttpHeaderNames; +import io.netty.handler.codec.http.HttpObjectAggregator; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.netty.handler.codec.http.HttpServerCodec; +import io.netty.handler.codec.http.HttpVersion; +import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler; +import io.netty.handler.stream.ChunkedWriteHandler; +import io.netty.util.concurrent.ImmediateEventExecutor; +import org.aesh.terminal.Connection; +import org.aesh.terminal.http.netty.HttpRequestHandler; +import org.aesh.terminal.http.netty.TtyWebSocketFrameHandler; +import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; + +/** + * Serves the Camel TUI dashboard to a web browser over WebSocket, using Aesh's HTTP/WebSocket terminal bridge. + * <p> + * Each incoming connection gets its own {@link CamelMonitor} instance (running the same live-monitoring logic as a + * local terminal session) driven by an {@link AeshBackend} wrapping that connection. + * <p> + * Binds to 127.0.0.1 only for security. + */ +class TuiWebServer { + + private static final Logger LOG = System.getLogger(TuiWebServer.class.getName()); + private final int port; + private final CamelJBangMain main; + private final ClassLoader classLoader; + private final String name; + private final long refreshInterval; + private final String theme; + private final ChannelGroup channels = new DefaultChannelGroup(ImmediateEventExecutor.INSTANCE); + private final EventLoopGroup bossGroup = new MultiThreadIoEventLoopGroup(1, NioIoHandler.newFactory()); + private final EventLoopGroup workerGroup = new MultiThreadIoEventLoopGroup(NioIoHandler.newFactory()); + private final ExecutorService sessionExecutor = Executors.newFixedThreadPool( + Math.max(4, Runtime.getRuntime().availableProcessors() * 2), r -> { + Thread t = new Thread(r, "tui-web-session"); + t.setDaemon(true); + return t; + }); + private Channel serverChannel; + private boolean stopped; + + TuiWebServer(int port, CamelJBangMain main, ClassLoader classLoader, String name, long refreshInterval, + String theme) { + this.port = port; + this.main = main; + this.classLoader = classLoader; + this.name = name; + this.refreshInterval = refreshInterval; + this.theme = theme; + } + + void start() throws IOException { + try { + ServerBootstrap bootstrap = new ServerBootstrap(); + serverChannel = bootstrap.group(bossGroup, workerGroup) + .channel(NioServerSocketChannel.class) + .childHandler(new WebServerInitializer()) + .bind("127.0.0.1", port) + .sync() + .channel(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + stop(); + throw new IOException("Interrupted while starting web terminal server", e); + } catch (Exception e) { + stop(); + Throwable cause = e.getCause(); + if (cause instanceof BindException bindException) { + throw bindException; + } + if (e instanceof BindException bindException) { + throw bindException; + } + throw new IOException("Failed to start web terminal server", e); + } + } + + synchronized void stop() { + if (stopped) { + return; + } + stopped = true; + if (serverChannel != null) { + serverChannel.close().syncUninterruptibly(); + } + channels.close().syncUninterruptibly(); + bossGroup.shutdownGracefully(0, 5, TimeUnit.SECONDS).syncUninterruptibly(); + workerGroup.shutdownGracefully(0, 5, TimeUnit.SECONDS).syncUninterruptibly(); + sessionExecutor.shutdownNow(); + } + + boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException { + long deadline = System.nanoTime() + unit.toNanos(timeout); + return bossGroup.terminationFuture().await(timeout, unit) + && workerGroup.terminationFuture().await(timeout, unit) + && sessionExecutor.awaitTermination(Math.max(0, deadline - System.nanoTime()), TimeUnit.NANOSECONDS); + } + + private void accept(Connection connection) { + sessionExecutor.submit(() -> { + try { + AeshBackend backend = new AeshBackend(connection); + CamelMonitor monitor = new CamelMonitor(main, classLoader); + monitor.name = name; + monitor.refreshInterval = refreshInterval; + monitor.theme = theme; + monitor.webBackend = backend; + monitor.call(); + } catch (Exception e) { + LOG.log(Level.WARNING, "Web TUI session ended with an error", e); + } finally { + try { + connection.close(); + } catch (Exception ignored) { + // connection already closing + } + } + }); + } + + private final class WebServerInitializer extends ChannelInitializer<SocketChannel> { + + @Override + protected void initChannel(SocketChannel channel) { + ChannelPipeline pipeline = channel.pipeline(); + pipeline.addLast(new HttpServerCodec()); + pipeline.addLast(new ChunkedWriteHandler()); + pipeline.addLast(new HttpObjectAggregator(65_536)); + pipeline.addLast(new OriginCheckingUpgradeHandler()); + pipeline.addLast(new HttpRequestHandler("/ws", "/tui/web")); + pipeline.addLast(new WebSocketServerProtocolHandler("/ws")); + pipeline.addLast(new TtyWebSocketFrameHandler(channels, TuiWebServer.this::accept)); + } + } + + private final class OriginCheckingUpgradeHandler extends SimpleChannelInboundHandler<FullHttpRequest> { + + @Override + protected void channelRead0(ChannelHandlerContext context, FullHttpRequest request) { + if ("/ws".equalsIgnoreCase(request.uri()) && !isAllowedOrigin(request)) { + context.writeAndFlush(new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.FORBIDDEN)) + .addListener(future -> context.close()); + return; + } + context.fireChannelRead(request.retain()); + } + } + + private boolean isAllowedOrigin(FullHttpRequest request) { + String origin = request.headers().get(HttpHeaderNames.ORIGIN); + // A missing Origin header is allowed on purpose: non-browser clients (curl, custom + // terminal clients) don't send one, and the loopback-only bind is the actual boundary here. + return origin == null || origin.equals("http://127.0.0.1:" + port) || origin.equals("http://localhost:" + port); Review Comment: **[Info]** Missing `Origin` is intentionally allowed for non-browser clients (curl, tests). This is consistent with the security-model doc (loopback bind is the trust boundary), but worth noting: **any local process** can open a WebSocket session without origin validation. ########## dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiWebServer.java: ########## @@ -0,0 +1,198 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.tui; + +import java.io.IOException; +import java.lang.System.Logger; +import java.lang.System.Logger.Level; +import java.net.BindException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import dev.tamboui.backend.aesh.AeshBackend; +import io.netty.bootstrap.ServerBootstrap; +import io.netty.channel.Channel; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.ChannelPipeline; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.MultiThreadIoEventLoopGroup; +import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.channel.group.ChannelGroup; +import io.netty.channel.group.DefaultChannelGroup; +import io.netty.channel.nio.NioIoHandler; +import io.netty.channel.socket.SocketChannel; +import io.netty.channel.socket.nio.NioServerSocketChannel; +import io.netty.handler.codec.http.DefaultFullHttpResponse; +import io.netty.handler.codec.http.FullHttpRequest; +import io.netty.handler.codec.http.HttpHeaderNames; +import io.netty.handler.codec.http.HttpObjectAggregator; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.netty.handler.codec.http.HttpServerCodec; +import io.netty.handler.codec.http.HttpVersion; +import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler; +import io.netty.handler.stream.ChunkedWriteHandler; +import io.netty.util.concurrent.ImmediateEventExecutor; +import org.aesh.terminal.Connection; +import org.aesh.terminal.http.netty.HttpRequestHandler; +import org.aesh.terminal.http.netty.TtyWebSocketFrameHandler; +import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; + +/** + * Serves the Camel TUI dashboard to a web browser over WebSocket, using Aesh's HTTP/WebSocket terminal bridge. + * <p> + * Each incoming connection gets its own {@link CamelMonitor} instance (running the same live-monitoring logic as a + * local terminal session) driven by an {@link AeshBackend} wrapping that connection. + * <p> + * Binds to 127.0.0.1 only for security. + */ +class TuiWebServer { + + private static final Logger LOG = System.getLogger(TuiWebServer.class.getName()); + private final int port; + private final CamelJBangMain main; + private final ClassLoader classLoader; + private final String name; + private final long refreshInterval; + private final String theme; + private final ChannelGroup channels = new DefaultChannelGroup(ImmediateEventExecutor.INSTANCE); + private final EventLoopGroup bossGroup = new MultiThreadIoEventLoopGroup(1, NioIoHandler.newFactory()); + private final EventLoopGroup workerGroup = new MultiThreadIoEventLoopGroup(NioIoHandler.newFactory()); + private final ExecutorService sessionExecutor = Executors.newFixedThreadPool( + Math.max(4, Runtime.getRuntime().availableProcessors() * 2), r -> { + Thread t = new Thread(r, "tui-web-session"); + t.setDaemon(true); + return t; + }); + private Channel serverChannel; + private boolean stopped; + + TuiWebServer(int port, CamelJBangMain main, ClassLoader classLoader, String name, long refreshInterval, + String theme) { + this.port = port; + this.main = main; + this.classLoader = classLoader; + this.name = name; + this.refreshInterval = refreshInterval; + this.theme = theme; + } + + void start() throws IOException { + try { + ServerBootstrap bootstrap = new ServerBootstrap(); + serverChannel = bootstrap.group(bossGroup, workerGroup) + .channel(NioServerSocketChannel.class) + .childHandler(new WebServerInitializer()) + .bind("127.0.0.1", port) + .sync() + .channel(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + stop(); + throw new IOException("Interrupted while starting web terminal server", e); + } catch (Exception e) { + stop(); + Throwable cause = e.getCause(); + if (cause instanceof BindException bindException) { + throw bindException; + } + if (e instanceof BindException bindException) { + throw bindException; + } + throw new IOException("Failed to start web terminal server", e); + } + } + + synchronized void stop() { + if (stopped) { + return; + } + stopped = true; + if (serverChannel != null) { + serverChannel.close().syncUninterruptibly(); + } + channels.close().syncUninterruptibly(); + bossGroup.shutdownGracefully(0, 5, TimeUnit.SECONDS).syncUninterruptibly(); + workerGroup.shutdownGracefully(0, 5, TimeUnit.SECONDS).syncUninterruptibly(); + sessionExecutor.shutdownNow(); + } + + boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException { + long deadline = System.nanoTime() + unit.toNanos(timeout); + return bossGroup.terminationFuture().await(timeout, unit) + && workerGroup.terminationFuture().await(timeout, unit) + && sessionExecutor.awaitTermination(Math.max(0, deadline - System.nanoTime()), TimeUnit.NANOSECONDS); + } + + private void accept(Connection connection) { + sessionExecutor.submit(() -> { + try { + AeshBackend backend = new AeshBackend(connection); + CamelMonitor monitor = new CamelMonitor(main, classLoader); + monitor.name = name; + monitor.refreshInterval = refreshInterval; + monitor.theme = theme; + monitor.webBackend = backend; + monitor.call(); + } catch (Exception e) { + LOG.log(Level.WARNING, "Web TUI session ended with an error", e); + } finally { + try { + connection.close(); + } catch (Exception ignored) { + // connection already closing + } + } + }); + } + + private final class WebServerInitializer extends ChannelInitializer<SocketChannel> { + + @Override + protected void initChannel(SocketChannel channel) { + ChannelPipeline pipeline = channel.pipeline(); + pipeline.addLast(new HttpServerCodec()); + pipeline.addLast(new ChunkedWriteHandler()); + pipeline.addLast(new HttpObjectAggregator(65_536)); + pipeline.addLast(new OriginCheckingUpgradeHandler()); + pipeline.addLast(new HttpRequestHandler("/ws", "/tui/web")); + pipeline.addLast(new WebSocketServerProtocolHandler("/ws")); + pipeline.addLast(new TtyWebSocketFrameHandler(channels, TuiWebServer.this::accept)); + } + } + + private final class OriginCheckingUpgradeHandler extends SimpleChannelInboundHandler<FullHttpRequest> { + + @Override + protected void channelRead0(ChannelHandlerContext context, FullHttpRequest request) { + if ("/ws".equalsIgnoreCase(request.uri()) && !isAllowedOrigin(request)) { Review Comment: **[Medium — Grok]** Origin check uses exact `"/ws".equalsIgnoreCase(request.uri())`. If the upgrade URI includes a query string (e.g. `/ws?token=…`), this check is skipped and a foreign `Origin` may reach the WebSocket handler. **Suggestion:** Parse the path only (`QueryStringDecoder` or strip `?…`) before comparing, or use `startsWith("/ws")` with a boundary check. ########## dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiWebServer.java: ########## @@ -0,0 +1,198 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.tui; + +import java.io.IOException; +import java.lang.System.Logger; +import java.lang.System.Logger.Level; +import java.net.BindException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import dev.tamboui.backend.aesh.AeshBackend; +import io.netty.bootstrap.ServerBootstrap; +import io.netty.channel.Channel; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.ChannelPipeline; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.MultiThreadIoEventLoopGroup; +import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.channel.group.ChannelGroup; +import io.netty.channel.group.DefaultChannelGroup; +import io.netty.channel.nio.NioIoHandler; +import io.netty.channel.socket.SocketChannel; +import io.netty.channel.socket.nio.NioServerSocketChannel; +import io.netty.handler.codec.http.DefaultFullHttpResponse; +import io.netty.handler.codec.http.FullHttpRequest; +import io.netty.handler.codec.http.HttpHeaderNames; +import io.netty.handler.codec.http.HttpObjectAggregator; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.netty.handler.codec.http.HttpServerCodec; +import io.netty.handler.codec.http.HttpVersion; +import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler; +import io.netty.handler.stream.ChunkedWriteHandler; +import io.netty.util.concurrent.ImmediateEventExecutor; +import org.aesh.terminal.Connection; +import org.aesh.terminal.http.netty.HttpRequestHandler; +import org.aesh.terminal.http.netty.TtyWebSocketFrameHandler; +import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; + +/** + * Serves the Camel TUI dashboard to a web browser over WebSocket, using Aesh's HTTP/WebSocket terminal bridge. + * <p> + * Each incoming connection gets its own {@link CamelMonitor} instance (running the same live-monitoring logic as a + * local terminal session) driven by an {@link AeshBackend} wrapping that connection. + * <p> + * Binds to 127.0.0.1 only for security. + */ +class TuiWebServer { + + private static final Logger LOG = System.getLogger(TuiWebServer.class.getName()); + private final int port; + private final CamelJBangMain main; + private final ClassLoader classLoader; + private final String name; + private final long refreshInterval; + private final String theme; + private final ChannelGroup channels = new DefaultChannelGroup(ImmediateEventExecutor.INSTANCE); + private final EventLoopGroup bossGroup = new MultiThreadIoEventLoopGroup(1, NioIoHandler.newFactory()); + private final EventLoopGroup workerGroup = new MultiThreadIoEventLoopGroup(NioIoHandler.newFactory()); + private final ExecutorService sessionExecutor = Executors.newFixedThreadPool( Review Comment: **[Medium — Grok]** Fixed-size pool but `Executors.newFixedThreadPool` uses an **unbounded** task queue. Each WebSocket accept enqueues a full `CamelMonitor.call()`; a localhost client opening many connections can queue unbounded work and exhaust memory/CPU even though only `max(4, 2×cpus)` run concurrently. **Suggestion:** Consider a bounded queue with a rejection policy, connection limit, or semaphore on admissions. ########## dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiWebServer.java: ########## @@ -0,0 +1,198 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.tui; + +import java.io.IOException; +import java.lang.System.Logger; +import java.lang.System.Logger.Level; +import java.net.BindException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import dev.tamboui.backend.aesh.AeshBackend; +import io.netty.bootstrap.ServerBootstrap; +import io.netty.channel.Channel; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.ChannelPipeline; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.MultiThreadIoEventLoopGroup; +import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.channel.group.ChannelGroup; +import io.netty.channel.group.DefaultChannelGroup; +import io.netty.channel.nio.NioIoHandler; +import io.netty.channel.socket.SocketChannel; +import io.netty.channel.socket.nio.NioServerSocketChannel; +import io.netty.handler.codec.http.DefaultFullHttpResponse; +import io.netty.handler.codec.http.FullHttpRequest; +import io.netty.handler.codec.http.HttpHeaderNames; +import io.netty.handler.codec.http.HttpObjectAggregator; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.netty.handler.codec.http.HttpServerCodec; +import io.netty.handler.codec.http.HttpVersion; +import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler; +import io.netty.handler.stream.ChunkedWriteHandler; +import io.netty.util.concurrent.ImmediateEventExecutor; +import org.aesh.terminal.Connection; +import org.aesh.terminal.http.netty.HttpRequestHandler; +import org.aesh.terminal.http.netty.TtyWebSocketFrameHandler; +import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; + +/** + * Serves the Camel TUI dashboard to a web browser over WebSocket, using Aesh's HTTP/WebSocket terminal bridge. + * <p> + * Each incoming connection gets its own {@link CamelMonitor} instance (running the same live-monitoring logic as a + * local terminal session) driven by an {@link AeshBackend} wrapping that connection. + * <p> + * Binds to 127.0.0.1 only for security. + */ +class TuiWebServer { + + private static final Logger LOG = System.getLogger(TuiWebServer.class.getName()); + private final int port; + private final CamelJBangMain main; + private final ClassLoader classLoader; + private final String name; + private final long refreshInterval; + private final String theme; + private final ChannelGroup channels = new DefaultChannelGroup(ImmediateEventExecutor.INSTANCE); + private final EventLoopGroup bossGroup = new MultiThreadIoEventLoopGroup(1, NioIoHandler.newFactory()); + private final EventLoopGroup workerGroup = new MultiThreadIoEventLoopGroup(NioIoHandler.newFactory()); + private final ExecutorService sessionExecutor = Executors.newFixedThreadPool( + Math.max(4, Runtime.getRuntime().availableProcessors() * 2), r -> { + Thread t = new Thread(r, "tui-web-session"); + t.setDaemon(true); + return t; + }); + private Channel serverChannel; + private boolean stopped; + + TuiWebServer(int port, CamelJBangMain main, ClassLoader classLoader, String name, long refreshInterval, + String theme) { + this.port = port; + this.main = main; + this.classLoader = classLoader; + this.name = name; + this.refreshInterval = refreshInterval; + this.theme = theme; + } + + void start() throws IOException { + try { + ServerBootstrap bootstrap = new ServerBootstrap(); + serverChannel = bootstrap.group(bossGroup, workerGroup) + .channel(NioServerSocketChannel.class) + .childHandler(new WebServerInitializer()) + .bind("127.0.0.1", port) + .sync() + .channel(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + stop(); + throw new IOException("Interrupted while starting web terminal server", e); + } catch (Exception e) { + stop(); + Throwable cause = e.getCause(); + if (cause instanceof BindException bindException) { + throw bindException; + } + if (e instanceof BindException bindException) { + throw bindException; + } + throw new IOException("Failed to start web terminal server", e); + } + } + + synchronized void stop() { + if (stopped) { + return; + } + stopped = true; + if (serverChannel != null) { + serverChannel.close().syncUninterruptibly(); + } + channels.close().syncUninterruptibly(); + bossGroup.shutdownGracefully(0, 5, TimeUnit.SECONDS).syncUninterruptibly(); + workerGroup.shutdownGracefully(0, 5, TimeUnit.SECONDS).syncUninterruptibly(); + sessionExecutor.shutdownNow(); + } + + boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException { + long deadline = System.nanoTime() + unit.toNanos(timeout); + return bossGroup.terminationFuture().await(timeout, unit) + && workerGroup.terminationFuture().await(timeout, unit) + && sessionExecutor.awaitTermination(Math.max(0, deadline - System.nanoTime()), TimeUnit.NANOSECONDS); + } + + private void accept(Connection connection) { + sessionExecutor.submit(() -> { Review Comment: **[Low — Grok]** Bare `sessionExecutor.submit(...)` with no `RejectedExecutionException` handling. After `stop()` calls `shutdownNow()`, a late WebSocket accept on a Netty thread can throw on the event loop. **Suggestion:** Catch `RejectedExecutionException` in `accept()` and close the connection cleanly. ########## dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java: ########## @@ -752,7 +790,12 @@ private boolean handleGlobalKeys(KeyEvent ke, TuiRunner runner) { boolean textEditing = probeEditing || sourceSearchActive || logSearchActive || spanFilterActive || beanFilterActive || classpathFilterActive || mavenDepsFilterActive || sqlInputActive || catalogFilterActive || filesBrowserTextActive; + // A browser session only views the shared monitor; it must not be able to quit the TUI Review Comment: **[Medium — Grok]** Comment says browser session "views the shared monitor" and must not quit the process others use — but `TuiWebServer.accept()` creates an **independent** `CamelMonitor` per connection (L142–151). Swallowing `q`/Ctrl+C here prevents the browser user from closing their own session, while AI `/quit` (`AiSlashCommandRegistry` L90–94 → `requestExit()` → `tui::quit()`) still exits that session. **Suggestion:** Either allow `q`/Ctrl+C to call `runner.quit()` for `webBackend != null` (only that session), or also block `/quit` in web sessions — and fix the comment. ########## dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiWebServer.java: ########## @@ -0,0 +1,198 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.tui; + +import java.io.IOException; +import java.lang.System.Logger; +import java.lang.System.Logger.Level; +import java.net.BindException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import dev.tamboui.backend.aesh.AeshBackend; +import io.netty.bootstrap.ServerBootstrap; +import io.netty.channel.Channel; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.ChannelPipeline; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.MultiThreadIoEventLoopGroup; +import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.channel.group.ChannelGroup; +import io.netty.channel.group.DefaultChannelGroup; +import io.netty.channel.nio.NioIoHandler; +import io.netty.channel.socket.SocketChannel; +import io.netty.channel.socket.nio.NioServerSocketChannel; +import io.netty.handler.codec.http.DefaultFullHttpResponse; +import io.netty.handler.codec.http.FullHttpRequest; +import io.netty.handler.codec.http.HttpHeaderNames; +import io.netty.handler.codec.http.HttpObjectAggregator; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.netty.handler.codec.http.HttpServerCodec; +import io.netty.handler.codec.http.HttpVersion; +import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler; +import io.netty.handler.stream.ChunkedWriteHandler; +import io.netty.util.concurrent.ImmediateEventExecutor; +import org.aesh.terminal.Connection; +import org.aesh.terminal.http.netty.HttpRequestHandler; +import org.aesh.terminal.http.netty.TtyWebSocketFrameHandler; +import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; + +/** + * Serves the Camel TUI dashboard to a web browser over WebSocket, using Aesh's HTTP/WebSocket terminal bridge. + * <p> + * Each incoming connection gets its own {@link CamelMonitor} instance (running the same live-monitoring logic as a + * local terminal session) driven by an {@link AeshBackend} wrapping that connection. + * <p> + * Binds to 127.0.0.1 only for security. + */ +class TuiWebServer { + + private static final Logger LOG = System.getLogger(TuiWebServer.class.getName()); + private final int port; + private final CamelJBangMain main; + private final ClassLoader classLoader; + private final String name; + private final long refreshInterval; + private final String theme; + private final ChannelGroup channels = new DefaultChannelGroup(ImmediateEventExecutor.INSTANCE); + private final EventLoopGroup bossGroup = new MultiThreadIoEventLoopGroup(1, NioIoHandler.newFactory()); + private final EventLoopGroup workerGroup = new MultiThreadIoEventLoopGroup(NioIoHandler.newFactory()); + private final ExecutorService sessionExecutor = Executors.newFixedThreadPool( + Math.max(4, Runtime.getRuntime().availableProcessors() * 2), r -> { + Thread t = new Thread(r, "tui-web-session"); + t.setDaemon(true); + return t; + }); + private Channel serverChannel; + private boolean stopped; + + TuiWebServer(int port, CamelJBangMain main, ClassLoader classLoader, String name, long refreshInterval, + String theme) { + this.port = port; + this.main = main; + this.classLoader = classLoader; + this.name = name; + this.refreshInterval = refreshInterval; + this.theme = theme; + } + + void start() throws IOException { + try { + ServerBootstrap bootstrap = new ServerBootstrap(); + serverChannel = bootstrap.group(bossGroup, workerGroup) + .channel(NioServerSocketChannel.class) + .childHandler(new WebServerInitializer()) + .bind("127.0.0.1", port) + .sync() + .channel(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + stop(); + throw new IOException("Interrupted while starting web terminal server", e); + } catch (Exception e) { + stop(); + Throwable cause = e.getCause(); + if (cause instanceof BindException bindException) { + throw bindException; + } + if (e instanceof BindException bindException) { + throw bindException; + } + throw new IOException("Failed to start web terminal server", e); + } + } + + synchronized void stop() { + if (stopped) { + return; + } + stopped = true; + if (serverChannel != null) { + serverChannel.close().syncUninterruptibly(); + } + channels.close().syncUninterruptibly(); + bossGroup.shutdownGracefully(0, 5, TimeUnit.SECONDS).syncUninterruptibly(); + workerGroup.shutdownGracefully(0, 5, TimeUnit.SECONDS).syncUninterruptibly(); + sessionExecutor.shutdownNow(); + } + + boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException { Review Comment: **[Low — Grok]** `awaitTermination(timeout)` waits the full `timeout` on `bossGroup`, then again on `workerGroup`, then only the *remaining* time on `sessionExecutor`. Total wait can approach **2× the requested timeout**. **Suggestion:** Track a single deadline and pass `max(0, deadline - now)` to each await call. ########## dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiWebServer.java: ########## @@ -0,0 +1,198 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.tui; + +import java.io.IOException; +import java.lang.System.Logger; +import java.lang.System.Logger.Level; +import java.net.BindException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import dev.tamboui.backend.aesh.AeshBackend; +import io.netty.bootstrap.ServerBootstrap; +import io.netty.channel.Channel; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.ChannelPipeline; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.MultiThreadIoEventLoopGroup; +import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.channel.group.ChannelGroup; +import io.netty.channel.group.DefaultChannelGroup; +import io.netty.channel.nio.NioIoHandler; +import io.netty.channel.socket.SocketChannel; +import io.netty.channel.socket.nio.NioServerSocketChannel; +import io.netty.handler.codec.http.DefaultFullHttpResponse; +import io.netty.handler.codec.http.FullHttpRequest; +import io.netty.handler.codec.http.HttpHeaderNames; +import io.netty.handler.codec.http.HttpObjectAggregator; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.netty.handler.codec.http.HttpServerCodec; +import io.netty.handler.codec.http.HttpVersion; +import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler; +import io.netty.handler.stream.ChunkedWriteHandler; +import io.netty.util.concurrent.ImmediateEventExecutor; +import org.aesh.terminal.Connection; +import org.aesh.terminal.http.netty.HttpRequestHandler; +import org.aesh.terminal.http.netty.TtyWebSocketFrameHandler; +import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; + +/** + * Serves the Camel TUI dashboard to a web browser over WebSocket, using Aesh's HTTP/WebSocket terminal bridge. + * <p> + * Each incoming connection gets its own {@link CamelMonitor} instance (running the same live-monitoring logic as a + * local terminal session) driven by an {@link AeshBackend} wrapping that connection. + * <p> + * Binds to 127.0.0.1 only for security. + */ +class TuiWebServer { + + private static final Logger LOG = System.getLogger(TuiWebServer.class.getName()); + private final int port; + private final CamelJBangMain main; + private final ClassLoader classLoader; + private final String name; + private final long refreshInterval; + private final String theme; + private final ChannelGroup channels = new DefaultChannelGroup(ImmediateEventExecutor.INSTANCE); + private final EventLoopGroup bossGroup = new MultiThreadIoEventLoopGroup(1, NioIoHandler.newFactory()); + private final EventLoopGroup workerGroup = new MultiThreadIoEventLoopGroup(NioIoHandler.newFactory()); + private final ExecutorService sessionExecutor = Executors.newFixedThreadPool( + Math.max(4, Runtime.getRuntime().availableProcessors() * 2), r -> { + Thread t = new Thread(r, "tui-web-session"); + t.setDaemon(true); + return t; + }); + private Channel serverChannel; + private boolean stopped; + + TuiWebServer(int port, CamelJBangMain main, ClassLoader classLoader, String name, long refreshInterval, + String theme) { + this.port = port; + this.main = main; + this.classLoader = classLoader; + this.name = name; + this.refreshInterval = refreshInterval; + this.theme = theme; + } + + void start() throws IOException { + try { + ServerBootstrap bootstrap = new ServerBootstrap(); + serverChannel = bootstrap.group(bossGroup, workerGroup) + .channel(NioServerSocketChannel.class) + .childHandler(new WebServerInitializer()) + .bind("127.0.0.1", port) + .sync() + .channel(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + stop(); + throw new IOException("Interrupted while starting web terminal server", e); + } catch (Exception e) { + stop(); + Throwable cause = e.getCause(); + if (cause instanceof BindException bindException) { + throw bindException; + } + if (e instanceof BindException bindException) { + throw bindException; + } + throw new IOException("Failed to start web terminal server", e); + } + } + + synchronized void stop() { + if (stopped) { + return; + } + stopped = true; + if (serverChannel != null) { + serverChannel.close().syncUninterruptibly(); + } + channels.close().syncUninterruptibly(); + bossGroup.shutdownGracefully(0, 5, TimeUnit.SECONDS).syncUninterruptibly(); + workerGroup.shutdownGracefully(0, 5, TimeUnit.SECONDS).syncUninterruptibly(); + sessionExecutor.shutdownNow(); + } + + boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException { + long deadline = System.nanoTime() + unit.toNanos(timeout); + return bossGroup.terminationFuture().await(timeout, unit) + && workerGroup.terminationFuture().await(timeout, unit) + && sessionExecutor.awaitTermination(Math.max(0, deadline - System.nanoTime()), TimeUnit.NANOSECONDS); + } + + private void accept(Connection connection) { + sessionExecutor.submit(() -> { + try { + AeshBackend backend = new AeshBackend(connection); + CamelMonitor monitor = new CamelMonitor(main, classLoader); + monitor.name = name; + monitor.refreshInterval = refreshInterval; + monitor.theme = theme; + monitor.webBackend = backend; + monitor.call(); + } catch (Exception e) { + LOG.log(Level.WARNING, "Web TUI session ended with an error", e); + } finally { + try { + connection.close(); + } catch (Exception ignored) { + // connection already closing + } + } + }); + } + + private final class WebServerInitializer extends ChannelInitializer<SocketChannel> { + + @Override + protected void initChannel(SocketChannel channel) { + ChannelPipeline pipeline = channel.pipeline(); + pipeline.addLast(new HttpServerCodec()); + pipeline.addLast(new ChunkedWriteHandler()); + pipeline.addLast(new HttpObjectAggregator(65_536)); + pipeline.addLast(new OriginCheckingUpgradeHandler()); + pipeline.addLast(new HttpRequestHandler("/ws", "/tui/web")); Review Comment: **[Medium — Grok / security]** Static page is served without `X-Frame-Options: DENY` or `Content-Security-Policy: frame-ancestors 'none'`. A remote site can iframe `http://127.0.0.1:<port>/`; the browser then sends a loopback `Origin` on WebSocket upgrade (allowed), enabling **clickjacking** of the full TUI on the loopback trust boundary. **Suggestion:** Add frame-denial headers in `HttpRequestHandler` or a small Netty handler for all HTML responses. ########## docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc: ########## @@ -760,6 +760,25 @@ The MCP server exposes tools organized by purpose: See xref:camel-jbang-mcp.adoc[Camel MCP Server] for more about MCP and AI integration with Camel. +== Web Browser Access + +The TUI can also be reached from a web browser on the same host, using the same dashboard you'd see in a +local terminal -- useful when you prefer a browser session to a terminal window. + +[source,bash] +---- +camel tui --web +---- + +This starts a web terminal server on `localhost:8090` (configurable with `--web-port`). +Open `http://localhost:8090` in a browser to get a full xterm.js terminal driving the same +TUI dashboard, with the same tabs, keyboard shortcuts, and F2 actions menu as a local session. + +Like the MCP server, the web server is bound to `127.0.0.1` only -- it never listens on +external interfaces -- and there is no authentication beyond that. Each browser connection +gets its own independent TUI session (its own process discovery and navigation state), the Review Comment: **[Low — docs]** States browser sessions have "the same … keyboard shortcuts" as local terminal, but web sessions hide the `q`/quit footer hint (`OverviewTab`) and swallow `q`/Ctrl+C (`CamelMonitor` L795–798). Consider noting that quit behaves differently in browser sessions. ########## dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiWebServerTest.java: ########## @@ -0,0 +1,213 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.tui; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.io.PrintWriter; +import java.net.BindException; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.WebSocket; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; +import org.apache.camel.test.AvailablePortFinder; +import org.apache.camel.test.AvailablePortFinder.Port; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.Isolated; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@Isolated +class TuiWebServerTest { + + private TuiWebServer server; + + @AfterEach + void tearDown() { + if (server != null) { + server.stop(); + } + } + + @Test + void startBindsToLoopbackAndAcceptsTcpConnections() throws Exception { + try (Port reserved = AvailablePortFinder.find()) { + server = newServer(reserved.getPort()); + + server.start(); + + try (Socket socket = new Socket()) { + socket.connect(new InetSocketAddress("127.0.0.1", reserved.getPort()), 2000); + assertThat(socket.isConnected()).isTrue(); + } + } + } + + @Test + void stopClosesTheListeningPort() throws Exception { + try (Port reserved = AvailablePortFinder.find()) { + server = newServer(reserved.getPort()); + server.start(); + + server.stop(); + + assertThatThrownBy(() -> { + try (Socket socket = new Socket()) { + socket.connect(new InetSocketAddress("127.0.0.1", reserved.getPort()), 2000); + } + }).isInstanceOf(IOException.class); + } + } + + @Test + void rejectsWebSocketUpgradeFromForeignOrigin() throws Exception { + try (Port reserved = AvailablePortFinder.find()) { + server = newServer(reserved.getPort()); + server.start(); + + assertThat(webSocketHandshake(reserved.getPort(), "https://attacker.invalid")) + .startsWith("HTTP/1.1 403"); + } + } + + @Test + void acceptsWebSocketUpgradeFromTheLoopbackPage() throws Exception { + try (Port reserved = AvailablePortFinder.find()) { + server = newServer(reserved.getPort()); + server.start(); + + assertThat(webSocketHandshake(reserved.getPort(), "http://127.0.0.1:" + reserved.getPort())) Review Comment: **[Medium — Grok]** A successful `101` handshake triggers `TtyWebSocketFrameHandler` → `accept()` → full `CamelMonitor.call()` on a background thread. This test only asserts the status line but may spawn a heavyweight monitor session with no guaranteed teardown before `@AfterEach stop()`. **Suggestion:** Mock/stub the accept callback in unit tests, or assert session cleanup; consider `@Isolated` + short-lived connection abort (as in L146) immediately after handshake. ########## dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiBackendHelper.java: ########## @@ -29,10 +30,15 @@ private TuiBackendHelper() { static TuiRunner createTuiRunner() throws Exception { Terminal activeTerminal = EnvironmentHelper.getActiveTerminal(); - if (activeTerminal != null) { - JLineBackend backend = new JLineBackend(activeTerminal); - return TuiRunner.create(TuiConfig.builder().backend(backend).mouseCapture(true).build()); - } - return TuiRunner.create(TuiConfig.builder().mouseCapture(true).build()); + // Build the JLine backend explicitly rather than leaving backend selection to + // TamboUI's ServiceLoader-based auto-discovery: with tamboui-aesh-backend also on the + // classpath (for --web), auto-discovery can pick AeshBackend for the local session too, + // which drives a native PosixSysTerminal that doesn't shut down cleanly here. Review Comment: **[Info — looks good]** Explicit JLine backend avoids ServiceLoader picking `AeshBackend` for the local session when `tamboui-aesh-backend` is on the classpath. Good fix for clean shutdown. ########## dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/web/index.html: ########## @@ -0,0 +1,277 @@ +<!-- + + Licensed to the Apache Software Foundation (ASF) under one or more + contributor license agreements. See the NOTICE file distributed with + this work for additional information regarding copyright ownership. + The ASF licenses this file to You under the Apache License, Version 2.0 + (the "License"); you may not use this file except in compliance with + the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +--> +<!doctype html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Camel TUI</title> + <link rel="stylesheet" href="/vendor/xterm.css" /> + <style> + :root { + --camel-orange: #e97826; + --camel-navy: #303284; + --camel-white: #fff; + } + * { box-sizing: border-box; } + html, body { + margin: 0; + padding: 0; + height: 100vh; + overflow: hidden; + background: #1e1e1e; + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + } + body { + display: flex; + flex-direction: column; + } + + .site-nav { + flex-shrink: 0; + display: flex; + align-items: center; + gap: .75rem; + height: 52px; + padding: 0 1.25rem; + background: var(--camel-navy); + color: var(--camel-white); + border-bottom: 3px solid var(--camel-orange); + box-shadow: 0 2px 10px rgba(0, 0, 0, .35); + } + .logo-mark { + flex-shrink: 0; + width: 28px; + height: 28px; + object-fit: cover; + object-position: left center; + } + .brand-name { font-weight: 700; font-size: .92rem; } + .brand-sub { font-size: .68rem; opacity: .65; } + .nav-spacer { flex: 1; } + + .connection-status { + flex-shrink: 0; + padding: 3px 10px; + border-radius: 99px; + font-size: .68rem; + font-weight: 700; + letter-spacing: .04em; + text-transform: uppercase; + white-space: nowrap; + } + .status-connecting { background: #4a3a1a; color: #fbbf24; } + .status-connected { background: #1a472a; color: #4ade80; } + .status-disconnected { background: #4a1a1a; color: #f87171; } + + #terminal-wrapper { + position: relative; + flex: 1; + min-height: 0; + } + #terminal-container { + position: absolute; + inset: 0; + padding: 12px; + background: #000; + } + + .disconnect-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, .82); + } + .disconnect-overlay[hidden] { display: none; } + .disconnect-box { + max-width: 26rem; + padding: 1.25rem 1.5rem; + border-radius: 8px; + border-left: 4px solid var(--camel-orange); + background: #232323; + color: var(--camel-white); + box-shadow: 0 10px 30px rgba(0, 0, 0, .5); + } + .disconnect-title { + font-weight: 700; + font-size: 1rem; + margin-bottom: .35rem; + } + .disconnect-detail { + font-size: .85rem; + opacity: .8; + } + </style> +</head> +<body> + <nav class="site-nav" aria-label="Site navigation"> + <img class="logo-mark" src="/images/camel-logo.png" alt="" aria-hidden="true"> + <div> + <div class="brand-name">Apache Camel</div> + <div class="brand-sub">Camel TUI</div> + </div> + <div class="nav-spacer"></div> + <div id="status" class="connection-status status-connecting">Connecting...</div> + </nav> + <div id="terminal-wrapper"> + <div id="terminal-container"></div> + <div id="disconnect-overlay" class="disconnect-overlay" hidden> + <div class="disconnect-box"> + <div class="disconnect-title">Connection lost</div> + <div class="disconnect-detail" id="disconnect-detail"></div> + </div> + </div> + </div> + + <script src="/vendor/xterm.js"></script> + <script src="/vendor/xterm-addon-fit.js"></script> + <script> + (function () { + 'use strict'; + + var statusEl = document.getElementById('status'); + var overlayEl = document.getElementById('disconnect-overlay'); + var overlayDetailEl = document.getElementById('disconnect-detail'); + + function setStatus(status, message) { + statusEl.className = 'connection-status status-' + status; + statusEl.textContent = message; + } + + function showDisconnectOverlay(message) { + overlayDetailEl.textContent = message; + overlayEl.hidden = false; + } + + function hideDisconnectOverlay() { + overlayEl.hidden = true; + } + + function connect() { + var wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + var wsUrl = wsProtocol + '//' + window.location.host + '/ws'; + + setStatus('connecting', 'Connecting...'); + hideDisconnectOverlay(); + + var socket = new WebSocket(wsUrl); + var term = null; + var fitAddon = null; + var resizeTimeout = null; + + socket.onopen = function () { + setStatus('connected', 'Connected'); + + term = new Terminal({ + cursorBlink: true, + cursorStyle: 'block', + fontFamily: '"DejaVu Sans Mono", "Liberation Mono", "Courier New", monospace', + fontSize: 14, + theme: { + background: '#000000', + foreground: '#f0f0f0', + cursor: '#f0f0f0', + cursorAccent: '#000000', + selectionBackground: 'rgba(255, 255, 255, 0.3)' + }, + allowProposedApi: true + }); + + fitAddon = new FitAddon.FitAddon(); + term.loadAddon(fitAddon); + term.open(document.getElementById('terminal-container')); + + function sendInit() { + fitAddon.fit(); + // Message shape ({action: 'init'|'read'|'resize', ...}) is dictated by + // org.aesh:terminal-http's server-side HttpTtyConnection - it is not ours to change. + socket.send(JSON.stringify({ + action: 'init', + type: 'xterm-256color', + cols: term.cols, + rows: term.rows, + userAgent: navigator.userAgent + })); + } + + // fitAddon.fit() right after open() can measure a stale/default size before the + // browser has finished laying out the page - defer past one paint so the server + // starts with the terminal's real dimensions instead of drifting and needing a + // resize correction. + requestAnimationFrame(function () { + requestAnimationFrame(sendInit); + }); + + socket.onmessage = function (event) { + term.write(event.data); + }; + + term.onData(function (data) { + socket.send(JSON.stringify({ action: 'read', data: data })); + }); + + term.onResize(function (size) { + socket.send(JSON.stringify({ action: 'resize', cols: size.cols, rows: size.rows })); + }); + + function handleResize() { + if (resizeTimeout) { + clearTimeout(resizeTimeout); + } + resizeTimeout = setTimeout(function () { + if (fitAddon && term) { + fitAddon.fit(); + } + }, 100); + } + + window.addEventListener('resize', handleResize); + if (window.ResizeObserver) { Review Comment: **[Low — Grok]** On disconnect, `window.removeEventListener('resize', …)` runs but the `ResizeObserver` is never disconnected and the `Terminal` instance is never disposed. Reloading is required to GC; long-lived tabs that disconnect/reconnect would leak listeners. **Suggestion:** Store observer reference and call `disconnect()` in `onclose`; call `term.dispose()` if available. ########## dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiWebServerTest.java: ########## @@ -0,0 +1,213 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.tui; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.io.PrintWriter; +import java.net.BindException; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.WebSocket; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; +import org.apache.camel.test.AvailablePortFinder; +import org.apache.camel.test.AvailablePortFinder.Port; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.Isolated; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@Isolated +class TuiWebServerTest { + + private TuiWebServer server; + + @AfterEach + void tearDown() { + if (server != null) { + server.stop(); + } + } + + @Test + void startBindsToLoopbackAndAcceptsTcpConnections() throws Exception { + try (Port reserved = AvailablePortFinder.find()) { + server = newServer(reserved.getPort()); + + server.start(); + + try (Socket socket = new Socket()) { + socket.connect(new InetSocketAddress("127.0.0.1", reserved.getPort()), 2000); + assertThat(socket.isConnected()).isTrue(); + } + } + } + + @Test + void stopClosesTheListeningPort() throws Exception { + try (Port reserved = AvailablePortFinder.find()) { + server = newServer(reserved.getPort()); + server.start(); + + server.stop(); + + assertThatThrownBy(() -> { + try (Socket socket = new Socket()) { + socket.connect(new InetSocketAddress("127.0.0.1", reserved.getPort()), 2000); + } + }).isInstanceOf(IOException.class); + } + } + + @Test + void rejectsWebSocketUpgradeFromForeignOrigin() throws Exception { + try (Port reserved = AvailablePortFinder.find()) { + server = newServer(reserved.getPort()); + server.start(); + + assertThat(webSocketHandshake(reserved.getPort(), "https://attacker.invalid")) + .startsWith("HTTP/1.1 403"); + } + } + + @Test Review Comment: **[Low — coverage gap]** Tests cover `127.0.0.1` origin and foreign origin, but not `http://localhost:<port>` (also allowed by `isAllowedOrigin`). Worth adding for symmetry. ########## dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java: ########## @@ -539,9 +569,14 @@ public void resetIntegrationTabState() { applyLogPin(); applyRatePer(); applyConfirmActions(); - // Intercept Ctrl+C: quit the TUI cleanly instead of letting - // the JVM tear down the classloader while we're still running - Signal.handle(new Signal("INT"), sig -> tui.quit()); + if (webBackend == null) { + // Intercept Ctrl+C: quit the TUI cleanly instead of letting + // the JVM tear down the classloader while we're still running. + // Signal.handle is process-wide and would clobber concurrent sessions + // (e.g. browser connections via --web), so only the local terminal + // session registers it. + Signal.handle(new Signal("INT"), sig -> tui.quit()); Review Comment: **[Info — looks good]** Correctly scopes `Signal.handle(INT)` to the local terminal session only. Process-wide signal handlers would break concurrent browser sessions. ########## docs/user-manual/modules/ROOT/pages/security-model.adoc: ########## @@ -641,6 +641,17 @@ be closed as `not a vulnerability`. "MBean operation X executes code or sends to endpoint Y when invoked from a JMX or Jolokia connection" describes the documented contract, not a framework vulnerability. +* *The Camel TUI's `--mcp` and `--web` servers.* Camel TUI Review Comment: **[Info — looks good]** Clear framing of `--web` alongside `--mcp` as opt-in loopback management surfaces. Matches Camel's documented trust model. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
