终端共享工具:Shell 搬到浏览器
浏览器中运行远程终端的开源工具集合,加速远程协作和调试效率。
浏览器中运行远程终端的开源工具集合,加速远程协作和调试效率。
你好,我是 Maneshwar。我正在开发 git-lrc——一款会在每次 commit 时运行的微型 AI 代码审查工具。它完全免费,并在 Github 上开放源代码。欢迎为 git-lrc 点 Star,帮助更多开发者发现这个项目。也请试用一下,并分享你的反馈。
你很可能经历过这样的场面。
你正在一台机器上调试问题,队友说:“能直接让我看看吗?”于是你开始通过视频通话共享一台 4K 显示器,让对方眯着眼睛辨认 11px 的等宽字体,而这些文字经过视频压缩后,已经糊得像一碗燕麦粥。
其实有更好的办法,而且已经存在很多年了。
ttyd、TermPair 和 sshx 这类工具,可以把你的真实终端直接放进其他人的浏览器里。是真正的文本,可以真正地选中。
我很好奇它们究竟是怎么做到的,于是把这三个项目都 clone 下来读了一遍。
结果发现,它们在用三种截然不同的方式解决同一个问题,而这些差异确实很有意思。
让我们一起体验一下 shell shock。
要理解后面所有与 Web 相关的内容,你首先需要知道一个概念:伪终端,也就是 PTY。
当你在终端应用中运行 bash 时,bash 并不是直接在与你的键盘通信。
它是在与一个文件通信。
准确来说,是一对相互连接的文件描述符,由内核创建,用来模拟一台来自 1965 年的真实物理电传打字机。
一端是 master,另一端是 slave。
你的终端模拟器持有 master。Bash 持有 slave,而且 bash 完全不知道自己认错了对象。
你向 master 写入的任何内容,都会作为键盘输入出现在 bash 中。bash 打印的任何内容,也会从 master 端返回。
这就是全部的把戏。
所谓终端共享工具,不过是一些持有 master 端的程序,然后把这些字节转发到比你笔记本上的一个窗口更有意思的地方。
下面是 sshx 在 crates/sshx/src/terminal/unix.rs 中做的事情:
use nix::pty::{self, Winsize};
use nix::unistd::{execvp, fork, ForkResult, Pid};
use nix::libc::{login_tty, TIOCGWINSZ, TIOCSWINSZ};
pub struct Terminal {
child: Pid,
master_read: File,
master_write: File,
}
先 fork,在子进程中调用 login_tty,让 slave fd 成为它的控制终端,再通过 execvp 启动 shell,同时由父进程保留 master。
ttyd 用 C 做了同样的事情。
TermPair 用 Rust 做了同样的事情。
三个代码库,共用一套古老的 POSIX 握手流程。
所有人都在这里选择了同一个岔路口。
对了,TIOCSWINSZ 用来设置终端尺寸。
当你调整浏览器窗口大小时,最终触发的正是这个 ioctl。这样 vim 才能正确重绘,而不是把自己的界面涂抹得满屏都是。
调整尺寸的处理在这里绝非锦上添花,而是整个系统的承重结构。
ttyd 是那种“不是,真的,就这么简单”的实现。
一个基于 libwebsockets 和 libuv 构建的 C 进程。
它既是 Web 服务器,也是终端宿主。
没有 relay,没有独立客户端,也没有密钥交换。
它的协议简单得令人赏心悦目。以下内容来自 src/server.h:
// client -> server
#define INPUT '0'
#define RESIZE_TERMINAL '1'
#define PAUSE '2'
#define RESUME '3'
#define JSON_DATA '{'
// server -> client
#define OUTPUT '0'
#define SET_WINDOW_TITLE '1'
#define SET_PREFERENCES '2'
一个单字节 opcode,后面跟上 payload。这就是完整的线上传输格式。
'0' 加上你的按键内容向上传输,'0' 加上终端输出向下返回。
之所以存在 PAUSE 和 RESUME,是因为快速滚动输出一个巨大文件时,cat 可能比浏览器处理得更快。这样客户端就能施加 backpressure,而不是直接崩溃。
下面是一次完整的往返流程:
ttyd 有两个细节非常值得借鉴:
前端直接打包在二进制文件里。
html/ 中的 Preact 和 xterm.js 应用会被打包、gzip 压缩,然后以内联 C 字节数组的形式写入 src/html.h。
最终你会得到一个独立的可执行文件,不需要担心把静态文件目录放错位置。
代价是,只要修改一个 .tsx 文件,就必须运行 yarn run inline,然后重新构建 C 二进制文件。这个坑通常只会让人意外一次。
默认情况下,它是只读的。必须传入 -W,查看者才能真正输入内容。
ttyd -p 7681 -c user:pass -O -W bash
话虽如此,ttyd 的威胁模型是:“你信任服务器,因为服务器就是你自己的机器。”
除非启用 TLS,否则字节会以明文形式通过网络传输。
这就引出了一个有趣的问题。
如果你不信任服务器呢?
一旦你想通过互联网与其他人共享终端,就需要一个拥有公网 IP 的中间人。现在,这个中间人也能读取你输入的所有内容。
TermPair 和 sshx 都采纳了“不信任这个家伙”的建议。
它们把服务器变成一个盲 relay:服务器只负责在终端宿主与浏览器之间转发 ciphertext,从结构上就不具备解密任何内容的能力。
绿色表示“能够读取你的终端”。
整场游戏的目标,就是尽量缩小绿色区域。
客户端负责加密,浏览器负责解密,所以双方都需要密钥。
服务器绝不能拥有它。但浏览器中的整个页面,又都是服务器发送过来的。
那么,如何把秘密交给一个由服务器亲自发送给你的页面?
答案是 URL fragment。
https://sharemyclau.de/s/abc123#key-goes-right-here
# 后面的所有内容都不会被包含在 HTTP 请求中发送出去。
它完全是客户端侧的结构。
浏览器用它来实现锚点滚动和 hash 路由。它会留在地址栏中,也能通过 JavaScript 的 location.hash 访问,而服务器只能看到 /s/abc123。
因此,你只需分享一个链接。接收者的浏览器会从自己的地址栏中读取密钥,而位于中间的 relay 只能看到一个不透明的 session ID,以及一串看似噪声的数据流。
下面是完整的端到端流程:
这种方案的安全性完全取决于链接本身。任何拿到完整 URL 的人,都能进入这个 session。
所以别把它贴到公开的 Slack 频道里,然后再表现得一脸惊讶。
TermPair 和 sshx 都使用 AES,但它们做出了不同的选择,而这些差异很有启发性。
以下内容来自 crates/termpair-common/src/encryption.rs:
pub fn iv_from_count(count: u64) -> [u8; IV_LENGTH] {
let mut iv = [0u8; IV_LENGTH];
let bytes = count.to_le_bytes();
iv[..bytes.len()].copy_from_slice(&bytes);
iv
}
GCM 会顺带提供身份验证,因此遭到篡改的消息会解密失败,而不是悄无声息地变成乱码,再被你的终端当成 escape sequences 执行。
IV 就是一个普通的消息计数器。只要不在同一密钥下重复使用,这种做法完全没有问题。
在 GCM 中复用 Nonce,并不是“安全性稍微降低一点”,而是“这是你的密钥,感谢参与游戏”。
所以 constants.rs 中有以下内容:
pub const ROTATION_THRESHOLD: u64 = 1 << 20;
pub const MAX_MESSAGES_PER_KEY: u64 = ROTATION_THRESHOLD * 2;
发送大约一百万条消息后,密钥就会轮换。
协议中为此专门设计了一个 aes_key_rotation event。
终端输出和浏览器输入分别使用不同的密钥,再加上一个用于握手的 bootstrap key,这样它们的计数器空间就不会发生冲突。
这项工作做得非常严谨,而当它被正确完成时,往往又几乎不会被人察觉。
sshx 做出了另一种选择。以下内容来自 crates/sshx/src/encrypt.rs,其中的注释让我很喜欢:
const SALT: &str =
"This is a non-random salt for sshx.io, since we want to stretch the security of 83-bit keys!";
let hasher = Argon2::new(
Algorithm::Argon2id,
Version::V0x13,
Params::new(19 * 1024, 2, 1, Some(16)).unwrap(),
);
URL 中的密钥只有 83 bit 的 entropy,因为 sshx 希望共享链接足够短,短到真的适合粘贴进聊天窗口。
按照现代标准,83 bit 并不算多,所以它会使用具有实际内存成本参数的 Argon2id 对密钥进行处理。现在,每次暴力破解尝试都需要付出 19MB 内存和真实的 CPU 时间,而不是一纳秒。
这是一种有意为之的权衡:用原始 entropy 换取易用性,再通过 KDF 把安全性补回来。
接着,它使用 CTR 模式以及一种巧妙的寻址方案:
pub fn segment(&self, stream_num: u64, offset: u64, data: &[u8]) -> Vec<u8> {
assert_ne!(stream_num, 0, "stream number must be nonzero"); // security check
let mut iv = [0; 16];
iv[0..8].copy_from_slice(&stream_num.to_be_bytes());
let mut cipher = Aes128Ctr64BE::new(&self.aes_key.into(), &iv.into());
cipher.seek(offset);
cipher.apply_keystream(&mut buf);
buf
}
因为 CTR 模式支持 seek,sshx 可以直接加密“stream M 中 offset N 位置的字节”,而不必先处理前面的任何内容。
这不是为了炫耀密码学技巧,而是一个架构决策:它意味着浏览器重新连接时,可以说“截至第 4096 个字节的内容我都有了,把后面的发给我”,然后服务器直接从 buffer 中提供剩余数据。
加密和可恢复连接,是被放在一起设计的。
assert_ne!(stream_num, 0) 这道保护存在的原因是,stream 0 被用于 encrypted zeros block,以证明你拥有正确的密钥。
如果复用它,就意味着复用 keystream。
很高兴看到安全检查被明确写进代码,而不是被当作理所当然的前提。
这是这几个代码库中我最喜欢的细节。
远程终端存在一个根本性问题:你按下的键必须先传到服务器,进入 PTY,再从 PTY 出来,最后返回给你,你才能看到对应的字符。
在延迟 200ms 的链路上,打字的感觉就像在糖浆里移动。
sshx 使用与 Mosh 相同的方式解决了这个问题:predictive echo。以下内容来自 src/lib/typeahead.ts:
// A terminal "local echo" or typeahead addon for xterm.js.
//
// This is forked from VSCode's typeahead implementation at
// https://github.com/microsoft/vscode/blob/1.80.1/...
客户端会进行预测。你按下 k,它会立刻绘制一个 k(颜色稍暗),与此同时,真实的网络往返在后台进行。
当服务器的真实输出抵达时,它会进行校正。
如果预测正确,视觉上不会发生任何变化。
如果预测错误——例如你正在 vim 中,或者遇到了密码提示符——它就会回滚。
难点在于判断什么时候不应该预测。
如果在密码提示符中进行预测,就会把你的密码回显到屏幕上。这绝对是一种让你永远失去朋友的难忘方式。
因此,这个 addon 会跟踪终端模式,并在不够确定时放弃预测。
礼貌地抢在输出前面。
最后一层是生命周期管理。Session 可以长期存在,但 connection 不会。Wi-Fi 会断开,笔记本也会休眠。
sshx 在协议中通过 sequence number 处理这个问题,相关定义位于 crates/sshx-core/proto/sshx.proto:
message TerminalData {
uint32 id = 1;
bytes data = 2;
uint64 seq = 3; // Sequence number of the first byte
}
message SequenceNumbers {
map<uint32, uint64> map = 1; // Active shells and their sequence numbers
}
每个 shell 都是一条带有 byte offset 的 stream。重新连接时,客户端会发送自己上次接收到了哪里,服务器则重放缺少的部分。
再结合可 seek 的 CTR 加密,实现恢复连接几乎不需要额外成本。
它还必须决定,应该在什么时候彻底放弃一个 session:
/// Timeout for a disconnected session to be evicted and closed.
const DISCONNECTED_SESSION_EXPIRY: Duration = Duration::from_secs(300);
backend client 断开五分钟后,session 就会被关闭,并从 DashMap 中删除。否则,每一台崩溃的笔记本都会永久泄漏内存。
而且,由于 sshx 运行着一个全球分布式 mesh,session 状态还会通过 crates/sshx-server/src/state/mesh.rs 写入 Redis。这样你就能连接到最近的服务器,而不必每次都横跨一片大洋。
与此同时,TermPair 使用硬性限制来控制规模:MAX_TERMINALS: 200、MAX_BROWSERS_PER_TERMINAL: 50、MAX_WS_MSGS_PER_SEC: 500。如果你运行的是一台规模不大的普通服务器,而不是一套 mesh,这就是一个合理的答案。
这确实取决于你想做什么。
如果网络本身已经可信,就使用 ttyd。Homelab、LAN、VPN 后方、嵌入式设备,或者一台 OpenWrt 路由器。它是一个没有 runtime dependencies 的小型 C 二进制文件,而且大概会比我们所有人活得都久。执行 ttyd -W bash,事情就完成了。
如果你需要一个心智模型简单的 end-to-end encryption 方案,并且希望能够自行托管 relay,就使用 TermPair。它采用带密钥轮换的三密钥设计,容易理解,而且代码量足够小,一个下午就能读完。
如果你想要打磨完善的产品体验,就使用 sshx。无限画布上的多个终端、其他人的光标实时移动、predictive echo、全球 mesh、自动重新连接。需要注意的是,README 明确表示不支持 self-hosting,所以它的使用方式是“使用 sshx.io”,而不是“运行你自己的实例”。
从核心来看,这些工具中的每一个都只是 fork()、一个 PTY master fd,以及一个不断把字节塞进 WebSocket 的循环。
这部分就像是穿着 2020 年代连帽衫的 1970 年代技术。
除此之外的一切——加密、sequence number、密钥轮换、predictive echo、session eviction——都是为了回答同一个问题:你有多信任中间那台机器?ttyd 的回答是:“它是我的,所以完全信任。”
TermPair 和 sshx 的回答则是:“完全不信任,这里有数学证明。”
把同一个想法的三种实现放在一起对照阅读,是我最近经历过的最高效的一堂架构课。
如果某个概念出现在每一种实现中,它就是不可或缺的。
如果它只出现在某一种实现中,它就是产品决策。
只看一个代码库时,很难发现这种区别;看过三个之后,它就一目了然。
这比又一篇 framework 教程有趣多了,而且从此以后,你再也无法用原来的眼光看待 location.hash。
AI agents 写代码很快。它们也会悄无声息地删除逻辑、改变行为并引入 bug——却不告诉你。你往往要到 production 环境中才会发现。
git-lrc 可以解决这个问题。它接入 git commit,在每一个 diff 落地前完成审查。只需 60 秒即可完成配置,而且完全免费。
欢迎提供任何反馈,也欢迎贡献代码!它已经上线、开放源代码,任何人现在都可以使用。
| 🇩🇰 丹麦语 | 🇪🇸 西班牙语 | 🇮🇷 波斯语 | 🇫🇮 芬兰语 | 🇯🇵 日语 | 🇳🇴 挪威语 | 🇵🇹 葡萄牙语 | 🇷🇺 俄语 | 🇦🇱 阿尔巴尼亚语 | 🇨🇳 中文 | 🇮🇳 印地语 |
今天的 GenAI 就像一辆没有刹车的赛车。它加速极快——你描述一些东西,大段代码就会立即出现。但 AI agents 会悄无声息地破坏系统:删除逻辑、放宽约束、引入昂贵的云端调用、泄漏凭据以及改变行为——却不告诉你。你往往要到 production 环境中才会发现。
git-lrc 就是你的制动系统。它接入 git commit,在每一个 diff 落地前运行 AI 审查。只需 60 秒即可完成配置,而且完全免费。
简而言之,git-lrc 可以帮助你在事故发生之前,防止宕机、安全漏洞和技术债。
快速概览:10 类风险 · 跟踪 100 多种故障模式 · 覆盖每一次 commit……
如需采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。