
在运维和开发场景里能像Xshell一样在网页上点开一个“黑屏”直接敲命令其实是个非常刚需的能力。我最近用 Xterm.js Spring Boot WebSocket Apache SSHD 四个组件完整搭了一个Web版SSH终端浏览器里直接连服务器操作命令、滚动输出、VIM编辑都接近原生终端体验。这篇文章把这些组件的协作逻辑、核心实现和踩坑经验全部拆开讲一遍希望能帮做运维工具、云管理后台、内网穿透项目的同学省点弯路也适合对SSH协议和WebSocket双向通信有一定基础的开发者参考。1. 项目整体设计与思路拆解1.1 需求分析黑屏操作到底在解决什么做Web版SSH终端最本质的需求很简单让用户打开一个网页就能登录远程服务器执行命令、查看输出、断线重连。很多人第一反应是“前端直接用js库实现SSH协议不就行了”但实际操作下来你会发现问题很多——JS生态里确实存在SSH客户端库比如ssh2但它依赖Node.js环境没法直接在浏览器里跑。浏览器的安全沙箱也不允许任意TCP连接更别说走SSH的密钥协商流程。所以我们真正需要的是一个“中转”方案浏览器不直接连接服务器而是连接我们自己的后端服务后端服务负责真正的SSH连接并把数据转发给浏览器这个方案里的技术选型可以一句话概括Spring Boot 负责出HTTP接口和WebSocket服务Apache SSHD 负责在Java后端起一个真实的SSH客户端去连远程机器Xterm.js 负责在浏览器端还原一个“终端黑屏”的交互界面。整体架构非常像“前端只是显示器后端才是那双真正敲键盘的手”。1.2 技术选型对比为什么用Apache SSHD而不是JSch做Java领域SSH客户端大家第一反应通常是JSch。这个库老牌、用的人多但如果你是做Web端实时终端这种场景我建议优先看Apache SSHD。原因很直接Apache SSHD对底层SSH协议的支持更完整Channel Shell、Channel Exec、SFTP、端口转发都能用统一的框架管理而且与现代OpenSSH服务端的算法协商兼容性更好。实际项目里我遇到过这种情况用JSch去连一台配置了较新OpenSSH的服务器直接报“Algorithm negotiation fail”但Apache SSHD默认的密钥交换算法、主机密钥算法和加密算法列表更贴近当前主流服务器的配置基本开箱即用。另外Apache SSHD自身包含了一个完整的SSH客户端组件它的事件模型、超时控制、流式IO设计得比JSch清晰和Spring Boot的异步模型结合起来很顺手。当然JSch也不是不能用只是如果你要做“多会话并发管理”这种稍复杂的功能Apache SSHD的Session管理、Channel生命周期管理会让你少掉很多头发。1.3 整体架构浏览器、Spring Boot、Apache SSHD三方如何协作整个系统可以简单画成三层展示层浏览器里的Xterm.js终端模拟器接收用户的键盘输入并把服务器的输出渲染成终端效果接入层Spring Boot应用对外提供WebSocket端点内部维护每个WebSocket连接和对应SSH会话的绑定关系连接层Apache SSHD客户端远程连接目标服务器执行用户的命令并把服务器返回的数据写回WebSocket数据流向可以记成一句口诀键盘输入进WebSocketWebSocket交给SSHSSH输出回到WebSocketWebSocket再交给Xterm.js渲染。Xterm.js在这里扮演的是一个纯展示组件它不认识SSH协议也不需要认识所有协议处理都在Java后端完成。这样做有一个额外好处未来不管你是要加审计录屏、命令拦截、敏感操作报警还是想对企业服务器做统一的堡垒机接入都可以在后端这一层加逻辑前端完全不用动。2. 核心组件解析与实操要点2.1 Xterm.js让浏览器拥有真实终端的能力Xterm.js 是当前Web终端实现的事实标准很多开源项目Kubernetes Dashboard、Code Server、Portainer的终端功能底层都是它。它不是一段普通的textarea而是完整实现了终端模拟需要的状态机包括光标移动、彩色输出、滚动缓冲区、VIM快捷键甚至鼠标交互事件。我使用的版本是xterm5.3.0其中几个常用模块需要单独说清楚Terminal核心实例负责创建终端区域所有输出都通过term.write()写入FitAddon让终端尺寸自适应外层容器算好当前终端所能容纳的列数和行数再告诉后端调整SSH的Pty窗口大小WebLinksAddon识别终端输出里的URL并让它们可点击非常实用对于普通场景你不用关心Xterm.js内部是怎么处理ANSI转义序列的只需要理解一点服务端返回的任何字符数据都会进来而终端输出里包含的\x1b[32m这种ANSI颜色码Xterm.js会解析成对应的颜色渲染出来。这意味着你在服务器里跑了ls --colorWeb终端也是能显示五彩颜色的。2.2 Apache SSHDJava世界的SSH协议实现Apache SSHD并不是只能做SSH服务端它同样提供了完整的SSH客户端能力。我们用到的几个类和接口大致是SshClient客户端入口负责创建与远程服务器的SSH连接。整个应用里通常只需要一个SshClient实例因为它是线程安全的ClientSession一个SSH连接会话对应一次到服务器的登录ClientChannel会话上可以打开的通道ChannelShell代表一个交互式Shell通道ChannelExec代表执行单条命令的通道对于交互式终端一定要选择ChannelShell因为它会给你一个真实的PTY伪终端支持vim、top这类全屏交互程序。ChannelExec那种“执行完就结束”的方式做不成交互终端。连接流程简写一下SshClient client SshClient.setUpDefaultClient(); client.start(); ClientSession session client.connect(user, host, port) .verify(10, TimeUnit.SECONDS) .getSession(); session.addPasswordIdentity(password); session.auth().verify(5, TimeUnit.SECONDS); ClientChannel channel session.createChannel(ClientChannel.CHANNEL_SHELL); channel.open().verify(5, TimeUnit.SECONDS);之后通过channel.getInvertedOut()读取服务器输出通过channel.getInvertedIn()向服务器写入命令。理解了这两条流整个系统的数据管道就通了。2.3 Spring Boot WebSocket双向通道的搭建Spring Boot内置了WebSocket支持我建议直接基于spring-boot-starter-websocket做不需要额外引入第三方库。需要继承TextWebSocketHandler或者BinaryWebSocketHandler这里有个关键选型问题传输SSH数据时用文本格式还是二进制格式表面上看服务器输出都是文本但终端里可能包含非UTF-8编码的内容比如某些程序输出的GBK编码字符、二进制控制序列如果用TextWebSocketHandlerSpring会强制按字符串处理遇到不完整的多字节字符就会报错。所以我的方案是使用BinaryWebSocketHandler数据全部按byte[]发送和接收。Xterm.js收到二进制数据后按UTF-8解码也能正常显示这样能少踩很多编解码的坑。另外还要处理好连接生命周期。每个WebSocket连接建立时我们创建一个SSH会话当WebSocket断开时必须同步关掉SSH连接否则服务器上会挂着一堆僵尸进程最后撑爆文件句柄。3. 实操过程与核心环节实现3.1 环境准备与依赖配置项目基于Spring Boot 2.7.x继续开发Java版本建议使用11以上。Maven依赖里核心只有这么几个dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId /dependency dependency groupIdorg.apache.sshd/groupId artifactIdsshd-core/artifactId version2.9.2/version /dependency dependency groupIdcom.github.mwiede/groupId artifactIdjsch/artifactId version0.2.16/version /dependency注意一点上面这个jsch的依赖可加可不加。如果你只用Apache SSHD就够不需要引JSch。我后面实际调试的时候因为某些兼容性排查需要对比连接调试临时加了这个依赖。生产环境如果确定用Apache SSHD就把JSch的依赖删掉减少依赖冲突。前端资源我直接用npm引入Xterm.js的包也可以使用CDN。建议直接本地静态资源部署毕竟是要在内网用的工具CDN不一定能访问。资源版本锁定下来别用“latest”这种标签不然过几个月可能行为就变了。3.2 后端SSH连接服务实现封装一个SSHService它做的事情是接收目标主机、端口、用户名、密码建立一个SSH会话并返回这个会话的ChannelShell。Component public class SSHService { private SshClient sshClient; PostConstruct public void init() { sshClient SshClient.setUpDefaultClient(); sshClient.start(); } PreDestroy public void destroy() { sshClient.stop(); } public ClientChannel openShell(String host, int port, String username, String password) throws IOException { ClientSession session sshClient.connect(username, host, port) .verify(10, TimeUnit.SECONDS) .getSession(); session.addPasswordIdentity(password); // 认证失败时这行会抛出异常 session.auth().verify(5, TimeUnit.SECONDS); // 打开Shell通道 ClientChannel channel session.createChannel(ClientChannel.CHANNEL_SHELL); channel.open().verify(5, TimeUnit.SECONDS); return channel; } }把SshClient定义成单例是有讲究的。它内部维护了自己的连接池、调度线程池不需要每个会话都新建。verify()后面的超时时间也很有必要服务器网络不通时不会让你的后端线程无限等下去。另外需要强调session.auth()这个调用如果密码错误Apache SSHD默认情况下会抛异常这时候你需要把错误消息原样返回给前端显示“认证失败”而不是笼统地报一个“系统错误”。3.3 WebSocket通道与SSH流对接这一个环节是整个项目的灵魂。我们需要写一个WebSocket处理器它有几件事要做连接建立后从URL参数里拿到目标服务器信息调用SSHService建立SSH连接开启一个异步任务把SSH通道的输入流搬到WebSocket当WebSocket消息到来时把二进制数据写入SSH通道的输出流连接关闭时关闭SSH通道和会话Component public class SSHWebSocketHandler extends BinaryWebSocketHandler { private final SSHService sshService; Override public void afterConnectionEstablished(WebSocketSession session) { MapString, String params session.getAttributes(); String host params.get(host); int port Integer.parseInt(params.get(port)); String user params.get(username); String password params.get(password); ClientChannel channel sshService.openShell(host, port, user, password); session.getAttributes().put(channel, channel); // 启动读线程SSH输出 - WebSocket Executors.newSingleThreadExecutor().submit(() - { try (InputStream input channel.getInvertedOut()) { byte[] buffer new byte[1024]; int len; while ((len input.read(buffer)) ! -1) { if (session.isOpen()) { session.sendMessage(new BinaryMessage(buffer, 0, len, true)); } else { break; } } } catch (IOException e) { // 对端关闭正常结束 } }); } Override protected void handleBinaryMessage(WebSocketSession session, BinaryMessage message) { ClientChannel channel (ClientChannel) session.getAttributes().get(channel); try { // WebSocket输入 - SSH channel.getInvertedIn().write(message.getPayload().array()); channel.getInvertedIn().flush(); } catch (IOException e) { // 写入失败一般意味着SSH连接已断开 } } Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { ClientChannel channel (ClientChannel) session.getAttributes().get(channel); if (channel ! null) { try { channel.close(); } catch (IOException ignored) { } } } }这里有个很重要的细节为什么SSH输出要用一个独立线程去读因为input.read()是阻塞的如果放在Spring MVC默认的WebSocket线程里面执行一旦没有输出线程就卡住了其他WebSocket消息没法处理。所以一定只能放在单独的读线程里不能让IO阻塞影响Spring的消息循环。BinaryMessage构造时的true表示这是消息的最后一帧。因为WebSocket本身支持分片如果你不明确标记结束前端的消息可能会一直处于pending状态。3.4 前端Xterm.js接入与初始化前端实现实际上比后端简单不少。核心就是用Xterm.js创建一个终端然后建立WebSocket连接把两者数据绑定起来。import { Terminal } from xterm; import { FitAddon } from xterm-addon-fit; const term new Terminal({ cursorBlink: true, fontSize: 14, fontFamily: Menlo, Monaco, Courier New, monospace, scrollback: 5000, theme: { background: #1e1e1e, foreground: #d4d4d4 } }); const fitAddon new FitAddon(); term.loadAddon(fitAddon); term.open(document.getElementById(terminal)); fitAddon.fit(); const ws new WebSocket( ws://${location.host}/ssh?host${host}port${port}username${user}password${encodeURIComponent(pass)} ); ws.binaryType arraybuffer; ws.onmessage (event) { // Uint8Array - string交给终端渲染 const data new Uint8Array(event.data); term.write(data); }; term.onData((data) { // 用户的键盘输入发给后端 ws.send(new TextEncoder().encode(data)); });这段代码有几个点需要单独说第一term.onData接收的data不只是用户敲的字母还包括特殊控制键序列回车键是\r退格是\x7f上下左右是\x1b[A这样的转义序列。这些序列会被原封不动地传给后端从而让SSH会话里的shell识别出你按了哪些特殊键。如果这里做特殊处理过滤了控制键终端行为就会变怪。第二为什么term.write()接收的是二进制而非文本。因为WebSocket服务端发来的不一定是完整的UTF-8字节序列一个中文字符可能在两个WebSocket帧里被切开如果前端提前按字符串解码就会乱码。让Xterm.js自己处理二进制流是最稳的它会缓存未完成的序列等后续字节到齐后再解码。第三ws.binaryType arraybuffer是必须的。不设置的话浏览器默认把二进制消息当成Blob你用起来很别扭还得再去转一次。ArrayBuffer在内存里可以直接交给Uint8Array使用性能也比Blob好。3.5 窗口尺寸同步与Resize处理SSH协议里有个能力客户端可以通知服务端“我的终端尺寸变了”服务端收到后会发送SIGWINCH给进程从而让vim、htop这类程序重新布局界面。如果不做这个处理前端浏览器窗口拉伸后终端列数不会变会导致命令行输出换行混乱。官方做法是前端监听终端容器的尺寸变化然后调用Xterm.js的fitAddon.fit()重新计算列数行数再通过推送给后端一个特殊消息让Apache SSHD调整PTY尺寸。我约定了一个简单的小协议当后端收到字符串resize:80,24时就调用channel.resize(80, 24)。const observer new ResizeObserver(() { fitAddon.fit(); const dims term.cols , term.rows; ws.send(new TextEncoder().encode(resize: dims)); }); observer.observe(document.getElementById(terminal));后端在这一段做一个判断Override protected void handleTextMessage(WebSocketSession session, TextMessage message) { String payload message.getPayload(); if (payload.startsWith(resize:)) { String[] parts payload.substring(7).split(,); int cols Integer.parseInt(parts[0]); int rows Integer.parseInt(parts[1]); ClientChannel channel (ClientChannel) session.getAttributes().get(channel); channel.resize(cols, rows); } }实际上因为我前面用的是BinaryWebSocketHandlerhandleTextMessage不会触发。更稳的做法是统一把resize消息用TextMessage发然后WebSocket处理器同时继承TextWebSocketHandler和BinaryWebSocketHandler重写两个方法或者用一个BinaryWebSocketHandler但前端()时统一用encode。看你的偏好我的做法是干脆只用二进制通道resize命令也转成二进制后端在handleBinaryMessage里先判断字节数组开头是否是resize:的ASCII码。这样不用再混用文本和二进制两种消息类型逻辑清晰。需要单独提醒的是首次打开终端的时候也必须要主动发送一次resize消息。否则你在初始化前已经让容器渲染好了但后端手头PTY还是默认的80x24两者不一致页面显示就会和实际输出不匹配。4. 常见问题与排查技巧实录4.1 中文乱码与编码传输问题Web终端最经典的问题就是中文乱码。字符编码链路涉及三个环节远程服务的shell locale、SSH协议传输、浏览器解码。任何一个环节不对都会导致中文显示异常。实际排查时先看服务器端的locale设置echo $LANG如果输出是POSIX或C说明前端的UTF-8环境可能压根没建立这时候你在终端里跑中文文件名会得到一堆转义序列输出乱码是很自然的。再检查后端代码有没有做什么“多此一举”的编解码转换。我踩过的一个坑是在把SSH输入流的数据写入WebSocket之前先做了new String(buffer, UTF-8)的转换结果数据在传输过程中被重新编码了一次导致源头是UTF-8字节流但前端又被当成字符串重新编码。正确的做法始终是SSH输入流读到的字节原封不动地写入WebSocket前面代码就是这么写的不要做任何字符集转换。4.2 连接假死、输入无反应问题有用户反馈网页打开正常刚进去的ls、whoami都正常但过几分钟之后输入任何命令都不响应了。排查思路应该从两个层面考虑第一层检查SSH连接本身是否还活着。可以用抓包工具观察目标服务器与后端之间的TCP连接是否还在。如果发现TCP已经断开说明是被网络的空闲超时清理了需要在Apache SSHD客户端打开keepalive机制SshClient.setUpDefaultClient(); client.setSessionHeartbeatInterval(Duration.ofSeconds(30)); client.setSessionHeartbeat(TcpipClientSessionHeartbeatController....);第二层检查WebSocket连接是否还在。如果WebSocket因为后端没活跃数据而超时浏览器和后端都还认为连接没断但实际上代理层已经关掉了。最直接的办法是在前端加一个定时心跳setInterval(() { if (ws.readyState WebSocket.OPEN) { ws.send(new TextEncoder().encode(ping)); } }, 20000);后端在收到ping时不需要向SSH写任何数据直接给一个pong即可目的就是维持代理和网关的连接活跃状态。4.3 认证失败与算法协商问题使用Apache SSHD连接某些老式网络设备比如思科交换机、某些交换机管理接口时可能会遇到Auth fail或者算法协商失败。这类问题通常不是用户名密码错误而是SSH客户端默认启用的密钥交换算法、主机密钥算法和对方不兼容。Apache SSHD提供了SshClient.setUpDefaultClient()后按需修改属性列表的能力。我遇到过的一个实际案例是某台旧Linux服务器的sshd_config里只支持diffie-hellman-group1-sha1这一种密钥交换算法新版SSHD客户端默认列表里没有它连接直接失败。解决方案是在客户端显式加上这个算法client.setKeyExchangeFactories( Arrays.asList( new BuiltinDHFactories.BCDHG1(), new BuiltinDHFactories.DHGEX_SHA256(), new BuiltinDHFactories.ECDH_SHA2_NISTP256() ) );出于安全考量这种弱算法不应该在生产环境的默认配置里放开。如果你确实需要连老设备建议只限制在一台专用的网关服务器上并且单独配置不要让全网的Web终端都退回到弱算法。4.4 终端僵尸进程与资源泄漏这是我最想强调的一个问题。项目上线初期测试连续开几十个终端之后服务器上的进程数暴涨最后把整个机的内存耗光了。排查后发现用户关闭浏览器标签页时WebSocket的close事件不一定能及时触发或者触发了但我们没正确关闭SSH通道。正确的做法是双重保险afterConnectionClosed里不但关闭Channel还要关闭整个ClientSession因为ChannelShell只是通道底层Session还占着一个SSH连接在后端为每个WebSocket会话添加一个超时任务比如30分钟内没有消息活动就主动关闭连接释放资源public class SSHWebSocketHandler extends BinaryWebSocketHandler { private final MapWebSocketSession, ScheduledFuture? timeouts new ConcurrentHashMap(); // 每次收到消息时都刷新这个连接的过期时间 Override protected void handleBinaryMessage(WebSocketSession session, BinaryMessage message) throws Exception { refreshTimeout(session); // ... 原有逻辑 } private void refreshTimeout(WebSocketSession session) { ScheduledFuture? old timeouts.remove(session); if (old ! null) { old.cancel(false); } timeouts.put(session, scheduler.schedule(() - { try { session.close(CloseStatus.GOING_AWAY); } catch (IOException ignored) { } }, 30, TimeUnit.MINUTES)); } }这个方案配合上服务器端对SSH连接数的监控能明显降低资源泄漏风险。我实测过连续跑一天不关页面、只开关浏览器标签的场景僵尸会话数量从30多个降到了0个。4.5 综合问题速查表现象可能原因处理方式页面打开一片白屏Xterm.js的fitAddon.fit()调用了但容器尺寸为0检查#terminal容器高度是否设置通常在CSS里给height: 100%才能撑开输入命令没反应WebSocket断了但页面没感知加心跳重连逻辑onclose事件里自动重连输出刷得飞快导致卡顿消息频率过高渲染跟不上Xterm.js的write接口内部有缓冲把后端回包频率控制一下比如合包发送ssh密码包含特殊字符导致URL解析失败直接在URL里传了明文密码使用encodeURIComponent编码或者改用POST接口建立连接打开终端白屏但后端日志无异常前端静态资源路径问题或JS报错打开浏览器控制台看具体报错检查Xterm.js的js和css是否正确引入部分颜色显示不出来服务器用了自定义LS_COLORS终端没启用真彩色Xterm.js颜色主题配好同时确认SSH服务端是否启用了256colorterm模式5. 实操心得再多说一句整套系统跑通之后我个人最大的体会是Web终端其实是一个典型的“双向数据流管道”应用架构一旦理解透彻剩下的都是细节打磨。Xterm.js负责显示Apache SSHD负责真实会话Spring Boot WebSocket负责搬运整个链条里每一个环节出现问题表现都是终端异常但排查路径各有不同。前端的问题通常很好定位打开浏览器DevTools看WebSocket有没有消息进看Network面板有没有报错基本就能锁定。后端的问题则需要用到jstack看线程状态还要会看SSH相关日志。Apache SSHD的日志级别调到DEBUG之后非常啰嗦但排查算法协商、认证失败这类问题几乎离不开它。如果让我给要复刻这个项目的同学一个最实用的建议那就是先把最简单的“WebSocket回显”跑通再往上叠加SSH。也就是说前端先用一个假的WebSocket后端你在前端敲个字母后端把它原样弹回来。这一步通了就证明WebSocket通道本身没问题之后再让后端真正去连接SSH排除问题时会省很多事。另外还有一个细节建议密码不要在前端直接以明文形式写在URL里传两次。实际项目里我建议前端只传一个一次性token后端拿着token去配置中心或者安全存储里换取真实密码。这不是什么高深的技术但安全细节还是越早考虑越稳妥。我做完这个项目之后又把底层做了改造把Apache SSHD换成了普通socket连接的通用终端支持telnet协议再往上叠加了命令审计和录屏回放。整个架构理论上是可以不断扩展的。如果你也打算做一个面向团队的Web版运维入口把这个基础工程打好后面加什么都会很顺手。