ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring Boot集成Xterm.js与Apache SSHD构建Web SSH终端实战

Spring Boot集成Xterm.js与Apache SSHD构建Web SSH终端实战 你有没有遇到过这种场景人不在工位上笔记本上也没装 Xshell但就是想快速登录内网服务器执行几条命令、看个日志。这时候如果有个浏览器就能打开的 SSH 终端不需要任何本地客户端体验和 Xshell 的黑屏会话几乎一样那该多方便。这就是我今天要分享的实战项目用 Xterm.js 做前端终端渲染Spring Boot 提供 WebSocket 服务Apache SSHD 在 Java 后端扮演 SSH 客户端连接远程服务器最终把浏览器变成一个能用的 Web 版 SSH 终端支持敲命令、开 vim、跑 top、传控制键和真正的黑屏终端没太大差别。这个方案特别适合 Java 后端开发、运维工具爱好者以及想在公司内部做统一运维入口、在线宿主机终端、堡垒机雏形的人。文章里不会有太多花哨东西全是我实际搭建过程中跑通的工程代码和踩过的坑照着做基本能复现。1. 整体架构拆解从浏览器到服务器四条链路各有分工1.1 四个组件各司其职先搞清楚谁干什么整个 Web SSH 终端从浏览器按下按键到远程服务器最终返回字符中间经过四条链路浏览器里的 Xterm.js 负责把远程服务器返回的字节流渲染成终端画面同时捕获你输入的每一个按键转成终端字节序列发给后端。Xterm.js 本质上是一个用 TypeScript 写的前端终端模拟器它可以正确处理 ANSI 转义序列、光标移动、颜色控制、滚动缓冲区这些麻烦事。Spring Boot 在这里是整个系统的中枢。它对外提供 WebSocket 端点接收浏览器的连接、按键输入、窗口尺寸变化同时维护每个 WebSocket 会话对应的 SSH 会话生命周期。Apache SSHD 是后端和远程 Linux 服务器建立 SSH 连接的库。名字容易让人误解它不是跑在服务器上做 SSH 服务端用的而是纯 Java 实现的 SSH 协议库既支持服务端实现也支持客户端实现。在我们的场景里它就是充当 SSH 客户端连接远程服务器的 22 端口完成认证打开一个交互式 shell 通道。最后是远程服务器上的 OpenSSH 服务它接收 Apache SSHD 的连接返回一个伪终端把 ls、cat、vim 这些交互式程序的输出通过 SSH 加密通道流回来。整条链路是浏览器和 Linux 之间唯一的真实连接路径。1.2 三个关键技术选型我为什么这么选先说说为什么不用自己写前端终端。终端渲染是天坑键盘特殊键、终端宽高、滚动回放、ANSI 颜色解析、CtrlC 的中断信号、全屏程序光标的切换每一个细节单独拿出来都要写不少代码。Xterm.js 是 Chromium 内部和很多在线 IDE 都在用的方案稳定性和兼容性已经经过充分验证没有理由自己重复造轮子。Apache SSHD 和 JSch 的选择上要做个简单对比。JSch 过去在 Java SSH 工具里很常见但近年更新缓慢SSH 新算法支持不够及时密钥格式也容易出现兼容问题。Apache SSHD 由 Apache Mina 社区维护支持密码认证、公钥认证、证书认证、SFTP、SCP、端口转发在协议完整性上做得更全面。而且它的 ClientChannel 直接封装了 shell 会话能力和我们的需求非常契合。数据通道选择上WebSocket 几乎是最优解。SSH 是一个双向持续的数据流服务器可能随时输出数据用户的输入也需要低延迟地发出去。HTTP 请求响应的单向模型做不了服务端主动推送SSE 也只是服务端到客户端单向。WebSocket 全双工、长连接、低延迟浏览器原生支持天然适合这种实时流场景。实测下来从前端 keydown 开始到远程命令回显到屏幕本地链路的延迟可以做到几十毫秒以内体感上已经很接近原生 Xshell 了。1.3 一条按键的完整旅行我用一条最简单的 ls 命令来串整个流程。你在浏览器终端敲下 lXterm.js 捕获按键通过 onData 回调把字符字节发送到 WebSocket后端处理器收到消息把它写入 Apache SSHD 的 shell 通道输出流这个字节进入 SSH 加密隧道经过网络传输最终被服务器上的 sshd 进程交给目标伪终端处理。服务器执行 shell 解析如果命令没输完不会有输出按下回车后ls 进程运行把目录列表写到伪终端设备文件sshd 读取输出加密回传Apache SSHD 从输入流中读到数据后端代码把这段字节数组封装成 WebSocket 的二进制消息发给浏览器Xterm.js 拿到字节流解析 ANSI 控制序列把字符一步步渲染到 canvas 和 DOM 上。整个过程写起来很长实际跑起来是毫秒级别。理解这条链路后后面调试就很好办了卡在哪一步就去排查哪一步的连接和相关流。真正常出问题的位置几乎都在 WebSocket 建立阶段和 SSH 认证阶段。2. 后端工程搭建Spring Boot WebSocket Apache SSHD2.1 依赖引入与工程骨架准备我建议直接用 Spring Boot 2.7 或 3.x 版本JDK 至少 8 起建议 17 或者 21。Spring Boot 内置容器已经支持 WebSocket不需要额外引入嵌入式 Tomcat 相关的配置yml 也不需要为 WebSocket 单独开什么配置项。需要的就是两个核心依赖。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId /dependency dependency groupIdorg.apache.sshd/groupId artifactIdsshd-core/artifactId version2.12.0/version /dependencysshd-core 2.12.0 是相对新的稳定版本对 OpenSSH 新私钥格式、新密钥交换算法支持得比较完整。后续如果遇到算法兼容问题优先考虑升级这个库而不是手动改加密配置。2.2 WebSocket 端点注册与连接管理先用 EnableWebSocket 声明启用 WebSocket 支持然后实现 WebSocketConfigurer把处理器注册到 / ssh 路径上。Configuration EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(new SSHWebSocketHandler(), /ssh) .setAllowedOrigins(*); } }开发阶段 setAllowedOrigins(*) 图方便上线前一定要收敛改成实际的域名白名单否则别人可以随意从任何网站连到你的 WebSocket 端点发起连接。这个我后面在安全环节还会再说。处理器继承 TextWebSocketHandler重写生命周期方法在 afterConnectionEstablished 里保存 WebSocket 会话在 handleTextMessage 里处理消息在 afterConnectionClosed 里销毁 SSH 连接。2.3 SSH 连接创建核心代码与认证细节处理器收到前端第一条消息时解析出目标主机、端口、用户名、密码或密钥然后创建 SSH 会话并打开 shell 通道。Component public class SSHWebSocketHandler extends TextWebSocketHandler { private final ObjectMapper objectMapper new ObjectMapper(); private final MapString, SshConnection sshConnections new ConcurrentHashMap(); Override protected void handleTextMessage(WebSocketSession wsSession, TextMessage message) throws Exception { String payload message.getPayload(); // 客户端心跳直接回一个 pong保持连接不要被中间层断开 if (ping.equals(payload)) { if (wsSession.isOpen()) { wsSession.sendMessage(new TextMessage(pong)); } return; } if (init.equals(parseType(payload))) { ConnectRequest request objectMapper.readValue(payload, ConnectRequest.class); SshConnection conn connectSsh(wsSession, request); sshConnections.put(wsSession.getId(), conn); return; } // 普通终端输入直接写入 SSH 会话的输入流 SshConnection conn sshConnections.get(wsSession.getId()); if (conn ! null) { conn.getLocalInput().write(payload.getBytes(StandardCharsets.UTF_8)); conn.getLocalInput().flush(); } } private SshConnection connectSsh(WebSocketSession wsSession, ConnectRequest request) throws Exception { SshClient client SshClient.setUpDefaultClient(); client.start(); ClientSession session client.connect(request.getUsername(), request.getHost(), request.getPort()) .verify(5L, TimeUnit.SECONDS) .getSession(); if (StringUtils.hasText(request.getPassword())) { session.addPasswordIdentity(request.getPassword()); } AuthResult authResult session.auth().verify(10L, TimeUnit.SECONDS); if (authResult ! AuthResult.SUCCESS) { throw new RuntimeException(SSH 认证失败请检查用户名密码); } ClientChannel channel session.createShellChannel(); channel.setPtyType(xterm-256color); channel.setPtyColumns(request.getCols()); channel.setPtyRows(request.getRows()); channel.open().verify(3L, TimeUnit.SECONDS); SshConnection conn new SshConnection(); conn.setClient(client); conn.setSession(session); conn.setChannel(channel); conn.setLocalInput(channel.getInvertedIn()); conn.setRemoteOutput(channel.getInvertedOut()); // 启动读线程把远程输出持续转发到 WebSocket Thread reader new Thread(() - { byte[] buffer new byte[8192]; int len; try { while ((len conn.getRemoteOutput().read(buffer)) 0) { if (wsSession.isOpen()) { wsSession.sendMessage(new BinaryMessage(buffer, 0, len)); } } } catch (IOException | IllegalStateException e) { closeQuietly(wsSession); } }); reader.setDaemon(true); reader.start(); return conn; } Override public void afterConnectionClosed(WebSocketSession wsSession, CloseStatus status) throws Exception { SshConnection conn sshConnections.remove(wsSession.getId()); if (conn ! null) { try (SshClient c conn.getClient(); ClientSession s conn.getSession(); ClientChannel ch conn.getChannel()) { // 自动关闭释放资源 } } } }这中间有两个容易出错的细节。第一个getInvertedIn 和 getInvertedOut 的命名很绕简单记忆就是 InvertedOut 是远程返回的数据流要在这里读InvertedIn 是本地输入的数据流要在这里写方向千万不要搞反。第二个Apache SSHD 不同小版本的 API 命名也有调整有的版本用 getOutputStream、getInputStream你按 IDE 提示来就行核心方向别理解错。2.4 生命周期管理与资源释放的细节每个 WebSocket 会话对应一个 SSH 会话这个映射必须严格维护。用户关掉浏览器、刷新页面、网络断开都会触发 afterConnectionClosed这里面一定要把 SSH Client、ClientSession、ClientChannel 全部关闭。关的顺序也要注意先关 channel再关 session最后关 client。我用的是 try-with-resources 写法JDK 7 以后推荐这种方式简单可靠。有个坑是 WebSocket 可能因为网络异常突然断开SSH 还是 connection open 状态如果没有 where connection close 时强制清理服务器上会残留一堆僵尸 sshd 进程。生产环境建议加一个定时任务定期检查 sshConnections 里的 WebSocket 会话是否已经关闭关闭的直接清理。另外SSH 会话本身还有一个超时问题。可以在创建时调用 session.setKeepAliveInterval(Duration.ofSeconds(30))让 SSH 层自己发 keepalive 包避免远程服务器的 TCP idle 超时把连接断开。3. Xterm.js 前端实战黑屏里的每一次敲击3.1 终端初始化与样式引入前端部分我用原生 JavaScript 做示例方便理解原理实际项目里按你喜欢的 React/Vue 集成方式调整即可。先装包新版包名是 xterm/xterm注意老项目里常看到的 xterm 包名现在是历史遗留。初始化终端几个关键参数直接给上。npm install xterm/xtermimport { Terminal } from xterm/xterm; import xterm/xterm/css/xterm.css; const term new Terminal({ cursorBlink: true, cursorStyle: block, fontSize: 14, fontFamily: Menlo, Consolas, monospace, lineHeight: 1.2, convertEol: true, theme: { background: #1e1e1e, foreground: #d4d4d4, } }); term.open(document.getElementById(terminal)); term.focus();cursorBlink 让光标闪烁更接近现代终端体验convertEol 建议打开否则有些程序输出 \r 时会出现诡异的换行问题。字体推荐等宽字体中文环境注意选字体族里带中文的text 渲染出来的中文字符才不会发虚。3.2 WebSocket 连接与数据编解码连接 WebSocket 时binartType 要设置成 arraybuffer。WebSocket 原生支持发送二进制帧SSH 通道的原始数据是字节流UTF-8 编码的文本通道处理不了所有二进制的转义序列和高字节字符所以直接用二进制帧最省事。const socket new WebSocket(ws://${location.host}/ssh); socket.binaryType arraybuffer; socket.onopen () { // 打开后发送连接参数建立 SSH 会话 socket.send(JSON.stringify({ type: init, host: 192.168.1.100, port: 22, username: root, password: your-password, cols: term.cols, rows: term.rows })); }; socket.onmessage (evt) { term.write(new Uint8Array(evt.data)); }; term.onData(data { if (socket.readyState WebSocket.OPEN) { socket.send(data); } });term.onData 把所有按键输入都发出去包括普通字符、回车、退格、方向键、Ctrl 组合键。Xterm.js 已经把这些处理成终端协议要求的转义序列后端原样转发给 SSH 通道即可这也是它能保持和原生终端一致体验的关键。3.3 窗口大小同步别让 vim 和 top 乱掉终端窗口大小直接决定了远程伪终端有多少行多少列。如果前端终端容器宽度变化后不通知后端远程终端还是一个旧的行列数vim、top、htop 这种全屏程序就会错乱界面被截断或者光标位置飘到奇怪的地方。监听窗口尺寸变化term.onResize(({ cols, rows }) { if (socket.readyState WebSocket.OPEN) { socket.send(JSON.stringify({ type: resize, cols, rows })); } });后端收到 resize 消息后调用 ClientChannel 的窗口调整方法新版 SSHD 一般是 sendWindowChange老版本可能是 resize参数就是 columns 和 rows。conn.getChannel().sendWindowChange(rows, cols);这一步不能省。我第一次做的时候没同步尺寸vim 打开后右边总有 10 个字符宽度的空白就是远程终端行列数和前端渲染不一致导致的。3.4 心跳保活与断线重连WebSocket 虽然长连接但中间可能经过一层或多层代理Nginx 层如果有过长的连接空闲会默默切开连接。TCP 层本身也不会主动探测这种假连接。所以前端需要定期发送心跳包。最简单的方案每 30 秒发一个 ping后端回一个 pong中间层就知道这条连接还有数据在流动不会按空闲超时踢掉。后端收到 ping 就回 pong前面代码里已经写了。断线重连也很有必要。很多时候只是 WiFi 闪断SSH 会话可能还保持着前端设计一个自动重连逻辑能大幅提升体验。function connect() { const socket new WebSocket(...); socket.onclose () { term.write(\r\n连接已断开3 秒后自动重连...\r\n); setTimeout(connect, 3000); }; }注意重连成功后要重新发送 init 消息因为旧 SSH 会话已经在后端清掉了。4. 常见问题与排查技巧连不上、花屏、假死4.1 WebSocket 连不上先查三个地方如果浏览器控制台报 WebSocket 连接错误先说结论九成问题出在路径、跨域、鉴权三选一。第一个是路径问题。Spring Boot WebSocket 端点路径是否带 context pathNginx 反向代理时有没有正确转发 Upgrade 头。开发环境没 Nginx但如果项目设置了 server.servlet.context-path那 WebSocket 的完整路径是 context-path /ssh不要只写 /ssh。第二个是跨域。浏览器对 WebSocket 一样有 Origin 校验后端 setAllowedOrigins(*) 可以绕开但生产环境收紧后要确保前端域名在允许列表里。第三个也是最常被忽略的你的项目如果引入了 Spring SecurityWebSocket 端点默认会被拦截器挡掉。需要在 Security 配置里放行 /ssh 端点或者走 Security 的 WebSocket 握手认证流程。这个坑我踩过现象就是握手直接 403。4.2 WebSocket 已连接但不输出任何内容如果连接状态已经是 OPEN说明握手成功但屏幕一直黑着没反应调试思路按链路从前往后查。先用浏览器端看后端有没有给 WebSocket 发消息。如果浏览器收到的消息内容为空说明问题在 SSH 通道。检查后端有没有真正启动 reader 线程消费 remoteOutput 流。很多初学者写了 channel.open() 就结束了远程输出流没人读数据永远到不了前端。如果确定 reader 线程在跑再看读到的数据量是不是 0。可能 SSH 认证成功了 shell 通道也开了但是伪终端没有正常返回数据这时候检查 pty 类型写没写xterm-256color 和默认未知终端在某些环境下行为差异很大。4.3 终端显示乱码、花屏、vim 布局错乱乱码一般和字符编码有关。先确认你的 Java 虚拟机默认字符集是不是 UTF-8Spring Boot 3 大版本默认就是 UTF-8老项目手动改过 file.encoding 的要排查一下。再确认远程服务器的 localeLANG 环境变量如果是 POSIX 或者 C中文显示乱码大概率是远程侧的问题管理员在目的机器上配置好 UTF-8 环境即可通过 export LANGen_US.UTF-8 测试。花屏和布局错乱绝大多数情况是终端类型或尺寸同步问题。终端类型必须是 xterm-256color尺寸方向别反了sendWindowChange 第一个参数通常是 rows第二个是 cols有些版本 API 参数叫法是 height width传反了 vim 界面就四分五裂。4.4 连接假死和资源不释放连接假死有两个层次。前端 WebSocket 还开着但是按键没反应通常是 SSH 通道被远程网络问题卡住了。SSH 层加 keepalivesession.setKeepAliveInterval(30 秒) 能让底层及时发现对端失联。前端心跳配合30 秒发一次 ping双重保障。资源不释放最常见的场景是用户直接关了浏览器标签页beforeunload 和 onclose 都不一定可靠触发后端要有兜底。定期遍历 websocket session 的 isOpen 状态发现已经关闭的就主动清理对应 SSH 连接。否则时间长了服务器进程列表里全是残留的 sshd 进程用户会话列表越来越长最后把端口资源耗尽。4.5 认证失败密码加密格式与密钥格式问题排查密码认证失败除了密码真错以外还有一个很容易踩的坑就是服务器配置了 PasswordAuthentication no只允许密钥登录。这时候不管密码多对都会失败。所以服务器端 sshd_config 至少要允许密码认证或密钥认证中的一种。密钥认证失败常见原因是 OpenSSH 生成的新格式私钥默认 aes-256-cbc 加密、非 PEM 格式在旧版本 Java SSH 库里解析失败。解决办法是用工具转成 PEM 格式或者直接升级 sshd-core 到最新版本。推荐第二种新版库对格式兼容做得更好。做成表格更好查问题现象常见原因排查手段WebSocket 握手失败跨域未放行 / Security 拦截 / 路径错误浏览器 Network 面板看握手响应码后端日志看异常连接建立但无输出reader 线程未启动 / pty 类型未设置后端加日志打印读到的字节数中文乱码前后端编码不一致 / 远程 locale 非 UTF-8统一 UTF-8远程临时 export LANG 测试vim/top 错乱没有同步 cols/rows / 方向传反前端 onResize 发后端后端 sendWindowChange断线后残留进程WebSocket 关闭未清理 SSHafterConnectionClosed 清理 定时任务兜底密钥认证失败私钥格式不兼容升级 sshd-core 或转为 PEM 格式4.6 进阶从连接器到统一运维入口项目跑通之后很多人不满足于连一台机器想把它做成公司内部的统一运维入口。顺着现有架构往下走有三个可以做的扩展点。第一是批量登录能力。Apache SSHD 的 SshClient 是可以复用的一个 client 实例可以创建多个 ClientSession连接多台机器。在业务层做按需创建配合并发控制就能实现类似批量登录、多标签页同时管理的效果比每个 WebSocket 会话都 new 一个 SshClient 要节省大量握手开销。第二是文件传输。Apache SSHD 自带 SFTP 支持把 WebSocket 消息类型加一个 upload/download就可以实现浏览器里拖拽文件上传到服务器、点击下载远端文件。这个用同一个 SSH 连接复用会话不需要另外建通道部署成本可控。第三是审计和回放。把 Xterm.js 收到的字节流和用户输入的字节流全部落库配合时间戳就是一套完整的操作审计录像。前端每次 replay 时把字节流按相同速度喂给一个隐藏的 Xterm.js 实例即可这个功能在安全合规场景下价值很高。最后说一句我实际做完这个项目后最大的感触是Web 终端这个需求看起来简单里面涉及的技术点横跨前端渲染、实时通信、网络协议、服务端资源管理每一层都有它的脾性。这次把从零到可用的完整路径和常见坑都整理出来了后续你对这块有兴趣建议直接在现有代码上继续改比如把前端改成 Vue 组件、加一个多会话管理面板、或者接一个统一的运维身份认证系统都比重新起一个项目要快得多。
返回列表