ARTICLE DETAIL

资讯详情

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

开源Web终端OpenShell实践:从PTY到WebSocket的完整搭建指南

开源Web终端OpenShell实践:从PTY到WebSocket的完整搭建指南 我在地铁上被叫去救过一回火客户那边测试环境突然起不来手边没有电脑手机上的终端App连是能连上但每次断线重连都要重新找会话小键盘敲命令敲到怀疑人生。那次之后我就一直在想为什么不能有个网页版的终端——打开浏览器就是Shell断网了会话还在服务器上挂着换设备接着敲。OpenShell就是奔着这个需求去的一个自托管的开源Web终端或者说是一个轻量的在线Shell工作台不需要安装客户端浏览器访问即可支持多会话、断线重连、用户隔离还能嵌到内部系统里用。这篇文章会把我从选型到上线踩过的坑、调优的过程整个摊开适合正在考虑给团队或者自己搭Web终端的人参考也适合想了解伪终端、WebSocket、xterm.js这套组合到底怎么配合的读者。1. OpenShell要解决什么问题从一次远程救火说起1.1 手机连服务器这件事为什么这么别扭主流终端工具其实已经很多了手机上也有Termius、Blink这类App但真到了火急火燎的时候痛点还是藏不住。第一是密钥管理手机和电脑的密钥要同步换手机又要折腾一遍第二是会话断线地铁隧道里信号一抖SSH就断了重连之后之前的命令输出全没了第三是协作场景你在工位上帮同事排查问题经常需要他把服务器IP、账号、密码发过来看完还得催他改密码体验很差。OpenShell解决的其实不是没有SSH工具的问题而是终端环境能不能跟着我走的问题。公司内部架一台OpenShell服务员工在任何设备、任何网络条件下打开浏览器、登录账号就进入一个和自己本地终端体验差不多的环境会话在服务端持续存活断线重连不会丢上下文需要协助时也不用把服务器凭据丢给同事只给他开一个受限会话就行。1.2 OpenShell的产品边界做终端但不只做终端我最初只是想做一个网页版SSH但做下来发现如果只做终端价值不大市面上一堆现成工具。真正有价值的是围绕终端加一层会话管理和身份管理多会话管理每个用户可以在浏览器里开多个标签页对应多个独立Shell会话随时切换、随时关掉服务端会话不丢失。会话保持浏览器刷新、笔记本合盖、甚至断网十分钟重新打开页面后还能attach回原来的会话。用户隔离多用户共用一台OpenShell服务器时每个人只能看到和维护自己的会话工作目录也是独立的。文件快捷入口在页面侧边栏放一个简易的目录浏览和文件上传/下载面板省去从本地拖文件到服务器的麻烦。同时我明确划掉了两个大坑不做IDE代码编辑有VS Code Remote好了不做可视化运维面板监控、日志系统是另一摊事。OpenShell的定位就是一个纯粹的、带会话管理能力的浏览器终端专注把终端体验做扎实。2. 技术选型为什么是node-pty xterm.js Socket.IO2.1 终端程序不是简单的执行命令刚开始想过用最朴素的方案前端发一个命令过来后端用child_process.exec跑一下把stdout返回去。这个方案五分钟就能跑通但一碰交互式程序就露馅了。你试想一下在Web终端里敲top、vim、python的REPLexec方式只能等进程结束才能拿到全部输出top永远不会有第一次刷屏vim根本没法渲染因为它们都依赖一个东西——伪终端Pseudo TerminalPTY。我习惯用一个比喻来理解PTY终端就像一个小剧场程序是台上的演员键盘是观众递上去的纸条屏幕是舞台上的大字报。演员需要随时看到观众的反应观众也能实时打断演员。操作系统提供的PTY设备就是这座剧场的物理场地它让程序以为自己连着一个真实的终端设备程序往里写ANSI转义序列控制光标和颜色程序通过它读取原始按键输入。没有PTY很多程序会退化成非交互模式输出不加颜色甚至直接拒绝启动。对比一下就清楚了能力child_process.execnode-pty支持交互式程序vim/top不支持支持实时双向数据流弱依赖stdio管道原生支持类似真实终端窗口尺寸同步无法控制可以动态resize信号处理SIGINT等需要手动kill终端环境自带跨平台支持支持Node生态里node-pty最成熟2.2 三个核心依赖的选型理由OpenShell的三根柱子是xterm.js、node-pty、Socket.IO。选型的时候其实对比过几轮最后选它们的理由很具体xterm.js浏览器端终端模拟器的事实标准VS Code的终端底层也是它现在叫xterm。它对ANSI转义序列、Unicode、IME输入法、鼠标事件的支持非常完整之前试过自己用DOM模拟终端输出碰到花屏和光标定位问题直接投降用xterm.js省下至少几千行代码。node-ptyC绑定直接调用操作系统的pty能力安装方便API清爽就是spawn一个伪终端、onData收输出、write写输入。Go那边creack/pty也很强但我主栈是Node和xterm.js、Socket.IO放一起用最顺。Socket.IO虽然原生WebSocket也能干这事但终端场景里断线重连、多标签页room管理、二进制数据传输这些需求几乎是刚需Socket.IO全都内置了。手写一套重连逻辑不难难的是把各种边界情况处理好没必要重复造轮子。我也试用过gotty和ttyd单文件部署确实方便但做多用户、多会话、自定义页面和权限体系的时候扩展性就有点吃力。OpenShell定位成一个可持续往里加功能的小系统所以选Node全栈。项目结构大致是这样openshell/ ├── server/ │ ├── index.js # Socket.IO入口鉴权与路由 │ ├── session-manager.js # 会话创建/回收/心跳 │ └── audit.js # 审计日志 ├── client/ │ ├── index.html │ ├── terminal.js # xterm.js初始化和数据绑定 │ └── style.css ├── Dockerfile └── package.json3. 核心链路实现一个按键从浏览器到进程内部3.1 服务端用node-pty创建一个会话先看最核心的代码。每个用户打开一个新终端标签时服务端要做的就是spawn出一个伪终端进程const pty require(node-pty); function createSession(userId, cwd) { const sessionId crypto.randomUUID(); const ptyProcess pty.spawn(process.env.SHELL || bash, [], { name: xterm-256color, cols: 80, rows: 24, cwd: cwd || process.env.HOME, env: { ...process.env, LANG: C.UTF-8, TERM: xterm-256color, }, encoding: utf8, }); const session { id: sessionId, pty: ptyProcess, userId, createdAt: Date.now(), lastActiveAt: Date.now(), }; sessions.set(sessionId, session); return session; }这里每个参数都值得说一下。name字段对应终端类型会影响TERM环境变量xterm-256color能让绝大多数程序正确使用256色cols和rows是创建窗口的初始尺寸前端fit插件算出来的值会覆盖它cwd决定默认目录这里我会按用户隔离来传不同用户落在不同工作目录env里的LANG直接关系到中文不乱码这个后文细说。session对象用一个Map存着key是UUID。mapSession这个数据结构很关键OpenShell的所有功能几乎都要查它attach时查、输入时查、resize时查、清理僵尸会话时也要遍历它。3.2 数据链路为什么终端输出要用二进制事件发终端输出的特点是流量可能瞬间很大而且内容里塞满了ANSI控制序列比如颜色码、光标移动、清屏指令。如果把这些数据包一层JSON序列化再用文本事件发出去会发生三件事特殊字符被转义导致体积膨胀、前端拿到后要parse一遍、缓冲和背压处理变得困难。所以OpenShell里所有终端输出都走Socket.IO的二进制事件直接送ArrayBufferptyProcess.onData(data { const buf new TextEncoder().encode(data); io.to(session: sessionId).emit(pty-output, buf); }); socket.on(pty-input, (sessionId, input) { const session sessions.get(sessionId); if (!session || session.userId ! socket.data.userId) { return; // 权限检查别人不能往你的会话里注入命令 } session.lastActiveAt Date.now(); session.pty.write(input); });前端收到后会做一次二进制到字符串的转换再交给xterm.js渲染。这里有一个细节ArrayBuffer在Socket.IO里传输默认会自动转成Blob前端拿到Blob后要再转回字符串。我直接在客户端侧启用了一个自定义解析或者用socket.io的parser设置让二进制原样落到xterm.write。别小看这一步搞错了的话中文输入和大量日志输出都会出问题。3.3 前端xterm.js的绑定逻辑前端初始化xterm.js的代码相对固定重点是要接上fit插件和themeimport { Terminal } from xterm; import { FitAddon } from xterm-addon-fit; import { Terminal as XTerm } from xterm; const term new XTerm({ cursorBlink: true, fontSize: 14, fontFamily: JetBrains Mono, Consolas, Noto Sans Mono CJK SC, monospace, scrollback: 5000, theme: { background: #1e1e2e, foreground: #cdd6f4, cursor: #f5c2e7 }, }); const fitAddon new FitAddon(); term.loadAddon(fitAddon); term.open(document.getElementById(terminal)); fitAddon.fit();双向绑定的逻辑不复杂const socket io(); socket.emit(session:attach, { sessionId }); socket.on(pty-output, (data) { const str typeof data string ? data : new TextDecoder().decode(data); term.write(str); }); term.onData(input socket.emit(pty-input, sessionId, input)); window.addEventListener(resize, () { fitAddon.fit(); socket.emit(pty-resize, { sessionId, cols: term.cols, rows: term.rows }); });整条按键链路可以这样描述你在浏览器里按下ls回车xterm.js监听到字符通过Socket.IO的pty-input事件发到服务端服务端查出会话对应的pty进程调用pty.write写入操作系统把这个输入交给bashbash执行命令并输出结果输出经过ANSI编码回传到node-pty的onData服务端捕获后用二进制事件广播回前端xterm.js解析ANSI序列并绘制到屏幕上。整个过程在毫秒级完成体感和本地终端几乎没有差别。4. 多会话管理与终端尺寸同步两个最容易出问题的细节4.1 会话的创建、复用与清理多会话管理的设计一开始我只用了一个全局Map后来很快发现有三个问题要补断线重连后如何attach回原会话、过期会话何时回收、多用户之间如何隔离可见性。OpenShell的做法是这样的Socket.IO断线时服务端不立即销毁pty而是给会话打一个disconnected标记并启动一个60秒的计时器超过60秒用户还没回来就硬回收。这个思路借鉴了tmux的分离会话模型好处是临时断网、刷新页面都不丢终端状态。attach时用户携带sessionId和token服务端校验token并确认该session的userId和登录用户一致才允许他join对应的Socket.IO room。socket.on(session:attach, ({ sessionId, token }) { const session sessions.get(sessionId); if (!session || !checkToken(token, session.userId)) { socket.emit(session:error, { message: 无权访问此会话 }); return; } socket.data.userId session.userId; socket.join(session: sessionId); socket.emit(session:attached, { cols: session.pty.cols, rows: session.pty.rows }); });回收逻辑单独跑一个定时器每30秒扫一遍sessions Map把最后活跃时间超过10分钟且当前没有活跃socket连接的会话杀掉。注意这个10分钟和前面60秒是两个维度60秒解决断线马上重连的场景10分钟是彻底没人用的清理策略。如果是临时共享会话可以调短如果希望长期挂一些服务进程就得配合服务端进程管理来做不能一味靠超时回收。4.2 尺寸同步SSH正常但Web终端乱版的根因很多人做Web终端会遇到一个怪现象初始化时一切正常但把浏览器窗口一拉大vim界面错位、命令输出折行全乱了。原因是pty创建时的cols和rows是固定的你窗口变了内核终端设备不知道程序还在按旧尺寸排版。这个坑的修复思路其实很直接每次容器尺寸变化都要重新算一遍xterm.js的实际行列数然后把新尺寸传给服务端服务端调pty.resizesocket.on(pty-resize, ({ sessionId, cols, rows }) { const session sessions.get(sessionId); if (!session) return; session.pty.resize(cols, rows); session.lastActiveAt Date.now(); });但实测下来有三个地方容易漏。第一不要只监听window.resize因为你的终端容器可能在侧边栏收起或展开时尺寸变了但浏览器窗口没变要用ResizeObserver监听容器的实际变化。第二fitAddon.fit()必须在终端可见的时候调用如果页面初始化为display:nonefit算出来的是0必须等标签页激活后再fit一次。第三不要频繁无脑发resize连续resize事件要做节流我用的100毫秒内合并一次不然窗口拖动时服务端会被resize事件淹没。4.3 中文输入法、CtrlC、换行符这些输入细节值得单独讲第一个是中文输入法。xterm.js在IME处理上已经做得不错但OpenShell在实践中遇到一个典型问题拼音输入法候选框在终端里不显示或者上屏顺序错乱。解决方案比较务实企业微信/钉钉等浏览器里现在普遍支持浏览器原生composition事件xterm.js新版本已经处理了大部分如果用户反馈还是有问题建议在CSS里强制设置正确字体族并把终端的fontSize调大一点候选框渲染错的概率会低很多。第二个是CtrlC。xterm.js默认会把CtrlC作为按键序列传给ptynode-pty的write会把\x03发给终端bash自动把它转成SIGINT信号中断前台进程。这里的一个反模式是有人为了更安全在前端拦截CtrlC做二次确认结果导致SIGINT信号永远到不了进程kill -9都用不了体验严重割裂。OpenShell里我完全不拦截最多在UI层显示一行提示。第三个是Windows下换行符和回车的问题。用node-pty在Linux服务器上跑bash返回的全是\n没问题。但如果你的后端跑在Windows上或者通过WSL中转输出可能会混入\r\n。xterm.js默认的convertEol参数要视情况打开。不过目前OpenShell主要面向Linux服务器这个坑只在开发调试时踩了一脚生产环境没有遇到。5. 部署与安全能跑通Demo和能上线是两回事5.1 端口暴露的终端本质上是一个Web Shell先泼一盆冷水一个可以操作服务器Shell的Web应用如果裸奔在公网上等于把服务器密码贴在大门上。OpenShell从第一个可运行版本开始安全设计就是优先级最高的事项。最基础的是认证。OpenShell用一个轻量的JWT机制用户先登录获取token后续所有Socket连接都要携带这个token每次创建会话、attach会话都要校验。其次是用户隔离每个用户在服务端对应一个独立账户pty.spawn的时候用这个用户身份去创建工作目录配合Linux系统权限A用户就碰不到B用户的文件。如果要求更严每个用户跑独立容器是最干净的方案但部署复杂度会高OpenShell默认提供系统用户隔离配合可选的白名单命令比如限制rm、mkfs这类危险命令适合只想给新人开放基础的Linux学习环境时用。还有两个安全细节我觉得必须写进清单会话必须有超时与心跳机制长时间空闲自动锁屏防止同事路过你工位直接往终端里敲rm -rf。所有终端输入要落审计日志出了事故能定位是谁、在什么时候、执行了什么命令。这点后面单独展开。5.2 Docker部署三分钟跑起来OpenShell的Docker部署方式是我每天实际在用的直接给出DockerfileFROM node:20-alpine # alpine默认没有bash而且pty最好有完整shell环境 RUN apk add --no-cache bash openssh-client tini curl WORKDIR /app COPY package*.json ./ RUN npm ci --omitdev COPY . . ENV LANGC.UTF-8 EXPOSE 8080 # tini负责回收孤儿进程node-pty会派生很多子进程 ENTRYPOINT [tini, --, node, server.js]有两个点是我踩过坑才明白的。一是alpine镜像默认只有sh很多用户上来敲bash的习惯会触发not found所以必须装bash。二是tini必须装因为node-pty会在容器里fork出真正的bash进程如果入口进程崩溃bash会成为孤儿进程留在容器里tini作为1号进程能正确回收所有子进程。docker-compose里加几个环境变量就行version: 3 services: openshell: build: . ports: - 8080:8080 environment: - OPEN_SHELL_ALLOWED_USERSadmin,ops - OPEN_SHELL_SESSION_TTL600 - OPEN_SHELL_AUTH_SECRETchange-me volumes: - /home:/home:ro restart: unless-stopped这里/home以只读方式挂载是出于安全考虑Web终端里用户可以读取文件但即便配置出错也不能让其他路径被随意篡改。要看你的使用场景动态调整。5.3 Nginx反向代理与WebSocket生产环境不可能直接拿Node进程接公网Nginx反代是标配。WebSocket有一个关键的坑反向代理必须支持协议升级server { listen 443 ssl; server_name terminal.example.com; ssl_certificate /etc/letsencrypt/live/terminal.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/terminal.example.com/privkey.pem; location / { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_read_timeout 3600s; } }Upgrade和Connection这两个头是WebSocket握手的核心少了它们浏览器会一直建立不了连接。proxy_read_timeout也要调大终端会话可能长时间没输出默认60秒超时的话一条长时间运行的命令会被Nginx掐断。5.4 审计日志出了事故能还原现场终端是一个高危操作入口审计必须做。OpenShell的做法是把每个会话的原始输入记录下来同时额外记录一些结构化的元数据字段说明user_id操作者session_id会话标识command_raw实时记录到的键盘输入source_ip来源IPstart_time / end_time会话起止时间exit_code会话最终退出码这里有一个容易做错的点直接把所有输入原样记日志会把用户输入sudo密码也记录下来造成二次泄露。OpenShell的audit模块会过滤掉密码验证阶段的输入做法是检测到sudo提示符时暂停记录直到出现新的shell提示符再恢复。6. 实测与调优我跑了两周看到的坑6.1 并发上来后CPU暴涨问题出在背压OpenShell上线到团队内部后第一批反馈是40个并发会话的时候Node进程单核CPU到了90%以上。定位后发现不是node-pty的锅而是数据广播没有做背压控制。终端输出是典型的生产者-消费者模式当某个会话在跑find /或docker build时输出速率远高于浏览器单帧能渲染的量数据在Socket.IO层堆积CPU全耗在序列化、传输、内存分配上。解决思路是参照流式处理里的背压backpressure机制实现了一套简易控制服务端每发一批数据先检查socket.bufferedAmount如果超过阈值就通知前端暂停渲染并暂时停止往该socket发送等消费端缓冲降低后再恢复if (socket.bufferedAmount 1024 * 1024) { socket.emit(flow:control, { status: pause }); // 记录当前会话暂停发送 }前端收到pause后xterm.js照常write但请求服务端不要再推新数据等定时器触发且缓冲降到安全线以下再发送resume。这套机制上线后同样40个并发会话CPU稳定在30%左右。6.2 中文乱码、emoji变豆腐块字符链路上三个环节都要对中文乱码是Web终端最常见的诡异问题不是每个用户都遇到但遇到就很烦。OpenShell排查下来发现乱码可能出现在三个环节任何一个不对都会花屏服务端locale容器里默认locale是POSIX不认UTF-8shell输出中文前先决错误。解决方法是启动时设置环境变量并安装locale包。pty创建时的env给node-pty传env时必须显式带上LANG和LC_ALL否则bash子进程会继承一个错误locale。前端字体xterm.js字体族里没有中文字体就会fallback到系统默认不同平台渲染差异很大。我在fontFamily里显式加了 Noto Sans Mono CJK SC同时在CSS里加了font-smooth的配置。三层都处理干净后中文显示就正常了。6.3 大段日志输出卡顿批量写入和scrollback限制都得上还有一类卡顿是纯前端的一条命令刷出十几万行日志xterm.js每帧接收的数据量过大渲染线程阻塞终端直接失去响应。OpenShell的优化是两项配合使用一是把scrollback从默认的1000行调高到5000但不要更高否则内存占用会失控二是对高频输出做批量写入把100毫秒内收到的数据累积起来一次性write给xtermlet outputBuffer ; setInterval(() { if (outputBuffer) { term.write(outputBuffer); outputBuffer ; } }, 100); socket.on(pty-output, (data) { outputBuffer decodeData(data); });这样即使服务端推流很快前端也是以固定帧率消费数据终端不会再一卡一卡地闪烁。实测跑docker build几百行输出流畅度明显改善。6.4 我日常怎么用这套系统OpenShell上线两周后我的使用习惯变成了这样日常工作还是开本地终端但出差、上下班路上手机浏览器里存了一个书签点开就是自己的服务器终端断开重连上下文还在同事临时要看个日志我直接在后台开一个只读目录的会话把链接发过去他不用装任何工具就能操作给新人做Linux培训的时候也不需要统一装虚拟机打开浏览器就能练命令每人的会话互相隔离。如果你也想搭一套类似的系统我建议按这个顺序来先跑通node-pty和xterm.js的最小链路再补会话管理和用户隔离最后再考虑安全加固和部署自动化。不要一上来就堆容器、配集群终端这种交互系统的核心价值在流畅度和稳定性功能可以慢慢加底层的输入输出链路必须做扎实。最后分享一个我习惯的小技巧OpenShell的前端打包产物很小直接用Nginx托管静态文件再把Socket.IO通过同一个路径反代整个系统跑起来只占不到200MB内存特别适合放在一台1核1G的小机器上长期运行。只要把安全清单上的几项做齐这套工具会是你远程维护服务器时最顺手的一层入口。
返回列表