Backend Development 18 min read

Implementing a WebSSH Terminal with Spring Boot, WebSocket, JSch, and xterm.js

This article walks through building a WebSSH solution from scratch using Spring Boot for the backend, WebSocket for real‑time communication, JSch for SSH handling, and xterm.js on the frontend to render a terminal‑like interface, complete with code examples and deployment tips.

Java Architect Essentials
Java Architect Essentials
Java Architect Essentials
Implementing a WebSSH Terminal with Spring Boot, WebSocket, JSch, and xterm.js

Preface

Recently a project required a WebSSH terminal feature. After reviewing existing open‑source tools such as GateOne, webssh, and shellinabox, I decided not to use them because they depend heavily on Python and many external files, which is unsuitable for a production environment. Therefore I wrote my own WebSSH implementation and open‑sourced it.

GitHub project address: https://github.com/NoCortY/WebSSH

Technology Selection

Because WebSSH needs real‑time data exchange, a long‑lived WebSocket connection is chosen. For ease of development the framework is SpringBoot, and I also explored JSch for Java SSH connections and xterm.js for the frontend terminal UI.

Thus the final stack is SpringBoot + WebSocket + JSch + xterm.js.

Import Dependencies

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.1.7.RELEASE</version>
    <relativePath />
</parent>
<dependencies>
<dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
<dependency>
        <groupId>com.jcraft</groupId>
        <artifactId>jsch</artifactId>
        <version>0.1.54</version>
    </dependency>
<dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-websocket</artifactId>
    </dependency>
<dependency>
        <groupId>commons-io</groupId>
        <artifactId>commons-io</artifactId>
        <version>1.4</version>
    </dependency>
    <dependency>
        <groupId>commons-fileupload</groupId>
        <artifactId>commons-fileupload</artifactId>
        <version>1.3.1</version>
    </dependency>
</dependencies>

A Simple xterm.js Example

xterm.js provides a WebSocket‑based terminal emulator that mimics tools like SecureCRT or XShell.

<!doctype html>
<html>
  <head>
    <link rel="stylesheet" href="node_modules/xterm/css/xterm.css" />
    <script src="node_modules/xterm/lib/xterm.js"></script>
  </head>
  <body>
    <div id="terminal"></div>
    <script>
      var term = new Terminal();
      term.open(document.getElementById('terminal'));
      term.write('Hello from \x1B[1;3;31mxterm.js\x1B[0m $ ');
    </script>
  </body>
</html>

The page renders a shell‑like interface, which serves as the basis for the full WebSSH implementation.

Backend Implementation

xterm.js only provides the UI; actual SSH communication is handled by the Java backend using JSch and WebSocket.

WebSocket Configuration

/**
 * @Description: WebSocket configuration
 * @Author: NoCortY
 * @Date: 2020/3/8
 */
@Configuration
@EnableWebSocket
public class WebSSHWebSocketConfig implements WebSocketConfigurer {
    @Autowired
    WebSSHWebSocketHandler webSSHWebSocketHandler;
    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        // Register handler and enable CORS
        registry.addHandler(webSSHWebSocketHandler, "/webssh")
                .addInterceptors(new WebSocketInterceptor())
                .setAllowedOrigins("*");
    }
}

Interceptor and Handler Implementation

Interceptor generates a random UUID for each session (since the project has no user module) and stores it in the WebSocket session attributes.

public class WebSocketInterceptor implements HandshakeInterceptor {
    @Override
    public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response,
                                 WebSocketHandler wsHandler, Map
attributes) throws Exception {
        if (request instanceof ServletServerHttpRequest) {
            String uuid = UUID.randomUUID().toString().replace("-", "");
            attributes.put(ConstantPool.USER_UUID_KEY, uuid);
            return true;
        }
        return false;
    }
    @Override
    public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response,
                               WebSocketHandler wsHandler, Exception ex) {}
}
@Component
public class WebSSHWebSocketHandler implements WebSocketHandler {
    @Autowired
    private WebSSHService webSSHService;
    private Logger logger = LoggerFactory.getLogger(WebSSHWebSocketHandler.class);

    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        logger.info("User:{}, connected to WebSSH", session.getAttributes().get(ConstantPool.USER_UUID_KEY));
        webSSHService.initConnection(session);
    }

    @Override
    public void handleMessage(WebSocketSession session, WebSocketMessage
message) throws Exception {
        if (message instanceof TextMessage) {
            logger.info("User:{}, sent command:{}",
                session.getAttributes().get(ConstantPool.USER_UUID_KEY), message.toString());
            webSSHService.recvHandle(((TextMessage) message).getPayload(), session);
        } else if (message instanceof BinaryMessage) {
            // handle binary if needed
        } else if (message instanceof PongMessage) {
            // handle pong
        } else {
            System.out.println("Unexpected WebSocket message type: " + message);
        }
    }

    @Override
    public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
        logger.error("Data transmission error");
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        logger.info("User:{} disconnected from WebSSH", session.getAttributes().get(ConstantPool.USER_UUID_KEY));
        webSSHService.close(session);
    }

    @Override
    public boolean supportsPartialMessages() { return false; }
}

Core Business Logic (WebSSHService Interface)

