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 7553fe866d9..abbfa6bf9a0 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 @@ -16,6 +16,7 @@ import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; +import java.util.Map; import static org.eclipse.dirigible.components.terminal.endpoint.TerminalWebsocketConfig.TERMINAL_PREFIX; @@ -23,9 +24,12 @@ class ProcessRunnable implements Runnable { private static final Logger logger = LoggerFactory.getLogger(ProcessRunnable.class); - /** The command. */ + /** The command line, split on whitespace into the program and its arguments. */ private final String command; + /** The variables added to the environment the command is started with. */ + private final Map environment; + /** The process. */ private volatile Process process; @@ -33,9 +37,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); } /** @@ -53,8 +59,10 @@ public Process getProcess() { @Override public void run() { try { - this.process = Runtime.getRuntime() - .exec(this.command); + ProcessBuilder processBuilder = new ProcessBuilder(this.command.split("\\s+")); + processBuilder.environment() + .putAll(this.environment); + this.process = processBuilder.start(); pipeToLog("dirigible-ttyd-stdout-reader", process.getInputStream()); pipeToLog("dirigible-ttyd-stderr-reader", process.getErrorStream()); 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 9b198fb218c..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 @@ -29,6 +29,7 @@ import java.io.FileOutputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.util.Map; /** * The Class TerminalWebsocketConfig. @@ -39,8 +40,13 @@ 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"; + /** One credential per process, so a ttyd still closing for an earlier context accepts it too. */ + private static final TtydCredential CREDENTIAL = TtydCredential.generate(); /** The ttyd process, started on context init and destroyed when the context closes. */ private ProcessRunnable ttydProcess; @@ -62,7 +68,7 @@ public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { */ @Bean public WebSocketHandler getConsoleWebsocketHandler() { - return new TerminalWebsocketHandler(); + return new TerminalWebsocketHandler(CREDENTIAL.token()); } /** @@ -80,7 +86,7 @@ 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())); Thread ttydThread = new Thread(processRunnable, "dirigible-ttyd-launcher"); ttydThread.setDaemon(true); ttydThread.start(); @@ -114,7 +120,10 @@ 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. The script execs ttyd, so destroying + * the started process stops ttyd itself. * * @param file the file * @throws FileNotFoundException the file not found exception @@ -123,8 +132,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 - """; + 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)) { 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/ProcessRunnableTest.java b/components/ide/ide-terminal/src/test/java/org/eclipse/dirigible/components/terminal/endpoint/ProcessRunnableTest.java index 6ab26db3390..ea46439f01d 100644 --- a/components/ide/ide-terminal/src/test/java/org/eclipse/dirigible/components/terminal/endpoint/ProcessRunnableTest.java +++ b/components/ide/ide-terminal/src/test/java/org/eclipse/dirigible/components/terminal/endpoint/ProcessRunnableTest.java @@ -16,6 +16,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.time.Duration; +import java.util.Map; import java.util.Optional; import java.util.concurrent.TimeUnit; @@ -33,7 +34,7 @@ class ProcessRunnableTest { @Test void destroy_is_safe_before_the_process_has_started() { - ProcessRunnable runnable = new ProcessRunnable("noop"); + ProcessRunnable runnable = new ProcessRunnable("noop", Map.of()); assertDoesNotThrow(runnable::destroy); assertNull(runnable.getProcess(), "no process should have been started"); @@ -42,7 +43,7 @@ void destroy_is_safe_before_the_process_has_started() { @Test @EnabledOnOs({OS.LINUX, OS.MAC}) void reader_threads_are_daemon_and_the_process_is_destroyed() throws Exception { - ProcessRunnable runnable = new ProcessRunnable("sleep 30"); + ProcessRunnable runnable = new ProcessRunnable("sleep 30", Map.of()); try { runnable.run(); 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)); + } +}