Spring Boot + SSH 客户端:在浏览器中执行远程命令

在日常运维工作中,我们经常需要通过SSH连接多台服务器进行操作。传统的SSH客户端虽然功能完善,但在某些场景下存在一些限制。

本文将介绍如何使用Spring Boot开发一个Web SSH客户端,让用户可以通过浏览器直接连接和操作远程服务器。

这种方案在企业内部运维管理、临时访问、移动办公等场景中具有一定的实用价值。

Web SSH客户端的应用场景

相比传统SSH客户端,Web SSH在以下场景中具有实际价值:

传统SSH客户端的局限性

客户端依赖:需要在每台设备上安装SSH客户端软件

统一管理困难:难以统一管理服务器连接配置和用户权限

操作审计不便:缺乏统一的操作日志记录和管理

移动设备支持有限:在手机、平板上操作体验较差

防火墙限制:某些网络环境下SSH端口可能被阻止

Web SSH的实际优势

无需安装客户端:通过浏览器即可使用,降低部署成本

统一权限管理:可以集中管理用户的服务器访问权限

操作记录可追溯:所有SSH操作都可以记录和审计

移动设备友好:在移动设备上也能提供相对较好的使用体验

绕过端口限制:通过HTTP/HTTPS端口提供服务

这些特点使得Web SSH在企业内部运维平台、云服务管理后台、教学环境等场景中有实际的应用价值。

技术方案设计

本文将基于以下技术栈实现Web SSH客户端:

后端技术选型

Spring Boot 3.x:提供Web框架和自动配置能力

JSch库:Java实现的SSH2客户端,用于建立SSH连接

WebSocket:实现浏览器与服务器间的双向实时通信

Spring JdbcTemplate:轻量级数据库操作,存储服务器配置和操作记录

前端技术选型

HTML + JavaScript:构建Web界面,无需复杂框架

Xterm.js:在浏览器中模拟终端界面

WebSocket API:与后端建立实时通信连接

系统架构

markdown 复制代码
浏览器终端 ←→ WebSocket ←→ Spring Boot应用 ←→ SSH连接 ←→ 目标服务器
     ↓                         ↓
  用户界面                   数据存储
  命令输入                   操作记录
  结果显示                   配置管理

核心流程:用户在浏览器中输入SSH连接信息,Spring Boot后端使用JSch库建立SSH连接,通过WebSocket将终端数据实时传输到前端Xterm.js组件进行显示。

核心功能实现

1. 项目初始化

首先创建Spring Boot项目并添加必要的依赖:

xml 复制代码
<?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>
        <relativePath/>
    </parent>
    
    <groupId>com.example</groupId>
    <artifactId>web-ssh-client</artifactId>
    <version>1.0.0</version>
    <packaging>jar</packaging>
    
    <dependencies>
        <!-- Spring Boot核心依赖 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        
        <!-- WebSocket支持 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-websocket</artifactId>
        </dependency>
        
        <!-- SSH客户端 -->
        <dependency>
            <groupId>com.jcraft</groupId>
            <artifactId>jsch</artifactId>
            <version>0.1.55</version>
        </dependency>
        
        <!-- JDBC支持 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>
        
        <!-- H2数据库(开发测试用) -->
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>runtime</scope>
        </dependency>
        
        <!-- MySQL驱动(生产环境用) -->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>
        
        <!-- JSON处理 -->
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
        </dependency>
    </dependencies>
</project>

2. SSH连接管理器

创建SSH连接管理器,负责建立和维护SSH连接:

typescript 复制代码
@Component
@Slf4j
public class SSHConnectionManager {
    
    private final Map<String, Session> connections = new ConcurrentHashMap<>();
    private final Map<String, ChannelShell> channels = new ConcurrentHashMap<>();
    
