# 技術說明

> HTML: https://remote.ivjn.us/docs/zh-TW/technical/

這份文件描述當前程式碼裡的幾條核心機制。訊息欄位、幀格式和握手時序的完整表見 [線路協議](/docs/zh-TW/websocket-protocol.md)。

## 運維端和裝置都向外連線 exchange

家用和企業分支的裝置通常在 NAT 後面，exchange 無法主動撥進去。因此兩邊都向 exchange 發起 WebSocket：

- 裝置連 `wss://<主机>/ws/device`（明文除錯時是 `ws://`），在首幀裡帶上裝置令牌註冊
- 運維端連 `wss://<主机>/ws/operator`，握手使用運維令牌

exchange 自己只做鑑權、路由和留痕。監聽地址由 `--listen` 或 `REMOTE_LISTEN` 決定，TLS 由前面的反向代理終止。

## 兩套會話編號

運維端和裝置各自維護會話編號，兩邊的數字互不佔用。運維端發出請求時就可以帶上自己的編號，不必先等伺服器端分配再發後續幀。裝置側編號由這臺裝置當前這條連線單獨分配，從 1 起遞增，兩個運維端即使都使用本機編號 1，裝置上也會落到不同的編號。

編號 0 不分配給任何會話，留給連線級訊息。運維側編號為 0，或這條運維連線已經佔用了該編號時，登記直接失敗，不會寫入路由表。

翻譯表在 `crates/exchange/src/hub/session_table.rs`。`hub.rs` 把翻譯結果掛到具體連線上：裝置側是「裝置編號 → 運維編號」，運維側是「運維編號 →（裝置連線, 裝置編號）」。

## 轉發時只改寫會話編號

PTY 位元組和命令的 stdout/stderr 走二進位幀。幀頭 9 位元組：1 位元組流型別、4 位元組 `session_id`、4 位元組載荷長度，後面是裸載荷。定義在 `crates/proto/src/frame.rs`。

exchange 轉發這種幀時只替換 `session_id`：

- 運維端發往裝置：`crates/exchange/src/ws/operator.rs` 用 `DataFrame::with_session_id` 換成裝置側編號，再把編碼後的位元組放進裝置的傳送佇列
- 裝置發往運維端：`crates/exchange/src/ws/device.rs` 的 `handle_data` 把裝置側編號換回運維端自己的編號。原始碼註釋寫的就是這一處改寫

載荷原樣透過。路由不根據 PTY 按鍵或命令輸出的內容做判斷；查不到會話時幀被丟棄。控制訊息是 JSON 文字幀，開啟和結束會話時同樣換成對端那一套編號，業務欄位保持原樣。

## 裝置重連會頂替舊連線

同一裝置再次連上時，新連線寫入線上表並頂替舊連線，舊連線交給呼叫方關閉。不能靠拒絕新連線來處理「重複連線」：半開的舊連線可能還掛在表裡，拒絕會讓裝置一直等到舊連線逾時。

每條裝置連線有一個 `epoch`（UUID），在 `Hub::register_device` 裡生成。斷開清理走 `Hub::unregister_device`，只有表裡當前記錄的 epoch 與這次清理帶來的 epoch 一致時才摘掉裝置並標為離線。舊連線的清理若晚於新連線註冊，epoch 對不上，函式回傳空列表，新連線繼續留在表裡。

## 傳送佇列有界，寫滿就斷開這條連線

每條 WebSocket 對應一條容量 512 的佇列，實現在 `crates/exchange/src/hub/outbound.rs` 的 `channel`。路由路徑上持有連線表的鎖，所以投遞只用 `Sender::try_send`，不在鎖裡等待對端。

佇列已滿時 `try_send` 回傳 `SendError::Lagging`，並置位關閉訊號。`Close` 控制訊息不進佇列，只置位同一訊號，因此佇列堵死時仍然能要求連線退出。裝置側與運維側的接入迴圈都在 `tokio::select!` 裡等待 `sender.closed()`（`crates/exchange/src/ws/device.rs`、`crates/exchange/src/ws/operator.rs`）。訊號一到，轉發迴圈結束，隨後按 epoch 登出這臺裝置或清理這個運維端的會話。對端已經把接收端丟掉時回傳 `SendError::Closed`，與「來不及消費」區分開。

## 令牌是字首加密文，密文只存 argon2

明文形如 `<prefix>.<secret>`。`crates/exchange/src/auth.rs` 的 `split_token` 按第一個 `.` 拆開，空字首或空密文都視為格式錯誤。

簽發時字首是 8 位元組隨機數的十六進位，密文是 32 位元組隨機數的 base64url。資料庫只儲存兩列：

- `token_prefix`：明文，帶唯一索引，校驗時按它做一次查詢
- `token_hash`：密文的 argon2id PHC 字串，由 `hash_secret` 生成

裝置和運維帳號都按這個格式存。校驗時用字首取出雜湊，再用 `verify_secret` 比對密文。明文只在簽發當時回傳給呼叫方，庫裡還原不出來。

argon2 參數是 8 MiB 記憶體、2 次迭代、並行度 1，低於庫的預設檔位。密文是 256 位隨機值，慢雜湊擋不住額外的窮舉，卻會把裝置同時重連變成握手延遲。這組參數只適用於這種隨機令牌。

## 相關文件

- [開發說明](/docs/zh-TW/development.md)
- [使用說明](/docs/zh-TW/getting-started.md)
- [線路協議](/docs/zh-TW/websocket-protocol.md)
- 部署指南在倉庫 `docs/deployment.md`（含伺服器地址，不在本站公開）
