把 SSH 客户端从本地软件换成浏览器这个想法我第一次听到时有点不屑一顾。远程管理服务器而已PuTTY、Termius、Windows Terminal 哪个不好用直到有一天我在浏览器里打开一个 WebSSH 页面连上内网主机看到 bash 提示符正常渲染、vim 排版不乱、top 曲线持续刷新的时候我才意识到网页终端这条路不仅走得通而且“打开网址就能管理主机”这种体验对运维场景来说是刚需。这篇内容适合两类人一是想给团队搭一个轻量运维入口二是想搞明白 WebSocket 和 SSH 如何桥接的开发者。我会按“原理 → 选型 → 后端 → 前端 → 踩坑 → 安全”的顺序把一条能跑通的最小链路完整拆给你这是我自己验证过的实现方式不保证生产级但保证你能看懂每一步在干什么。1. 为什么浏览器能当终端WebSSH的真面目是“两层协议翻译链”1.1 从本地SSH客户端到网页终端中间发生了什么先忘掉代码想清楚一个核心问题传统 SSH 客户端OpenSSH、PuTTY做的事本质上是“加密的终端字节流”的双向搬运。用户在本地终端里按下一个键字符经过加密通道送到远程 shell远程程序的输出经过加密通道送回来渲染在本地终端窗口里。浏览器里没有原生的 SSH 协议栈也没有通用的 TCP Socket 能力所以你不能让浏览器直接去连接服务器的 22 端口。WebSSH 的思路很直接把这条链路拆成两段。浏览器这一侧用 WebSocket 与 WebSSH 服务端通信传输的是“终端字节流”WebSSH 服务端另一边用 SSH 协议与目标服务器通信。服务端在这里充当一个协议翻译器从浏览器收到的内容原封不动地扔进 SSH channel从 SSH channel 读到的输出原封不动地推给浏览器。注意“原封不动”这个词这是 WebSSH 和普通 HTTP 应用最大的区别它搬运的不是结构化 JSON而是原始字节流。1.2 如果理解不了数据流后面所有调试都会一团糟我习惯用一句话概括整条链路“用户的每个按键最终会变成远程 shell 的 stdin远程 shell 的 stdout最终会变成浏览器终端里的一串字符。”某个字符进入浏览器终端组件xterm.js被 onData 事件捕获通过 WebSocket 发送到 WebSSH 后端后端调用 SSH 库的 channel.send 写入远程会话远程命令产生输出SSH channel 的 recv 读到字节后端通过 WebSocket 将字节推回浏览器浏览器把字节交给 xterm.js 的 write 方法渲染出来。你把这里的角色替换成同声传译就明白了浏览器是“说人话的用户”远程 sshd 是“只说 SSH 协议的外国人”WebSSH 服务端是翻译。翻译不负责理解内容只负责准确传达。如果某个环节把字节做了错误编码转换、擅自增删字符、或者改变顺序终端就会乱码、卡顿、花屏。所以在实现时最重要的原则是中间层尽量不做任何额外加工除非真的需要。后面我会反复强调这个原则。2. 选型对比为什么Python paramiko xterm.js够用且好走2.1 主流方案横评你其实没有太多选择WebSSH 看起来小众但生态里能用的库并不少。我把自己真正考虑过的方案列个表格方便你横向比较方案语言/框架优势短板适合场景paramiko FastAPIPython生态成熟、API 直观、出问题易排查paramiko 是同步阻塞的需要小心处理事件循环快速实现、中小团队内部工具AsyncSSH FastAPIPython原生异步性能和并发更好API 偏底层学习成本稍高追求吞吐量、并发连接较多ssh2 Node.jsNode.js事件驱动自然贴合 WebSocket回调/流式写法上手需要时间Node 技术栈团队Apache MINA SSHDJava功能全面、生产级项目重、配置复杂企业级堡垒机GateOne / TTYD现成方案不用自己写代码定制困难不想维护代码2.2 为什么我选paramiko以及怎么避开它的短板我选 Python paramiko主要是三个原因。第一paramiko 的 API 足够简单。SSHClient 连接、invoke_shell、channel.recv、channel.send这四件事就能覆盖 WebSSH 的绝大部分需求文档和网上资料都很丰富遇到问题一搜就有答案。第二FastAPI 的 WebSocket 支持非常干净。用装饰器声明一个/ssh/{host}路由accept 之后就能 receive_text 和 send_bytes天然适合这个消息双向透传场景。第三paramiko 虽然同步但“简单实现”完全可以绕过去。paramiko 的阻塞点主要在 connect 和 invoke_shell 阶段我可以用 asyncio.to_thread 扔到线程池里跑连接建立之后SSH channel 的 recv 和一个普通 socket 的 recv 没有本质区别我用轮询方式就能拿到数据。后面第一章 3 里你会看到具体写法。python ssh2 2.3 前端为什么几乎是唯一的答案前端终端模拟器主流选择基本就是 xterm.js。别被“模拟器”三个字吓到它不是虚拟机而是一个用 Canvas 和 DOM 把“字节流 → 屏幕像素”渲染出来的组件库。它支持 ANSI 颜色、光标移动、TTY 尺寸调整甚至可以配合扩展包做搜索、Fit 自适应、Web Links 识别。你要做的只是把它接入 WebSocket终端产生字符时把数据发出去WebSocket 收到数据时把字节交给终端。其他所有终端行为比如光标移动、滚动、复制粘贴、IME 输入法xterm.js 都处理好了。这也是为什么几乎所有开源 WebSSH 项目的前端都是它。3. 后端桥接用FastAPI和paramiko搭一个能跑的管道3.1 建立连接阻塞调用要扔出事件循环后端是整个 WebSSH 的核心我的实现分三步走建立 SSH 连接、forward 输出、处理输入。先看核心代码后面我再逐段解释。import asyncio import paramiko from fastapi import FastAPI, WebSocket, WebSocketDisconnect import uvicorn app FastAPI() class WebSSHConnection: def __init__(self, websocket: WebSocket, host: str, port: int, username: str, password: str): self.websocket websocket self.host host self.port port self.username username self.password password self.ssh_client: paramiko.SSHClient | None None self.channel: paramiko.Channel | None None def open_ssh_channel(self, width: int 120, height: int 40): self.ssh_client paramiko.SSHClient() self.ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) self.ssh_client.connect( hostnameself.host, portself.port, usernameself.username, passwordself.password, timeout10, allow_agentFalse, look_for_keysFalse, ) # 保活防止空闲时被防火墙/交换机静默断连 transport self.ssh_client.get_transport() if transport: transport.set_keepalive(30) # 以假终端方式打开通道term 类型必须是 xterm self.channel self.ssh_client.invoke_shell(termxterm, widthwidth, heightheight) self.channel.settimeout(0.0) async def forward_output(self): 把 SSH channel 的输出持续转发给浏览器 try: while not self.channel.closed: if self.channel.recv_ready(): data self.channel.recv(4096) if not data: break await self.websocket.send_bytes(data) elif self.channel.exit_status_ready(): break else: await asyncio.sleep(0.01) except WebSocketDisconnect: pass finally: self.cleanup() async def handle_input(self): 接收浏览器的输入写入 SSH channel try: while True: text await self.websocket.receive_text() # 简化版 resize 控制消息生产环境建议用独立控制帧 if text.startswith(__resize__:): try: cols, rows map(int, text.split(:, 2)[1].split(,)) if self.channel and not self.channel.closed: self.channel.resize_pty(widthcols, heightrows) except (ValueError, AttributeError): pass continue if self.channel and not self.channel.closed: self.channel.send(text.encode(utf-8)) except WebSocketDisconnect: pass def cleanup(self): try: if self.channel: self.channel.close() if self.ssh_client: self.ssh_client.close() except Exception: pass app.websocket(/ssh/{host}) async def ssh_endpoint(websocket: WebSocket, host: str): await websocket.accept() # 简单实现演示用固定账号连接 conn WebSSHConnection( websocket, hosthost, port22, usernameroot, passwordpassword, ) try: await asyncio.to_thread(conn.open_ssh_channel, 120, 40) except Exception as exc: await websocket.send_text(f\r\n连接失败{exc}\r\n) await websocket.close() return output_task asyncio.create_task(conn.forward_output()) input_task asyncio.create_task(conn.handle_input()) done, pending await asyncio.wait( {output_task, input_task}, return_whenasyncio.FIRST_COMPLETED, ) for task in pending: task.cancel() if pending: await asyncio.gather(*pending, return_exceptionsTrue) conn.cleanup()关于里面的两个关键点多说几句。asyncio.to_thread是我专门用来解决 paramiko 同步阻塞的手段。如果你直接在 FastAPI 的 async 函数里调用conn.open_ssh_channelSSH 握手的几秒时间里整个事件循环会被卡住其他所有 WebSocket 连接都会跟着遭殃。扔到线程池后握手在自己的线程里完成事件循环不被阻塞。channel.settimeout(0.0)配合recv_ready()是典型的非阻塞读取写法。每轮循环先问 channel“有没有数据”有就 recv没有就睡 10 毫秒再问。好处是简单、不会因为读不到数据而永久卡住坏处是有轻微的轮询开销。对个人工具和轻量内部系统来说这 10 毫秒的延迟感知不到。如果你想进一步优化性能可以去了解 asyncssh 或 select loop.add_reader 的方案那就是另一篇博客了。3.2 输入转发和 resize 控制为什么这样设计handle_input里我只处理两种 WebSocket 消息resize 指令和普通字符输入。普通字符输入就是channel.send(text.encode(utf-8))注意 encode 这一步不能漏paramiko 的 channel.send 需要 bytes 而不是 str。resize 指令是我自定义的文本协议__resize__:cols,rows。为什么需要它因为 SSH 的 pseudo-terminal简称 pty有一个固定的行列尺寸。服务器上的 vim、top、htop 会根据这个尺寸来决定怎么排版。浏览器窗口被用户拉大拉小时前端 xterm.js 的列数和行数会发生变化如果不告诉 SSH 端远程 pty 还停留在最初那个 120x40输出排版就会乱。关于这个坑我在后面第 5 节还会详细展开。必须承认用一个以__resize__开头的文本消息来区分控制指令属于“简单实现”的取舍。如果用户真的在终端里输入了__resize__:10,20恰好又被当成控制消息理论上会出现一次错误的 resize但实际概率极低。生产环境更规范的做法是WebSocket 文本帧全部当作 JSON 控制协议二进制帧全部当作终端输入把两种消息彻底分开。3.3 生命周期管理断开时一定要清理SSH连接WebSSH 这类服务最容易被忽略的是连接生命周期。浏览器标签页一关WebSocket 会断但如果后端的 SSH channel 没释放服务器上就会残留一个孤儿 bash 进程积累多了就是事故。我的做法是用asyncio.wait同时等两个任务谁先结束就取消另一个。比如浏览器关闭时handle_input会因 WebSocketDisconnect 退出这时forward_output还会在循环里可能正尝试 send_bytes 给一个已经断开的连接也会很快报错退出。无论哪个任务先结束最后都会执行conn.cleanup()把 channel 和 ssh_client 都关掉。这里有个细节值得注意cleanup 方法必须幂等。如果 forward_output 的 finally 里调用了 cleanup后面主流程又调了一次两次执行也不能抛异常。我的代码里两次 close 都被 try/except 包住就是为了应对这种重复清理。4. 前端落地xterm.js接入时最容易忽略的四个细节4.1 初始化与Fit自适应前端我用的是传统的 CDN 引入方式快速验证链路最方便。HTML 部分很短核心逻辑都在 script 里。!DOCTYPE html html langzh-CN head meta charsetUTF-8 titleWebSSH/title link relstylesheet hrefhttps://cdn.jsdelivr.net/npm/xterm4.19.0/css/xterm.css script srchttps://cdn.jsdelivr.net/npm/xterm4.19.0/lib/xterm.min.js/script script srchttps://cdn.jsdelivr.net/npm/xterm-addon-fit0.9.0/lib/xterm-addon-fit.min.js/script /head body div idterminal styleheight: 100vh;/div script const term new Terminal({ cursorBlink: true, fontSize: 14, fontFamily: Menlo, Monaco, Consolas, monospace, scrollback: 1000, convertEol: true, }); const fitAddon new FitAddon.FitAddon(); term.loadAddon(fitAddon); term.open(document.getElementById(terminal)); fitAddon.fit(); const params new URLSearchParams(location.search); const host params.get(host) || localhost; const ws new WebSocket(ws://${location.host}/ssh/${host}); ws.binaryType arraybuffer; ws.onopen function () { sendResize(); term.focus(); }; ws.onmessage function (ev) { if (ev.data instanceof ArrayBuffer) { term.write(new Uint8Array(ev.data)); } else { term.write(ev.data); } }; ws.onclose function () { term.write(\r\n\x1b[31m连接已关闭\x1b[0m\r\n); }; term.onData(function (data) { if (ws.readyState WebSocket.OPEN) { ws.send(data); } }); function sendResize() { if (ws.readyState WebSocket.OPEN) { ws.send(__resize__:${term.cols},${term.rows}); } } window.addEventListener(resize, function () { fitAddon.fit(); sendResize(); }); /script /body /htmlfitAddon.fit()是自适应尺寸的关键。它会把终端行列数调整到和容器大小匹配同时修改 term.cols 和 term.rows。这一步必须在终端 open 之后执行否则拿不到任何布局信息。窗口 resize 事件里我先 fit 再发送 resize 消息这个顺序不能反因为必须先拿到最新行列值后端才知道该把 pty 改到多大。4.2 onData不只是键盘输入粘贴和IME也要走这条路很多第一次接触 xterm.js 的人会以为只需要监听 keydown 事件再发送 keyCode 就行。千万别这么干。xterm.js 的onData事件已经把按键、组合键、IME 输入、粘贴内容统一处理成了字符串你只管把它发给后端即可。举个例子用户在中文输入法里输入一串拼音最终上屏的汉字xterm.js 会以字符串形式通过 onData 一次性给出如果只监听 keydown你拿到的是一堆孤立的键盘码根本拼不出完整输入。还有粘贴大段文本时onData 会一次性给出整段文本WebSocket 会把它发到后端后端写进 SSH channel。这个设计看起来很省事但要小心如果你直接从浏览器粘贴一个几百KB的脚本到终端这些数据会一次性涌入后端可能超过 TCP 发送缓冲导致前端假死。真要支持大块粘贴前端最好做切片发送比如每 4096 字节一小块中间稍微间隔 10ms让后端有足够的处理时间。4.3 输出帧要按二进制处理别用text这是我早期踩得最狠的一个坑。最初实现时后端把 SSH 输出 recv 到的 bytes 用websocket.send_text(base64.b64encode(data).decode())发给前端前端再 base64 解码。听起来没毛病但多了一层编码转换还要额外处理字符串拼接的边界问题。后来我改成直接 send_bytes前端设置ws.binaryType arraybuffer在 onmessage 里判断ev.data instanceof ArrayBuffer转成 Uint8Array 交给 term.write。这个方案更直接不用关心 base64也不用担心终端输出的字节流里有没有非法 UTF-8 序列。记住一句话凡是终端字节流能走二进制就不要走文本。终端输出里有大量 ANSI 控制序列比如\x1b[31m它们是合法的 ASCII但某些程序会输出原始二进制数据这时如果按 text 帧发送FastAPI 在做 UTF-8 编码转换时可能直接抛异常。4.4 连接关闭时的前端反馈WebSocket 关闭时一定要在终端里给用户一个明确提示否则用户看到页面毫无反应会以为是终端卡死了。我在 onclose 里写入了一行红色文字“连接已关闭”顺手加了回车符和 ANSI 颜色码这样即使正在跑的程序被中断用户也能一眼看到发生了什么。另外如果 WebSocket 连接还没建立完成用户就开始敲键盘term.onData 里会有ws.readyState WebSocket.OPEN这层保护防止把数据发送到一个尚未打开或已经关闭的连接上。5. 实测踩坑从窗口错乱到连接假死的排查记录5.1 最常踩的坑resize没同步vim和top集体排版错乱我第一次把前后端跑通时随便敲命令都正常但一打开 vim 就发现屏幕下半部分全是空白的移动光标后内容又叠在一起。一开始以为是 xterm.js 渲染问题调了半天样式都没用。后来突然意识到远程 bash 启动时pty 的尺寸是后端 invoke_shell 时指定的 120x40而我当时的浏览器终端实际渲染了 80x24。远程 vim 认为自己在一个 120 列宽的终端里自然会把状态栏画在屏幕很靠下的位置而前端只有 24 行能显示出来的内容自然错乱。这个问题的修复就是前面说的 resize 联动前端 fitAddon.fit() 后发送__resize__:cols,rows后端调用 channel.resize_pty。修完再测vim 立刻恢复正常。5.2 空闲假死连上后放着不动十分钟后敲命令没反应这个问题是我在真实环境中遇到的比乱码更隐蔽。现象是 WebSSH 页面连接一切正常但只要闲置超过十分钟再敲回车就没有任何反应浏览器控制台也没有报错。排查之后发现是两个原因叠加。第一目标服务器和客户端之间的网络设备有 idle session 超时机制长时间没有 SSH 数据包连接会被静默断开。第二paramiko 默认没有应用层 keepalive。解决办法是拿到 transport 后执行transport.set_keepalive(30)paramiko 会每隔 30 秒发一个 SSH keepalive 包让网络设备认为这个连接仍然是活跃的。如果你的 WebSSH 部署在 Nginx 后面还要注意 Nginx 对 WebSocket 的 proxy_read_timeout。默认值通常是 60 秒超过 60 秒没有从上游收到数据就会断开连接。做法是把 proxy_read_timeout 调大比如 3600同时依赖浏览器 WebSocket 的协议层 ping/pong 机制维持连接。协议层 ping 是由浏览器和 Nginx 协作处理的不需要你写业务代码。5.3 非UTF-8输出乱码终端字节流的还原姿势另一个高频问题是乱码。比如目标服务器是 GBK 编码的旧环境ls 列出的文件名、某些应用日志在浏览器端会显示成“锟斤拷”一类的东西。如果你用文本帧传输出问题会非常棘手因为字节流在中间被强制用 UTF-8 解码了。我的建议是彻底放弃文本传输方案统一走 binary。前端收到字节后原样交给 xterm.jsxterm.js 会按照终端当前的语言环境和字体渲染乱不乱码是渲染层的事传输层不要再掺合。这也是为什么我强烈推荐 send_bytes arraybuffer 的原因。5.4 数据顺序错乱WebSocket保序但不代表你可以乱并发生成数据还有一个隐蔽的坑来自我的一个同事的教训。他在我的轮询循环之外又加了一个定时任务每隔 5 秒向前端推一个“心跳字符”结果终端时不时出现指令互相穿插、输出顺序混乱的情况。WebSocket 本身是保证消息顺序的但一个 WebSocket 连接如果同时有多个 async 任务在 send_bytes谁先执行到 send 操作是不确定的。GitHub 终端渲染最怕的就是顺序被打破一个 ANSI 序列被拆成两截、错位拼到另一段输出里屏幕就会花掉。一个 WebSocket 连接永远只能有一个 writer。想要心跳或者保活消息应该和业务输出走同一条消息队列或者用独立的控制通道而不是开第二个发送任务。6. 安全底线开放给团队之前先想清楚这四件事6.1 登录认证与主机白名单是两条独立防线我见过不少“内网工具”直接把 WebSSH 页面放在服务器上没有登录认证谁拿到地址都能打开。这个风险不用我说你也应该明白任何时候都要先想清楚 Page 谁能访问。简单实现可以在 Nginx 层加 Basic Auth或者前端套一个登录网关但更稳妥的是在应用层做一个登录页登录成功后才算建立 WebSocket 连接。第二道防线是主机白名单不要让用户在页面上随意输入目标 host 和 port——那样等于把你的全部内网主机暴露给了拿到页面的人。更好的设计是后端维护一份预置主机列表用户只能从列表里选择不能自由输入。6.2 会话隔离一个连接不能串到另一个会话每个 WebSocket 连接都必须有独立的 SSHClient 和 channel 实例。早期我图省事把 SSHClient 设计成全局单例结果两个用户同时连接时一个用户敲的命令会混到另一个用户的会话里这比布置错误还要可怕十倍。我的建议是连接入口处新建一个 WebSSHConnection 实例在 finally 里确保清理。除非你设计的是“共享会议终端”这种特殊功能否则永远不要在全局变量里保存 channel 对象。6.3 操作审计连了什么、做了什么得有痕迹给团队内部的 WebSSH 加审计非常简单远程 shell 启动时不直接跑 bash而是跑script命令让它把会话内容记录到日志文件。比如script -q -f /var/log/ssh_session_$(date %s).log这样用户在 WebSSH 里做过的所有操作都会写入对应日志文件。配合后端的 WebSocket 连接日志连接时间、目标主机、断开时间基本能满足内部审计要求。系统级别更严格的做法是把 SSH 的 AuthorizedKeysCommand 和堡垒机结合那就是另一个话题了。6.4 传输安全别让密码在网络上裸奔浏览器里的ws://和http://一样是明文传输密码和终端内容会直接暴露在网络链路上。如果你的 WebSSH 部署在内网至少也应该在 Nginx 层做一层 TLS 终结让浏览器走wss://。如果你需要从外部访问千万不要直接把端口映射到公网而是走公司统一的远程访问网关或堡垒机让它负责身份认证和加密。换句话说WebSSH 本身是你的运维工具不是暴露给公网的入口。我在实际使用中还有一个体会连接信息不要靠用户在页面上填而是做成“主机列表 一键连接”的形态密码只存后端配置文件里前端页面永远看不到目标密码。这个习惯做起来很简单但能同时降低误操作和安全风险两个问题。写到这里WebSSH 的最小实现链路已经完整了剩下的就是把代码跑起来然后按你自己的使用场景去扩展。