    /**
     * 建立SSH连接
     */
    public String createConnection(String host, int port, String username, String password) {
        try {
            JSch jsch = new JSch();
            Session session = jsch.getSession(username, host, port);
            
            // 配置连接参数
            Properties config = new Properties();
            config.put("StrictHostKeyChecking", "no");
            config.put("PreferredAuthentications", "password");
            session.setConfig(config);
            session.setPassword(password);
            
            // 建立连接
            session.connect(30000); // 30秒超时
            
            // 创建Shell通道
            ChannelShell channel = (ChannelShell) session.openChannel("shell");
            channel.setPty(true);
            channel.setPtyType("xterm", 80, 24, 640, 480);
            
            // 生成连接ID
            String connectionId = UUID.randomUUID().toString();
            
            // 保存连接和通道
            connections.put(connectionId, session);
            channels.put(connectionId, channel);
            
            log.info("SSH连接建立成功: {}@{}:{}", username, host, port);
            return connectionId;
            
        } catch (JSchException e) {
            log.error("SSH连接失败: {}", e.getMessage());
            throw new RuntimeException("SSH连接失败: " + e.getMessage());
        }
    }
    
    /**
     * 获取SSH通道
     */
    public ChannelShell getChannel(String connectionId) {
        return channels.get(connectionId);
    }
    
    /**
     * 获取SSH会话
     */
    public Session getSession(String connectionId) {
        return connections.get(connectionId);
    }
    
    /**
     * 关闭SSH连接
     */
    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连接已关闭: {}", connectionId);
    }
    
    /**
     * 检查连接状态
     */
    public boolean isConnected(String connectionId) {
        Session session = connections.get(connectionId);
        return session != null && session.isConnected();
    }
}

3. WebSocket配置

配置WebSocket,实现浏览器与服务器的实时通信:

less 复制代码
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
    
    @Autowired
    private SSHWebSocketHandler sshWebSocketHandler;
    
    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(sshWebSocketHandler, "/ssh")
                .setAllowedOriginPatterns("*"); // 生产环境中应该限制域名
    }
}

4. WebSocket处理器

创建WebSocket处理器,处理SSH命令的发送和接收:

java 复制代码
@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<>();
    
    // 为每个WebSocket会话添加同步锁
    private final Map<WebSocketSession, Object> sessionLocks = new ConcurrentHashMap<>();
    
    @Override
    public void afterConnectionEstablished(WebSocketSession session) {
        log.info("WebSocket连接建立: {}", 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("未知的消息类型: {}", type);
            }
        } catch (Exception e) {
            log.error("处理WebSocket消息失败", e);
            sendError(session, "处理消息失败: " + e.getMessage());
        }
    }
    
    /**
     * 处理SSH连接请求
     */
    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();
            boolean enableCollaboration = jsonNode.has("enableCollaboration") && 
                                        jsonNode.get("enableCollaboration").asBoolean();
            
            // 存储用户信息
            sessionUsers.put(session, username);
            
            // 建立SSH连接
            String connectionId = connectionManager.createConnection(host, port, username, password);
            sessionConnections.put(session, connectionId);
            
            // 启动SSH通道
            ChannelShell channel = connectionManager.getChannel(connectionId);
            startSSHChannel(session, channel);

            // 发送连接成功消息
            Map<String, Object> response = new HashMap<>();
            response.put("type", "connected");
            response.put("message", "SSH连接建立成功");
            sendMessage(session, response);
            
        } catch (Exception e) {
            log.error("建立SSH连接失败", e);
            sendError(session, "连接失败: " + e.getMessage());
        }
    }
    
    /**
     * 处理命令执行请求
     */
    private void handleCommand(WebSocketSession session, JsonNode jsonNode) {
        String connectionId = sessionConnections.get(session);
        if (connectionId == null) {
            sendError(session, "SSH连接未建立");
            return;
        }
        
        String command = jsonNode.get("command").asText();
        ChannelShell channel = connectionManager.getChannel(connectionId);
        String username = sessionUsers.get(session);
        
        if (channel != null && channel.isConnected()) {
            try {
                // 发送命令到SSH通道
                OutputStream out = channel.getOutputStream();
                out.write(command.getBytes());
                out.flush();
            } catch (IOException e) {
                log.error("发送SSH命令失败", e);
                sendError(session, "命令执行失败");
            }
        }
    }
    
    /**
     * 启动SSH通道并处理输出
     */
    private void startSSHChannel(WebSocketSession session, ChannelShell channel) {
        try {
            // 连接通道
            channel.connect();
            
            // 处理SSH输出
            InputStream in = channel.getInputStream();
            
            // 在单独的线程中读取SSH输出
            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 {
                            // 没有数据时短暂休眠,避免CPU占用过高
                            Thread.sleep(10);
                        }
                    }
                } catch (IOException | InterruptedException e) {
                    log.warn("SSH输出读取中断: {}", e.getMessage());
                }
            }, "SSH-Output-Reader-" + session.getId()).start();
            
        } catch (JSchException | IOException e) {
            log.error("启动SSH通道失败", e);
            sendError(session, "通道启动失败: " + 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("调整终端大小失败", e);
                }
            }
        }
    }
    
    /**
     * 处理断开连接
     */
    private void handleDisconnect(WebSocketSession session) {
        String connectionId = sessionConnections.remove(session);
        String username = 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连接关闭: {}", session.getId());
    }
    
    /**
     * 发送消息到WebSocket客户端(线程安全)
     */
    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("发送WebSocket消息失败", e);
            }
        }
    }
    
    /**
     * 发送错误消息
     */
    private void sendError(WebSocketSession session, String error) {
        sendMessage(session, Map.of(
            "type", "error",
            "message", error
        ));
    }
    
    /**
     * 从会话中获取用户信息
     */
    private String getUserFromSession(WebSocketSession session) {
        // 简化实现,实际应用中可以从session中获取认证用户信息
        return "anonymous";
    }
    
    /**
     * 从会话中获取主机信息
     */
    private String getHostFromSession(WebSocketSession session) {
        // 简化实现,实际应用中可以保存连接信息
        return "unknown";
    }
}