/**
 * @Description: WebSSH business logic
 * @Author: NoCortY
 * @Date: 2020/3/7
 */
public interface WebSSHService {
    void initConnection(WebSocketSession session);
    void recvHandle(String buffer, WebSocketSession session);
    void sendMessage(WebSocketSession session, byte[] buffer) throws IOException;
    void close(WebSocketSession session);
}

Initialize Connection

public void initConnection(WebSocketSession session) {
    JSch jSch = new JSch();
    SSHConnectInfo sshInfo = new SSHConnectInfo();
    sshInfo.setjSch(jSch);
    sshInfo.setWebSocketSession(session);
    String uuid = String.valueOf(session.getAttributes().get(ConstantPool.USER_UUID_KEY));
    // Store the SSH connection info in a map
    sshMap.put(uuid, sshInfo);
}

Handle Client Data

The handler distinguishes between a connection request (username/password) and a command execution request.

public void recvHandle(String buffer, WebSocketSession session) {
    ObjectMapper mapper = new ObjectMapper();
    WebSSHData data = null;
    try {
        data = mapper.readValue(buffer, WebSSHData.class);
    } catch (IOException e) {
        logger.error("JSON conversion error");
        logger.error("Exception: {}", e.getMessage());
        return;
    }
    String userId = String.valueOf(session.getAttributes().get(ConstantPool.USER_UUID_KEY));
    if (ConstantPool.WEBSSH_OPERATE_CONNECT.equals(data.getOperate())) {
        SSHConnectInfo info = sshMap.get(userId);
        executorService.execute(() -> {
            try {
                connectToSSH(info, data, session);
            } catch (JSchException | IOException e) {
                logger.error("WebSSH connection error");
                logger.error("Exception: {}", e.getMessage());
                close(session);
            }
        });
    } else if (ConstantPool.WEBSSH_OPERATE_COMMAND.equals(data.getOperate())) {
        String command = data.getCommand();
        SSHConnectInfo info = sshMap.get(userId);
        if (info != null) {
            try {
                transToSSH(info.getChannel(), command);
            } catch (IOException e) {
                logger.error("WebSSH command error");
                logger.error("Exception: {}", e.getMessage());
                close(session);
            }
        } else {
            logger.error("Unsupported operation");
            close(session);
        }
    }
}

Send Data to Frontend

public void sendMessage(WebSocketSession session, byte[] buffer) throws IOException {
    session.sendMessage(new TextMessage(buffer));
}

Close Connection

public void close(WebSocketSession session) {
    String userId = String.valueOf(session.getAttributes().get(ConstantPool.USER_UUID_KEY));
    SSHConnectInfo info = sshMap.get(userId);
    if (info != null) {
        if (info.getChannel() != null) info.getChannel().disconnect();
        sshMap.remove(userId);
    }
}

Frontend Implementation

The frontend consists of a full‑screen div that hosts the xterm.js terminal and JavaScript that connects to the WebSocket, sends user input, and renders server output.

Page Layout

<!doctype html>
<html>
<head>
    <title>WebSSH</title>
    <link rel="stylesheet" href="../css/xterm.css" />
</head>
<body>
    <div id="terminal" style="width:100%;height:100%"></div>
    <script src="../lib/jquery-3.4.1/jquery-3.4.1.min.js"></script>
    <script src="../js/xterm.js" charset="utf-8"></script>
    <script src="../js/webssh.js" charset="utf-8"></script>
    <script src="../js/base64.js" charset="utf-8"></script>
</body>
</html>

WebSocket Connection and Data Flow

openTerminal({
    // In a real project these values would be passed dynamically
    operate: 'connect',
    host: 'IP_ADDRESS',
    port: 'PORT',
    username: 'USERNAME',
    password: 'PASSWORD'
});
function openTerminal(options) {
    var client = new WSSHClient();
    var term = new Terminal({
        cols: 97,
        rows: 37,
        cursorBlink: true,
        cursorStyle: "block",
        scrollback: 800,
        tabStopWidth: 8,
        screenKeys: true
    });
    term.on('data', function (data) {
        // Callback when user types
        client.sendClientData(data);
    });
    term.open(document.getElementById('terminal'));
    term.write('Connecting...');
    client.connect({
        onError: function (error) {
            term.write('Error: ' + error + '\r\n');
        },
        onConnect: function () {
            client.sendInitData(options);
        },
        onClose: function () {
            term.write('\rconnection closed');
        },
        onData: function (data) {
            term.write(data);
        }
    });
}

Result Showcase

Connection screen:

Successful connection:

Command execution (e.g., ls , vim , top ) shows typical terminal output as demonstrated in the following screenshots:

Conclusion

We have completed a fully functional WebSSH project without any external components; the backend is pure Java using SpringBoot, making deployment straightforward. The architecture can be extended with features such as file upload/download to achieve an Xftp‑like experience.

JavaWebSocketSpringBootSSHJSchWebSSHxterm.js
Java Architect Essentials
Written by

Java Architect Essentials

Committed to sharing quality articles and tutorials to help Java programmers progress from junior to mid-level to senior architect. We curate high-quality learning resources, interview questions, videos, and projects from across the internet to help you systematically improve your Java architecture skills. Follow and reply '1024' to get Java programming resources. Learn together, grow together.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.