ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

浏览器里的Shell:OpenShell核心设计与工程落地

浏览器里的Shell:OpenShell核心设计与工程落地 先说点实际的。我手上有几台跑着不同服务的Linux机器平时远程维护基本都是开终端、敲ssh、连上去、干活。这个流程用久了最大的感触是换台电脑就得重新折腾客户端临时想查个状态又懒得打开终端团队里新来的同事每次都要教一遍怎么连服务器、怎么配密钥。后来我把“在浏览器里直接开一个Shell”这个想法落地成了一套小工具代号就叫OpenShell。说白了OpenShell是一个基于Web的终端环境浏览器打开一个页面里面就是一个能敲命令、能跑vim、能看日志的完整Shell会话背后是服务器上的真实Shell进程。整个项目用到了Node.js、WebSocket、node-pty和xterm.js这套常见组合做完之后远程维护的体验好了非常多。这篇就把它的核心设计、搭建过程、踩坑记录和一些能直接抄走的扩展思路都整理出来适合正在折腾自建运维工具、想给团队做个浏览器终端的同学参考。1. 为什么需要一个浏览器里的 Shell1.1 传统远程终端到底麻烦在哪先聊聊痛点。传统远程连接方案本身没什么问题但真实使用场景里总有几个绕不开的坎第一是客户端的安装和配置。开发机、测试机、公司电脑、自己笔记本每到一台新设备都要装一遍终端工具、配置一遍密钥和别名。偶尔遇到一台临时机器连客户端都没有只能干瞪眼。第二是会话的“随手可用”。很多时候我只是想快速跑一条命令看看服务器负载或者改一个配置文件的某个参数。为这个开一个完整的桌面终端流程真的很重。尤其是手机上有紧急情况时在手机上操作终端工具体验很一般。第三是团队的协作和授权问题。让别人帮你排查问题要么把SSH账号和私钥发给对方要么让对方通过聊天工具截图来看你操作。前者极不安全后者效率极低。OpenShell这类方案把终端能力放到了浏览器里这些痛点基本都能缓解。浏览器是几乎所有设备都有的不用装客户端用URL加上鉴权参数就能访问甚至可以做成“只读分享”的模式让别人看着你操作而不是把账号交出去。1.2 OpenShell能解决什么场景从我的实际使用来看OpenShell不是要替代你习惯的远程连接工具而是把Shell能力Web化、业务化让它能嵌入到更多场景里。个人场景最常见的是“临时应急”。比如出差时用平板打开服务器上的OpenShell页面跑几个命令确认服务状态。因为监听在内网或者经过自己控制的入口比暴露整个远程登录端口要收敛得多。团队场景更有意思。运维后台里内置一个网页终端同事登录后台之后直接点一下就能进到某台机器不用去记地址、账号、密钥。对非技术人员不直接给完整Shell而是做成预置脚本的按钮。这些我在后面的进阶玩法部分会展开写。所以OpenShell的核心价值就是把“命令行能力”变成一种可以被Web页面承载、被业务系统调用的服务。做这个项目之前我一直觉得MyShell这种工具是个玩具真做完之后才发现玩具是挡不住需求的。2. 核心架构与关键技术拆解2.1 一条命令从键盘到屏幕的旅程先建立整体认知。OpenShell的架构其实是一条数据管道从你的键盘按下到屏幕渲染完整流程是这样的前端页面加载xterm.js完成一个“假终端”的初始化。当你敲下ls这个字母时xterm.js捕捉到键盘输入通过term.onData回调拿到这段输入数据。前端通过WebSocket发送一个JSON消息给后端消息类型是input内容就是ls。后端收到之后把这段数据原样写入PTY伪终端的主设备。PTY把数据交给真正跑着的bash进程bash解析命令、执行、产生输出。输出会写回PTYnode-pty监听到输出数据触发了onData回调。后端把输出包成output类型的JSON消息通过WebSocket推给浏览器。xterm.js收到后调用term.write把包含ANSI转义序列的原始输出渲染成你看到的终端画面。这个流程看起来不复杂但每个环节都决定了体验的成败。最关键的体会是浏览器里的终端不是“模拟一个黑框”而是要在真实终端和虚拟终端之间做好信息的翻译和搬运。前端负责“看起来像终端”后端负责“真的是终端”。2.2 为什么必须是PTY而不是普通子进程刚开始做的时候我想过用一个更简单的方案后端用Node的child_process.spawn直接跑bash把stdout和stderr拿回来推给前端。这个方案写起来简单但一跑就露馅了。你会发现vim打开后直接提示 “Vim: Warning: Output is not to a terminal”然后编辑界面彻底错乱top也只输出一屏静态数据而不是那个动态刷新的界面需要交互输入密码的命令完全没法工作。原因是普通子进程没有“终端”这个概念。程序不知道自己在和谁对话没有控制终端也就无法处理行编辑、信号、光标控制和各种特殊控制序列。这就像你对着一个没有屏幕的服务器喊话它回应了但你看不到画面。node-pty解决了这个问题。它在底层通过openpty这类系统调用给子进程分配一个伪终端设备。程序看到这个设备会相信自己正在一个终端里运行于是输出各种控制序列来排版、刷新、交互。而这些“假想中的终端画面”正好被xterm.js在浏览器里还原出来。用生活里的例子比喻普通spawn像是把一个人关进没有窗户的房间他看不到外面的情况只能吼几嗓子PTY是给他装了一台闭路电视他以为自己面对的是真实观众其实镜头和屏幕都在你手里。这个“善意的幻觉”是整个Web终端成立的基础。2.3 为什么前端要选xterm.js浏览器里渲染终端比大部分人想象的要复杂得多。终端输出不只是普通文本而是夹杂着大量控制序列的字符流。比如\x1b[31m表示变红\x1b[2J表示清屏光标要移动、行要擦除、滚动区域要重新计算。这些控制序列的历史可以追溯到几十年前的终端标准。如果全部自己实现那基本等于重新写一遍终端模拟器。所以前端我直接选了xterm.js它在浏览器里模拟终端这件事上经过大量生产环境验证VS Code内置的集成终端用的就是它的核心。xterm.js帮我们处理了字体度量、字符宽度、行高、光标闪烁、IME输入框的位置、复制粘贴、颜色主题这些细碎但又必须做对的事情。它还提供了addon机制比如fit插件能根据页面容器尺寸计算出正确的终端行数和列数对适配不同屏幕尺寸特别重要。我把xterm.js称为“浏览器里的假终端屏幕”而node-pty是“服务器里的真终端设备”两端各司其职互相配合。2.4 为什么用WebSocket而不是HTTP轮询终端输入输出是高频、双向、持续的流式数据不适合用HTTP的请求-响应模型来实现。如果只用HTTP浏览器需要把输入POST给后端然后不断通过GET轮询输出。这带来的问题很麻烦终端输出不是固定节奏的轮询间隔设得短了大量请求都在空转白占资源设得长了画面刷新有明显延迟敲命令的响应感很差。Socket轮询还有时序问题容易出现输出乱序。WebSocket天然适合这个场景。一次握手之后客户端和服务端之间的连接保持打开双向都可以随时推送数据延迟低、开销小。配合ws这个Node生态里成熟的库稳定性和性能都足够可靠。在实际协议设计上我用了JSON格式的消息而不是裸的字节流。每条消息带上type字段区分是输入内容、输出内容、窗口resize、进程退出还是心跳包。这样后续扩展功能时不需要改动传输机制只需要增加消息类型。3. 实操半小时搭出一个可用的 OpenShell3.1 准备环境与安装依赖先说明需要的基础环境一台Linux服务器安装Node.js16以上版本和npm。我建议在开发机上先跑通再接上真实的生产服务器。另外需要确保服务器上能运行bashnode-pty工作的前提是目标Shell是可执行程序。初始化项目非常简单mkdir openshell cd openshell npm init -y npm install express ws node-pty这里三个依赖分别承担不同职责express负责托管静态页面ws负责WebSocket通信node-pty负责创建和管理伪终端。其中node-pty是原生模块安装时偶尔会触发编译所以如果安装失败先确认系统有python3、make和一些基础编译工具再重新安装一次。建议顺手装上nodemon作为开发期自动重启工具npm install -D nodemon此外建立一个public目录用来放前端页面最终的项目结构很简单openshell/ server.js public/ index.html3.2 后端服务进程、会话与消息协议后端是整个OpenShell的心脏负责搭Web服务、鉴权、创建PTY、转发消息。我把完整代码贴在下面然后在关键位置做解释。const express require(express); const http require(http); const { WebSocketServer } require(ws); const os require(os); const pty require(node-pty); const app express(); const server http.createServer(app); const PORT process.env.PORT || 8080; const HOST process.env.HOST || 127.0.0.1; const TOKEN process.env.SHELL_TOKEN || dev-token-please-change; app.use(express.static(public)); const wss new WebSocketServer({ noServer: true }); server.on(upgrade, (req, socket, head) { const url new URL(req.url, http://${req.headers.host}); const token url.searchParams.get(token) || ; if (token ! TOKEN) { socket.destroy(); return; } wss.handleUpgrade(req, socket, head, (ws) { wss.emit(connection, ws, req); }); }); wss.on(connection, (ws) { const shell pty.spawn(process.env.SHELL || bash, [], { name: xterm-256color, cols: 80, rows: 24, cwd: os.homedir(), env: { ...process.env, TERM: xterm-256color, LANG: C.UTF-8 } }); shell.onData((data) { ws.send(JSON.stringify({ type: output, data })); }); ws.on(message, (raw) { let msg; try { msg JSON.parse(raw.toString()); } catch (err) { return; } if (msg.type input) { shell.write(msg.data); } else if (msg.type resize) { const cols parseInt(msg.cols, 10) || 80; const rows parseInt(msg.rows, 10) || 24; shell.resize(cols, rows); } }); ws.on(close, () { try { shell.kill(); } catch (e) {} }); shell.onExit(({ exitCode }) { ws.send(JSON.stringify({ type: exit, exitCode })); ws.close(); }); }); server.listen(PORT, HOST, () { console.log([OpenShell] running at http://${HOST}:${PORT}); });这段代码里有几个细节值得讲一下。鉴权部分我没有用大家常见的verifyClient写法因为新版ws已经移除了这个参数。现在比较稳妥的做法是noServer: true然后在HTTP服务的upgrade事件里主动校验query参数里的token校验通过再调用handleUpgrade升级连接。这样token校验发生在WebSocket连接真正建立之前不会出现“连接建立了又立刻被断开”的尴尬。pty.spawn的参数要注意。name和env.TERM都设置为xterm-256color告诉Shell前端终端支持的颜色和控制序列能力这能避免一部分终端显示异常。env里我额外传了LANGC.UTF-8后续会说为什么这个对中文显示很重要。后端的消息处理目前就两个类型input直接写入ptyresize调整终端尺寸。不要小看这个resize如果漏掉vim和htop这类全屏程序会严重错位。启动服务PORT8080 SHELL_TOKENyour-secret-token node server.js启动后服务默认监听127.0.0.1这是刻意为之。对外访问交给Nginx这类反向代理去处理后面会写配置。3.3 前端页面让浏览器变成一个终端前端页面负责把浏览器变成一个终端界面。我直接贴出完整的index.html代码不长但把核心环节都覆盖了。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleOpenShell/title link relstylesheet hrefhttps://cdn.jsdelivr.net/npm/xterm5.3.0/css/xterm.min.css /head body div idterminal styleheight: 100vh;/div script srchttps://cdn.jsdelivr.net/npm/xterm5.3.0/lib/xterm.min.js/script script srchttps://cdn.jsdelivr.net/npm/xterm-addon-fit0.8.0/lib/xterm-addon-fit.min.js/script script const term new Terminal({ cursorBlink: true, fontSize: 14, theme: { background: #1e1e2e } }); const fit new FitAddon.Fit(); term.loadAddon(fit); term.open(document.getElementById(terminal)); fit.fit(); const token new URLSearchParams(location.search).get(token) || ; const ws new WebSocket(ws://${location.host}?token${encodeURIComponent(token)}); ws.onopen () { ws.send(JSON.stringify({ type: resize, cols: term.cols, rows: term.rows })); }; ws.onmessage (event) { const msg JSON.parse(event.data); if (msg.type output) { term.write(msg.data); } else if (msg.type exit) { term.write(\r\n[process exited: ${msg.exitCode}]\r\n); } }; term.onData((data) { ws.send(JSON.stringify({ type: input, data })); }); let resizeTimer; window.addEventListener(resize, () { clearTimeout(resizeTimer); resizeTimer setTimeout(() { fit.fit(); ws.send(JSON.stringify({ type: resize, cols: term.cols, rows: term.rows })); }, 200); }); /script /body /html这里有几个关键点WebSocket连接建立后第一件事就是把当前的终端尺寸发给后端。此时如果后端已经有bash在跑就能立刻按照新窗口大小来排版。如果不发这条消息bash会一直以为自己在一个80x24的老式终端里工作。term.onData是前端到后端的“传送带”。终端里所有按键输入包括普通字符、退格键、方向键、粘贴的内容都会在这个回调里拿到原始数据然后原封不动发给后端。不要在这里做任何过滤因为终端输入是流式的一个中文字符可能分成多个字节段到达自作聪明地处理反而会把数据搞坏。resize增加了防抖处理。窗口大小改变是高频事件每次都发消息会导致后端频繁调用PTY的resize偶尔还会和正在输出的大量数据产生竞争。200毫秒的延迟不会让用户感觉到迟滞但会让整个链路稳很多。如果你的内网环境无法访问CDN也很简单到npm包里把xterm的css、js和fit addon的js文件拷到public目录然后改一下script和link标签的路径就行。3.4 上线前的第一轮安全加固OpenShell这类工具本质上是把命令行权限开放给了Web端所以安全不是加分项是底线。第一次做完demo之后我做了下面这几件事才敢把它放到真实环境。第一服务绝不监听公网地址。直接让Node进程只监听127.0.0.1对外统一走Nginx反向代理。这样即使Node本身出了什么问题也不会把一个不带额外保护的Web终端直接暴露出去。第二token不要长期固定更不能放在前端代码里。示例代码里把token放在URL上是为了演示方便实际上自己用的时候最好由后端接口动态签发短时有效的token再把页面跳转过去。这样token即使泄露过段时间也会失效。第三Nginx反向代理必须正确处理WebSocket的Upgrade协议。我的Nginx虚拟主机里有一段关键配置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_read_timeout 3600s; }如果漏掉前两行浏览器会一直建立不了WebSocket连接。最后一行proxy_read_timeout也重要后面讲掉线问题时会再提到。第四永远不要用root用户启动服务。WebShell会让网页和执行系统命令之间只有一层薄薄的鉴权一旦被攻破root权限的后果是灾难性的。我一般会单独创建一个低权限用户来跑工作目录也限定在这个用户能访问的范围内。4. 踩坑实录与常见问题排查4.1 中文乱码与特殊按键异常中文显示乱码是Web终端里的经典问题。我在第一次跑通后输入中文看到的是一堆问号和方块排查了好一阵。先检查服务器的locale。如果系统里没有生成对应的中文locale光有UTF-8编码的确认也没有用。在bash里执行locale命令看当前的字符集设置。我在后端启动pty时设置了LANGC.UTF-8这个值在绝大多数现代Linux系统上都能保证UTF-8环境同时避免了对特定中文locale包的依赖。还有一个经常被忽略的点是TERM环境变量。如果TERM设置得不对一些程序会退回最保守的输出模式影响中文显示和光标控制。把TERM设置为xterm-256color之后大部分中文能够正常显示。如果你发现退格键输出的是^?或者方向键输出的是^[[A这种内容说明Shell没有正确理解键盘控制序列。一般配好TERM之后能解决。实在不行在用户的.bashrc里加上stty erase ^H可以强制把退格键映射到正确的控制符。这个坑在普通终端里很少见但在Web终端里遇到的概率不小因为浏览器的键盘事件和原生终端还是有差异的。4.2 resize失效导致画面错乱如果打开一个全屏程序后窗口尺寸发生变化画面却还停留在旧宽度就会出现折行、重叠和光标错位的问题。这个问题的根源很简单后端PTY不知道终端已经变宽了。xterm.js的fit插件能把DOM容器尺寸换算成对应的列数和行数然后通过消息发给后端调用PTY的resize。这里的坑在于fit插件的换算必须发生在xterm.js成功挂载到DOM之后而且容器尺寸稳定之后才能得到准确的数字。所以在页面初始化的最短时间里我记得先调了一次fit.fit()再发送初始尺寸。窗口大小变化时还要注意避免狂发消息。我用的是200毫秒防抖等窗口拉伸停止后再通知后端。这样既保证画面不错位也不会因为频繁resize导致后端开销变大。4.3 WebSocket掉线与会话丢失掉线是Web终端绕不开的话题。我的服务一开始部署到Nginx后面发现终端只要静默超过一分钟连接就被切断。排查之后确认是Nginx默认的proxy_read_timeout只有60秒超过这段时间没有数据从后端读到Nginx就掐断连接。方向是对的但只靠调大超时时间不是最优解更合理的做法是加WebSocket心跳。浏览器端每个一段时间发一个ping类型的消息后端收到后原样返回。这样连接始终保持活动状态Nginx也不会因为长时间没有数据而判定连接空闲。即使有了心跳也要正视一个事实WebSocket连接一旦断开后端为了释放资源通常会杀掉对应的PTY和Shell进程。如果你正在跑一个长时间任务比如编译、数据同步掉线就全完了。我的经验是无论Web终端做得多稳只要涉及长任务都建议在Shell里手动挂到tmux或screen里执行。tmux天然支持会话分离和重新附着这和Web终端的断线重连是互补关系前端断线重建之后重新tmux attach就能找回现场。把tmux当作Web终端的“会话保险”习惯之后会安心很多。4.4 多用户并发时的输入竞态如果只是自己用并发问题基本不存在。但如果你想做一个团队工具就一定要想清楚多人同时连接同一个Shell会话会发生什么。每个WebSocket连接在后端对应一个独立的PTY实例这是隔离。但如果业务上想让多个用户看到同一个终端输出需要把同一个PTY的输出广播给多个连接。这里有个棘手的地方多个用户的输入都汇入同一个Shell进程时命令字符会交叉完全没法用。我的建议是分模式处理观众模式的用户只能收输出不能发输入操作者模式同一时间只允许一个人输入。如果确实需要多人共同操作那把输入请求做成“抢锁”机制拿到锁的用户才有权写入其他用户看到锁的状态并等待。这个设计在实现上并不复杂但能避免大量奇怪的问题。4.5 一次从“裸奔”到加固的复盘最值得记录的一次问题是我刚开始把token放在前端JS默认值里。当时想着反正是内部工具只要没人知道地址就行。结果地址被一个爬虫扫到了带着默认token连上来在服务器上跑了一通扫描命令。好在那台机器是测试机没有造成实际损失。但这给了一个很大的教训Web终端的曝光面是“只要URL可达就会有人来试”和你以为的“没人知道地址”完全是两回事。从那以后所有的token一律从环境变量读取部署时由配置中心注入连接加上访问频率限制所有命令操作记录日志。我整理了一张快速排查表基本覆盖了常见的运行问题现象可能原因处理方式中文乱码locale环境不对设置LANGC.UTF-8确认前端字符集UTF-8退格键显示^?TERM错误或erase未设置设置TERMxterm-256colorstty erase ^H画面错位resize消息缺失fit插件加防抖后端调用pty.resize连接静默断开反向代理空闲超时调大read_timeout增加心跳刷新后进程没了WebSocket断开后PTY被杀改用tmux维护长任务或做会话恢复多人同时输入乱掉多个输入写入同一PTY做输入锁或观众/操作者模式分离5. 进阶玩法把 OpenShell 变成团队工具5.1 嵌入运维后台与统一登录OpenShell跑通之后最自然的进化方向是嵌入已有的运维后台。你不必把它做成一个孤立的站点完全可以用iframe把它嵌入到后台的某个页面里。建议在管理后台里做一个独立的“终端”路由进入页面时先向后端请求一个短期token然后带着token把页面跳转到OpenShell地址。这样用户不需要单独记住OpenShell的URL和token整个体验是登录运维后台点一下机器进入终端。iframe嵌入最大的坑在于浏览器对混合内容和Cookie的处理不过如果都在同一域名下基本没有阻碍。唯一要注意的是iframe盖不住WebSocket连接对跨域的限制所以前端页面和后端服务尽量同域名或者做好CORS配置。5.2 只跑白名单命令的受限终端不是所有用户都需要完整的Shell权限。我遇到过业务同事想“看服务器状态但不想给账号”的需求这时候直接把完整Shell交出去风险太大。一种思路是做一个命令白名单拦截器在后端解析前端发过来的输入只允许运行预先配置好的命令其他的一律拒绝。判断方法很简单每次拿到输入时去掉首尾空格判断是否以白名单里的命令开头。但要提醒的是Shell交互式终端的输入过程是碎片化的用户输入一个长命令时前端会分段发送数据你不能只凭其中一个片段做判断。比较稳妥的做法是把输入按行缓存起来收到回车符时拼成完整命令再校验。这个缓存逻辑会稍微增加代码复杂度但也值得写。还有一种更稳的思路干脆不开放完整Shell而是给用户提供界面上的几个按钮每个按钮对应一个固定脚本脚本执行结果用简单文本渲染在页面上。这样既完成任务又完全避免了命令执行风险。5.3 会话录制与操作审计给OpenShell加上审计能力也很重要。最简单的方式是在后端把PTY产生的所有输出数据按会话存入文件这就是一份天然的“操作录像”。Linux自带的script命令可以做类似的事情script -t 2 timing.log -a session.log把整个会话的时间轴和内容都记录下来。OpenShell的WebSocket消息已经完全覆盖了输入和输出所以在后端记录日志也不难把msg.type input的原始数据和shell.onData的输出数据都追加到对应会话的日志文件每条带上时间戳。之后如果团队要求“谁在什么时间执行过什么命令”用这套日志就能说清楚。如果想让审计更直观还可以将输出流记录成asciinema的格式通过回放工具完整还原当时的终端画面。这个方案适合用来写操作文档或者做故障复盘。5.4 值得参考的几个开源实现OpenShell这类Web终端项目在开源社区有不少优秀的同类实现它们的代码和设计思路都很有参考价值。ttyd是一个用C语言写的单文件Web终端工具依赖libwebsockets编译出来一个二进制文件就能跑特别适合嵌入式设备。它的实现思路非常简洁值得看看是如何处理并发连接和PTY生命周期的。gotty用Go语言实现最突出的特点是支持share功能可以把本地终端通过临时地址分享给别人查看或操作。如果你想做共享终端它的实现很有借鉴意义。wetty和我的技术栈最接近也是Node.js生态底层同样用了node-pty和xterm.js这套组合。它的代码结构组织得很清晰如果你想把OpenShell做得更完整直接去读wetty的源码比从零摸索更快。还有sshx这类主打协作会话的终端项目把多人实时协作、实时同步输出做得非常精细。如果团队有远程结对排查问题的需求它的模式值得研究。最后分享一点个人体会折腾完OpenShell这一整套我自己最大的感受是Web终端这类项目技术门槛其实不在Web而在对终端协议和进程生命周期的理解。前端只是一个“假屏幕”后端要让程序相信自己在真实终端里跑这个“善意的谎言”由PTY来编织而xterm.js负责把这个谎言完美地呈现出来。如果你也想动手搞一个我建议走我踩过的这条路先抄一个最小可运行的版本跑通之后再做安全、多用户、日志这些加固。很多功能一开始别想太多但token鉴权必须从一开始就加因为补安全欠账的代价永远比一开始就做要高得多。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进