5. 服务器信息管理

使用JdbcTemplate进行服务器配置的数据操作:

ini 复制代码
@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;
    
    // 构造函数、getter和setter省略
}

@Repository
public class ServerRepository {
    
    @Autowired
    private JdbcTemplate jdbcTemplate;
    
    private final String INSERT_SERVER = """
        INSERT INTO servers (name, host, port, username, password, created_at, updated_at) 
        VALUES (?, ?, ?, ?, ?, ?, ?)
        """;
    
    private final String SELECT_ALL_SERVERS = """
        SELECT id, name, host, port, username, password, created_at, updated_at 
        FROM servers ORDER BY created_at DESC
        """;
    
    private final String SELECT_SERVER_BY_ID = """
        SELECT id, name, host, port, username, password, created_at, updated_at 
        FROM servers WHERE id = ?
        """;
    
    private final String UPDATE_SERVER = """
        UPDATE servers SET name=?, host=?, port=?, username=?, password=?, updated_at=? 
        WHERE id=?
        """;
    
    private final String DELETE_SERVER = "DELETE FROM servers WHERE id = ?";
    
    public Long saveServer(ServerConfig server) {
        KeyHolder keyHolder = new GeneratedKeyHolder();
        
        jdbcTemplate.update(connection -> {
            PreparedStatement ps = connection.prepareStatement(INSERT_SERVER, Statement.RETURN_GENERATED_KEYS);
            ps.setString(1, server.getName());
            ps.setString(2, server.getHost());
            ps.setInt(3, server.getPort());
            ps.setString(4, server.getUsername());
            ps.setString(5, server.getPassword());
            ps.setTimestamp(6, Timestamp.valueOf(LocalDateTime.now()));
            ps.setTimestamp(7, Timestamp.valueOf(LocalDateTime.now()));
            return ps;
        }, keyHolder);
        
        return keyHolder.getKey().longValue();
    }
    
    public List<ServerConfig> findAllServers() {
        return jdbcTemplate.query(SELECT_ALL_SERVERS, this::mapRowToServer);
    }
    
    public Optional<ServerConfig> findServerById(Long id) {
        try {
            ServerConfig server = jdbcTemplate.queryForObject(SELECT_SERVER_BY_ID, 
                    this::mapRowToServer, id);
            return Optional.ofNullable(server);
        } catch (EmptyResultDataAccessException e) {
            return Optional.empty();
        }
    }
    
    public void updateServer(ServerConfig server) {
        jdbcTemplate.update(UPDATE_SERVER,
                server.getName(),
                server.getHost(), 
                server.getPort(),
                server.getUsername(),
                server.getPassword(),
                Timestamp.valueOf(LocalDateTime.now()),
                server.getId());
    }
    
    public void deleteServer(Long id) {
        jdbcTemplate.update(DELETE_SERVER, id);
    }
    
