Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;

Expand All @@ -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<String, List<String>> 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);
}
Expand All @@ -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);
}

/**
Expand All @@ -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());
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,26 +16,32 @@
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;

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<String, String> environment;

/** The process. */
private volatile Process process;

/**
* 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<String, String> environment) {
this.command = command;
this.environment = Map.copyOf(environment);
}

/**
Expand All @@ -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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Map;

/**
* The Class TerminalWebsocketConfig.
Expand All @@ -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;
Expand All @@ -62,7 +68,7 @@ public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
*/
@Bean
public WebSocketHandler getConsoleWebsocketHandler() {
return new TerminalWebsocketHandler();
return new TerminalWebsocketHandler(CREDENTIAL.token());
}

/**
Expand All @@ -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();
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand All @@ -40,6 +46,23 @@ class TerminalWebsocketHandler extends BinaryWebSocketHandler implements SubProt
/** The session to client. */
private static final Map<String, TerminalWebsocketClientEndpoint> 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.
*
Expand Down Expand Up @@ -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)));
Expand All @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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");
Expand All @@ -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();

Expand Down
Loading
Loading