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.

Java Tech Enthusiast
Java Tech Enthusiast
Java Tech Enthusiast
Build a Web SSH Client with Spring Boot: From Architecture to Code

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 management

Core 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

System Architecture Diagram
System Architecture Diagram
Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

JavaBackend DevelopmentSpring BootWebSocketSSHWeb SSH
Java Tech Enthusiast
Written by

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!

0 followers
Reader feedback

How this landed with the community

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.