    private ServerConfig mapRowToServer(ResultSet rs, int rowNum) throws SQLException {
        ServerConfig server = new ServerConfig();
        server.setId(rs.getLong("id"));
        server.setName(rs.getString("name"));
        server.setHost(rs.getString("host"));
        server.setPort(rs.getInt("port"));
        server.setUsername(rs.getString("username"));
        server.setPassword(rs.getString("password"));
        server.setCreatedAt(rs.getTimestamp("created_at").toLocalDateTime());
        server.setUpdatedAt(rs.getTimestamp("updated_at").toLocalDateTime());
        return server;
    }
}

@Service
public class ServerService {
    
    @Autowired
    private ServerRepository serverRepository;
    
    public Long saveServer(ServerConfig server) {
        // 密码加密存储(生产环境建议)
        // server.setPassword(encryptPassword(server.getPassword()));
        return serverRepository.saveServer(server);
    }
    
    public List<ServerConfig> getAllServers() {
        List<ServerConfig> servers = serverRepository.findAllServers();
        // 不返回密码信息到前端
        servers.forEach(server -> server.setPassword(null));
        return servers;
    }
    
    public Optional<ServerConfig> getServerById(Long id) {
        return serverRepository.findServerById(id);
    }
    
    public void deleteServer(Long id) {
        serverRepository.deleteServer(id);
    }
}

6. 文件传输功能

集成SFTP文件传输

ini 复制代码
@Service
@Slf4j
public class FileTransferService {

    /**
     * 上传文件到远程服务器
     */
    public void uploadFile(ServerConfig server, MultipartFile file, String remotePath) throws Exception {
        Session session = null;
        ChannelSftp sftpChannel = null;

        try {
            session = createSession(server);
            sftpChannel = (ChannelSftp) session.openChannel("sftp");
            sftpChannel.connect();

            // 确保远程目录存在
            createRemoteDirectory(sftpChannel, remotePath);

            // 上传文件
            String remoteFilePath = remotePath + "/" + file.getOriginalFilename();
            try (InputStream inputStream = file.getInputStream()) {
                sftpChannel.put(inputStream, remoteFilePath);
            }

            log.info("文件上传成功: {} -> {}", file.getOriginalFilename(), remoteFilePath);

        } finally {
            closeConnections(sftpChannel, session);
        }
    }

