ARTICLE DETAIL

资讯详情

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

OpenShell实战:基于WebSocket与pty构建浏览器远程终端工作台

OpenShell实战:基于WebSocket与pty构建浏览器远程终端工作台 1. 项目背景与核心定位OpenShell 到底是什么标题里只有 OpenShell 一个词。基于开源社区近几年的惯例以及 Shell 类工具的实际痛点我把它理解为一个开源的 Web 终端会话管理服务目标是把运维中最常用的 SSH 连接、命令执行、会话保存从本地终端搬到浏览器里做成一个可以多人访问、集中管控、权限分明的命令行工作台。很多团队遇到的实际场景是这样的开发、测试、运维手头各自维护着几十台服务器本地 SSH 的 host key、别名、跳板机配置乱成一锅粥新人入职光同步 ssh_config 就要折腾半天要查一台机器的状态得先找同事要 IP、要账号、要密码。OpenShell 这类工具要解决的就是这个问题——把零散的 SSH 访问统一收拢到一个 Web 界面里谁可以看哪台机器、谁可以执行哪些命令全部由服务端控制。和传统的 xshell、 finalshell 这类本地客户端不同OpenShell 强调的是服务化而非客户端化。客户端工具你装在你自己的电脑上配置只对你自己有意义服务化工具部署在服务器上所有人都通过浏览器访问同一套入口配置只维护一份权限只控制一套。这种模式在中小团队里尤其受欢迎因为它的维护成本低、上手门槛低浏览器打开就能用不用装任何额外软件。从技术栈选型来看这个方向目前最主流的组合是Go 或 Rust 做服务端 WebSocket 做实时通道 node-pty / axterm 做终端模拟。Go 生态里有非常成熟的库Rust 则在性能和内存安全上有优势。我自己在实际项目中更倾向于 Rust后面在架构拆解里会详细讲为什么。适合快速上手这篇内容的人包括三类一是被服务器管理搞得焦头烂额的小团队运维二是想给团队搭一套内部命令行入口的后端开发三是对 Web 终端实现原理感兴趣的初学者。读完你会知道 OpenShell 这类系统从零怎么搭、核心难点在哪、生产环境要注意什么。2. 核心原理拆解Web 终端这件事是怎么跑通的Web 终端看起来就是个网页里的黑框框你敲命令它就回显好像没什么了不起。但真正做过的人都知道这里面的难点不在显示而在会话。2.1 pty 与伪终端你不能直接把命令输出塞给浏览器最直觉的实现方式是把ls、cat这类命令的输出直接推给前端。但这有个致命问题很多程序自己会判断当前终端是不是交互式终端如果不是行为就会改变。比如你直接执行ls --colorauto输出结果虽然是一样的但程序不会输出终端控制序列——颜色、光标移动、清屏这些看不见的指令统统丢失。更麻烦的是 vim、top、htop 这类程序它们依赖全屏交互底层要用 ANSI 转义序列来控制光标位置和画面重绘直接捕获标准输出拿到的完全不是你想看的画面。所以 Web 终端的底层一定要有pty伪终端这个东西。你可以把 pty 理解成一个「假的终端设备」它在系统里模拟一个真实终端的输入输出能力程序往 pty 里写东西时它认为自己是在往一个真正的终端屏幕上写会把颜色、光标控制序列原样输出你的程序读权限流时可以直接从 pty 标准输入写入按键数据程序会认为自己收到了真实键盘输入。具体到 Linux 系统上这就是openpty()系统调用干的事。它会返回一对文件描述符一个给 slave从设备你的 Shell 进程跑在里面一个给 master主设备你的服务端进程读它的输出、往它写输入。Rust 生态里面portable-pty这个 crate 把这层封装得很好跨平台、好复用不需要你自己去搞一堆 libc 调用来管理 termios、winsize 这些底层细节。2.2 会话通道为什么必须用 WebSocket浏览器和服务器之间要实时双向传数据。这里有两个选择一是用 HTTP 短轮询实时拉取输出二是用 WebSocket 建立长连接服务端主动推数据。绝大多数实现都会选 WebSocket道理很简单SSH 终端是高频低延迟交互场景。你在终端里敲一个tail -f程序要持续不断地吐日志你在 vim 里按方向键服务器要立刻响应并重绘整个画面。如果走 HTTP 轮询延迟会累积在轮询间隔上——300ms 的间隔就已经能明显感觉到卡顿100ms 又会对服务器造成巨大的请求压力。WebSocket 建立一次连接后数据可以随时往任何一个方向推延迟以毫秒计而且中途还可以同时传输窗口缩放、心跳包等控制数据。这就是为什么所有主流的 Web 终端实现——code-server、ttyd、xterm.js 官方示例——全部选择 WebSocket 作为前后端通信底座。2.3 前端渲染终端模拟器不是简单的黑框后端把原始数据推过来前端要把它变成看起来像个终端的画面。这里有两个技术路径一是用现成的终端模拟器库xterm.js 是事实标准二是直接拿pre标签自己渲染文本流。xterm.js 本质上是一个用 TypeScript 写的终端模拟器它在浏览器里实现了一个虚拟终端的状态机——能够解析 ANSI 转义序列、维护光标位置、更新屏幕缓冲区。简单说它把我们从手写解析\x1b[31m这类转义码中解放出来。你只需要调用terminal.write(data)把后端的数据喂给它它会自动换行、着色、滚动用户每一次键盘输入它会触发onData事件你把这个事件的 payload 通过 WebSocket 发回服务端就可以。体验上基本能做到和本地终端 90% 的还原度。自己用pre标签渲染看起来很酷但你会被一堆细节坑死中文宽字符、制表符对齐、滚动性能、不同系统下换行符差异、控制序列解析……每一个都够你折腾几天。所以没有特殊需求直接拥抱 xterm.js 是明智选择。3. 完整实现方案从零到生产可用这一节我把 OpenShell 完整的搭建过程走一遍。基于可复现的目标下面的示例以 Rust 实现服务端架构上保持轻量。如果你更熟悉 Go思维路径完全一致只是依赖库的 API 不同。3.1 技术选型与项目结构先明确我们最终的架构前端React xterm.js负责终端渲染和交互界面后端Rust axum tokio负责建立 WebSocket 连接、创建 pty、管理会话生命周期进程管理portable-ptycrate封装 pty 底层操作数据格式JSON over WebSocket消息类型简单清晰项目结构上我建议这样切分openshell/ ├── server/ # Rust 服务端 │ ├── src/ │ │ ├── main.rs │ │ ├── session.rs # 会话管理 │ │ └── ws.rs # WebSocket 处理 │ └── Cargo.toml ├── web/ # 前端 │ ├── src/ │ │ ├── App.tsx │ │ └── Terminal.tsx │ └── package.json └── deploy/ # 部署文件Dockerfile 等这个结构的好处是前后端完全分离后续想给 Web 端加一个命令行 client、或者给不同的服务单独做容器都不用改动核心逻辑。3.2 后端核心逻辑实现进入实际操作。先照着搭建 Rust 项目cargo new openshell-server cd openshell-server cargo add axum tokio --features tokio/full cargo add portable-pty cargo add serde_json serde --features serde/derive这是最基础的依赖。axum负责 HTTP 和 WebSocket 层tokio提供异步运行时portable-pty处理伪终端创建和数据读写。核心的 pty 会话创建逻辑长得像这样use portable_pty::{native_pty_system, CommandBuilder, PtySize}; use tokio::sync::mpsc; use tokio_tungstenite::WebSocketStream; struct PtySession { writer: Boxdyn Write Send, reader: Boxdyn Read Send, // 后续再加退出状态管理 } fn create_session() - PtySession { let pty_system native_pty_system(); let pair pty_system.openpty(PtySize { rows: 24, cols: 80, pixel_width: 0, pixel_height: 0, }) .expect(创建 pty 失败); let cmd CommandBuilder::new(bash); let mut child pair.slave.spawn_command(cmd).expect(启动 shell 失败); // 注意此处需要保存 child 引用以便后续处理进程退出 drop(pair.slave); PtySession { writer: pair.master.try_clone_writer().unwrap(), reader: pair.master.try_clone_reader().unwrap(), } }这里有几个容易踩的细节。第一PtySize的宽高要后面前端汇报上来xterm.js 内部会根据容器尺寸计算它要多少行多少列服务端拿初始值创建 pty 后后续前端缩放时还要调一次 resize 指令。第二spawn_command里的 bash 路径要按实际环境来如果你的服务器基础镜像没有 bash 而是只有 sh这里就得改。第三drop(pair.slave)这一步别省——保持 slave 端打开会让 shell 进程以为终端还连着进程不会正常执行退出逻辑。WebSocket 消息处理的骨架下面是这样的。我在ws.rs里定义了两类方向的消息客户端到服务端input用户敲击键盘的字节流resize终端尺寸变化附带新的 rows / colsping保持连接活跃的心跳服务端到客户端outputpty 读到的原始输出exitshell 进程退出的通知pong心跳回复整个连接的转发逻辑浓缩在这里// 伪代码重点看流程 async fn websocket_handler(ws: WebSocketStream...) { let mut session create_session(); let (mut ws_sender, mut ws_receiver) ws.split(); // 任务1把 pty 读到的数据推给前端 let pty_reader_task tokio::spawn(async move { let mut buf [0u8; 4096]; loop { let n session.reader.read(mut buf).await?; if n 0 { break; } ws_sender.send(Message::Text(format!({{\type\:\output\,\data\:{}}}, serde_json::to_string(String::from_utf8_lossy(buf[..n]))?))).await?; } // shell 退出发送 exit ws_sender.send(Message::Text({\type\:\exit\}.into())).await?; }); // 任务2把前端发来的数据写给 pty let pty_writer_task tokio::spawn(async move { while let Some(msg) ws_receiver.next().await { let msg msg?; if let Message::Text(text) msg { let v: Value serde_json::from_str(text)?; match v[type].as_str() { Some(input) { let data v[data].as_str().unwrap_or(); session.writer.write_all(data.as_bytes()).await?; session.writer.flush().await?; } Some(resize) { // 调用 pty 的 resize 函数 } _ {} } } } }); }这段代码是理解整个链路的关键。注意两个任务之间的协作关系一个负责pty - 浏览器一个负责浏览器 - pty它们互相独立靠 tokio 并行运行。真正生产环境还要处理断线重连、会话保持、鉴权这些但骨架就是这个。3.3 前端接入与交互细节前端这一侧核心代码量反而更少。xterm.js 的使用模式非常固定import { Terminal } from xterm; import { FitAddon } from xterm-addon-fit; import xterm/css/xterm.css; const term new Terminal({ cursorBlink: true, fontSize: 14, fontFamily: Menlo, Monaco, Courier New, monospace, theme: { background: #1e1e1e, foreground: #d4d4d4, }, }); const fitAddon new FitAddon(); term.loadAddon(fitAddon); term.open(document.getElementById(terminal-container)); fitAddon.fit(); // WebSocket 连接 const ws new WebSocket(ws://${location.host}/ws); ws.onopen () { // 连接建立后把当前终端尺寸汇报给服务端 ws.send(JSON.stringify({ type: resize, rows: term.rows, cols: term.cols, })); }; term.onData(data { ws.send(JSON.stringify({ type: input, data })); }); ws.onmessage event { const msg JSON.parse(event.data); if (msg.type output) { term.write(msg.data); } if (msg.type exit) { // 显示 shell 已退出 } }; // 窗口变化时重置尺寸 window.addEventListener(resize, () { fitAddon.fit(); ws.send(JSON.stringify({ type: resize, rows: term.rows, cols: term.cols, })); });这里有个非常实用的经验FitAddon的fit()很脆弱依赖容器此时处于可见、有确定尺寸的状态。如果你把终端放在一个 tab 切换的页面里要等 tab 激活、容器渲染完成后再调用fit()否则得到的长宽是 0pty 里 shell 的 prompt 换行逻辑会直接错乱。3.4 生产部署Docker 容器化与访问控制开发环境跑通之后部署阶段有几个点必须处理容器化、鉴权、HTTPS。这套系统如果裸奔内网等于把服务器最高权限交到每一个能访问的人手里风险极大。我的建议是至少做到三层第一Docker 化保证环境一致。一个最小可用的 DockerfileFROM rust:1.75 as builder WORKDIR /app COPY server/ . RUN cargo build --release FROM debian:bookworm-slim RUN apt-get update apt-get install -y bash openssh-client COPY --frombuilder /app/target/release/openshell-server /usr/local/bin/ EXPOSE 8080 CMD [openshell-server]第二接入统一认证。最简单的做法是在 nginx 层做 Basic Auth 或接入现有的 OAuth2 Proxy让请求先经过认证网关再到达 OpenShell 服务这样应用本身不用维护用户体系密码泄漏的风险也小。第三设置 WebSocket 子协议和 Origin 校验。后端在处理握手时检查Origin请求头只允许来自你自己域名的连接。这一步可以防掉一大部分 CSRF 类的恶意连接。实现方式在 axum 里就是在 ws handler 里取 header 判断不合法直接拒绝。4. 常见问题与排查技巧实录Web 终端这类项目坑非常多。我把实际踩过、见过的问题整理成表每条后面补上排查思路。现象根因解决思路终端频繁卡顿输入延迟明显没开心跳连接被中间代理断开后前端仍认为连接正常前端开启 WebSocket ping 定时器检测到断线立即重连并恢复会话vim / htop 界面乱码、显示错位pty 初始尺寸不对或 reszie 指令没有及时发给服务端前端在fit()之后立刻发送 resize不要在 onload 前调用 fit输入中文出现乱码或重复字字节流被包在 JSON 字符串里UTF-8 字符被截断将String::from_utf8_lossy改为按完整 UTF-8 边界切分或改用二进制帧传输多个用户同时打开同一个会话互相干扰没收起会话管理逻辑一个 pty 对应一个 session id只允许一个活跃消费者其他用户只能观看或断开服务端内存持续增长旧会话的 pty 进程没有被 kill用child.kill()确保连接关闭时回收所有子进程还要处理 shell 派生的孙进程部署后浏览器能访问但打不开 WebSocket反向代理没配 Upgrade 头nginx 需要显式设置proxy_http_version 1.1、proxy_set_header Upgrade $http_upgrade等画面宽度错乱回车换行不对容器 CSS 宽度变化但 pty 尺寸没同步对容器尺寸变化使用ResizeObserver监听同步调用 fit 和 resize这里挑两个详细说因为它们的坑藏得最深。第一个是中文输入乱码。我最初实现时直接把前端传来的字符串原样写入 pty看似没毛病但一旦用户输入法处于组字阶段浏览器会先把拼音字母发出去再发确认后的汉字。这两部分拼接起来后可能刚好在半个 UTF-8 字符的边界上后端用from_utf8_lossy转字符串就会产生替换字符。这个问题在英文环境永远测不出来一上生产就暴露。稳妥方案是后端直接用Binary帧传递原始字节流前端用 TextEncoder 编码后发送避免两次编解码。第二个是 nginx 反代配置。WebSocket 是长连接如果你在前面挂了一个传统负载均衡默认配置下代理服务器会在几秒到几十秒内无操作时断开连接。需要在入口层同时配置长连接超时参数proxy_read_timeout、proxy_send_timeout都拉到数小时同时确认 Upgrade 头被透传。有一次我在生产环境排查终端用着用着就断开的问题从代码入手查了半天最后发现是前面有一台默认配置 60s 超时的 nginx改了配置立刻稳定。这类问题代码无解只能在架构层解决。5. 安全加固与权限控制最后这部分虽然放在文末但它应该是整个系统最早上心的地方。OpenShell 这类工具本质上是一个数字万能钥匙权限模型做不好整个服务器集群就等于门户大开。我建议在最小权限原则下做四件事5.1 会话限制与黑白名单。服务端要维护一个可访问主机列表连接请求进来时先校验目标是否在允许范围内。不要相信前端传什么就连什么。比如前端提一个connect: rootrandom-ip服务端必须先比对白名单再决定是否建立 pty。5.2 操作审计。所有终端会话的全部输入输出都应该被记录。最简单的实现是在pty reader循环里把读到的每一段数据同时写入一个结构化日志。记录时可以格式化为{ time: 2024-06-01T10:00:00Z, session_id: abc-123, user: zhangsan, target: 10.0.0.5, data: ls -la /data\r\n }审计日志不是摆设团队里排查谁动了生产库这种问题时这份日志就是唯一的事实依据。5.3 只读模式和命令过滤。不是所有人都需要完整控制权。可以抽象出只读会话创建 pty 之后把所有输入都拦截掉只推输出。命令过滤用起来要小心shell 的别名、变量展开、复合命令能让任何黑名单失效我自己的建议是能不挡就不挡改在权限边界上做隔离——让危险操作发生在特定用户、特定容器内而不是靠字符串匹配去拦。5.4 透明加密。与其让用户把私钥传到 Web 界面里不如让 OpenShell 服务端统一保管一个专用 SSH key用ssh -i /path/to/key方式来建连。这样私钥不会散落在用户的机器和浏览器缓存中失陷面更小。如果追求极致安全可以让 OpenShell 不直接持有所需密钥而是通过本地的 ssh-agent 转发来实现连接目标主机时的身份认证。在实际操作中我的体会是Web 终端的管理价值和服务化优势是真实的但它的安全性完全取决于部署者是否认真对待权限、审计、加密三层设计。飞书、钉钉这类企业内部工具能放心用类似能力是因为背后的权限和审计体系搞得很重。小团队用开源方案至少要保证连接有记录、命令有依据、密钥不落地这三条底线。这个项目后续还可以继续扩展——比如基于角色的权限模型、会话录播回放、一键分发到多主机执行命令都是很自然的方向。核心的那套 pty WebSocket xterm.js 链路一旦想明白上面这些功能都只是在这个底盘上继续加砖加瓦而已。
返回列表