你好,我是 Maneshwar。我正在打造 git-lrc,一個會在每次提交時執行的微型 AI 程式碼審查工具。它是免費的,且在 GitHub 上以可取得原始碼的方式提供。歡迎 為 git-lrc 按星 幫助開發者發現這個專案。也請試用看看並分享你的回饋。

你大概也做過這種事。

你正在機器上除錯某些東西,同事說「你可以直接讓我看看嗎」,結果你就得在視訊通話中分享一台 4K 螢幕,讓對方瞇著眼看已經被壓縮成燕麥粥的 11px 等寬文字。

其實有更好的做法,而且這方法已經存在很多年了。

ttydTermPairsshx 這類工具,會把你真正的終端機放進別人的瀏覽器裡。真實文字。真實選取。

真實複製貼上。

我很好奇它們到底怎麼辦到的,所以我把這三個都複製下來研究了一遍。

結果發現,它們其實是在用三種非常不同的方式解同一個問題,而且差異真的很有意思。

讓我們一起被 shell shock 一下。

到底什麼是終端機

在任何網頁相關的東西講得通之前,你得先理解一個概念:偽終端,也就是 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 正確重繪,而不是把畫面抹得一塌糊塗。

在這裡,處理視窗大小調整不是可有可無,而是攸關成敗。

Image description

最簡單的版本:ttyd

ttyd 就是那種「不是,真的,就這樣」的實作。

一個 C 行程,建立在 libwebsockets 和 libuv 之上。

就是 Web 伺服器,也就是終端機主機。

沒有中繼,沒有獨立用戶端,沒有金鑰交換。

這個協定漂亮地簡單到近乎笨拙。出自 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'

單一位元組的操作碼,後面接負載資料。這就是整個線上格式。

'0' 加上你的按鍵輸入往上送,'0' 加上終端機輸出往下回。

之所以有 PAUSERESUME,是因為一個高速滾動的大型檔案 cat 出來時,可能會比瀏覽器更快,所以用戶端可以施加背壓,而不是直接掛掉。

下面是完整的往返流程:

Image description

ttyd 有兩個很值得借鑑的細節:

前端直接打包進二進位檔。

html/ 裡的 Preact 和 xterm.js 應用程式會被打包、gzip 壓縮,並內嵌到 src/html.h 裡,變成一個 C 陣列。

你會得到一個單一可執行檔,不需要額外的靜態檔案目錄,也不會放錯地方。

代價是,改動 .tsx 檔時,你得先跑 yarn run inline 再重建 C 二進位檔,這件事通常只會讓人意外一次。

預設是唯讀的。 你必須加上 -W 才能讓觀看者真的輸入。

ttyd -p 7681 -c user:pass -O -W bash

話說回來,ttyd 的威脅模型是「你信任伺服器,因為伺服器就是你的機器。」

只要你沒有開 TLS,傳輸中的位元組就是明文。

這就引出了一個有趣的問題。

如果你不信任伺服器呢?

當你想跟網際網路另一端的人共享終端機時,你就需要一個有公開 IP 的中介。而現在那個中介就能讀到你輸入的一切。

Image description

TermPair 和 sshx 都採用了那位老兄的建議。

它們把伺服器做成一個盲中繼:只負責在終端機主機和瀏覽器之間路由密文,從結構上就無法解密任何內容。

Image description

綠色代表「可以讀你的終端機」。

整個遊戲,就是把綠色區塊縮到最小。

金鑰傳遞的小技巧

因此,用戶端加密,而瀏覽器解密。兩邊都需要金鑰。

伺服器不能有金鑰。但瀏覽器的整個存在又是由伺服器提供的。

那你要怎麼把秘密交給一個由伺服器送來的頁面?

答案是 URL fragment。

https://sharemyclau.de/s/abc123#key-goes-right-here

# 後面的所有內容不會隨 HTTP 請求送出。

這是純用戶端的結構。

瀏覽器會把它拿來做錨點捲動和 hash 路由,而它會留在網址列裡,也會留在 JavaScript 的 location.hash 裡,伺服器則只會看到 /s/abc123

所以你只要分享一個連結,接收者的瀏覽器就會從自己的網址列讀出金鑰,而中間的中繼只會看到一個不透明的工作階段 ID 和一串雜訊。

Image description

下面是完整的端到端流程:

Image description

這個設計的安全性完全取決於那個連結。任何拿到完整 URL 的人都能取得這個工作階段。

別把它貼到公開的 Slack 頻道裡,然後再假裝驚訝。

兩種加密風格

TermPair 和 sshx 都用了 AES,但它們做了不同的選擇,而這些差異很值得學。

TermPair:使用計數器 IV 的 AES-128-GCM

出自 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 會自帶驗證,所以如果訊息被竄改,解密會失敗,而不會悄悄變成亂碼,然後你的終端機再把那些亂碼當成跳脫序列解析。

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 事件專門處理這件事。

終端機輸出和瀏覽器輸入分開使用不同金鑰,另外再加上一把啟動用的金鑰做握手,因此計數器空間不會衝突。

這是一種很謹慎的作法,而且做對了通常不會被注意到。

sshx:經過 Argon2 拉伸的金鑰搭配 AES-CTR

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(),
);

你網址裡的金鑰只有 83 位元的熵,因為 sshx 想讓分享連結短到真的可以直接貼在聊天裡。

以現代標準來看,83 位元並不多,所以它對這把金鑰跑了 Argon2id,並使用了真實的記憶體成本參數。現在每猜一次都要付出 19MB 記憶體和實際 CPU 時間,而不是幾奈秒。