    /**
     * 从远程服务器下载文件
     */
    public byte[] downloadFile(ServerConfig server, String remoteFilePath) throws Exception {
        Session session = null;
        ChannelSftp sftpChannel = null;

        try {
            session = createSession(server);
            sftpChannel = (ChannelSftp) session.openChannel("sftp");
            sftpChannel.connect();

            try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
                 InputStream inputStream = sftpChannel.get(remoteFilePath)) {
                
                byte[] buffer = new byte[8192];
                int bytesRead;
                while ((bytesRead = inputStream.read(buffer)) != -1) {
                    outputStream.write(buffer, 0, bytesRead);
                }

                log.info("文件下载成功: {}", remoteFilePath);
                return outputStream.toByteArray();
            }

        } finally {
            closeConnections(sftpChannel, session);
        }
    }

    /**
     * 列出远程目录内容
     */
    @SuppressWarnings("unchecked")
    public List<FileInfo> listDirectory(ServerConfig server, String remotePath) throws Exception {
        Session session = null;
        ChannelSftp sftpChannel = null;
        List<FileInfo> files = new ArrayList<>();

        try {
            session = createSession(server);
            sftpChannel = (ChannelSftp) session.openChannel("sftp");
            sftpChannel.connect();

            Vector<ChannelSftp.LsEntry> entries = sftpChannel.ls(remotePath);
            
            for (ChannelSftp.LsEntry entry : entries) {
                String filename = entry.getFilename();
                if (!filename.equals(".") && !filename.equals("..")) {
                    SftpATTRS attrs = entry.getAttrs();
                    files.add(new FileInfo(
                        filename,
                        attrs.isDir(),
                        attrs.getSize(),
                        attrs.getMTime() * 1000L, // Convert to milliseconds
                        getPermissionString(attrs.getPermissions())
                    ));
                }
            }

            log.info("目录列表获取成功: {}, 文件数: {}", remotePath, files.size());
            return files;

        } finally {
            closeConnections(sftpChannel, session);
        }
    }

    /**
     * 创建远程目录
     */
    public void createRemoteDirectory(ServerConfig server, String remotePath) throws Exception {
        Session session = null;
        ChannelSftp sftpChannel = null;

        try {
            session = createSession(server);
            sftpChannel = (ChannelSftp) session.openChannel("sftp");
            sftpChannel.connect();

            createRemoteDirectory(sftpChannel, remotePath);
            log.info("远程目录创建成功: {}", remotePath);

        } finally {
            closeConnections(sftpChannel, session);
        }
    }

    /**
     * 删除远程文件或目录
     */
    public void deleteRemoteFile(ServerConfig server, String remotePath, boolean isDirectory) throws Exception {
        Session session = null;
        ChannelSftp sftpChannel = null;

        try {
            session = createSession(server);
            sftpChannel = (ChannelSftp) session.openChannel("sftp");
            sftpChannel.connect();

            if (isDirectory) {
                sftpChannel.rmdir(remotePath);
            } else {
                sftpChannel.rm(remotePath);
            }

            log.info("远程文件删除成功: {}", remotePath);

        } finally {
            closeConnections(sftpChannel, session);
        }
    }

    /**
     * 重命名远程文件
     */
    public void renameRemoteFile(ServerConfig server, String oldPath, String newPath) throws Exception {
        Session session = null;
        ChannelSftp sftpChannel = null;

        try {
            session = createSession(server);
            sftpChannel = (ChannelSftp) session.openChannel("sftp");
            sftpChannel.connect();

            sftpChannel.rename(oldPath, newPath);
            log.info("文件重命名成功: {} -> {}", oldPath, newPath);

        } finally {
            closeConnections(sftpChannel, session);
        }
    }

    /**
     * 批量上传文件
     */
    public void uploadFiles(ServerConfig server, MultipartFile[] files, String remotePath) throws Exception {
        Session session = null;
        ChannelSftp sftpChannel = null;

        try {
            session = createSession(server);
            sftpChannel = (ChannelSftp) session.openChannel("sftp");
            sftpChannel.connect();

            // 确保远程目录存在
            createRemoteDirectory(sftpChannel, remotePath);

            for (MultipartFile file : files) {
                if (!file.isEmpty()) {
                    String remoteFilePath = remotePath + "/" + file.getOriginalFilename();
                    try (InputStream inputStream = file.getInputStream()) {
                        sftpChannel.put(inputStream, remoteFilePath);
                        log.info("文件上传成功: {}", file.getOriginalFilename());
                    }
                }
            }

            log.info("批量上传完成,共上传 {} 个文件", files.length);

        } finally {
            closeConnections(sftpChannel, session);
        }
    }

    // 私有辅助方法

    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 config = new Properties();
        config.put("StrictHostKeyChecking", "no");
        config.put("PreferredAuthentications", "password");
        session.setConfig(config);
        session.connect(10000); // 10秒超时

        return session;
    }

    private void createRemoteDirectory(ChannelSftp sftpChannel, String remotePath) {
        try {
            String[] pathParts = remotePath.split("/");
            String currentPath = "";

            for (String part : pathParts) {
                if (!part.isEmpty()) {
                    currentPath += "/" + part;
                    try {
                        sftpChannel.mkdir(currentPath);
                    } catch (SftpException e) {
                        log.error(e.getMessage(),e);
                    }
                }
            }
        } catch (Exception e) {
            log.warn("创建远程目录失败: {}", e.getMessage());
        }
    }

    private void closeConnections(ChannelSftp sftpChannel, Session session) {
        if (sftpChannel != null && sftpChannel.isConnected()) {
            sftpChannel.disconnect();
        }
        if (session != null && session.isConnected()) {
            session.disconnect();
        }
    }

    private String getPermissionString(int permissions) {
        StringBuilder sb = new StringBuilder();
        
        // Owner permissions
        sb.append((permissions & 0400) != 0 ? 'r' : '-');
        sb.append((permissions & 0200) != 0 ? 'w' : '-');
        sb.append((permissions & 0100) != 0 ? 'x' : '-');
        
        // Group permissions
        sb.append((permissions & 0040) != 0 ? 'r' : '-');
        sb.append((permissions & 0020) != 0 ? 'w' : '-');
        sb.append((permissions & 0010) != 0 ? 'x' : '-');
        
        // Others permissions
        sb.append((permissions & 0004) != 0 ? 'r' : '-');
        sb.append((permissions & 0002) != 0 ? 'w' : '-');
        sb.append((permissions & 0001) != 0 ? 'x' : '-');
        
        return sb.toString();
    }

    // 文件信息内部类
    public static class FileInfo {
        private String name;
        private boolean isDirectory;
        private long size;
        private long lastModified;
        private String permissions;

        public FileInfo(String name, boolean isDirectory, long size, long lastModified, String permissions) {
            this.name = name;
            this.isDirectory = isDirectory;
            this.size = size;
            this.lastModified = lastModified;
            this.permissions = permissions;
        }

        // Getters
        public String getName() { return name; }
        public boolean isDirectory() { return isDirectory; }
        public long getSize() { return size; }
        public long getLastModified() { return lastModified; }
        public String getPermissions() { return permissions; }
    }
}

