尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

用Go和xterm.js打造轻量WebSSH网关:OpenShell实战解析

用Go和xterm.js打造轻量WebSSH网关:OpenShell实战解析 如果你手头要管七八台不同环境的服务器大概也经历过这种状态桌面上开着 SecureCRT、PuTTY、Windows Terminal 好几个窗口每个窗口连不同机房还要记着哪个窗口对应哪台机器、用的是哪把私钥、agent 转发开没开。我受够这种体验之后花了两个周末写了一个叫 OpenShell 的 Web 终端管理平台把整个登录、鉴权、会话管理全部收进浏览器里。这篇文章把我从需求拆解到架构设计、核心代码落地再到实际部署踩坑的全过程整理出来给同样想做或者正在做 WebSSH 网关的朋友做个参考。1. OpenShell 的由来为什么放着现成方案不用非要自己写开始动手之前我当然先看过市面上那些开源的 WebSSH 项目。有的功能很重内置了完整的审计、工单、操作审批流想在团队里跑起来得先配数据库、配 Redis、配对象存储再搭一套用户中心有的轻倒是轻但只支持密码登录公司内部服务器全部走密钥认证根本没法用还有的干脆把后端逻辑写死在某个特定云平台上换到自建机房就很难受。我当时的诉求其实很朴素团队里五六个后端和运维每个人手头有权限的机器不一样希望在一个统一入口登录看到自己名下的资产列表点开就能进终端操作过程最好能录下来出问题时有据可查不需要审批流那一套重型功能但起码要有基本的账号隔离和命令审计。现实是现成方案要么太重重到我不想维护要么太死定制起来连源码都要大改。OpenShell 定位就是一个轻量、自托管的 Web 终端网关核心功能只有三个资产纳管、在线终端、会话录制。这个定位直接决定了后面的技术选型所有超出这三件事的功能我一概没做。技术栈选定也比较顺。前端用 xterm.js这是目前浏览器终端模拟的事实标准Visual Studio Code 的集成终端就是基于它做的渲染性能和兼容性不用担心。后端用 Go标准库里的golang.org/x/crypto/ssh客户端非常成熟而且打包出来是单个二进制部署时不用装运行时正好符合我“轻量”的要求。会话存储落地成文件每条会话目录下记录时间线暂时不需要数据库。如果你也在评估要不要自己写我给个比较实在的判断标准你的需求如果超过四个“点”而且涉及多人协作、权限审批、工单流转那就别自己造轮子老老实实去部署现成的堡垒机体系如果你的需求只是“把终端搬到浏览器里顺带做点审计”自己写一个反而更可控代码量也不会大到收不住。2. 整体链路拆解从浏览器按键到远端 Shell 回显中间发生了什么OpenShell 的交互链路可以拆成五段每一段都有各自的职责和需要注意的坑。2.1 数据通路浏览器、Nginx、网关、目标机器四跳结构浏览器里的 xterm.js 负责把用户的按键转换成字节流同时把接到的字节流渲染成终端画面。它和 OpenShell 网关之间不是普通 HTTP 请求而是一条 WebSocket 长连接。网关拿到字节流之后把它作为标准输入写给远端 SSH session远端命令产生的标准输出和标准错误再由网关原样读回通过同一条 WebSocket 推给浏览器。链路中可以加一层 Nginx 做反代把 WebSocket 升级请求转发给后端服务同时统一处理 TLS。实际操作下来四跳结构里大头延迟基本可以忽略真正的网络开销发生在 WebSocket 段和 SSH 段各自的缓冲机制上这个后面在踩坑部分再展开。2.2 为什么选 WebSocket 而不是轮询或者 SSE终端场景最大的特点是双向、实时、持续。HTTP 轮询要做双向实时通信前端得不停地发 POST 上去拿数据服务端必须维护待回显的消息队列既浪费网络资源又凭空增加延迟。SSE 虽然能实现服务端单向推送但浏览器往服务器传数据还是得走另外的请求本质上还是两条通道SSE 还有并发连接数限制。WebSocket 一条连接承载双向消息恰好是终端模拟器最舒服的模型所以我从一开始就没有犹豫。2.3 PTY 的分配终端之所以是终端而不是管道连接目标机器之后OpenShell 不是简单地把 WebSocket 收到的字节流原样塞进 SSH channel 就完事了。要真正让远端认为你在用一个终端必须向目标机器请求一个伪终端PTY。只有申请了 PTY远端程序才会启用行编辑、颜色转义、光标控制这些终端行为vim才能全屏编辑top才能动态刷新界面。请求 PTY 的时候需要传终端类型、列数、行数和一组终端模式。终端类型我用的是xterm-256color绝大多数 Linux 服务器的 terminfo 都带这个条目256 色支持对识别主题色很有帮助。列数和行数从浏览器端 xterm.js 的cols和rows实时同步过来窗口大小一变就要立刻通知后端调整 PTY 尺寸否则显示会断行或者内容超出可视区域只显示一部分。2.4 会话录制的切入时机在数据流上做旁路记录录制功能如果在后端把 SSH 会话整条记录下来存成 ttyrec 格式的文本流回放时再按时间戳喂给同一个终端模拟器就能看到用户当时看到的全部界面。这个设计的好处是不干扰正常数据转发的路径。输入输出走主线录制只负责复制一份数据打上时间戳落盘即使录制模块出故障也不会中断正在进行的终端操作。3. 核心代码落地把终端搬进浏览器的关键实现工程项目最重要的是先跑通一条最小链路再逐步加功能。OpenShell 的第一步是最小闭环浏览器输入字符SSH 会话执行输出回到浏览器。下面这段代码是整个闭环的前端核心。3.1 前端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, convertEol: false }); const fitAddon new FitAddon(); term.loadAddon(fitAddon); term.open(document.getElementById(terminal)); fitAddon.fit(); const ws new WebSocket(wss://your-host/api/ws?token${token}); ws.onmessage (ev) { term.write(ev.data); }; term.onData((data) { ws.send(data); }); window.addEventListener(resize, () { fitAddon.fit(); ws.send(JSON.stringify({ type: resize, cols: term.cols, rows: term.rows })); });这段代码里有三个细节值得注意。第一term.onData返回的数据是终端解析后的输入字节序列包括普通字符、回车换行、退格、方向键转义序列等直接透传给 WebSocket 即可。第二resize 消息我用 JSON 封装而终端数据直接传原始字节流。这样后端可以根据消息类型区分“这是用户输入”和“这是窗口尺寸变更指令”解析成本很低。第三xterm.js 的convertEol我设成false因为远端 SSH 会话的 PTY 自己会处理\r\n转换前端不需要额外干预。如果设成true反而可能在某些程序里出现双倍换行。3.2 后端Go 建立 SSH 会话并做双向转发后端核心逻辑用 Go 实现。建立 SSH 连接、申请 PTY、创建 Shell、双向转发核心代码可以压缩成下面这段func handleWebSocket(ws *websocket.Conn, r *http.Request) { targetAddr : r.URL.Query().Get(host) :22 sshConfig : ssh.ClientConfig{ User: r.URL.Query().Get(user), Auth: []ssh.AuthMethod{sshAgentAuth()}, HostKeyCallback: ssh.InsecureIgnoreHostKey(), // 生产环境必须换成固定公钥校验 Timeout: 10 * time.Second, } client, err : ssh.Dial(tcp, targetAddr, sshConfig) if err ! nil { ws.WriteMessage(websocket.TextMessage, []byte(err.Error())) return } defer client.Close() session, err : client.NewSession() if err ! nil { ws.WriteMessage(websocket.TextMessage, []byte(err.Error())) return } defer session.Close() modes : ssh.TerminalModes{ ssh.ECHO: 1, ssh.TTY_OP_ISPEED: 14400, ssh.TTY_OP_OSPEED: 14400, } if err : session.RequestPty(xterm-256color, rows, cols, modes); err ! nil { ws.WriteMessage(websocket.TextMessage, []byte(err.Error())) return } stdin, _ : session.StdinPipe() stdout, _ : session.StdoutPipe() if err : session.Shell(); err ! nil { ws.WriteMessage(websocket.TextMessage, []byte(err.Error())) return } go func() { buf : make([]byte, 4096) for { n, err : stdout.Read(buf) if err ! nil { break } ws.WriteMessage(websocket.TextMessage, buf[:n]) } }() for { _, data, err : ws.ReadMessage() if err ! nil { break } var msg map[string]interface{} if json.Unmarshal(data, msg) nil { if msg[type] resize { session.WindowChange(int(msg[rows].(float64)), int(msg[cols].(float64))) continue } } stdin.Write(data) } }这里的认证方式我先用了 SSH agent也就是把本机的 SSH 私钥代理转发给远端使用。实际部署时 OpenShell 服务端应该自己持有私钥完成后端统一鉴权浏览器用户不需要接触任何私钥材料。HostKeyCallback那段我注释提醒过了示例里为了快速跑通用了InsecureIgnoreHostKey等于跳过了对目标机器公钥的校验这个在生产环境绝对不能省。正确做法是把目标机器公钥固定到 known_hosts或者至少做一次首次连接时的指纹校验和持久化。3.3 认证与会话生命周期谁在什么时候接管连接OpenShell 对外暴露的接口只有两个登录接口和 WebSocket 接口。登录成功后签发一个短期 tokenWebSocket 连接握手时带上这个 token后端校验通过才建立 SSH 连接。这个设计的核心价值是把“平台账号”和“服务器账号”彻底解耦。平台账号决定你能不能点开这台机器的终端服务器账号由 OpenShell 服务端统一配置用户拿到的是一个可操作的终端而不是服务器的 SSH 口令。会话生命周期也做了超时控制。WebSocket 空闲超过 30 分钟自动断开SSH 连接跟着释放SSH 通道层设置 keepalive每 30 秒检查一次。这样就算用户关掉浏览器忘记退出服务端也不会被僵尸会话拖死。4. 真正折磨人的不是功能而是细节我在实测中踩过的那些坑打通最小闭环只花了一个晚上但让 OpenShell 能稳定地给多人日常使用前后用掉了我大半个月的碎片时间。下面这几个坑都是真实遇到过、并且会严重影响体验的写出来省得你再踩一遍。4.1 第一坑输出乱码——字符编码这层窗户纸比我以为的厚第一次连上一台 CentOS 7 机器执行top界面整体正常但中文进程名变成了一堆问号。查下去发现目标机器的LANGzh_CN.GB18030输出的是 GB18030 编码字节流而浏览器端 xterm.js 强制按 UTF-8 解码自然乱码。最干净的解法是在建立 SSH 会话后主动执行一行命令把远端环境固定成 UTF-8export LANGC.UTF-8现代 SSH 服务端默认会把客户端的 locale 环境变量传递过去所以我可以在 OpenShell 分配 PTY 之前请求这个环境变量或者干脆在创建 session 之前先跑一条export再进入交互 Shell。实测下来这个方法能覆盖绝大多数 Linux 发行版Windows 的 OpenSSH 服务端需要注意下它不完全遵守这个规则至少保证远端 shell 是 PowerShell 时会按 UTF-8 输出。4.2 第二坑窗口拖动引发的 resize 风暴浏览器窗口拖动的过程中resize事件会高频触发每一次都会触发一次完整的数据链路浏览器发 JSON、后端解包、调用session.WindowChange、远端 PTY 重绘、输出重新推回浏览器。这个风暴在窗口尺寸连续变化时会瞬时拉高 CPU 和网络占用而且远端vim这类全屏程序会不断重绘看起来就是在疯狂闪烁。解决方法是给 resize 事件加防抖拖拽期间只取最后一次尺寸生效let resizeTimer; window.addEventListener(resize, () { clearTimeout(resizeTimer); resizeTimer setTimeout(() { fitAddon.fit(); ws.send(JSON.stringify({ type: resize, cols: term.cols, rows: term.rows })); }, 150); });150 毫秒的延迟体感上完全没有变化但事件量能降一个数量级。这个经验同样适用于你以后做任何“拖动时高频变化、停止时才有意义”的 UI 场景。4.3 第三坑网络抖动导致 SSH 会话成了僵尸连接有一次同事反馈 OpenShell 里挂着的某个会话点啥都没反应。我查到 WebSocket 连接还开着但底层的 SSH 连接已经被网络设备静默断掉了TCP 层完全不知道对端已经消失。SSH 协议层有 keepalive 机制但默认不在客户端侧主动发全局请求于是死连接就这么挂着。解决办法是双层保活Go 的ssh.Client支持配置KeepAliveInterval和KeepAliveMaxCount我会定期发送 keepalive 请求连续多次无响应就把连接主动断开。WebSocket 层也加了应用层 ping/pong前端每隔 15 秒发一个 ping后端在 15 秒内没收到 pong 就强制断开这条会话。这样一来“网络闪断但没人发现”的情况基本被扼杀在萌芽里。值得提醒的是WebSocket 本身的 ping/pong 帧不保证所有中间代理都支持所以应用层 ping/pong 才是兜底方案。4.4 第四坑输入法组合态打断操作流程中文输入法下输完拼音按下空格上屏xterm.js 偶尔会出现输入内容丢失或者候选框卡住的情况。原因是输入法组合态的composition事件和onData字符事件互相干扰xterm.js 默认没有做特殊处理。我在前端做了兼容检测到compositionstart时设置一个标志位onData里先判断标志位组合态期间不转发数据compositionend之后再把完整的输入内容一次性发出去。另外成本最低的兜底方案是在帮助文档里提示用户终端场景尽量用英文输入法但作为负责任的项目前端层面还是要尽量兼容中文用户的基本输入习惯。4.5 第五坑录制的会话回放时间线漂移会话录制最开始的版本是每收到一段输出就追加一行文本回放时按固定速率匀速播放。结果发现实际回放和真实操作完全对不上用户等着命令执行时录制文件里是一大段静默用户快速操作时回放又来不及显示。后来我改成每一条录制记录都带上纳秒时间戳保存成 JSONL 格式的一行一个事件。回放程序按时间戳逐个渲染两个事件之间等待真实的时间差。这样既忠实还原了操作过程也方便按时间点跳转定位找问题的时候不用从头放完。录制文件本身还能被其他 ttyrec 兼容工具读取不会变成孤岛格式。5. 权限、安全与审计把轻量堡垒机的思路落进 OpenShell做终端网关最不能省的就是权限和审计设计虽然 OpenShell 定位是轻量但这块我一点没缩水。5.1 账号体系和资产权限隔离平台账号我接入了公司已有的 LDAP登录时通过 LDAP 校验身份这样团队里人员入职离职的账号生命周期就交给 HR 系统和 LDAP 同步自动处理OpenShell 不用维护一份独立的用户表。登录成功后签发一个 JWT token有效期 8 小时WebSocket 握手时校验 token 里的用户身份和资产权限。资产按标签组织比如“生产”“测试”“数据库”这些。登录用户只能看到自己有权限的标签下的资产这是运维侧维护一个简单的 RBAC 映射表实现的。由于 LDAP 本身不知道服务器资产这个维度OpenShell 内部保存一份用户到资产标签的授权关系管理人员在后台维护。5.2 私钥集中在服务端托管用户登录后所有 SSH 认证都在 OpenShell 服务端完成私钥永远不会下发到浏览器端。这样做的好处是用户不需要在自己的电脑上配置任何 SSH 私钥打开浏览器登录平台就能操作被授权的所有机器。安全边界很清晰所有从浏览器到服务器的流量都会经过 OpenShell 记录和审计。有几个部署细节值得特别说明。生产环境 OpenShell 服务端存私钥的地方必须设置严格的文件权限避免同机其他进程读到。如果部署在云服务器上优先使用云平台的密钥管理服务来托管私钥不要把私钥直接写进容器镜像。5.3 命令审计记录完整会话也提取关键行为会话录制已经覆盖了“做了什么”但审计还需要“提交了什么命令”。我加了一个旁路解析模块从输入字节流里把完整的命令行提取出来按行切分后写入独立的审计日志。之所以说“提取”而不是“监听”是因为终端场景下用户输入是一个字节一个字节过来的必须等回车符出现才能拼出完整命令还要过滤掉退格、方向键这些编辑行为。这个模块本质上是一个简单的行编辑器状态机实现起来不复杂但对审计的完整性很关键。审计日志我按天滚动留存 180 天。查询页支持按用户、按资产、按时间段过滤定位问题时输入关键词直接就能跳到该时间点的会话回放。提示命令审计提取的是用户在终端里敲入的原始输入它不能识别程序内部重新包装后执行的命令。比如用户执行了一个脚本脚本内部做的事情不会一条条暴露在命令审计里。要完整追踪这种行为还得靠系统层的命令审计和文件监控OpenShell 在终端语义层做不到这一步。5.4 部署侧的安全底线OpenShell 本身不存储用户口令这一点很大程度降低了被拖库的风险。部署时我只把 OpenShell 暴露在内网前面再挂一层公司的统一认证网关做二次校验。WebSocket 接口强制走 TLS密码登录这类入口只允许从网关转发过来不允许直接访问。如果团队没有统一认证网关最低限度也要在 OpenShell 前面加一层 HTTP Basic Auth 或者 OAuth2 Proxy把非预期的公网访问挡在外面。6. 实测数据、资源占用与团队实际使用反馈部署完 OpenShell 至今已经稳定跑了三周中间经历过大概十几个内部用户的日常使用。我记录了几组比较有参考价值的数据给考虑仿照这个项目的人一个预期区间。6.1 资源占用与并发表现OpenShell 服务端部署在一台 2C4G 的轻量云主机上同时跑着 Nginx 和 OpenShell 本身。场景CPU 占用内存占用体感延迟空闲状态仅登录界面0% - 1%约 180 MB-1 个活跃终端操作1% - 3%约 220 MB与原生 SSH 基本无差异10 个活跃终端并发6% - 10%约 520 MB页面切换略有延迟键入正常30 个活跃终端并发15% - 20%约 1.2 GB命令回显偶有 200ms 内延迟从数据看OpenShell 本身的开销非常可控真正的瓶颈主要集中在 WebSocket 消息转发和 SSH 进程的文件描述符数量上。我把 Nginx 的worker_connections调到了 2048proxy_read_timeout和proxy_send_timeout都调成 3600 秒避免长连接因为默认超时被意外断开。6.2 几个明显的收益和仍然存在的短板收益方面团队最直观的感受是“终于不用在自己电脑上配各种 SSH 密钥了”。新同事入职的配置时间从平均半天缩短到十分钟只要有浏览器和平台账号就能开始干活。操作审计也实实在在帮了忙有一次排查线上事故靠命令审计的检索记录快速定位到是谁在几点几分对哪台机器执行了哪条命令这在以前至少要翻一堆个人终端的历史记录。短板也很明显。一是剪贴板互通还有局限本地复制的内容要在浏览器终端里粘贴需要走 WebSocket 中转大文本时体验不如原生终端二是 OpenShell 目前只支持单标签页单会话无法像某些商业产品那样在一个浏览器页签里平铺多个终端窗口团队里有不少同事反馈这个需求三是移动端的 xterm.js 触控体验还在能用的阶段没有针对触摸做专门的优化。这三件事我排进了下一轮的迭代计划。6.3 后续规划从“能用”到“好用”还差几步项目主页上我列了三个主要方向第一是给录制回放加上全文检索目前是按时间戳顺序播放定位问题还是得靠肉眼扫如果能把命令审计和录制回放串在一起按命令搜索效率能提升很多第二是支持多标签页多会话管理让用户在一个页面里同时操作多台机器第三是做一个简单的只读分享链接方便把某个会话的回放发给不在权限范围内的人查看这个对跨团队协作排查问题很有用。如果你也正在盘算类似的项目我的建议是从最小闭环先跑起来先把“浏览器能登录服务器、能敲命令、能看到输出”这三件事做成再做权限、审计这类增强功能。千万别一上来就设计十全十美的架构终端网关这个领域真实用户反馈带来的修正远比预想设计值钱。最后分享一个部署层面的小细节OpenShell 的健康检查接口不要只返回服务进程活着我后来让它顺带检查一下到目标机器第一跳的 TCP 连通性这样探活脚本能更早发现是网络问题还是业务问题报警排查链路会顺畅很多。这个习惯对我帮助很大写在这里给同样在运营自托管基础设施的朋友做个参考。
返回列表