From 5ac5552cb2d31aceabe075ec470dc698c0891e5b Mon Sep 17 00:00:00 2001 From: ThuF Date: Wed, 28 Oct 2026 17:35:45 +0200 Subject: [PATCH 1/2] ide-terminal: rework how the terminal backend is started and connected (#8300) --- .../TerminalWebsocketClientEndpoint.java | 41 +++++++--- .../terminal/endpoint/ProcessRunnable.java | 14 +++- .../endpoint/TerminalWebsocketConfig.java | 17 ++-- .../endpoint/TerminalWebsocketHandler.java | 48 ++++++++++- .../terminal/endpoint/TtydCredential.java | 63 +++++++++++++++ .../TerminalWebsocketHandlerTest.java | 79 +++++++++++++++++++ .../terminal/endpoint/TtydCredentialTest.java | 50 ++++++++++++ 7 files changed, 289 insertions(+), 23 deletions(-) create mode 100644 components/ide/ide-terminal/src/main/java/org/eclipse/dirigible/components/terminal/endpoint/TtydCredential.java create mode 100644 components/ide/ide-terminal/src/test/java/org/eclipse/dirigible/components/terminal/endpoint/TerminalWebsocketHandlerTest.java create mode 100644 components/ide/ide-terminal/src/test/java/org/eclipse/dirigible/components/terminal/endpoint/TtydCredentialTest.java diff --git a/components/ide/ide-terminal/src/main/java/org/eclipse/dirigible/components/terminal/client/TerminalWebsocketClientEndpoint.java b/components/ide/ide-terminal/src/main/java/org/eclipse/dirigible/components/terminal/client/TerminalWebsocketClientEndpoint.java index 0c0315ccdbd..94696a3735a 100644 --- a/components/ide/ide-terminal/src/main/java/org/eclipse/dirigible/components/terminal/client/TerminalWebsocketClientEndpoint.java +++ b/components/ide/ide-terminal/src/main/java/org/eclipse/dirigible/components/terminal/client/TerminalWebsocketClientEndpoint.java @@ -12,13 +12,14 @@ import java.io.IOException; import java.net.URI; import java.nio.ByteBuffer; +import java.util.List; +import java.util.Map; -import jakarta.websocket.ClientEndpoint; +import jakarta.websocket.ClientEndpointConfig; import jakarta.websocket.CloseReason; import jakarta.websocket.ContainerProvider; -import jakarta.websocket.OnClose; -import jakarta.websocket.OnMessage; -import jakarta.websocket.OnOpen; +import jakarta.websocket.Endpoint; +import jakarta.websocket.EndpointConfig; import jakarta.websocket.Session; import jakarta.websocket.WebSocketContainer; @@ -28,12 +29,15 @@ /** * Terminal Websocket Proxy Client. */ -@ClientEndpoint(subprotocols = "tty") -public class TerminalWebsocketClientEndpoint { +public class TerminalWebsocketClientEndpoint extends Endpoint { /** The Constant logger. */ private static final Logger logger = LoggerFactory.getLogger(TerminalWebsocketClientEndpoint.class); + private static final String SUBPROTOCOL = "tty"; + + private static final String AUTHORIZATION_HEADER = "Authorization"; + /** The session. */ private Session session = null; @@ -44,11 +48,21 @@ public class TerminalWebsocketClientEndpoint { * Instantiates a new x terminal websocket client endpoint. * * @param endpointURI the endpoint URI + * @param authorization the Authorization header of the handshake */ - public TerminalWebsocketClientEndpoint(URI endpointURI) { + public TerminalWebsocketClientEndpoint(URI endpointURI, String authorization) { + ClientEndpointConfig config = ClientEndpointConfig.Builder.create() + .preferredSubprotocols(List.of(SUBPROTOCOL)) + .configurator(new ClientEndpointConfig.Configurator() { + @Override + public void beforeRequest(Map> headers) { + headers.put(AUTHORIZATION_HEADER, List.of(authorization)); + } + }) + .build(); try { WebSocketContainer container = ContainerProvider.getWebSocketContainer(); - container.connectToServer(this, endpointURI); + container.connectToServer(this, config, endpointURI); } catch (Exception e) { throw new RuntimeException(e); } @@ -67,13 +81,15 @@ public Session getSession() { * Callback hook for Connection open events. * * @param session the session which is opened. + * @param config the configuration the session was opened with */ - @OnOpen - public void onOpen(Session session) { + @Override + public void onOpen(Session session, EndpointConfig config) { if (logger.isInfoEnabled()) { logger.info("[ws:terminal-client] connected: " + session.getId()); } this.session = session; + session.addMessageHandler(ByteBuffer.class, this::onMessage); } /** @@ -82,7 +98,7 @@ public void onOpen(Session session) { * @param session the session which is getting closed. * @param reason the reason for connection close */ - @OnClose + @Override public void onClose(Session session, CloseReason reason) { if (logger.isInfoEnabled()) { logger.info("[ws:terminal-client] disconnected: " + session.getId()); @@ -95,8 +111,7 @@ public void onClose(Session session, CloseReason reason) { * * @param message the message */ - @OnMessage - public void onMessage(ByteBuffer message) { + private void onMessage(ByteBuffer message) { if (this.messageHandler != null) { try { this.messageHandler.handleMessage(message); diff --git a/components/ide/ide-terminal/src/main/java/org/eclipse/dirigible/components/terminal/endpoint/ProcessRunnable.java b/components/ide/ide-terminal/src/main/java/org/eclipse/dirigible/components/terminal/endpoint/ProcessRunnable.java index 28f61722f51..c93c9005374 100644 --- a/components/ide/ide-terminal/src/main/java/org/eclipse/dirigible/components/terminal/endpoint/ProcessRunnable.java +++ b/components/ide/ide-terminal/src/main/java/org/eclipse/dirigible/components/terminal/endpoint/ProcessRunnable.java @@ -15,6 +15,7 @@ import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; +import java.util.Map; import static org.eclipse.dirigible.components.terminal.endpoint.TerminalWebsocketConfig.TERMINAL_PREFIX; @@ -25,6 +26,9 @@ class ProcessRunnable implements Runnable { /** The command. */ private final String command; + /** The variables added to the environment the command is started with. */ + private final Map environment; + /** The process. */ private Process process; @@ -32,9 +36,11 @@ class ProcessRunnable implements Runnable { * Instantiates a new process runnable. * * @param command the command + * @param environment the variables added to the environment the command is started with */ - ProcessRunnable(String command) { + ProcessRunnable(String command, Map environment) { this.command = command; + this.environment = Map.copyOf(environment); } /** @@ -52,8 +58,10 @@ public Process getProcess() { @Override public void run() { try { - this.process = Runtime.getRuntime() - .exec(this.command); + ProcessBuilder processBuilder = new ProcessBuilder(this.command); + processBuilder.environment() + .putAll(this.environment); + this.process = processBuilder.start(); Thread reader = new Thread(() -> { try { diff --git a/components/ide/ide-terminal/src/main/java/org/eclipse/dirigible/components/terminal/endpoint/TerminalWebsocketConfig.java b/components/ide/ide-terminal/src/main/java/org/eclipse/dirigible/components/terminal/endpoint/TerminalWebsocketConfig.java index 963846af50c..a58c8b7ed12 100644 --- a/components/ide/ide-terminal/src/main/java/org/eclipse/dirigible/components/terminal/endpoint/TerminalWebsocketConfig.java +++ b/components/ide/ide-terminal/src/main/java/org/eclipse/dirigible/components/terminal/endpoint/TerminalWebsocketConfig.java @@ -27,6 +27,7 @@ import java.io.FileOutputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.util.Map; /** * The Class TerminalWebsocketConfig. @@ -37,8 +38,12 @@ public class TerminalWebsocketConfig implements WebSocketConfigurer { static final String TERMINAL_PREFIX = "[ws:terminal] {}"; + static final String TTYD_HOST = "127.0.0.1"; + static final int TTYD_PORT = 9000; private static final Logger logger = LoggerFactory.getLogger(TerminalWebsocketConfig.class); private static final String UNIX_FILE = "ttyd.sh"; + private static final String CREDENTIAL_VARIABLE = "DIRIGIBLE_TERMINAL_CREDENTIAL"; + private static final TtydCredential CREDENTIAL = TtydCredential.generate(); private static volatile boolean started = false; static { @@ -62,7 +67,7 @@ public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { */ @Bean public WebSocketHandler getConsoleWebsocketHandler() { - return new TerminalWebsocketHandler(); + return new TerminalWebsocketHandler(CREDENTIAL.token()); } private synchronized static void runTTYD() { @@ -84,7 +89,7 @@ private static void startTTYD() { String command = "./" + unixFile.getName(); logger.info("Starting ttyd using command [{}] and file [{}]", command, unixFile); - ProcessRunnable processRunnable = new ProcessRunnable(command); + ProcessRunnable processRunnable = new ProcessRunnable(command, Map.of(CREDENTIAL_VARIABLE, CREDENTIAL.credential())); new Thread(processRunnable).start(); started = true; @@ -106,7 +111,9 @@ private static File createUnixFile() throws IOException { } /** - * Creates the shell script. + * Creates the shell script. The backend listens on the loopback interface only, where the terminal + * websocket connects to it, and takes its credential from the environment the script is started + * with, so it is written neither to the file nor to the log. * * @param file the file * @throws FileNotFoundException the file not found exception @@ -115,8 +122,8 @@ private static File createUnixFile() throws IOException { private static void createShellScriptFile(File file) throws FileNotFoundException, IOException { String command = """ #!/bin/sh - ttyd -p 9000 --writable sh - """; + ttyd -i %s -p %d -c "$%s" --writable sh + """.formatted(TTYD_HOST, TTYD_PORT, CREDENTIAL_VARIABLE); logger.info("Creating file [{}] with content [{}]", file, command); try (FileOutputStream fos = new FileOutputStream(file)) { IOUtils.write(command, fos, StandardCharsets.UTF_8); diff --git a/components/ide/ide-terminal/src/main/java/org/eclipse/dirigible/components/terminal/endpoint/TerminalWebsocketHandler.java b/components/ide/ide-terminal/src/main/java/org/eclipse/dirigible/components/terminal/endpoint/TerminalWebsocketHandler.java index 04650753f8d..19e0b09a3a2 100644 --- a/components/ide/ide-terminal/src/main/java/org/eclipse/dirigible/components/terminal/endpoint/TerminalWebsocketHandler.java +++ b/components/ide/ide-terminal/src/main/java/org/eclipse/dirigible/components/terminal/endpoint/TerminalWebsocketHandler.java @@ -9,6 +9,8 @@ */ package org.eclipse.dirigible.components.terminal.endpoint; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; import org.eclipse.dirigible.components.terminal.client.TerminalWebsocketClientEndpoint; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -20,11 +22,15 @@ import java.net.URI; import java.net.URISyntaxException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import static org.eclipse.dirigible.components.terminal.endpoint.TerminalWebsocketConfig.TERMINAL_PREFIX; +import static org.eclipse.dirigible.components.terminal.endpoint.TerminalWebsocketConfig.TTYD_HOST; +import static org.eclipse.dirigible.components.terminal.endpoint.TerminalWebsocketConfig.TTYD_PORT; /** * The Console Websocket Handler. @@ -40,6 +46,23 @@ class TerminalWebsocketHandler extends BinaryWebSocketHandler implements SubProt /** The session to client. */ private static final Map SESSION_TO_CLIENT = new ConcurrentHashMap<>(); + /** ttyd's client opens a session with a JSON object, the only message starting with a brace. */ + private static final byte HANDSHAKE_COMMAND = '{'; + + private static final String AUTH_TOKEN = "AuthToken"; + + /** The token the backend was started with. */ + private final String ttydToken; + + /** + * Instantiates a new terminal websocket handler. + * + * @param ttydToken the token the backend was started with + */ + TerminalWebsocketHandler(String ttydToken) { + this.ttydToken = ttydToken; + } + /** * After connection established. * @@ -71,7 +94,8 @@ public void afterConnectionEstablished(WebSocketSession session) { */ private TerminalWebsocketClientEndpoint startClientWebsocket(WebSocketSession session) throws URISyntaxException { - final TerminalWebsocketClientEndpoint clientEndPoint = new TerminalWebsocketClientEndpoint(new URI("ws://localhost:9000/ws")); + final TerminalWebsocketClientEndpoint clientEndPoint = + new TerminalWebsocketClientEndpoint(new URI("ws://" + TTYD_HOST + ":" + TTYD_PORT + "/ws"), "Basic " + ttydToken); // add listener clientEndPoint.addMessageHandler(message -> session.sendMessage(new BinaryMessage(message))); @@ -95,11 +119,31 @@ protected void handleBinaryMessage(WebSocketSession session, BinaryMessage messa if (clientEndPoint != null) { synchronized (clientEndPoint) { // send message to websocket - clientEndPoint.sendMessage(message.getPayload()); + clientEndPoint.sendMessage(withAuthToken(message.getPayload(), ttydToken)); } } } + /** + * Sets the backend's token on the message ttyd's client opens a session with - the terminal page + * sends it without one - and passes every other message on unchanged. + * + * @param payload the message the terminal page sent + * @param token the token the backend was started with + * @return the message to send to the backend + */ + static ByteBuffer withAuthToken(ByteBuffer payload, String token) { + if (!payload.hasRemaining() || payload.get(payload.position()) != HANDSHAKE_COMMAND) { + return payload; + } + JsonObject handshake = JsonParser.parseString(StandardCharsets.UTF_8.decode(payload.duplicate()) + .toString()) + .getAsJsonObject(); + handshake.addProperty(AUTH_TOKEN, token); + return ByteBuffer.wrap(handshake.toString() + .getBytes(StandardCharsets.UTF_8)); + } + /** * Handle transport error. * diff --git a/components/ide/ide-terminal/src/main/java/org/eclipse/dirigible/components/terminal/endpoint/TtydCredential.java b/components/ide/ide-terminal/src/main/java/org/eclipse/dirigible/components/terminal/endpoint/TtydCredential.java new file mode 100644 index 00000000000..24c44240713 --- /dev/null +++ b/components/ide/ide-terminal/src/main/java/org/eclipse/dirigible/components/terminal/endpoint/TtydCredential.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.terminal.endpoint; + +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Base64; + +/** + * The credential the terminal backend (ttyd) is started with, generated by the platform instance + * that starts it. The terminal websocket presents it when it connects to the backend. + */ +final class TtydCredential { + + private static final String USER = "dirigible"; + private static final int PASSWORD_BYTES = 32; + + private final String credential; + + private TtydCredential(String credential) { + this.credential = credential; + } + + /** + * Generates a credential with a random password. + * + * @return the credential + */ + static TtydCredential generate() { + byte[] password = new byte[PASSWORD_BYTES]; + new SecureRandom().nextBytes(password); + return new TtydCredential(USER + ":" + Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(password)); + } + + /** + * The credential in the {@code user:password} form ttyd's {@code --credential} option takes. + * + * @return the credential + */ + String credential() { + return credential; + } + + /** + * The token ttyd checks: in the Basic authorization header of the websocket handshake and as the + * {@code AuthToken} of the client's first message. + * + * @return the Base64 form of the credential + */ + String token() { + return Base64.getEncoder() + .encodeToString(credential.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/components/ide/ide-terminal/src/test/java/org/eclipse/dirigible/components/terminal/endpoint/TerminalWebsocketHandlerTest.java b/components/ide/ide-terminal/src/test/java/org/eclipse/dirigible/components/terminal/endpoint/TerminalWebsocketHandlerTest.java new file mode 100644 index 00000000000..b40509e447c --- /dev/null +++ b/components/ide/ide-terminal/src/test/java/org/eclipse/dirigible/components/terminal/endpoint/TerminalWebsocketHandlerTest.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.terminal.endpoint; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +/** + * The terminal page opens a backend session without a token; the handler sets the backend's. + */ +class TerminalWebsocketHandlerTest { + + private static final String TOKEN = "ZGlyaWdpYmxlOnNlY3JldA=="; + + @Test + void sets_the_token_on_the_message_opening_the_session() { + ByteBuffer forwarded = TerminalWebsocketHandler.withAuthToken(bytes("{\"AuthToken\":\"\",\"columns\":80,\"rows\":24}"), TOKEN); + + JsonObject handshake = JsonParser.parseString(text(forwarded)) + .getAsJsonObject(); + assertEquals(TOKEN, handshake.get("AuthToken") + .getAsString()); + assertEquals(80, handshake.get("columns") + .getAsInt()); + assertEquals(24, handshake.get("rows") + .getAsInt()); + } + + @Test + void sets_the_token_when_the_page_sent_none() { + ByteBuffer forwarded = TerminalWebsocketHandler.withAuthToken(bytes("{\"columns\":80,\"rows\":24}"), TOKEN); + + assertEquals(TOKEN, JsonParser.parseString(text(forwarded)) + .getAsJsonObject() + .get("AuthToken") + .getAsString()); + } + + @Test + void passes_input_and_resize_messages_on_unchanged() { + ByteBuffer input = bytes("0ls -la\n"); + ByteBuffer resize = bytes("1{\"columns\":120,\"rows\":40}"); + + assertSame(input, TerminalWebsocketHandler.withAuthToken(input, TOKEN)); + assertSame(resize, TerminalWebsocketHandler.withAuthToken(resize, TOKEN)); + assertEquals("0ls -la\n", text(input)); + } + + @Test + void passes_an_empty_message_on_unchanged() { + ByteBuffer empty = ByteBuffer.allocate(0); + + assertSame(empty, TerminalWebsocketHandler.withAuthToken(empty, TOKEN)); + } + + private static ByteBuffer bytes(String text) { + return ByteBuffer.wrap(text.getBytes(StandardCharsets.UTF_8)); + } + + private static String text(ByteBuffer buffer) { + return StandardCharsets.UTF_8.decode(buffer.duplicate()) + .toString(); + } +} diff --git a/components/ide/ide-terminal/src/test/java/org/eclipse/dirigible/components/terminal/endpoint/TtydCredentialTest.java b/components/ide/ide-terminal/src/test/java/org/eclipse/dirigible/components/terminal/endpoint/TtydCredentialTest.java new file mode 100644 index 00000000000..1e88259f763 --- /dev/null +++ b/components/ide/ide-terminal/src/test/java/org/eclipse/dirigible/components/terminal/endpoint/TtydCredentialTest.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.terminal.endpoint; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import org.junit.jupiter.api.Test; + +/** + * Every platform instance starts the terminal backend with a credential of its own. + */ +class TtydCredentialTest { + + @Test + void generates_a_user_and_password_pair() { + String credential = TtydCredential.generate() + .credential(); + + assertTrue(credential.matches("dirigible:[A-Za-z0-9_-]{43}"), credential); + } + + @Test + void generates_a_different_password_each_time() { + assertNotEquals(TtydCredential.generate() + .credential(), + TtydCredential.generate() + .credential()); + } + + @Test + void the_token_is_the_base64_form_of_the_credential() { + TtydCredential credential = TtydCredential.generate(); + + assertEquals(credential.credential(), new String(Base64.getDecoder() + .decode(credential.token()), + StandardCharsets.UTF_8)); + } +} From 71cf973707d0dd28245da4f2936cd2dfaa765e80 Mon Sep 17 00:00:00 2001 From: ThuF Date: Thu, 29 Oct 2026 09:55:13 +0200 Subject: [PATCH 2/2] ide-terminal: the backend script execs ttyd, so stopping the started process stops ttyd (#8300) --- .../terminal/endpoint/TerminalWebsocketConfig.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/components/ide/ide-terminal/src/main/java/org/eclipse/dirigible/components/terminal/endpoint/TerminalWebsocketConfig.java b/components/ide/ide-terminal/src/main/java/org/eclipse/dirigible/components/terminal/endpoint/TerminalWebsocketConfig.java index b0fa0da6c34..2ca3bf59341 100644 --- a/components/ide/ide-terminal/src/main/java/org/eclipse/dirigible/components/terminal/endpoint/TerminalWebsocketConfig.java +++ b/components/ide/ide-terminal/src/main/java/org/eclipse/dirigible/components/terminal/endpoint/TerminalWebsocketConfig.java @@ -122,7 +122,8 @@ private static File createUnixFile() throws IOException { /** * Creates the shell script. The backend listens on the loopback interface only, where the terminal * websocket connects to it, and takes its credential from the environment the script is started - * with, so it is written neither to the file nor to the log. + * with, so it is written neither to the file nor to the log. The script execs ttyd, so destroying + * the started process stops ttyd itself. * * @param file the file * @throws FileNotFoundException the file not found exception @@ -131,7 +132,7 @@ private static File createUnixFile() throws IOException { private static void createShellScriptFile(File file) throws FileNotFoundException, IOException { String command = """ #!/bin/sh - ttyd -i %s -p %d -c "$%s" --writable sh + exec ttyd -i %s -p %d -c "$%s" --writable sh """.formatted(TTYD_HOST, TTYD_PORT, CREDENTIAL_VARIABLE); logger.info("Creating file [{}] with content [{}]", file, command); try (FileOutputStream fos = new FileOutputStream(file)) {