7. REST API控制器

创建REST API来管理服务器配置:

less 复制代码
@RestController
@RequestMapping("/api/servers")
public class ServerController {
    
    @Autowired
    private ServerService serverService;
    
    /**
     * 获取服务器列表
     */
    @GetMapping
    public ResponseEntity<List<ServerConfig>> getServers() {
        List<ServerConfig> servers = serverService.getAllServers();
        return ResponseEntity.ok(servers);
    }
    
    /**
     * 添加服务器
     */
    @PostMapping
    public ResponseEntity<Map<String, Object>> addServer(@RequestBody ServerConfig server) {
        try {
            Long serverId = serverService.saveServer(server);
            return ResponseEntity.ok(Map.of("success", true, "id", serverId));
        } 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); // 5秒超时
            session.disconnect();
            
            return ResponseEntity.ok(Map.of("success", true, "message", "连接测试成功"));
        } catch (Exception e) {
            return ResponseEntity.ok(Map.of("success", false, "message", "连接测试失败: " + e.getMessage()));
        }
    }
}

8. 前端实现

使用纯HTML + JavaScript集成xterm.js

xml 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Web SSH 企业版客户端</title>
    <!-- 引入xterm.js -->
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" />
    <script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
    <!-- 引入Font Awesome图标 -->
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.4.0/css/all.min.css">
    
    <style>
        /* 考虑篇幅,此处忽略样式代码 */
    </style>
