https://mp.weixin.qq.com/s/pRn9Rb8JmOlixVhwHGw2Pg

仓库地址:
https://github.com/yuboon/java-examples/tree/master/springboot-web-ssh

https://github.com/gogo1008/java-examples

在日常运维工作中,我们经常需要通过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: 与后端建立实时通信连接

系统架构
浏览器终端 ←→ WebSocket ←→ Spring Boot应用 ←→ SSH连接 ←→ 目标服务器
↓ ↓
用户界面 数据存储
命令输入 操作记录
结果显示 配置管理
核心流程:

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

核心功能实现

  1. 项目初始化
    首先创建Spring Boot项目并添加必要的依赖:
<?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>
  1. SSH连接管理器
    创建SSH连接管理器,负责建立和维护SSH连接:
@Component
@Slf4j
publicclassSSHConnectionManager {

    privatefinal Map<String, Session> connections = newConcurrentHashMap<>();
    privatefinal Map<String, ChannelShell> channels = newConcurrentHashMap<>();

    /**
     * 建立SSH连接
     */
    public String createConnection(String host, int port, String username, String password) {
        try {
            JSchjsch=newJSch();
            Sessionsession= jsch.getSession(username, host, port);

            // 配置连接参数
            Propertiesconfig=newProperties();
            config.put("StrictHostKeyChecking", "no");
            config.put("PreferredAuthentications", "password");
            session.setConfig(config);
            session.setPassword(password);

            // 建立连接
            session.connect(30000); // 30秒超时

            // 创建Shell通道
            ChannelShellchannel= (ChannelShell) session.openChannel("shell");
            channel.setPty(true);
            channel.setPtyType("xterm", 80, 24, 640, 480);

            // 生成连接ID
            StringconnectionId= 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());
            thrownewRuntimeException("SSH连接失败: " + e.getMessage());
        }
    }

    /**
     * 获取SSH通道
     */
    public ChannelShell getChannel(String connectionId) {
        return channels.get(connectionId);
    }

    /**
     * 获取SSH会话
     */
    public Session getSession(String connectionId) {
        return connections.get(connectionId);
    }

    /**
     * 关闭SSH连接
     */
    publicvoidcloseConnection(String connectionId) {
        ChannelShellchannel= channels.remove(connectionId);
        if (channel != null && channel.isConnected()) {
            channel.disconnect();
        }

        Sessionsession= connections.remove(connectionId);
        if (session != null && session.isConnected()) {
            session.disconnect();
        }

        log.info("SSH连接已关闭: {}", connectionId);
    }

    /**
     * 检查连接状态
     */
    publicbooleanisConnected(String connectionId) {
        Sessionsession= connections.get(connectionId);
        return session != null && session.isConnected();
    }
}
  1. WebSocket配置
    配置WebSocket,实现浏览器与服务器的实时通信:
@Configuration
@EnableWebSocket
publicclassWebSocketConfigimplementsWebSocketConfigurer {

    @Autowired
    private SSHWebSocketHandler sshWebSocketHandler;

    @Override
    publicvoidregisterWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(sshWebSocketHandler, "/ssh")
                .setAllowedOriginPatterns("*"); // 生产环境中应该限制域名
    }
}
  1. WebSocket处理器
    创建WebSocket处理器,处理SSH命令的发送和接收:
@Component
@Slf4j
publicclassSSHWebSocketHandlerextendsTextWebSocketHandler {

    @Autowired
    private SSHConnectionManager connectionManager;

    privatefinal Map<WebSocketSession, String> sessionConnections = newConcurrentHashMap<>();
    privatefinal Map<WebSocketSession, String> sessionUsers = newConcurrentHashMap<>();

    // 为每个WebSocket会话添加同步锁
    privatefinal Map<WebSocketSession, Object> sessionLocks = newConcurrentHashMap<>();

    @Override
    publicvoidafterConnectionEstablished(WebSocketSession session) {
        log.info("WebSocket连接建立: {}", session.getId());
        // 为每个会话创建同步锁
        sessionLocks.put(session, newObject());
    }

    @Override
    protectedvoidhandleTextMessage(WebSocketSession session, TextMessage message)throws Exception {
        try {
            Stringpayload= message.getPayload();
            ObjectMappermapper=newObjectMapper();
            JsonNodejsonNode= mapper.readTree(payload);

            Stringtype= 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连接请求
     */
    privatevoidhandleConnect(WebSocketSession session, JsonNode jsonNode) {
        try {
            Stringhost= jsonNode.get("host").asText();
            intport= jsonNode.get("port").asInt(22);
            Stringusername= jsonNode.get("username").asText();
            Stringpassword= jsonNode.get("password").asText();
            booleanenableCollaboration= jsonNode.has("enableCollaboration") && 
                                        jsonNode.get("enableCollaboration").asBoolean();

            // 存储用户信息
            sessionUsers.put(session, username);

            // 建立SSH连接
            StringconnectionId= connectionManager.createConnection(host, port, username, password);
            sessionConnections.put(session, connectionId);

            // 启动SSH通道
            ChannelShellchannel= connectionManager.getChannel(connectionId);
            startSSHChannel(session, channel);

            // 发送连接成功消息
            Map<String, Object> response = newHashMap<>();
            response.put("type", "connected");
            response.put("message", "SSH连接建立成功");
            sendMessage(session, response);

        } catch (Exception e) {
            log.error("建立SSH连接失败", e);
            sendError(session, "连接失败: " + e.getMessage());
        }
    }

    /**
     * 处理命令执行请求
     */
    privatevoidhandleCommand(WebSocketSession session, JsonNode jsonNode) {
        StringconnectionId= sessionConnections.get(session);
        if (connectionId == null) {
            sendError(session, "SSH连接未建立");
            return;
        }

        Stringcommand= jsonNode.get("command").asText();
        ChannelShellchannel= connectionManager.getChannel(connectionId);
        Stringusername= sessionUsers.get(session);

        if (channel != null && channel.isConnected()) {
            try {
                // 发送命令到SSH通道
                OutputStreamout= channel.getOutputStream();
                out.write(command.getBytes());
                out.flush();
            } catch (IOException e) {
                log.error("发送SSH命令失败", e);
                sendError(session, "命令执行失败");
            }
        }
    }

    /**
     * 启动SSH通道并处理输出
     */
    privatevoidstartSSHChannel(WebSocketSession session, ChannelShell channel) {
        try {
            // 连接通道
            channel.connect();

            // 处理SSH输出
            InputStreamin= channel.getInputStream();

            // 在单独的线程中读取SSH输出
            newThread(() -> {
                byte[] buffer = newbyte[4096];
                try {
                    while (channel.isConnected() && session.isOpen()) {
                        if (in.available() > 0) {
                            intlen= in.read(buffer);
                            if (len > 0) {
                                Stringoutput=newString(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());
        }
    }

    /**
     * 处理终端大小调整
     */
    privatevoidhandleResize(WebSocketSession session, JsonNode jsonNode) {
        StringconnectionId= sessionConnections.get(session);
        if (connectionId != null) {
            ChannelShellchannel= connectionManager.getChannel(connectionId);
            if (channel != null) {
                try {
                    intcols= jsonNode.get("cols").asInt();
                    introws= jsonNode.get("rows").asInt();
                    channel.setPtySize(cols, rows, cols * 8, rows * 16);
                } catch (Exception e) {
                    log.warn("调整终端大小失败", e);
                }
            }
        }
    }

    /**
     * 处理断开连接
     */
    privatevoidhandleDisconnect(WebSocketSession session) {
        StringconnectionId= sessionConnections.remove(session);
        Stringusername= sessionUsers.remove(session);

        if (connectionId != null) {
            connectionManager.closeConnection(connectionId);
        }
        // 清理锁资源
        sessionLocks.remove(session);
    }

    @Override
    publicvoidafterConnectionClosed(WebSocketSession session, CloseStatus status) {
        handleDisconnect(session);
        log.info("WebSocket连接关闭: {}", session.getId());
    }

    /**
     * 发送消息到WebSocket客户端(线程安全)
     */
    privatevoidsendMessage(WebSocketSession session, Object message) {
        Objectlock= sessionLocks.get(session);
        if (lock == null) return;

        synchronized (lock) {
            try {
                if (session.isOpen()) {
                    ObjectMappermapper=newObjectMapper();
                    Stringjson= mapper.writeValueAsString(message);
                    session.sendMessage(newTextMessage(json));
                }
            } catch (Exception e) {
                log.error("发送WebSocket消息失败", e);
            }
        }
    }

    /**
     * 发送错误消息
     */
    privatevoidsendError(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";
    }
}
  1. 服务器信息管理
    使用JdbcTemplate进行服务器配置的数据操作:
@Component
publicclassServerConfig {
    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
publicclassServerRepository {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    privatefinalStringINSERT_SERVER="""
        INSERT INTO servers (name, host, port, username, password, created_at, updated_at) 
        VALUES (?, ?, ?, ?, ?, ?, ?)
        """;

    privatefinalStringSELECT_ALL_SERVERS="""
        SELECT id, name, host, port, username, password, created_at, updated_at 
        FROM servers ORDER BY created_at DESC
        """;

    privatefinalStringSELECT_SERVER_BY_ID="""
        SELECT id, name, host, port, username, password, created_at, updated_at 
        FROM servers WHERE id = ?
        """;

    privatefinalStringUPDATE_SERVER="""
        UPDATE servers SET name=?, host=?, port=?, username=?, password=?, updated_at=? 
        WHERE id=?
        """;

    privatefinalStringDELETE_SERVER="DELETE FROM servers WHERE id = ?";

    public Long saveServer(ServerConfig server) {
        KeyHolderkeyHolder=newGeneratedKeyHolder();

        jdbcTemplate.update(connection -> {
            PreparedStatementps= 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 {
            ServerConfigserver= jdbcTemplate.queryForObject(SELECT_SERVER_BY_ID, 
                    this::mapRowToServer, id);
            return Optional.ofNullable(server);
        } catch (EmptyResultDataAccessException e) {
            return Optional.empty();
        }
    }

    publicvoidupdateServer(ServerConfig server) {
        jdbcTemplate.update(UPDATE_SERVER,
                server.getName(),
                server.getHost(), 
                server.getPort(),
                server.getUsername(),
                server.getPassword(),
                Timestamp.valueOf(LocalDateTime.now()),
                server.getId());
    }

    publicvoiddeleteServer(Long id) {
        jdbcTemplate.update(DELETE_SERVER, id);
    }

    private ServerConfig mapRowToServer(ResultSet rs, int rowNum)throws SQLException {
        ServerConfigserver=newServerConfig();
        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
publicclassServerService {

    @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);
    }

    publicvoiddeleteServer(Long id) {
        serverRepository.deleteServer(id);
    }
}
6. 文件传输功能
集成SFTP文件传输

@Service
@Slf4j
publicclassFileTransferService {

    /**
     * 上传文件到远程服务器
     */
    publicvoiduploadFile(ServerConfig server, MultipartFile file, String remotePath)throws Exception {
        Sessionsession=null;
        ChannelSftpsftpChannel=null;

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

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

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

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

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

    /**
     * 从远程服务器下载文件
     */
    publicbyte[] downloadFile(ServerConfig server, String remoteFilePath) throws Exception {
        Sessionsession=null;
        ChannelSftpsftpChannel=null;

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

            try (ByteArrayOutputStreamoutputStream=newByteArrayOutputStream();
                 InputStreaminputStream= sftpChannel.get(remoteFilePath)) {

                byte[] buffer = newbyte[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 {
        Sessionsession=null;
        ChannelSftpsftpChannel=null;
        List<FileInfo> files = newArrayList<>();

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

            Vector<ChannelSftp.LsEntry> entries = sftpChannel.ls(remotePath);

            for (ChannelSftp.LsEntry entry : entries) {
                Stringfilename= entry.getFilename();
                if (!filename.equals(".") && !filename.equals("..")) {
                    SftpATTRSattrs= entry.getAttrs();
                    files.add(newFileInfo(
                        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);
        }
    }

    /**
     * 创建远程目录
     */
    publicvoidcreateRemoteDirectory(ServerConfig server, String remotePath)throws Exception {
        Sessionsession=null;
        ChannelSftpsftpChannel=null;

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

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

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

    /**
     * 删除远程文件或目录
     */
    publicvoiddeleteRemoteFile(ServerConfig server, String remotePath, boolean isDirectory)throws Exception {
        Sessionsession=null;
        ChannelSftpsftpChannel=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);
        }
    }

    /**
     * 重命名远程文件
     */
    publicvoidrenameRemoteFile(ServerConfig server, String oldPath, String newPath)throws Exception {
        Sessionsession=null;
        ChannelSftpsftpChannel=null;

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

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

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

    /**
     * 批量上传文件
     */
    publicvoiduploadFiles(ServerConfig server, MultipartFile[] files, String remotePath)throws Exception {
        Sessionsession=null;
        ChannelSftpsftpChannel=null;

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

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

            for (MultipartFile file : files) {
                if (!file.isEmpty()) {
                    StringremoteFilePath= remotePath + "/" + file.getOriginalFilename();
                    try (InputStreaminputStream= 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 {
        JSchjsch=newJSch();
        Sessionsession= jsch.getSession(server.getUsername(), server.getHost(), server.getPort());
        session.setPassword(server.getPassword());

        Propertiesconfig=newProperties();
        config.put("StrictHostKeyChecking", "no");
        config.put("PreferredAuthentications", "password");
        session.setConfig(config);
        session.connect(10000); // 10秒超时

        return session;
    }

    privatevoidcreateRemoteDirectory(ChannelSftp sftpChannel, String remotePath) {
        try {
            String[] pathParts = remotePath.split("/");
            StringcurrentPath="";

            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());
        }
    }

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

    private String getPermissionString(int permissions) {
        StringBuildersb=newStringBuilder();

        // 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();
    }

    // 文件信息内部类
    publicstaticclassFileInfo {
        private String name;
        privateboolean isDirectory;
        privatelong size;
        privatelong lastModified;
        private String permissions;

        publicFileInfo(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; }
        publicbooleanisDirectory() { return isDirectory; }
        publiclonggetSize() { return size; }
        publiclonggetLastModified() { return lastModified; }
        public String getPermissions() { return permissions; }
    }
}
7. REST API控制器
创建REST API来管理服务器配置:

@RestController
@RequestMapping("/api/servers")
publicclassServerController {

    @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 {
            LongserverId= 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 {
            // 简单的连接测试
            JSchjsch=newJSch();
            Sessionsession= 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()));
        }
    }
}
  1. 前端实现
    使用纯HTML + JavaScript集成xterm.js
<!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>
  1. 数据库初始化
    创建必要的数据库表结构:
-- 服务器配置表
CREATE TABLE IF NOTEXISTS servers (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100) NOT NULL COMMENT '服务器名称',
    host VARCHAR(255) NOT NULL COMMENT '服务器地址',
    port INTDEFAULT22 COMMENT 'SSH端口',
    username VARCHAR(100) NOT NULL COMMENT '用户名',
    password VARCHAR(500) NOT NULL COMMENT '密码(建议加密存储)',
    created_at TIMESTAMPDEFAULTCURRENT_TIMESTAMP,
    updated_at TIMESTAMPDEFAULTCURRENT_TIMESTAMPONUPDATECURRENT_TIMESTAMP
);

-- 删除现有测试数据(避免重复插入)
DELETEFROM 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');
应用配置文件:

# 生产环境配置
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. 缓存优化
@Service
@EnableCaching
publicclassCachedServerService {

    @Cacheable(value = "servers", key = "#username")
    public List<Server> getUserServers(String username) {
        return serverRepository.findByCreatedBy(username);
    }

    @CacheEvict(value = "servers", key = "#username")
    publicvoidclearUserServersCache(String username) {
        // 清理缓存
    }
}
  1. 安全增强
@Component
publicclassSecurityEnhancements {

    /**
     * 密码加密存储
     */
    public String encryptPassword(String password) {
        try {
            Ciphercipher= 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) {
            thrownewRuntimeException("密码加密失败", e);
        }
    }

    /**
     * 操作审计
     */
    @EventListener
    publicvoidhandleSSHCommand(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连接数量控制
• 大量输出时的数据传输优化

仓库地址:
https://github.com/yuboon/java-examples/tree/master/springboot-web-ssh

文档更新时间: 2025-08-12 13:25   作者:admin