這是刻意拿原始熵換取可用性的設計,並用 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 模式可以隨機存取,sshx 就能加密「第 M 條串流中偏移量 N 的位元組」,而不用碰到前面的任何資料。

這不是密碼學炫技,而是一個架構決策:它表示重新連線的瀏覽器可以說「我已經有到第 4096 個位元組為止的內容了,把剩下的送我」,然後伺服器可以直接從快取緩衝區提供資料。

加密和續傳是一起設計的。

assert_ne!(stream_num, 0) 這個保護是因為 stream 0 用來放那個已加密的零區塊,用來證明你拿對了金鑰。

如果重複使用它,就等於重複使用同一段 keystream。

看到這種安全檢查被明寫出來,而不是靠假設,真的很不錯。

讓 sshx 感覺很快的部分

這是我在這些程式碼庫裡最喜歡的一個細節。

遠端終端機有一個根本問題:你的按鍵必須先送到伺服器,進入 PTY,再送回來,最後你才看得到字元。

在 200ms 的連線上,打字的感覺會像在糖漿裡移動。

sshx 用跟 Mosh 相同的方式解決這件事,也就是預測性回顯。出自 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 裡,或是你碰到了密碼提示,它就會回復。

麻煩的是要知道什麼時候不要預測。

如果在密碼提示上做預測回顯,就可能把你的密碼打在畫面上,這會是失去朋友的經典方式。

所以這個外掛會追蹤終端機模式,並在不夠確定時退出。

搶在輸出前面,但很有禮貌。

工作階段、狀態,以及那些無聊但重要的事

最後一層是生命週期。工作階段是長壽的,但連線不是。Wi‑Fi 會斷,筆電會睡眠。

sshx 用協定中的序號來處理這件事(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 都是一條帶有位元組偏移量的串流。重新連線時,用戶端會送出自己停在哪裡,伺服器再重播差異部分。

再加上可隨機存取的 CTR 加密,續傳幾乎是免費的。

它也必須決定什麼時候該放棄一個工作階段:

/// Timeout for a disconnected session to be evicted and closed.
const DISCONNECTED_SESSION_EXPIRY: Duration = Duration::from_secs(300);

如果超過五分鐘沒有後端用戶端,工作階段就會關閉,並從 DashMap 移除。不然每一台當機的筆電都會永遠洩漏記憶體。

而且因為 sshx 是全球分散式網狀架構,工作階段狀態也會透過 crates/sshx-server/src/state/mesh.rs 寫進 Redis,這樣你就可以連到最近的伺服器,而不是永遠跨越整個海洋。

另一方面,TermPair 則用硬性限制來約束規模(MAX_TERMINALS: 200MAX_BROWSERS_PER_TERMINAL: 50MAX_WS_MSGS_PER_SEC: 500),這對於你只是跑一台普通主機,而不是一個網狀系統時,算是合理的答案。

那到底該用哪一個

真的要看你在做什麼。

ttyd:如果網路本來就是可信任的。家用實驗室、區網、VPN 後面、嵌入式裝置、OpenWrt 路由器。它就是一個小小的 C 二進位檔,沒有執行期相依套件,會比我們都活得久。ttyd -W bash,完成。

TermPair:如果你想要端到端加密,而且心理模型簡單,還可以選擇自行架設中繼。三把金鑰加輪替的設計很好理解,程式碼也小到可以一個下午看完。

sshx:如果你想要完整而精緻的產品體驗。多個終端機、無限畫布、其他人的游標即時移動、預測性回顯、全球網狀架構、自動重連。值得注意的是,README 明確說不支援自架,所以這是「使用 sshx.io」,而不是「自己跑一個」。

Image description

結論

這些工具本質上全都只是 fork()、一個 PTY master 檔案描述元,以及一個把位元組塞進 WebSocket 的迴圈。

那部分是穿著 2020 年代連帽衫的 1970 年代技術。

其他所有東西,加密、序號、金鑰輪替、預測性回顯、工作階段回收,都是在回答同一個問題:你有多信任中間那台機器? ttyd 的答案是「那是我的機器,所以完全信任。」

TermPair 和 sshx 的答案是「完全不信任,這裡是數學證明。」

把同一個想法的三個實作並排讀,會是我最近學過最快的架構課。

如果一個概念在每個版本裡都出現,那它就是核心。

如果它只在某一個版本裡出現,那它就是產品決策。

這個區別從單一程式碼庫很難看出來,但從三個版本就一目了然。

去複製它們吧。

這比再看一篇框架教學有趣多了,而且你再也不會用同樣的眼光看待 location.hash 了。


Image description

AI 代理可以很快地寫程式碼。它們也會在不告訴你的情況下,悄悄移除邏輯、改變行為,並引入 bug——你通常要到上線後才會發現。

git-lrc 就是為了解決這件事。它會在 git commit 時掛鉤,並在每個 diff 進入版本庫之前先進行審查。只要 60 秒就能設定完成。完全免費。

歡迎提供任何回饋或貢獻!它已上線、以可取得原始碼的方式提供,並準備好讓任何人使用。

⭐ 到 GitHub 幫它按星:
{% github=https://github.com/HexmosTech/git-lrc


原文出處:https://dev.to/lovestaco/how-terminal-sharing-tools-put-your-shell-in-a-browser-328


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝11   💬1   ❤️6
324
🥈
我愛JS
📝2   💬6   ❤️3
113
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登