</head>
<body>
    <div class="main-container">
        <!-- 侧边栏 -->
        <div class="sidebar" id="sidebar">
            <div class="sidebar-header">
                <div class="sidebar-title">
                    <i class="fas fa-terminal"></i>
                    <span id="sidebarTitle">Web SSH</span>
                </div>
                <button class="sidebar-toggle" onclick="toggleSidebar()">
                    <i class="fas fa-bars"></i>
                </button>
            </div>
            
            <nav class="sidebar-nav">
                <div class="nav-item active" onclick="switchPage('ssh')">
                    <i class="fas fa-terminal nav-icon"></i>
                    <span class="nav-text">SSH连接</span>
                </div>
                <div class="nav-item" onclick="switchPage('files')">
                    <i class="fas fa-folder nav-icon"></i>
                    <span class="nav-text">文件管理</span>
                </div>
            </nav>
        </div>
        
        <!-- 主内容区 -->
        <div class="main-content">
            <!-- SSH连接页面 -->
            <div class="page-content active" id="page-ssh">
                <div class="content-header">
                    <h1 class="content-title">SSH连接管理</h1>
                    <div class="action-buttons">
                        <button class="btn btn-secondary" onclick="loadSavedServers()">
                            <i class="fas fa-download"></i> 加载保存的服务器
                        </button>
                    </div>
                </div>
                
                <!-- 连接面板 -->
                <div class="connection-panel">
                    <div class="connection-form">
                        <div class="form-group">
                            <label for="savedServers">快速连接</label>
                            <select id="savedServers" onchange="loadServerConfig()">
                                <option value="">选择已保存的服务器...</option>
                            </select>
                        </div>
                        <div class="form-group">
                            <label for="host">服务器地址</label>
                            <input type="text" id="host" placeholder="192.168.1.100 或 example.com" value="localhost">
                        </div>
                        <div class="form-group">
                            <label for="port">端口</label>
                            <input type="number" id="port" placeholder="22" value="22">
                        </div>
                        <div class="form-group">
                            <label for="username">用户名</label>
                            <input type="text" id="username" placeholder="root">
                        </div>
                        <div class="form-group">
                            <label for="password">密码</label>
                            <input type="password" id="password" placeholder="密码">
                        </div>
                        <div class="form-group">
                            <label for="serverName">服务器名称(可选)</label>
                            <input type="text" id="serverName" placeholder="给这个连接起个名字">
                        </div>
                    </div>
                    
                    
                    <div class="checkbox-group">
                        <input type="checkbox" id="saveServer">
                        <label for="saveServer">保存此服务器配置</label>
                    </div>
                    
                    <div style="margin-top: 20px; display: flex; gap: 10px;">
                        <button class="btn btn-primary" onclick="connectSSH()">
                            <i class="fas fa-plug"></i> 连接
                        </button>
                        <button class="btn btn-success" onclick="testConnection()" id="testBtn">
                            <i class="fas fa-check"></i> 测试连接
                        </button>
                        <button class="btn btn-danger" onclick="disconnectSSH()" disabled id="disconnectBtn">
                            <i class="fas fa-times"></i> 断开连接
                        </button>
                    </div>
                    
                    <!-- 状态提示 -->
                    <div id="alertContainer"></div>
                </div>
                
                <!-- 终端容器 -->
                <div class="terminal-container hidden" id="terminalContainer">
                    <!-- Tab栏 -->
                    <div class="terminal-tabs" id="terminalTabs">
                        <!-- tabs will be added dynamically -->
                    </div>
                    
                    <!-- Terminal内容区 -->
                    <div class="terminal-content" id="terminalContent">
                        <!-- terminals will be added dynamically -->
                    </div>
                    
                    <div class="status-bar">
                        <span id="statusBar">就绪</span>
                        <span id="terminalStats">行: 24, 列: 80</span>
                    </div>
                </div>
            </div>
            
            <!-- 文件管理页面 -->
            <div class="page-content" id="page-files">
                <div class="content-header">
                    <h1 class="content-title">文件管理器</h1>
                    <div class="action-buttons">
                        <button class="btn btn-primary" onclick="showUploadModal()">
                            <i class="fas fa-upload"></i> 上传文件
                        </button>
                        <button class="btn btn-success" onclick="createFolder()">
                            <i class="fas fa-folder-plus"></i> 新建文件夹
                        </button>
                    </div>
                </div>
                
                <div class="file-manager" id="fileManager">
                    <div class="file-manager-header">
                        <div class="file-path">
                            <button class="btn btn-secondary" onclick="navigateUp()">
                                <i class="fas fa-arrow-up"></i>
                            </button>
                            <input type="text" id="currentPath" value="/" readonly>
                            <button class="btn btn-secondary" onclick="refreshFiles()">
                                <i class="fas fa-sync"></i>
                            </button>
                        </div>
                        <div class="file-actions">
                            <select id="fileServerSelect" onchange="switchFileServer()" style="padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; background: white;">
                                <option value="">选择服务器...</option>
                            </select>
                        </div>
                    </div>
                    <div class="file-grid" id="fileGrid">
                        <div class="alert alert-info">
                            请先选择一个服务器来浏览文件
                        </div>
                    </div>
                </div>
            </div>
            
        </div>
    </div>
    
    <!-- 弹窗 -->
    <!-- 文件上传弹窗 -->
    <div class="modal" id="uploadModal">
        <div class="modal-content">
            <div class="modal-header">
                <h3 class="modal-title">上传文件</h3>
                <button class="modal-close" onclick="closeModal('uploadModal')">&times;</button>
            </div>
            <div>
                <div class="form-group">
                    <label for="uploadFiles">选择文件</label>
                    <input type="file" id="uploadFiles" multiple>
                </div>
                <div class="form-group">
                    <label for="uploadPath">上传路径</label>
                    <input type="text" id="uploadPath" value="/" required>
                </div>
                <div style="text-align: right; margin-top: 20px;">
                    <button type="button" class="btn btn-secondary" onclick="closeModal('uploadModal')">取消</button>
                    <button type="button" class="btn btn-primary" onclick="handleUpload(); return false;">上传</button>
                </div>
            </div>
        </div>
    </div>
    

    <!-- JavaScript代码 -->
    <script src="js/webssh-multisession.js"></script>
