Build a Web‑Based SSH Client with Spring Boot and xterm.js – Step‑by‑Step Guide
This tutorial walks you through creating a browser‑accessible SSH client using Spring Boot, JSch, WebSocket, and xterm.js, covering application scenarios, architecture, core implementation details, file transfer features, performance tweaks, and security best practices for production use.
Introduction
In daily operations, administrators often need to connect to multiple servers via SSH. Traditional SSH clients have limitations that can be addressed by a web‑based solution.
Application Scenarios
Web SSH is useful for internal operations, temporary access, and mobile work environments.
Traditional SSH Limitations
Client dependency – requires installation on each device.
Difficult unified management of configurations and permissions.
Lack of centralized audit logs.
Poor mobile device support.
Firewall restrictions may block SSH ports.
Web SSH Advantages
No client installation – works directly in the browser.
Centralized permission management.
Full operation logging and audit.
Mobile‑friendly interface.
Bypasses port restrictions using HTTP/HTTPS.
Technical Stack
Backend : Spring Boot 3.x, JSch, WebSocket, Spring JdbcTemplate.
Frontend : HTML, JavaScript, xterm.js, WebSocket API.
System Architecture
Browser Terminal ←→ WebSocket ←→ Spring Boot Application ←→ SSH Connection ←→ Target Server
↓ ↓
User Interface Data Store
Command Input Operation Log
Result Display Configuration ManagementCore Process
The user enters SSH connection details in the browser; the Spring Boot backend creates an SSH session via JSch, and terminal data is streamed through WebSocket to the xterm.js component for display.
Implementation Steps
1. Project Initialization
<?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>
<groupId>com.example</groupId>
<artifactId>web-ssh-client</artifactId>
<version>1.0.0</version>
<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 for SSH -->
<dependency>
<groupId>com.jcraft</groupId>
<artifactId>jsch</artifactId>
<version>0.1.55</version>
</dependency>
<!-- JDBC and H2 for demo storage -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
</project>2. SSH Connection Manager
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);
throw new RuntimeException("SSH connection failed: " + e.getMessage());
}
}
// Additional methods: getChannel, getSession, closeConnection, isConnected
}3. WebSocket Configuration
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Autowired
private SSHWebSocketHandler sshWebSocketHandler;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(sshWebSocketHandler, "/ssh")
.setAllowedOriginPatterns("*");
}
}4. WebSocket Handler
@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 {
JsonNode json = new ObjectMapper().readTree(message.getPayload());
String type = json.get("type").asText();
switch (type) {
case "connect": handleConnect(session, json); break;
case "command": handleCommand(session, json); break;
case "resize": handleResize(session, json); 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 error: " + e.getMessage());
}
}
// handleConnect, handleCommand, handleResize, handleDisconnect, sendMessage, sendError implementations omitted for brevity
}5. Server Information Management
@Component
public class ServerConfig {
private Long id;
private String name;
private String host;
private Integer port;
private String username;
private String password;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
// getters and setters omitted
}
@Repository
public class ServerRepository {
@Autowired
private JdbcTemplate jdbcTemplate;
private static final String INSERT_SERVER = """
INSERT INTO servers (name, host, port, username, password, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""";
private static final String SELECT_ALL_SERVERS = """
SELECT id, name, host, port, username, password, created_at, updated_at FROM servers ORDER BY created_at DESC
""";
// CRUD methods using jdbcTemplate omitted for brevity
}
@Service
public class ServerService {
@Autowired
private ServerRepository serverRepository;
public Long saveServer(ServerConfig server) {
// password encryption can be added here
return serverRepository.saveServer(server);
}
public List<ServerConfig> getAllServers() {
List<ServerConfig> list = serverRepository.findAllServers();
list.forEach(s -> s.setPassword(null)); // hide passwords
return list;
}
public Optional<ServerConfig> getServerById(Long id) { return serverRepository.findServerById(id); }
public void deleteServer(Long id) { serverRepository.deleteServer(id); }
}6. REST API Controller
@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()));
}
}
}7. Frontend Implementation
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Web SSH 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>
<!-- Sidebar, connection form, terminal container, file manager, modals, etc. -->
<script src="js/webssh-multisession.js"></script>
</body>
</html>8. Database Initialization
CREATE TABLE IF NOT EXISTS servers (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL COMMENT 'Server name',
host VARCHAR(255) NOT NULL COMMENT 'Server address',
port INT DEFAULT 22 COMMENT 'SSH port',
username VARCHAR(100) NOT NULL COMMENT 'Username',
password VARCHAR(500) NOT NULL COMMENT 'Password (store encrypted)',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- Insert sample data
INSERT INTO servers (name, host, port, username, password) VALUES
('Local Test Server', 'localhost', 22, 'root', 'password'),
('Development Server', '192.168.1.100', 22, 'dev', 'devpass'),
('Test Server', '192.168.1.101', 22, 'test', 'testpass'),
('Production Server', '192.168.1.200', 22, 'prod', 'prodpass');Performance Optimization & Best Practices
Use connection pooling, limit concurrent WebSocket sessions, cache server lists, and apply proper thread handling for SSH output streams.
Cache Optimization
@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) { }
}Security Enhancements
@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 article demonstrates how to build a functional Web SSH client with Spring Boot, WebSocket, and xterm.js, covering core architecture, code implementation, file transfer, security hardening, and performance tuning. The project serves as a practical example for learning real‑time WebSocket communication and SSH integration in Java.
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.
