ARTICLE DETAIL

资讯详情

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

OpenShell:基于WebSocket与xterm.js的浏览器终端实现与部署指南

OpenShell:基于WebSocket与xterm.js的浏览器终端实现与部署指南 做服务器运维这行最烦的就是临时在外面手边只有一部手机或者别人的电脑想登自己的服务器看一眼日志、重启个服务却发现没装SSH客户端整个人瞬间就卡住了。OpenShell就是为解决这个场景出现的——它是一个开源的Web终端工具把Shell环境搬进浏览器里。你在任何一台有浏览器的设备上打开一个网页输入密码就能进入服务器终端。跑命令、看日志、查状态跟本地开一个终端窗口的操作体验几乎一样。这个项目适合三类人一是被远程运维场景逼疯的一线运维想给自己留一条随时能登录的通道二是想给团队做统一Web管理入口的后端开发需要一个可二次开发的终端底座三是正在学前端又想体会前后端实时通信的学生这个项目把WebSocket、子进程管理、终端模拟这些知识点全串起来了。下面我以自己实际实现的一个版本为例完整拆解这个项目的技术架构、核心实现、踩坑记录和部署经验。我的实现版基于Python WebSocket后端前端用xterm.js做终端渲染这也是这类项目最常见的组合逻辑清晰、依赖少、可直接复刻。1. 项目思路拆解与方案选型1.1 这个项目到底要解决什么问题表面上看OpenShell要解决的是“在浏览器里跑命令”的问题但仔细琢磨会发现真正核心的问题是如何让服务器上的Shell进程和浏览器里的页面之间建立一条可靠的、低延迟的双向数据通道。传统远程操作依赖SSH客户端要求你提前装好软件、知道主机地址端口、准备好密钥或密码。当你在外面临时要登服务器时这套流程的成本变得很高移动设备上更是如此。OpenShell的核心诉求就是把“登录服务器”从“下载安装客户端配置密钥”压缩到“打开浏览器输入密码”。它做的是用Web协议模拟出一个终端入口把原本只给本地进程用的标准输入输出改造成可以通过网络传输的数据流。我在设计时心里始终有一条原则它不解决“如何安全地管理服务器”的所有问题它只解决“打开浏览器就能进Shell”这个入口困境。安全的部分靠登录鉴权、权限控制和部署方式来补齐这两件事必须分开看否则容易把工具做成裸奔的后门。1.2 为什么选“浏览器WebSocket”方案目前实现浏览器终端主流的思路有三条我分别对比一下再解释为什么最终选了中间那条。第一类是直接用现成的终端网关工具比如ttyd、Gotty它们把命令挂到HTTP服务上开箱即用。优点是半小时就能搭起来缺点是控制逻辑全部封装在工具内部你想接自己的登录体系、做命令白名单、定制交互只能去改别人的源码或者做一层不够灵活的转发二次开发成本高。第二类是完整自研前端用终端模拟器如xterm.js后端起一个WebSocket服务浏览器与服务器通过WebSocket连接做双向数据传输。这是我最终选的方案也是OpenShell这类项目的主流做法。第三类是降级方案用HTTP轮询代替WebSocket。前端定时向后端要输出再把输入用POST发过去。优点是实现最简单缺点是终端通信天然是全双工的——你敲一个键输入要立刻到服务器服务器程序在跑输出要立刻回浏览器。轮询模式要么延迟高要么资源浪费大命令输多了会有明显卡顿感基本淘汰。为什么选WebSocket因为终端通信对实时性要求极高。vim里移动光标、top里刷新进程列表每一次按键和每一次刷新都要立刻反馈。WebSocket是一条长连接两端随时可以互推数据不需要重复建连和轮询整个体验和本地终端非常接近。另外有个隐藏优势浏览器原生支持WebSocket客户端不用装任何插件完全契合“零安装”的设计目标。1.3 基本架构与组件划分动手写代码前我把整个系统分成四层后面实现的时候思路非常清晰前端终端层负责渲染终端界面、捕获键盘输入、展示输出。xterm.js底层是canvas和DOM混合作画支持ANSI颜色、光标控制、滚动回看文件体积控制得也不错。选它是这个项目里最不需要犹豫的决定。通信层负责前端和后端之间传输数据。WebSocket是大本营顺便承担心跳检测和会话维持。后端执行层负责接收前端发来的命令把它喂给真正的Shell子进程再把Shell的stdout和stderr捞回来转发给前端。鉴权与安全层负责登录验证、token签发、命令白名单、访问控制。很多同类项目只做“能连就行”我强烈建议把这层单独做出来后面运维和上线会轻松很多。这四层里最容易被忽视的是通信层的时序问题。WebSocket本身只是一条管道你怎么处理并发输出、怎么处理回显、怎么在断线时恢复才是真正考验功力的地方。接下来专门用一节讲这些细节。2. 核心模块细节与实操要点2.1 前端终端模拟层为什么是xterm.js做浏览器终端第一反应可能是自己用div模拟一个黑窗口收到什么就显示什么。这种玩法应付ls、echo这种简单命令还行一旦遇到vim、top、htop这种全屏交互程序就彻底崩了——因为它们不是靠纯文本输出而是靠大量光标控制、色彩转义、区域重绘指令这些指令普通文本容器根本解析不了。xterm.js已经把这一整套机制都实现了。它会自动解析ANSI转义序列把\x1b[2J解析成清屏操作把\x1b[31m解析成红色字体。这样我只需要把Shell进程的输出原样丢给xterm.js让它画出来再把用户敲的键原样丢给Shell进程完全不需要自己解析任何终端协议。用下来有个重要心得别在前端对输出做任何“清洗”或“格式化”操作。我早期想让日志更美观尝试在前端过滤掉颜色码结果vim界面全部错乱光标位置全不对。后来想明白了终端渲染是一个完整的协议状态机你动任何一部分都会影响后续状态。正确做法是让原始字节流完整走完整个链路Shell进程已经把该处理的事都处理好了不需要画蛇添足。2.2 后端通信与指令转发WebSocket的时序处理后端是整个系统的心脏职责是启动一个真正的Shell子进程然后把进程的stdin和stdout挂到WebSocket上。这里有一个新手必踩的坑当你把输入写进stdin时Shell自己会把输入回显到stdout。如果你在前端无脑显示所有后端发来的数据敲一个字母屏幕上会出现两个。怎么解决这个问题有两条路我最后选了更稳妥的那条。第一条前端开“本地回显”打字的时候前端先渲染一次同时把命令发给后端后端执行后有程序自己的输出再回来展示。听起来合理但副作用是当你用方向键上下翻历史命令时Shell会重新渲染整行输入前端本地回显算不准屏幕就花了。第二条不做任何前端回显一切交给Shell进程自己处理。具体操作是关闭xterm.js的本地回显让Shell把所有输出原样传到前端。这样按方向键、Tab补全时屏幕上的一切都是由Shell自己控制渲染的完全不可能出现“双字”问题。你可能会担心不做本地回显打字会不会有延迟实测下来同一局域网内WebSocket单向延迟基本在1-2ms以内人手速完全感知不到。跨公网场景下延迟取决于网络质量但通常也在可接受范围内。所以我的最终方案是不做前端回显一切交给Shell。这既是技术选型也是体验取舍。另外子进程启动时要把stderr合并到stdoutstderrasyncio.subprocess.STDOUT否则程序报错信息会丢失。这个坑我踩过一次调试一个Python脚本页面一直看不到traceback排查半天才发现是stderr没有合并。如果你发现终端里“所有输出正常但报错不显示”先查这一条。2.3 权限与安全设计不能只靠一个密码这类工具天生自带风险——它本质上是“浏览器访问Shell”的入口。如果被外人撞到等于把服务器大门钥匙挂在门框上。所以权限设计必须认真对待我的实现里至少有两道防护。第一道是登录鉴权。用户进来先过密码校验校验通过后签发一个短期token后续所有WebSocket请求都要携带token。这里有个细节token不要放在URL参数里否则会被记进访问日志和浏览器历史。浏览器WebSocket API虽然不支持自定义Header但我选择在WebSocket连接建立后的第一帧把token发过去把鉴权帧当成通信协议的一部分。后端收到连接后必须在1秒内收到有效token否则直接断开这个超时设计能挡住大量“挂机不认证”的无效连接。第二道是命令白名单。对于只做“看日志、查状态”这种轻量运维的场景我在服务器侧加一层拦截维护一个允许执行的命令前缀列表不在列表内的直接拒绝执行。比如只允许ls、top、tail、ps、df、free这类查询命令。这样即使有人不小心按到rm -rf也会被白名单机制拦下来。注意白名单拦截不能在前端做前端代码可以被绕过。必须在后端、真正的Shell启动之前检测。这套权限体系做完之后OpenShell才真正具备“给别人用”的资格。如果只是自己本地跑着玩可以适当简化但token机制建议保留因为它同时承担了会话识别的功能。2.4 界面与交互细节容易出彩也容易翻车的地方终端界面看起来就是一个黑框但细节非常多。我把自己踩过坑后完善的几个点分享出来终端窗口尺寸要跟随容器大小调整。xterm.js有fit插件但要注意在浏览器窗口resize时调用它否则终端内容会错位。实测发现容器宽度变化但终端没有自适应时长行输出会被截断看起来像命令出错了一样。快捷键冲突要处理。CtrlC在终端里代表中断信号但浏览器层面默认是复制。必须把这类组合键转发到后端而不是让浏览器处理。右键粘贴和CtrlV粘贴的体验。xterm.js默认行为在部分浏览器下不理想我加了一个右键点击自动弹出粘贴菜单的处理这个功能在手机上使用频率极高。会话断开后的提示。网络中断时如果页面只是静默停在原地用户会误以为终端卡了。我加了一行红色断开提示再提供一个重连按钮体验会完整很多。这些细节不会出现在功能验收清单里但真正用过的人会立刻感受到差别。做这类工具体验往往藏在边界情况里。3. 实操过程与核心环节实现3.1 技术选型与准备工作我最终采用的实现栈如下后端Python 3 websockets库 asyncio子进程模块轻量、部署简单、跨平台。前端纯HTML xterm.js不引入重型框架方便任何后端背景的读者直接读懂。通信WebSocket路径/ws。鉴权登录接口/login发放tokenWebSocket首帧校验。不用Node.js是因为我最熟Python而且在几十路终端并发的场景下Python的单线程异步事件循环足够支撑。如果你熟悉Node.js完全可以用ws库替换逻辑完全一样。准备阶段只需要三样东西一台能跑Python 3的服务器、一个能装pip依赖的环境、浏览器端的xterm.js文件可以用CDN也可以下载到本地。我建议下载到本地因为终端运维工具经常要部署在内网离线环境依赖外网CDN会把自己的工具废掉。3.2 后端服务核心实现后端关键代码可以分成三块。第一块是登录接口用于校验用户密码并发放tokenimport secrets import asyncio # 简化版token存储生产环境建议用Redis或数据库 VALID_TOKENS set() # 生产环境请从环境变量或配置中心读取不要硬编码 USERNAME admin PASSWORD_HASH 5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8 # 这里只是示例 async def login_handler(websocket): data await websocket.recv() # 前端以JSON格式提交 {username: ..., password: ...} import json payload json.loads(data) if ( payload.get(username) USERNAME and hashlib.sha256(payload.get(password, ).encode()).hexdigest() PASSWORD_HASH ): token secrets.token_urlsafe(16) VALID_TOKENS.add(token) await websocket.send(json.dumps({token: token})) else: await websocket.send(json.dumps({error: auth failed})) await websocket.close()第二块是WebSocket建立后的token鉴权。协议设计是连接建立后客户端必须在一秒内把token作为第一帧发过来否则服务端直接断开连接。这个“首帧鉴权”设计避免了未授权连接长时间占用资源async def ws_handler(websocket): try: token await asyncio.wait_for(websocket.recv(), timeout1.0) except asyncio.TimeoutError: await websocket.close(code4001, reasonAuth timeout) return if token not in VALID_TOKENS: await websocket.close(code4001, reasonInvalid token) return # 鉴权通过进入Shell主循环 await shell_loop(websocket)第三块是核心的进程转发逻辑用asyncio.create_subprocess_shell启动Shell再把两个数据流接到WebSocket上这里包含了日志滚动场景下输出读取方式的关键优化async def shell_loop(websocket): proc await asyncio.create_subprocess_shell( /bin/bash, stdinasyncio.subprocess.PIPE, stdoutasyncio.subprocess.PIPE, stderrasyncio.subprocess.STDOUT, ) async def pump_output(): # 把进程输出逐行转发到WebSocket while True: try: data await asyncio.wait_for(proc.stdout.readline(), timeout30.0) if not data: break await websocket.send(data.decode(utf-8, errorsreplace)) except asyncio.TimeoutError: # 30秒没有输出也继续循环等待后续输出 continue async def pump_input(): # 把WebSocket收到的输入喂给进程 while True: data await websocket.recv() if isinstance(data, str) and data ping: await websocket.send(pong) continue proc.stdin.write(data.encode()) await proc.stdin.drain() await asyncio.gather(pump_output(), pump_input())这里有个必须注意的点我用readline()而不是read(4096)。最初版本用固定缓冲区读取日志滚动输出时如果数据不足4096字节read会一直等待凑满才返回导致终端显示出现明显的延迟卡顿。readline()是每行触发一次回调大部分日志本身就是按行输出的体验会好很多。对于交互程序输出没有换行符的情况readline()会一直等但实际终端里这种场景很少而且30秒超时兜底能防止永久卡死。3.3 前端页面与终端容器实现前端页面核心只需要一个div来挂载终端对象。加载xterm.js之后创建终端实例并建立WebSocket连接import { Terminal } from xterm; import { FitAddon } from xterm-addon-fit; const term new Terminal({ cursorBlink: true, fontSize: 14, scrollback: 2000, convertEol: true, }); const fitAddon new FitAddon(); term.loadAddon(fitAddon); term.open(document.getElementById(terminal)); fitAddon.fit(); // 窗口尺寸变化时让终端自适应容器 window.addEventListener(resize, () fitAddon.fit()); // 建立WebSocket连接 const ws new WebSocket(ws://${location.host}/ws); ws.onopen () { // 建立后用首帧发送token握手 ws.send(localStorage.getItem(openshell_token)); }; ws.onmessage (event) { // 后端输出原样渲染到终端 term.write(event.data); }; ws.onclose () { // 输出断开提示 term.writeln(\r\n\x1b[31m连接已断开请刷新页面重连\x1b[0m); }; // 用户输入直接转发给后端 term.onData((data) { ws.send(data); });这段代码体现了前面说的“不做前端回显”原则onData只是把按键原样发给后端屏幕上出现什么完全由后端返回的数据决定。整个前端就是一个透明的双向管道。有一个细节值得说term.onData中拿到的data可能是单个字符也可能是组合键序列比如方向键会产生\x1b[A这样的转义序列你完全不需要解析直接转发就行。真正解析控制序列的工作在Shell进程内部它自己会区分普通字符和控制字符。前端代码保持这种“无知”状态反而是最稳定的。如果做了登录页面在登录成功后把token存到localStorage这样页面刷新后WebSocket重连时还能带着token鉴权不用重新输入密码。这个体验细节被很多人忽略实际操作中非常实用。3.4 服务端整合与启动最后把登录服务和WebSocket服务挂在同一个端口上。我用aiohttp写了一个轻量HTTP服务同时提供静态页面和两个接口from aiohttp import web app web.Application() app.router.add_get(/, index_handler) # 静态页面 app.router.add_post(/login, login_handler) # 登录接口 app.router.add_get(/ws, ws_handler) # WebSocket入口 web.run_app(app, port8080)浏览器直接访问http://服务器IP:8080输入密码就能进入终端。如果只在局域网内使用这个配置已经够用。如果要暴露到公网必须在网关层加TLSHTTPS/WSS否则账号密码在网络上明文传输这和把密码写在明信片上没有区别。部署时我用systemd管理进程配置了自动重启和开机启动。补一个最小可用的systemd服务文件方便直接复用[Unit] DescriptionOpenShell Web Terminal Afternetwork.target [Service] Useropenshell Groupopenshell WorkingDirectory/opt/openshell ExecStart/usr/bin/python3 /opt/openshell/main.py Restartalways RestartSec3 [Install] WantedBymulti-user.targetUseropenshell这行非常关键它保证服务进程以低权限用户运行即使OpenShell被攻破攻击者拿到的也只是普通用户权限而不是root。这类工具的权限边界一定要划清楚。4. 常见问题与排查技巧实录4.1 终端乱码问题表现执行ls命令时中文文件名变成乱码查看中文日志内容变成一堆问号。原因Shell进程输出的是UTF-8字节流但如果服务端环境的locale没有正确设置进程可能以其他编码输出。我吃过一次亏服务器locale是C.UTF-8本地是macOS两边编码对不上中文全花。解决在启动服务前强制指定编码环境export LANGen_US.UTF-8 export LC_ALLen_US.UTF-8另外要注意后端读取子进程输出时不要手动做编解码转换保持ASCII安全的字节流透传。前端xterm.js自己会按UTF-8处理代码里手动decode再encode是多余的还容易引入重复解码错误。我在代码里用了errorsreplace兜底防止个别非法字节导致整个输出崩溃。4.2 WebSocket连接频繁断开表现终端用着用着窗口卡住几秒后提示连接断开被迫重新登录。原因中间网络设备尤其是企业防火墙和运营商NAT设备会对长时间空闲的TCP连接做清理。WebSocket如果几分钟内没有数据包连接就会被静默杀掉。解决加心跳机制。前端每隔30秒发一个ping数据帧后端收到后回pong。这个数据帧只有几个字节但能有效告诉中间设备“连接还活着”。前端实现就是一行定时器setInterval(() { ws.send(ping); }, 30000);后端在pump_input里已经处理了ping帧收到后回复pong并跳过Shell输入不会把心跳包当成命令执行。如果连续3次心跳无响应前端就可以主动断开并提示用户重连而不是傻等。4.3 敲命令没有反应表现在终端里输入ls回车页面上没有任何输出。检查后端日志WebSocket是连着的也没有报错。原因这个问题我调试过两次两个不同的根因。第一是前端term.write写入速度跟不上后端输出速度导致WebSocket消息堆积浏览器渲染卡死。第二是某些情况下readline()读取阻塞——Shell在等待用户输入时比如执行了一个read命令后端进程既不输出也不退出readline()会一直挂着。解决前端加一个缓冲队列用term.write的callback或Promise机制控制写速度防止渲染堆积。同时给readline()加超时30秒没有新数据就跳出继续等。对大文件查看场景我在命令白名单里明确限制cat只允许看特定目录并建议用户用tail -n 500代替直接cat大文件——这是终端操作习惯问题改掉之后卡顿的求助少了一半。还有一个隐蔽的坑如果前端WebSocket收到消息后直接term.write而终端缓冲区还在处理上一帧短时间内大量小帧到达会连续触发多个重绘CPU占用飙升。xterm.js的write方法本身有内部缓冲但你在外部又包了一层队列的话要注意队列积压时的内存控制。我采用了简单做法判断一个布尔标志如果上一次write还没完成就先缓存到中间变量等回调触发后再写入新数据实测效果稳定。4.4 安全加固的三条硬经验这部分是我最想认真写的因为很多做同类项目的开发者都栽在这上面。总结三条硬经验登录接口必须有失败次数限制。没有限制的话密码迟早被暴力撞穿。我的实现是同一个IP连续失败5次后锁定10分钟对正常用户没有影响但能挡掉绝大多数脚本攻击。如果条件允许再加一个简单的验证码或者限流中间件成本很低收益很高。公网部署必须走HTTPS/WSS。不仅是因为加密传输还有浏览器本身的限制——HTTPS页面里访问ws://会被浏览器直接拦截只有在HTTPS下才能用wss://。这个限制反过来也是好事倒逼你做好加密。用Caddy或者Nginx做一层反向代理配上免费证书半小时能搞定。保持最小权限原则。我给这个服务单独建了一个系统用户openshell不允许登录shell没有sudo权限只能执行白名单内的查询命令。即使OpenShell被攻破攻击者能做的事也极其有限。这个设计我是在一次演练时深刻体会到的——当你假设“终端入口可能被攻破”时后面的权限设计思路就完全不一样了。4.5 常见问题速查表问题现象大概率原因处理办法中文显示乱码服务端locale未设为UTF-8启动前设置LANG和LC_ALL连接频繁断开中间设备清理空闲连接增加30秒心跳帧输入命令半天无响应前端写入堆积或readline阻塞切片发送加缓冲队列和超时CtrlC无效浏览器拦截了组合键在onData中直接转发所有组合键终端宽度显示错乱resize未调用fit插件监听window.resize触发fitAddon.fit()vim/top画面乱掉前端做了输出清洗停止清洗原样透传字节流报错信息不显示stderr未合并到stdout创建子进程时加stderrSTDOUT页面刷新后要重新登录token只存在内存中存入localStorage并在建连首帧发送写在最后的实际体验我把OpenShell部署在公司一台内网服务器上之后最大的变化不是“能用手机连服务器”这个功能本身而是把很多日常巡检时间碎片化利用了。以前要专门坐到电脑前现在排队、通勤的时候掏出手机浏览器就能看一眼服务状态跑两条查询命令确认没问题心里踏实很多。从技术复盘的角度我最想分享的一句话是这类工具的价值不在于“实现了浏览器终端”这个效果而在于你花了多少心思处理各个环节的异常。心跳、回显控制、编码、权限、输出缓冲这些细节堆起来才让一个工具从“能跑”变成“好用”。如果你也想做一个我的建议是第一版不用追求太多功能先把“登录终端心跳”三条主线跑通再用一个周末把安全加固和边缘体验补齐。做完这套东西你会对WebSocket通信、子进程管理和浏览器事件模型都有更深的体感这些知识在后续任何涉及实时通信的项目里都会反复用到。这个项目的扩展空间也很大。比如把终端输入输出录制成操作回放、给WhiteList命令加参数级别的校验、接入告警系统推送异常输出、或者做成多人共享一个会话的远程协助工具。OpenShell的骨架已经足够结实往上面长什么功能完全取决于你自己的运维场景需要什么。核心思路就一句话把终端这件事做好剩下的都是锦上添花。
返回列表