</body>
</html>

9. 数据库初始化

创建必要的数据库表结构:

sql 复制代码
-- 服务器配置表
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');

应用配置文件:

yaml 复制代码
# 生产环境配置
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/app_config?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
    driver-class-name: com.mysql.cj.jdbc.Driver
    username: root
    password: root
    hikari:
      maximum-pool-size: 20
      minimum-idle: 5
      connection-timeout: 30000

server:
  port: 8080
  servlet:
    context-path: /
  compression:
    enabled: true
    mime-types: text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json
  tomcat:
    max-connections: 200
    threads:
      max: 100
      min-spare: 10

logging:
  level:
    root: INFO
    com.example.webssh: DEBUG
  file:
    name: logs/webssh.log
  pattern:
    file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"

# 自定义配置
webssh:
  ssh:
    connection-timeout: 30000
    session-timeout: 1800000
    max-connections-per-user: 10
  file:
    upload-max-size: 100MB
    temp-dir: /tmp/webssh-uploads
  collaboration:
    enabled: true
    max-participants: 10
    session-timeout: 3600000

性能优化与最佳实践

1. 缓存优化

less 复制代码
@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) {
        // 清理缓存
    }
}

2. 安全增强

csharp 复制代码
@Component
public class SecurityEnhancements {
    
    /**
     * 密码加密存储
     */
    public String encryptPassword(String password) {
        try {
            Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
            cipher.init(Cipher.ENCRYPT_MODE, getSecretKey());
            byte[] encryptedPassword = cipher.doFinal(password.getBytes());
            return Base64.getEncoder().encodeToString(encryptedPassword);
        } catch (Exception e) {
            throw new RuntimeException("密码加密失败", e);
        }
    }
    
    /**
     * 操作审计
     */
    @EventListener
    public void handleSSHCommand(SSHCommandEvent event) {
        auditService.logSSHOperation(
            event.getUsername(),
            event.getServerHost(), 
            event.getCommand(),
            event.getTimestamp()
        );
    }
}

总结

本文介绍了如何使用Spring Boot开发一个基础的Web SSH客户端。

通过JSch库处理SSH连接,WebSocket实现实时通信,JdbcTemplate进行数据存储,我们构建了一个功能完整的Web SSH解决方案。

这个项目适合作为学习WebSocket通信、SSH协议应用的实践案例。

在实际生产环境中使用时,还需要考虑以下几个方面:

安全注意事项

  • 密码应该加密存储,不要明文保存
  • 添加用户认证机制,避免无权限访问
  • 考虑使用SSH密钥认证替代密码认证
  • 限制可连接的服务器范围和用户权限

性能优化

  • SSH连接池管理,避免频繁建立连接
  • WebSocket连接数量控制
  • 大量输出时的数据传输优化

仓库地址:github.com/yuboon/java...

相关推荐
听风同学35 分钟前
RAG的灵魂-向量数据库技术深度解析
后端·架构
橙序员小站39 分钟前
搞定系统面试题:如何实现分布式Session管理
java·后端·面试
老青蛙1 小时前
权限系统设计-功能设计
后端
粘豆煮包1 小时前
脑抽研究生Go并发-1-基本并发原语-下-Cond、Once、Map、Pool、Context
后端·go
IT_陈寒1 小时前
Vite5.0性能翻倍秘籍:7个极致优化技巧让你的开发体验飞起来!
前端·人工智能·后端
Edward.W1 小时前
用 Go + HTML 实现 OpenHarmony 投屏(hdckit-go + WebSocket + Canvas 实战)
开发语言·后端·golang
南囝coding2 小时前
Claude 封禁中国?为啥我觉得是个好消息
前端·后端
六边形工程师2 小时前
Docker安装神通数据库ShenTong
后端
六边形工程师2 小时前
快速入门神通数据库
后端
重生成为编程大王2 小时前
FreeMarker快速入门指南
java·后端