Build a Web SSH Client with Spring Boot: From Architecture to Code
This tutorial walks through creating a browser‑based SSH client using Spring Boot, JSch, WebSocket, and Xterm.js, covering application scenarios, system architecture, backend and frontend implementation, file transfer features, database setup, performance tuning, and security best practices.
Application Scenarios of Web SSH
Traditional SSH clients require installation, are hard to manage centrally, lack unified audit logs, provide poor mobile experience, and can be blocked by firewalls. A Web SSH client accessed through a browser over HTTP/HTTPS overcomes these limitations and is useful for internal operations, cloud management consoles, and teaching environments.
Technical Design
Backend Technology Selection
Spring Boot 3.x supplies the web framework and auto‑configuration. The JSch library implements the SSH2 client. WebSocket enables real‑time bidirectional communication. Spring JdbcTemplate is used for persisting server configurations and operation logs.
Frontend Technology Selection
Plain HTML + JavaScript builds the UI. Xterm.js renders a terminal emulator in the browser, and the WebSocket API connects the frontend to the backend.
System Architecture
Browser terminal ←→ WebSocket ←→ Spring Boot application ←→ SSH connection ←→ Target server
↓ ↓
User interface Data storage
Command input Operation log
Result display Config managementCore Process
Users enter SSH connection details in the browser. The Spring Boot backend creates an SSH session via JSch, then streams terminal data through WebSocket to the Xterm.js component for interactive use.
Implementation Steps
Project Initialization
Create a Spring Boot project and add the following dependencies:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
</parent>
<dependencies>
<!-- Spring Boot core -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- WebSocket support -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- JSch SSH client -->
<dependency>
<groupId>com.jcraft</groupId>
<artifactId>jsch</artifactId>
<version>0.1.55</version>
</dependency>
<!-- JDBC support -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<!-- H2 (dev) or MySQL (prod) -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<!-- JSON handling -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
</dependencies>
</project>SSH Connection Manager
import com.jcraft.jsch.*;
import lombok.*;
import org.slf4j.*;
import org.springframework.stereotype.*;
import java.util.*;
import java.util.concurrent.*;
@Component
@Slf4j
public class SSHConnectionManager {
private final Map<String, Session> connections = new ConcurrentHashMap<>();
private final Map<String, ChannelShell> channels = new ConcurrentHashMap<>();
public String createConnection(String host, int port, String username, String password) {
try {
JSch jsch = new JSch();
Session session = jsch.getSession(username, host, port);
session.setPassword(password);
Properties config = new Properties();
config.put("StrictHostKeyChecking", "no");
config.put("PreferredAuthentications", "password");
session.setConfig(config);
session.connect(30000);
ChannelShell channel = (ChannelShell) session.openChannel("shell");
channel.setPty(true);
channel.setPtyType("xterm", 80, 24, 640, 480);
String connectionId = UUID.randomUUID().toString();
connections.put(connectionId, session);
channels.put(connectionId, channel);
log.info("SSH connection established: {}@{}:{}", username, host, port);
return connectionId;
} catch (JSchException e) {
log.error("SSH connection failed: {}", e.getMessage());
throw new RuntimeException("SSH connection failed: " + e.getMessage());
}
}
public ChannelShell getChannel(String connectionId) {
return channels.get(connectionId);
}
public Session getSession(String connectionId) {
return connections.get(connectionId);
}
public void closeConnection(String connectionId) {
ChannelShell channel = channels.remove(connectionId);
if (channel != null && channel.isConnected()) {
channel.disconnect();
}
Session session = connections.remove(connectionId);
if (session != null && session.isConnected()) {
session.disconnect();
}
log.info("SSH connection closed: {}", connectionId);
}
public boolean isConnected(String connectionId) {
Session session = connections.get(connectionId);
return session != null && session.isConnected();
}
}WebSocket Configuration
import org.springframework.context.annotation.*;
import org.springframework.web.socket.config.annotation.*;
import org.springframework.beans.factory.annotation.*;
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Autowired
private SSHWebSocketHandler sshWebSocketHandler;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(sshWebSocketHandler, "/ssh")
.setAllowedOriginPatterns("*"); // In production restrict origins
}
}WebSocket Handler
import com.jcraft.jsch.*;
import com.fasterxml.jackson.databind.*;
import lombok.*;
import org.slf4j.*;
import org.springframework.stereotype.*;
import org.springframework.web.socket.*;
import org.springframework.web.socket.handler.*;
import java.util.*;
import java.util.concurrent.*;
@Component
@Slf4j
public class SSHWebSocketHandler extends TextWebSocketHandler {
@Autowired
private SSHConnectionManager connectionManager;
private final Map<WebSocketSession, String> sessionConnections = new ConcurrentHashMap<>();
private final Map<WebSocketSession, String> sessionUsers = new ConcurrentHashMap<>();
private final Map<WebSocketSession, Object> sessionLocks = new ConcurrentHashMap<>();
@Override
public void afterConnectionEstablished(WebSocketSession session) {
log.info("WebSocket connection opened: {}", session.getId());
sessionLocks.put(session, new Object());
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
try {
String payload = message.getPayload();
ObjectMapper mapper = new ObjectMapper();
JsonNode jsonNode = mapper.readTree(payload);
String type = jsonNode.get("type").asText();
switch (type) {
case "connect":
handleConnect(session, jsonNode);
break;
case "command":
handleCommand(session, jsonNode);
break;
case "resize":
handleResize(session, jsonNode);
break;
case "disconnect":
handleDisconnect(session);
break;
default:
log.warn("Unknown message type: {}", type);
}
} catch (Exception e) {
log.error("Failed to process WebSocket message", e);
sendError(session, "Message processing failed: " + e.getMessage());
}
}
private void handleConnect(WebSocketSession session, JsonNode jsonNode) {
try {
String host = jsonNode.get("host").asText();
int port = jsonNode.get("port").asInt(22);
String username = jsonNode.get("username").asText();
String password = jsonNode.get("password").asText();
sessionUsers.put(session, username);
String connectionId = connectionManager.createConnection(host, port, username, password);
sessionConnections.put(session, connectionId);
ChannelShell channel = connectionManager.getChannel(connectionId);
startSSHChannel(session, channel);
sendMessage(session, Map.of("type", "connected", "message", "SSH connection established"));
} catch (Exception e) {
log.error("Failed to establish SSH connection", e);
sendError(session, "Connection failed: " + e.getMessage());
}
}
private void handleCommand(WebSocketSession session, JsonNode jsonNode) {
String connectionId = sessionConnections.get(session);
if (connectionId == null) {
sendError(session, "SSH connection not established");
return;
}
String command = jsonNode.get("command").asText();
ChannelShell channel = connectionManager.getChannel(connectionId);
if (channel != null && channel.isConnected()) {
try {
OutputStream out = channel.getOutputStream();
out.write(command.getBytes());
out.flush();
} catch (Exception e) {
log.error("Failed to send SSH command", e);
sendError(session, "Command execution failed");
}
}
}
private void startSSHChannel(WebSocketSession session, ChannelShell channel) {
try {
channel.connect();
InputStream in = channel.getInputStream();
new Thread(() -> {
byte[] buffer = new byte[4096];
try {
while (channel.isConnected() && session.isOpen()) {
if (in.available() > 0) {
int len = in.read(buffer);
if (len > 0) {
String output = new String(buffer, 0, len, "UTF-8");
sendMessage(session, Map.of("type", "output", "data", output));
}
} else {
Thread.sleep(10);
}
}
} catch (Exception e) {
log.warn("SSH output read interrupted: {}", e.getMessage());
}
}, "SSH-Output-Reader-" + session.getId()).start();
} catch (Exception e) {
log.error("Failed to start SSH channel", e);
sendError(session, "Channel start failed: " + e.getMessage());
}
}
private void handleResize(WebSocketSession session, JsonNode jsonNode) {
String connectionId = sessionConnections.get(session);
if (connectionId != null) {
ChannelShell channel = connectionManager.getChannel(connectionId);
if (channel != null) {
try {
int cols = jsonNode.get("cols").asInt();
int rows = jsonNode.get("rows").asInt();
channel.setPtySize(cols, rows, cols * 8, rows * 16);
} catch (Exception e) {
log.warn("Terminal resize failed", e);
}
}
}
}
private void handleDisconnect(WebSocketSession session) {
String connectionId = sessionConnections.remove(session);
sessionUsers.remove(session);
if (connectionId != null) {
connectionManager.closeConnection(connectionId);
}
sessionLocks.remove(session);
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
handleDisconnect(session);
log.info("WebSocket connection closed: {}", session.getId());
}
private void sendMessage(WebSocketSession session, Object message) {
Object lock = sessionLocks.get(session);
if (lock == null) return;
synchronized (lock) {
try {
if (session.isOpen()) {
ObjectMapper mapper = new ObjectMapper();
String json = mapper.writeValueAsString(message);
session.sendMessage(new TextMessage(json));
}
} catch (Exception e) {
log.error("Failed to send WebSocket message", e);
}
}
}
private void sendError(WebSocketSession session, String error) {
sendMessage(session, Map.of("type", "error", "message", error));
}
}Server Information Management
Define a ServerConfig POJO with fields id, name, host, port, username, password, createdAt, updatedAt. Use ServerRepository with JdbcTemplate for CRUD operations, ServerService for business logic, and ServerController exposing REST endpoints:
@RestController
@RequestMapping("/api/servers")
public class ServerController {
@Autowired
private ServerService serverService;
@GetMapping
public ResponseEntity<List<ServerConfig>> getServers() {
return ResponseEntity.ok(serverService.getAllServers());
}
@PostMapping
public ResponseEntity<Map<String, Object>> addServer(@RequestBody ServerConfig server) {
try {
Long id = serverService.saveServer(server);
return ResponseEntity.ok(Map.of("success", true, "id", id));
} catch (Exception e) {
return ResponseEntity.badRequest().body(Map.of("success", false, "message", e.getMessage()));
}
}
@DeleteMapping("/{id}")
public ResponseEntity<Map<String, Object>> deleteServer(@PathVariable Long id) {
try {
serverService.deleteServer(id);
return ResponseEntity.ok(Map.of("success", true));
} catch (Exception e) {
return ResponseEntity.badRequest().body(Map.of("success", false, "message", e.getMessage()));
}
}
@PostMapping("/test")
public ResponseEntity<Map<String, Object>> testConnection(@RequestBody ServerConfig server) {
try {
JSch jsch = new JSch();
Session session = jsch.getSession(server.getUsername(), server.getHost(), server.getPort());
session.setPassword(server.getPassword());
session.setConfig("StrictHostKeyChecking", "no");
session.connect(5000);
session.disconnect();
return ResponseEntity.ok(Map.of("success", true, "message", "Connection test succeeded"));
} catch (Exception e) {
return ResponseEntity.ok(Map.of("success", false, "message", "Connection test failed: " + e.getMessage()));
}
}
}File Transfer Functions
The FileTransferService uses JSch’s SFTP channel to upload, download, list, create directories, delete, and rename files. Helper methods create sessions, handle remote directory creation, and convert permission bits to a Unix‑style string.
@Service
@Slf4j
public class FileTransferService {
public void uploadFile(ServerConfig server, MultipartFile file, String remotePath) throws Exception {
Session session = null;
ChannelSftp sftp = null;
try {
session = createSession(server);
sftp = (ChannelSftp) session.openChannel("sftp");
sftp.connect();
createRemoteDirectory(sftp, remotePath);
String remoteFile = remotePath + "/" + file.getOriginalFilename();
try (InputStream in = file.getInputStream()) {
sftp.put(in, remoteFile);
}
log.info("File uploaded: {} -> {}", file.getOriginalFilename(), remoteFile);
} finally {
closeConnections(sftp, session);
}
}
// downloadFile, listDirectory, deleteRemoteFile, renameRemoteFile, uploadFiles omitted for brevity
private Session createSession(ServerConfig server) throws JSchException {
JSch jsch = new JSch();
Session session = jsch.getSession(server.getUsername(), server.getHost(), server.getPort());
session.setPassword(server.getPassword());
Properties cfg = new Properties();
cfg.put("StrictHostKeyChecking", "no");
cfg.put("PreferredAuthentications", "password");
session.setConfig(cfg);
session.connect(10000);
return session;
}
private void closeConnections(ChannelSftp sftp, Session session) {
if (sftp != null && sftp.isConnected()) sftp.disconnect();
if (session != null && session.isConnected()) session.disconnect();
}
private void createRemoteDirectory(ChannelSftp sftp, String remotePath) {
try {
String[] parts = remotePath.split("/");
String cur = "";
for (String part : parts) {
if (!part.isEmpty()) {
cur += "/" + part;
try { sftp.mkdir(cur); } catch (SftpException ignored) {}
}
}
} catch (Exception e) {
log.warn("Failed to create remote directory: {}", e.getMessage());
}
}
}Frontend Implementation
The HTML page loads xterm.js, provides a sidebar for navigation, a connection form (host, port, username, password, optional name), and buttons to connect, test, and disconnect. A terminal container with tabs displays multiple sessions. JavaScript in webssh-multisession.js handles UI events, WebSocket messaging, and terminal rendering.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Web SSH Enterprise Client</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/css/xterm.css">
<script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/xterm.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/xterm-addon-fit.js"></script>
<!-- Additional CSS omitted for brevity -->
</head>
<body>
<div class="main-container">
<!-- Sidebar, main content, connection form, terminal container, file manager, modals, etc. -->
</div>
<script src="js/webssh-multisession.js"></script>
</body>
</html>Database Initialization
The following SQL creates the servers table and inserts sample records:
CREATE TABLE IF NOT EXISTS servers (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL COMMENT '服务器名称',
host VARCHAR(255) NOT NULL COMMENT '服务器地址',
port INT DEFAULT 22 COMMENT 'SSH端口',
username VARCHAR(100) NOT NULL COMMENT '用户名',
password VARCHAR(500) NOT NULL COMMENT '密码(建议加密存储)',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
DELETE FROM servers;
INSERT INTO servers (name, host, port, username, password) VALUES
('本地测试服务器', 'localhost', 22, 'root', 'password'),
('开发服务器', '192.168.1.100', 22, 'dev', 'devpass'),
('测试服务器', '192.168.1.101', 22, 'test', 'testpass'),
('生产服务器', '192.168.1.200', 22, 'prod', 'prodpass');Performance Optimization and Best Practices
Use Spring Cache to cache server lists per user, reducing database load. Implement a CachedServerService with @Cacheable and @CacheEvict. Security enhancements include AES/GCM password encryption and operation audit via Spring events.
@Service
@EnableCaching
public class CachedServerService {
@Cacheable(value = "servers", key = "#username")
public List<Server> getUserServers(String username) {
return serverRepository.findByCreatedBy(username);
}
@CacheEvict(value = "servers", key = "#username")
public void clearUserServersCache(String username) {
// cache cleared
}
}
@Component
public class SecurityEnhancements {
public String encryptPassword(String password) {
try {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, getSecretKey());
byte[] encrypted = cipher.doFinal(password.getBytes());
return Base64.getEncoder().encodeToString(encrypted);
} catch (Exception e) {
throw new RuntimeException("Password encryption failed", e);
}
}
@EventListener
public void handleSSHCommand(SSHCommandEvent event) {
auditService.logSSHOperation(event.getUsername(), event.getServerHost(), event.getCommand(), event.getTimestamp());
}
}Conclusion
This guide demonstrates how to build a functional Web SSH client with Spring Boot, JSch, WebSocket, and Xterm.js. It covers architecture, core implementation, file transfer, REST APIs, database setup, performance tuning, and security considerations, making it a solid learning example for WebSocket communication and SSH integration. For production use, apply the recommended security hardening, connection pooling, and scaling strategies.
Repository: https://github.com/yuboon/java-examples/tree/master/springboot-web-ssh
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Java Tech Enthusiast
Sharing computer programming language knowledge, focusing on Java fundamentals, data structures, related tools, Spring Cloud, IntelliJ IDEA... Book giveaways, red‑packet rewards and other perks await!
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
