# 線路協議

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

本文是 agent、exchange、cli 三端之間線路格式的完整規格。定義位於 `crates/proto`，三端共享同一份程式碼——任何欄位增刪只有一處定義，不一致會在編譯期而非某次線上會話中暴露。

面向兩類讀者：需要抓包排查問題的運維人員，以及要改協議或寫第三方客戶端的開發者。

---

## 目錄

- [總覽](#總覽)
- [傳輸與端點](#傳輸與端點)
- [協議版本](#協議版本)
- [控制訊息](#控制訊息)
  - [DeviceUp（agent → exchange）](#deviceupagent--exchange)
  - [DeviceDown（exchange → agent）](#devicedownexchange--agent)
  - [OperatorUp（cli → exchange）](#operatorupcli--exchange)
  - [OperatorDown（exchange → cli）](#operatordownexchange--cli)
  - [公共資料結構](#公共資料結構)
- [二進位制資料幀](#二進位制資料幀)
- [會話編號的雙空間翻譯](#會話編號的雙空間翻譯)
- [握手時序](#握手時序)
- [錯誤處理與失敗分類](#錯誤處理與失敗分類)
- [保活與超時](#保活與超時)
- [背壓](#背壓)
- [Android 螢幕控制](#android-螢幕控制)
- [端到端密封會話](#端到端密封會話)

---

## 總覽

協議分兩層，複用同一條 WebSocket 連線：

| 層 | 載體 | 編碼 | 承載什麼 |
|---|---|---|---|
| 控制訊息 | WebSocket **文字**幀 | JSON | 註冊、心跳、開啟/關閉會話、結果通知 |
| 資料幀 | WebSocket **二進位制**幀 | 9 位元組定長頭 + 裸位元組 | PTY 位元組流、命令的 stdout/stderr |

分開的理由：流式資料經 base64 塞進 JSON 會膨脹約 33%，而 PTY 是逐鍵回顯的高頻小包，這部分開銷直接體現為互動延遲。控制訊息本身低頻，JSON 的可讀性（抓包能直接看懂）收益更大。

一個 WebSocket 二進位制幀按約定**只承載一個資料幀**——多餘或缺失位元組都視為錯誤。

---

## 傳輸與端點

| 端點 | 誰連 | 鑑權方式 |
|---|---|---|
| `GET /ws/device` | agent | 連線建立後的**首幀**必須是 `register` 控制訊息，其中帶 `device_id` 與 `token` |
| `GET /ws/operator` | remote-cli | WebSocket 握手的 HTTP 請求頭 `Authorization: Bearer <运维令牌>` |

兩條路徑對應兩類互不通用的憑證。裝置令牌無法用於運維端點，反之亦然。

運維憑證走請求頭而非 URL 查詢串，因為 URL 會出現在反向代理日誌、瀏覽器歷史與 Referer 裡。裝置令牌走首幀而非請求頭，是因為 WebSocket 握手頭在某些嵌入式客戶端上不易自定義，且首幀方案讓「協議版本不匹配」和「令牌錯誤」能用同一條應答訊息統一表達。

生產環境必須使用 `wss://`：兩種憑證都以明文出現在握手階段。

---

## 協議版本

```rust
pub const PROTOCOL_VERSION: u16 = 1;
```

版本在握手時雙向比對，不匹配立即拒絕並給出明確提示。這樣做是為了避免版本錯配表現為「之後某條訊息莫名其妙解析失敗」——那種故障極難定位。

- 裝置側：agent 在 `register` 裡帶上自己的版本；服務端不匹配時回一條 `ok: false` 的 `register_ack`，`error` 欄位說明雙方版本，隨後關閉連線。
- 運維側：服務端連線建立後主動發 `welcome`，CLI 比對不一致時報錯並提示升級到與 `server_version` 匹配的版本。

**未知的訊息 `type` 一律拒絕**，不會被靜默當成某個已知型別處理。

---

## 控制訊息

所有控制訊息都是 JSON 物件，用 `type` 欄位做標籤（serde 的 internally-tagged 列舉），標籤值與欄位名均為 `snake_case`。`session_id` 透明序列化為裸數字。

### DeviceUp（agent → exchange）

| `type` | 欄位 | 用途 |
|---|---|---|
| `register` | `protocol_version: u16`<br>`device_id: string`<br>`token: string`<br>`info: DeviceInfo` | 連線後的**第一條**訊息，必須是它。服務端在收到之前不接受任何其它訊息，且限時 15 秒 |
| `heartbeat` | `metrics: Metrics` | 週期性業務指標上報。鏈路存活性由 WebSocket 原生 ping/pong 負責，與本訊息職責分開——心跳丟失不代表鏈路斷了，反之亦然 |
| `exec_started` | `session_id`<br>`job_id`<br>`hold`<br>`timeout_secs` | 命令已啟動；`job_id` 用於 hold 列表與重連附加 |
| `exec_exit` | `session_id`<br>`code: i32 \| null`<br>`timed_out: bool` | 命令執行結束。`code` 在程序被訊號終止時為 `null`；`timed_out` 表示因超時被強制終止 |
| `exec_list_resp` | `session_id`<br>`jobs: ExecHoldInfo[]` | hold 作業列表 |
| `ubus_resp` | `session_id`<br>`code: i32`<br>`data: any` | ubus 呼叫返回。`code` 是 ubus 自身的返回碼，0 為成功 |
| `android_resp` | `session_id`<br>`result: AndroidResult` | Android 螢幕操作完成；此訊息結束會話 |
| `pty_closed` | `session_id`<br>`code: i32 \| null` | PTY 會話結束，`code` 是 shell 的退出碼 |
| `session_error` | `session_id`<br>`message: string` | 會話級錯誤。裝置無法完成某個請求時回這條，而不是靜默丟棄 |

服務端收到已註冊連線上的第二條 `register` 會視為協議誤用並記錄告警。

示例：

```json
{"type":"register","protocol_version":1,"device_id":"lab-router","token":"a1b2….c2Vj…","info":{"hostname":"OpenWrt","model":"ath79/generic","openwrt_version":"OpenWrt 23.05.5","kernel":"5.15.137","arch":"mips","agent_version":"0.1.0"}}
{"type":"exec_exit","session_id":31,"code":0,"timed_out":false}
```

### DeviceDown（exchange → agent）

| `type` | 欄位 | 用途 |
|---|---|---|
| `register_ack` | `ok: bool`<br>`error: string \| null`<br>`heartbeat_interval_secs: u64` | 註冊結果。`ok: false` 時 `error` 給出原因，隨後服務端關閉連線。心跳間隔由服務端指定，便於裝置規模變大後統一調稀而無需逐臺改配置。agent 會把 0 夾到至少 1 秒，避免定時器空轉打滿 CPU |
| `device_access` | `managers: DeviceManager[]`<br>`updated_at: u64`（毫秒） | 誰對本裝置有許可權。`DeviceManager` = `operator_id`、`username`、`email?`、`role`、`level`（`manage`/`view`）、`source`（`role_default`/`grant`）。註冊後及許可權變化時下發，僅發給宣告瞭 `device_access` 能力的 agent，見 倉庫 `docs/permissions.md` |
| `exec_req` | `session_id`<br>`command: string`<br>`timeout_secs: u64`<br>`hold: bool`（預設 `true`）<br>`hold_timeout_secs?: u64` | 執行一條命令。`hold=true`（預設）時程序可在運維斷開後繼續跑，超時取 `hold_timeout_secs`（預設/0 → 3600，硬上限 86400）。`hold=false` 時運維端斷開即取消命令 |
| `exec_cancel` | `session_id` | 提前終止（kill）一次命令執行 |
| `exec_detach` | `session_id` | 停止向當前運維流式輸出，但保留 hold 程序 |
| `exec_list_req` | `session_id` | 查詢當前 hold 作業列表 |
| `exec_attach_req` | `session_id`<br>`job_id` | 把 hold 作業重新附加到本會話並回放緩衝輸出 |
| `exec_hold_kill` | `session_id`<br>`job_id` | 按 job_id 終止 hold 作業 |
| `exec_hold_tail` | `session_id`<br>`job_id` | 只跟隨新輸出（不回放緩衝） |
| `exec_hold_cat` | `session_id`<br>`job_id` | 轉儲當前緩衝後以 `exec_hold_cat_done` 結束 |
| `ubus_req` | `session_id`<br>`object: string`<br>`method: string`<br>`params: any` | 發起一次 ubus 呼叫。`params` 為 `null` 時不向 `ubus` 傳第三個位置引數 |
| `pty_open` | `session_id`<br>`rows: u16`<br>`cols: u16` | 開啟 PTY 會話。行列為 0 時兜底成 24×80 |
| `pty_resize` | `session_id`<br>`rows: u16`<br>`cols: u16` | 終端視窗尺寸變化 |
| `pty_close` | `session_id` | 關閉 PTY 會話 |
| `android_req` | `session_id`<br>`request: AndroidAction` | 單次 Android 螢幕操作 |
| `android_cancel` | `session_id` | 取消尚未完成的 Android 請求 |

### OperatorUp（cli → exchange）

鑑權在 WebSocket 握手階段完成，因此沒有登入類訊息。

| `type` | 欄位 | 用途 |
|---|---|---|
| `open_exec` | `session_id`<br>`device_id`<br>`command`<br>`timeout_secs`<br>`hold`（預設 true）<br>`hold_timeout_secs?` | 在目標裝置上執行命令。hold 預設開啟；`hold=true` 且超時 0 → 3600s。`hold=false` 且超時 0 → Exchange 預設 60s |
| `list_exec_holds` | `session_id`<br>`device_id` | 查詢 exec 作業和進行中的下載、上傳、目錄查詢 |
| `list_device_sessions` | `session_id`<br>`device_id` | 直接查詢 Exchange 路由表，不佔裝置會話槽位 |
| `kill_device_session` | `session_id`<br>`device_id`<br>`target_session_id` | 按裝置側 `target_session_id` 取消並本地釋放路由；包括 hold exec，不佔額外槽位 |
| `attach_exec` | `session_id`<br>`device_id`<br>`job_id` | 附加到 hold 作業並繼續收輸出 |
| `exec_hold_kill` / `exec_hold_tail` / `exec_hold_cat` | `session_id`<br>`device_id`<br>`job_id` | hold 作業 kill / tail / cat |
| `detach_session` | `session_id` | 結束前臺等待並 detach（不殺 hold 程序） |
| `open_ubus` | `session_id`<br>`device_id: string`<br>`object: string`<br>`method: string`<br>`params: any` | 發起一次 ubus 呼叫 |
| `open_pty` | `session_id`<br>`device_id: string`<br>`rows: u16`<br>`cols: u16` | 開啟互動式 PTY 會話 |
| `open_android` | `session_id`<br>`device_id: string`<br>`request: AndroidAction` | 發起 Android 螢幕操作；只讀賬號不允許 |
| `pty_resize` | `session_id`<br>`rows: u16`<br>`cols: u16` | 終端視窗尺寸變化。不帶 `device_id`——會話已經建立，服務端能查到目標裝置 |
| `close_session` | `session_id` | 主動結束會話：PTY 正常退出，或使用者中斷命令執行 |

四條 `open_*` 訊息裡的 `session_id` **由 CLI 自己分配**（見[會話編號的雙空間翻譯](#會話編號的雙空間翻譯)），因此發出請求後可以立刻發後續幀，不必等編號回執。

### OperatorDown（exchange → cli）

| `type` | 欄位 | 用途 |
|---|---|---|
| `welcome` | `protocol_version: u16`<br>`server_version: string` | 連線建立後服務端主動傳送，用於確認協議版本一致 |
| `device_sessions` | `session_id`<br>`sessions: DeviceSessionInfo[]`<br>`limit`<br>`count` | Exchange 當前裝置側會話列表 |
| `device_session_killed` | `session_id`<br>`target_session_id`<br>`released: bool` | 已本地釋放；不存在時為 false |
| `exec_exit` | `session_id`<br>`code: i32 \| null`<br>`timed_out: bool` | 命令結束，由裝置側同名訊息翻譯而來 |
| `ubus_resp` | `session_id`<br>`code: i32`<br>`data: any` | ubus 呼叫返回 |
| `android_resp` | `session_id`<br>`result: AndroidResult` | Android 返回值，已翻譯為運維側會話編號 |
| `pty_closed` | `session_id`<br>`code: i32 \| null` | PTY 會話結束 |
| `session_failed` | `session_id`<br>`reason: SessionFailure`<br>`message: string` | 會話失敗。`reason` 區分失敗原因，便於 CLI 決定是否值得重試 |

### 公共資料結構
`ExecHoldInfo`：`job_id`、`kind`（`exec` / `download` / `upload` / `file`）、`age_secs`、`session_id?`、`command_preview`、`status`、`timeout_secs`、`started_at_unix_ms`、`exit_code?`、`timed_out`。`kind` 預設相容為 `exec`，`age_secs` 預設為 0；列表響應按當前時鐘計算年齡。命令/檔案路徑摘要最多約 120 字元。非 exec 作業只能 kill，不能 tail/cat/attach。

`DeviceSessionInfo`：`session_id: u32`（裝置側編號）、`operator_session_id: u32`、`kind: string`、`hold: bool`、`age_secs: u64`、`deadline_unix_ms: u64`。會話列表/強制取消直接由 Exchange 回答，不向裝置新開會話；只讀賬號返回 `forbidden`。

#### `DeviceInfo`

裝置靜態資訊，`register` 時上報一次，落庫到 `devices.info`。除 `agent_version` 外全部可選——agent 在非 OpenWrt 環境（開發機、容器）上執行時這些來源大多不存在，此時應當照常註冊而不是啟動失敗。預設欄位在序列化時被省略。

| 欄位 | 型別 | 來源 |
|---|---|---|
| `hostname` | `string?` | `/proc/sys/kernel/hostname` |
| `model` | `string?` | `/etc/openwrt_release` 的 `DISTRIB_TARGET`，讀不到則退回 `ubus call system board` 的 `model` |
| `openwrt_version` | `string?` | `DISTRIB_DESCRIPTION`，或 ubus 的 `release.description` |
| `kernel` | `string?` | `/proc/sys/kernel/osrelease` |
| `arch` | `string?` | 編譯期常量（比執行時探測可靠） |
| `agent_version` | `string` | agent 自身版本，用於排查版本錯配 |

#### `Metrics`

每個心跳週期重新採集，只收集能從 `/proc` 直接讀到、無需 fork 子程序的專案。任何單項讀取失敗都不會導致整體失敗——丟一項指標遠好過讓心跳整個失敗、進而被判定為裝置離線。

| 欄位 | 型別 | 說明 |
|---|---|---|
| `uptime_secs` | `u64` | `/proc/uptime` |
| `load` | `[f32; 3]` | `/proc/loadavg` 的 1/5/15 分鐘平均負載 |
| `mem_total_kb` | `u64` | `/proc/meminfo` |
| `mem_available_kb` | `u64` | 用 `MemAvailable` 而非 `MemFree`——後者不含可回收的快取，在路由器上會顯著低估可用量，造成虛假告警 |
| `cpu_usage_pct` | `f32?` | 由兩次 `/proc/stat` 取樣差分算得；首次心跳無參照樣本，此時省略 |
| `interfaces` | `InterfaceStats[]` | `/proc/net/dev`。上報**累計**收發位元組而非速率，讓服務端保有按任意時間窗計算速率的自由。空陣列時省略 |

`InterfaceStats`：`{ name: string, rx_bytes: u64, tx_bytes: u64 }`

---

## 二進位制資料幀

```text
 0        1                    5                    9
 +--------+--------------------+--------------------+
 |  kind  |     session_id     |    payload_len     |  payload (payload_len 字节)
 |   u8   |    u32 (大端)      |    u32 (大端)      |
 +--------+--------------------+--------------------+
 └──────────── 9 字节定长头 ────────────┘
```

| 偏移 | 長度 | 欄位 | 說明 |
|---|---|---|---|
| 0 | 1 | `kind` | 流型別，見下表 |
| 1 | 4 | `session_id` | 大端 `u32`。0 保留為「不屬於任何會話」的哨兵值 |
| 5 | 4 | `payload_len` | 大端 `u32`，載荷位元組數，上限 1 MiB |
| 9 | `payload_len` | `payload` | 裸位元組，不做任何編碼或轉義 |

### 流型別

| 值 | 名稱 | 方向 | 說明 |
|---|---|---|---|
| 1 | `ExecStdout` | 裝置 → 運維 | 遠端命令的標準輸出 |
| 2 | `ExecStderr` | 裝置 → 運維 | 標準錯誤。與 stdout 分開傳輸，CLI 側才能區別呈現（例如標紅） |
| 3 | `PtyData` | **雙向** | PTY 位元組流。上行是鍵盤輸入，下行是螢幕輸出，同一型別雙向複用 |

agent 若在下行方向收到 `ExecStdout` / `ExecStderr`，會記錄告警並丟棄——那說明對端實現有誤。

### 設計說明

**`payload_len` 與 WebSocket 自身的長度欄位確實冗餘。** 保留它有兩個理由：解碼時能校驗出截斷（宣告 11 位元組卻只給 5 位元組必須被識破，而不是靜默接受成一個短包），以及日後若需要改走裸 TCP，幀格式無需變動。

**1 MiB 的載荷上限**不是為了正常流量——PTY 與命令輸出都是流式增量推送，遠達不到這個量級——而是為了讓對端無法用一個聲稱載荷 2 GiB 的幀頭撐爆服務端記憶體。

**空載荷是合法的**：PTY 會話中偶爾會出現零位元組讀取。此時整幀就是 9 位元組的頭。

**載荷是任意位元組**，包括非 UTF-8 序列，必須原樣保留。

### 解碼錯誤

| 錯誤 | 觸發條件 |
|---|---|
| `FrameTooShort { len }` | 總長度不足 9 位元組 |
| `UnknownStreamKind(v)` | 首位元組不是 1/2/3 |
| `PayloadTooLarge { len }` | 宣告的 `payload_len` 超過 1 MiB |
| `LengthMismatch { declared, actual }` | 宣告長度與實際剩餘位元組數不符 |

資料幀解碼失敗隻影響該幀：exchange 記錄一條告警並丟棄，不會斷開連線。

---

## 會話編號的雙空間翻譯

**這是協議裡最容易理解錯的部分。**

### 一句話概括

`session_id` 由**發起方**分配，且**只在它自己那條連線內唯一**。運維側和裝置側各自維護一套獨立的編號空間，exchange 在中間做翻譯。同一個邏輯會話在兩側有兩個不同的編號。

```text
  remote-cli                    exchange                      remote-agent
                                                        （设备 lab-router）

  ┌────────────┐        ┌────────────────────┐            ┌────────────┐
  │ 运维编号   │        │      路由表        │            │ 设备编号   │
  │  空间      │        │                    │            │  空间      │
  │            │        │  lab-router:       │            │            │
  │  session 7 │───────▶│    31 → (op-A, 7)  │───────────▶│ session 31 │
  │            │◀───────│                    │◀───────────│            │
  └────────────┘        │  op-A:             │            └────────────┘
                        │    7 → (lab-router,│
   由 CLI 自己的        │         31)        │             由 exchange
   SessionIdGenerator   └────────────────────┘             的 DeviceHandle
   分配（1,2,3…）                                          分配（1,2,3…）
```

### 為什麼要這樣設計

**避免一次往返。** 如果編號由服務端統一分配，CLI 開啟 PTY 後必須先等一條「你的會話編號是 31」的回執，才能開始發鍵盤輸入。讓 CLI 自己分配，`open_pty` 發出去的下一刻就能發資料幀。

**避免跨運維端的編號衝突。** 兩個不同的運維人員完全可能同時用本地編號 1 去操作同一臺裝置。裝置側必須能區分它們，否則一個人的輸出會流向另一個人。exchange 為每條裝置連線單獨分配裝置側編號，兩個 `session 1` 會被對映成裝置側的 `31` 和 `32`。

**避免裝置側編號被運維端行為影響。** 裝置側的編號完全由 exchange 掌控，任何運維端都無法指定或猜測。

### 兩張表

exchange 為每條連線維護一張表，兩張表始終成對更新：

| 位置 | 表 | 鍵 → 值 |
|---|---|---|
| `DeviceHandle.sessions` | 裝置側正向表 | 裝置編號 → (運維端傳送控制代碼, 運維編號, 運維賬號 ID, 會話型別, 審計行 ID) |
| `OperatorHandle.sessions` | 運維側反向索引 | 運維編號 → (裝置 ID, 裝置編號) |

反向索引存在的意義是清理：運維端斷開時，要能找到它在**各臺裝置**上留下的會話並逐一終止，否則裝置上會殘留無人接收輸出的程序和 PTY。

### 翻譯發生在哪

| 方向 | 訊息 | 翻譯動作 |
|---|---|---|
| 運維 → 裝置（`open_*`） | 控制訊息 | 在裝置的編號空間裡**分配**一個新編號，兩張表同時登記，下發給裝置的訊息裡用新編號 |
| 運維 → 裝置（`pty_resize` / `close_session`） | 控制訊息 | 用反向索引查出裝置編號，替換後轉發 |
| 運維 → 裝置 | 資料幀 | 用反向索引查出裝置編號，就地改寫幀頭的 4 個位元組，載荷不動 |
| 裝置 → 運維 | 控制訊息 | 用正向表查出運維編號與目標運維連線，替換後投遞 |
| 裝置 → 運維 | 資料幀 | 用正向表查出運維編號，就地改寫幀頭，載荷不動 |

資料幀的翻譯是**唯一**的改寫動作——exchange 不解析、不快取、不修改任何一個載荷位元組。`DataFrame::with_session_id` 就地替換而不重新分配載荷。

### 分配規則

兩側的分配器都從 1 開始遞增，**跳過 0**（0 是「不屬於任何會話」的哨兵，例如連線級錯誤訊息）。

- CLI 側（`SessionIdGenerator`）：溢位後迴繞並跳過 0。單條連線內用滿 40 億個會話不現實，但迴繞比 panic 更符合長駐程序的需要。
- 裝置側（`DeviceHandle::open_session`）：迴繞後若撞上仍存活的舊會話，繼續找下一個空位。

exchange 拒絕運維端使用 0 或重複使用仍在活動中的會話編號。裝置連線有效性、併發上限和兩側索引的登記在同一臨界區內檢查，避免多個運維連線同時開啟會話時突破上限。

### 查不到路由時的行為

**靜默丟棄，不報錯。** 會話剛被關閉而對端還在髮尾巴上的資料，屬於正常競態。這條規則對兩個方向都成立：

- 裝置回覆了一個已不存在的會話 → 記 debug 日誌，可能是運維端先行斷開
- 運維端發來無主會話的資料 → 記 trace 日誌並丟棄

### 會話的清理路徑

| 觸發 | 清理動作 |
|---|---|
| 裝置正常結束會話（`exec_exit` / `ubus_resp` / `android_resp` / `pty_closed` / `session_error`） | 同時移除正向路由和所屬連線的反向索引，回填審計，把翻譯後的訊息投給運維端 |
| 運維端 `close_session` | 移除兩側索引，通知原裝置連線終止，立即向運維端返回結束回執，並回填取消審計。重複關閉無操作 |
| 裝置斷開 | 取走該連線的全部會話，逐一向對應運維端發 `session_failed` / `device_disconnected`，並回填審計 `{"outcome":"device_disconnected"}` |
| 運維端斷開 | 遍歷反向索引找到各裝置上屬於它的會話，非 hold 會話發終止指令；hold exec 僅發 `exec_detach`，不殺程序。**只清理自己的會話**，同一裝置上其它運維端的會話不受影響 |
| 裝置重連頂替舊連線 | 舊連線上的會話全部作廢，逐一通知運維端 `device_disconnected` |

反向索引繫結具體的裝置連線，不會在裝置重連後按裝置 ID 轉向新連線。同一賬號的多個運維連線分別持有自己的索引，斷開其中一個不會取消另一個連線的會話。

主動取消的結束回執：exec 返回 `exec_exit`（`code: null`、`timed_out: false`），PTY 返回 `pty_closed`（`code: null`），ubus 返回 `session_failed`（`reason: device_error`）。這是 exchange 確認會話已關閉，不是遠端程序已經退出的證明；裝置後續返回的尾部資料和退出訊息會被丟棄。

達到併發上限時，Exchange 在同一會話鎖下先回收失去運維端或超過截止時間的路由，再重新檢查上限。exec 截止時間為實際下發時限加 15 秒寬限；檔案為 3600 秒加 15 秒；其它型別為 24 小時。截止時間內的 hold exec 不因運維端離開而被殺。回收/顯式 `kill_device_session` 會向裝置發對應取消指令並立即刪除兩側索引，無需裝置回覆；可用運維端收到 exec 的 `exec_exit`（`timed_out:true`）或其它型別的 `session_failed`（`device_error`、`session reclaimed`），審計盡力非同步回填。

### 裝置重連與 epoch

每條裝置連線在註冊時會拿到一個隨機的 `epoch`（UUID）。同一裝置再次連入時，**新連線頂替舊連線**——而不是拒絕新連線，因為無法可靠區分「重複連線」與「舊連線已死但服務端還沒發現（半開連線）」，拒絕會讓裝置在舊連線超時前一直連不上。

舊連線的清理邏輯可能**晚於**新連線的註冊才執行。因此登出時必須校驗 epoch：只有當連線表裡當前那條連線的 epoch 與自己一致時才真的移除。少了這道校驗，剛上線的裝置會被舊連線的清理邏輯立刻標記為離線。

---

## 握手時序

### 裝置側

```text
  remote-agent                                        exchange
      │                                                 │
      │  ①  WebSocket 握手（wss://host/ws/device）      │
      │────────────────────────────────────────────────▶│
      │◀────────────────────────────────────────────────│
      │            101 Switching Protocols              │
      │                                                 │
      │                                          ┌──────┴──────┐
      │                                          │ 启动 15 秒  │
      │                                          │ 注册计时器  │
      │                                          └──────┬──────┘
      │  ②  {"type":"register","protocol_version":1,    │
      │      "device_id":"…","token":"…","info":{…}}    │
      │────────────────────────────────────────────────▶│
      │                                                 │
      │                              ┌──────────────────┴──────────────────┐
      │                              │ a. 比对 protocol_version            │
      │                              │ b. split_token → 前缀查 devices 表  │
      │                              │ c. 校验 enabled                     │
      │                              │ d. argon2 verify（前缀不符也照做，  │
      │                              │    避免用耗时区分前缀是否存在）     │
      │                              └──────────────────┬──────────────────┘
      │                                                 │
      │  ③  {"type":"register_ack","ok":true,           │
      │      "error":null,"heartbeat_interval_secs":30} │
      │◀────────────────────────────────────────────────│
      │                                                 │
      │                                          ┌──────┴──────┐
      │                                          │ 登记连接表  │
      │                                          │ 顶替旧连接  │
      │                                          │ 回写 info 与│
      │                                          │ last_seen_at│
      │                                          └──────┬──────┘
      │                                                 │
      │  ④  退避曲线复位，写状态文件 state=connected     │
      │                                                 │
      │══════════════ 转发循环 ═══════════════════════════│
      │                                                 │
      │  heartbeat（每 30 秒，携带 Metrics）             │
      │────────────────────────────────────────────────▶│
      │  WebSocket Ping（随每次 heartbeat 发出）         │
      │────────────────────────────────────────────────▶│
      │                                     WebSocket Pong
      │◀────────────────────────────────────────────────│
      │                                     WebSocket Ping（每 30 秒）
      │◀────────────────────────────────────────────────│
      │  Pong（tungstenite 排队，agent 显式 flush）       │
      │────────────────────────────────────────────────▶│
```

**失敗路徑：**

- 首幀不是文字幀、不是合法 JSON、或不是 `register` → 記錄告警，直接關閉連線（不發 ack）
- 15 秒內未發出 `register` → 關閉連線
- 版本不匹配 → 回 `register_ack{ok:false, error:"协议版本不匹配：设备为 X，服务端为 Y"}`，關閉
- 令牌無效 / 裝置不存在 / 裝置已停用 → 一律回同一句 `"设备 ID 或令牌无效"`，關閉。**不區分原因**：區分開會把裝置清單變成可列舉的資訊

agent 側把這兩類失敗分開處理：

| agent 側分類 | 觸發 | 重連策略 |
|---|---|---|
| `Rejected` | 收到 `ok: false` 的 ack | 固定等待 **300 秒**。令牌錯誤不會因為重試而變對，頻繁重試只是浪費雙方資源並在服務端日誌裡刷屏；等久一點給人留出改配置的時間 |
| `Transport` | 連不上、握手超時（45 秒）、應答格式錯亂、連線中斷 | 指數退避：初始 1 秒，每次翻倍，上限 300 秒，乘以 [0.5, 1.5) 的隨機抖動 |

**退避曲線只在註冊成功後才復位**，而不是 TCP 一建立就復位。否則遇到「連上即被拒」的情況會退化成毫秒級的密集重試。

**抖動不是錦上添花。** 服務端重啟時成百上千臺裝置會在同一瞬間檢測到斷連，若都按同一條確定性曲線重連，它們會保持同步、成批次衝擊剛起來的服務端，把一次重啟放大成持續的雪崩。

### 運維側

```text
  remote-cli                                          exchange
      │                                                 │
      │  ①  WebSocket 握手（wss://host/ws/operator）    │
      │      Authorization: Bearer <prefix>.<secret>    │
      │────────────────────────────────────────────────▶│
      │                                                 │
      │                              ┌──────────────────┴──────────────────┐
      │                              │ 在 upgrade 之前完成鉴权：           │
      │                              │ split_token → 前缀查 operators 表   │
      │                              │ → argon2 verify → 校验 enabled      │
      │                              └──────────────────┬──────────────────┘
      │                                                 │
      │      鉴权失败 → HTTP 401「凭证无效」（不升级）   │
      │◀────────────────────────────────────────────────│
      │                                                 │
      │◀────────────────────────────────────────────────│
      │            101 Switching Protocols              │
      │                                                 │
      │  ②  {"type":"welcome","protocol_version":1,     │
      │      "server_version":"0.1.0"}                  │
      │◀────────────────────────────────────────────────│
      │                                                 │
      │  ③  比对 protocol_version（15 秒超时）          │
      │      不一致 → 报错退出，提示升级 CLI            │
      │                                                 │
      │══════════════ 可以发起会话 ═══════════════════════│
      │                                                 │
      │  {"type":"open_pty","session_id":1,             │
      │   "device_id":"lab-router","rows":40,"cols":120}│
      │────────────────────────────────────────────────▶│
      │                                                 │──▶ 查设备在线
      │                                                 │──▶ 查并发上限
      │                                                 │──▶ 查 readonly 角色
      │                                                 │──▶ 写审计（先于下发）
      │                                                 │──▶ 分配设备侧编号
      │  二进制帧（PtyData, session 1, 键盘输入）        │
      │────────────────────────────────────────────────▶│──▶ 改写为设备侧编号
      │                                                 │
      │  二进制帧（PtyData, session 1, 屏幕输出）        │
      │◀────────────────────────────────────────────────│◀── 改写为运维侧编号
```

注意 CLI **不必等待任何確認**就能發出第二步的資料幀——這正是讓發起方分配編號的意義。

---

## 錯誤處理與失敗分類

### `SessionFailure`

出現在 `session_failed` 訊息的 `reason` 欄位，JSON 值為 snake_case。

| 值 | 含義 | 誰產生 | 可重試 |
|---|---|---|---|
| `device_offline` | 裝置不存在，或當前不線上 | exchange 在 `open_*` 時發現連線表裡沒有該裝置 | ✅ |
| `device_disconnected` | 會話進行到一半，裝置掉線了 | 裝置斷開 / 被新連線頂替 / 請求下發時傳送佇列已關閉 | ✅ |
| `device_error` | 裝置側拒絕或執行失敗 | agent 回 `session_error`（PTY 建立失敗、命令啟動失敗、ubus 呼叫失敗），或轉發時對端消費過慢 | ❌ |
| `rate_limited` | 超出併發會話數上限 | exchange 在 `open_*` 時發現該裝置活躍會話數已達 `--max-sessions-per-device`（預設 32） | ✅ |
| `forbidden` | 運維憑證無權訪問該裝置 | 只讀賬號（`role = readonly`）嘗試發起任何會話 | ❌ |

`is_retryable()` 的分類刻意把 `forbidden` 和 `device_error` 排除在外——重試一個許可權問題或裝置內部錯誤沒有意義，只會讓 CLI 空轉。

CLI 收到後會在原始 `message` 之後追加一句可操作的提示，例如 `device_offline` 會提示用 `remote-cli device list` 確認裝置狀態。`device_error` 例外——裝置側的錯誤資訊本身就是最準確的說明，不該被套話稀釋。

### 分層的失敗語義

| 層級 | 表現 | 影響範圍 |
|---|---|---|
| 幀解碼失敗 | 記日誌，丟棄該幀 | 單幀 |
| 控制訊息解析失敗 | 記告警，忽略該訊息 | 單條訊息，連線繼續 |
| 會話級失敗 | `session_failed` / `session_error` | 單個會話，連線繼續 |
| 註冊失敗 | `register_ack{ok:false}` 後關閉 | 整條連線 |
| 版本不匹配 | 明確的錯誤訊息後關閉 | 整條連線 |
| 佇列滯後 | 主動關閉連線 | 整條連線 |

原則是：**能區域性化的失敗絕不上升為連線級故障**，因為一條裝置連線上可能同時跑著多個運維人員的會話。

---

## 保活與超時

| 機制 | 值 | 說明 |
|---|---|---|
| exchange WebSocket Ping | 每 `--heartbeat-secs`（預設 30 秒） | exchange 主動發往裝置和運維兩側。客戶端庫自動排隊 Pong；agent 收到 Ping 後必須顯式 flush sink，才能及時發出 Pong |
| agent WebSocket Ping | 每 `heartbeat_interval_secs`（服務端在 ack 中下發） | agent 在每次 JSON `heartbeat` 之外還傳送原生 Ping，避免 exchange Ping 延遲時觸及 Cloudflare 約 100 秒空閒斷連的運維邊界 |
| 連線靜默超時 | `heartbeat × --missed-pings`（預設 30 × 3 = 90 秒） | 裝置或運維連線在該時長內沒有任何入站訊息即判定失聯並斷開。Pong 也計入活動；`--missed-pings` 配成 0 時兜底為至少等一個心跳週期 |
| exchange 寫入超時 | 10 秒 | 單次 WebSocket 資料或 Ping 寫入超時即結束連線，避免慢連線阻塞清理 |
| 業務心跳 | 每 `heartbeat_interval_secs`（服務端在 ack 中下發） | agent 發 JSON `heartbeat` 攜帶 `Metrics`，同時另發 WebSocket Ping。指標上報與原生 ping/pong 的職責分開，不能僅憑業務心跳判斷鏈路狀態 |
| 線上/離線事件 | 通知觸發器 `online` / `offline` 的 `tick_count`（預設 3，範圍 1–100）；廣播與審計回退 `REMOTE_MISSED_THRESHOLD` / `REMOTE_ONLINE_THRESHOLD` | 運維配置兩種觸發器，而不是一條觸發器上的兩個計數。`offline` 在連續錯過 `tick_count` 次後通知離線，`online` 在連續成功 `tick_count` 次後通知上線。每條觸發器的 `device_ids` 可以是一個裝置 ID（字串）、一組裝置 ID（陣列），或省略 / `null` 表示工作區全部裝置。已經處於該觸發器報告的狀態時不會重複通知；要再次通知，需要先連續 `tick_count` 次相反結果重新武裝。程序內 presence 廣播和審計不讀這些觸發器，回退到 `REMOTE_MISSED_THRESHOLD` / `REMOTE_ONLINE_THRESHOLD`（預設也是 3）。套接字是否仍在連線表裡由 `--missed-pings` 決定，與這條計數分開 |
| 裝置註冊超時 | 15 秒 | 連線建立後必須在此時限內發出 `register` |
| agent 握手超時 | 每階段 45 秒（`HANDSHAKE_TIMEOUT`） | `connect_device_ws`（TCP、可選 HTTP CONNECT、TLS、WebSocket 握手）和傳送 `register` 後等待 `register_ack` 各自使用此時限，並非兩階段合計 45 秒 |
| agent 寫入 / Pong flush 超時 | 30 秒（`WRITE_TIMEOUT`） | 每次 send（含註冊、JSON 心跳、Ping、會話輸出）和註冊成功後的 Pong flush 各自限時；寫入失敗或超時結束連線並重連。註冊等待期間的 Pong flush 受上述 45 秒 ack 等待時限約束 |
| agent TCP 保活 | 空閒 30 秒後探測 | 直連或 HTTP CONNECT 後啟用 `TCP_NODELAY` 和 TCP keepalive；這是 TCP 層設定，不代替 WebSocket Ping/Pong |
| CLI 歡迎超時 | 15 秒 | CLI 連上後等待 `welcome` 的時限 |
| 命令執行超時 | 由 `open_exec` 的 `timeout_secs` 指定 | agent 側夾取到至少 1 秒。超時後 SIGTERM 整個程序組，3 秒寬限期後 SIGKILL |
| ubus 呼叫超時 | 15 秒（agent 內固定） | ubus 是本地 RPC，超過這個時間基本可以斷定對應服務卡住了 |

空閒 shell 的客戶端仍會回覆 Pong；agent 透過顯式 flush 發出庫自動排隊的應答，不必等下一次業務心跳，因此不會因沒有鍵盤輸入而被判定失聯（#42）。`--heartbeat-secs` 必須大於 0，非法值在啟動引數解析時被拒絕。

---

## 背壓

exchange 為每條連線維護一個容量 **512** 條訊息的有界傳送佇列。

佇列寫滿意味著對端消費速度已經跟不上——PTY 會話裡通常是網路卡死或客戶端掛起。此時的處理是**主動斷開該連線**，而不是繼續緩衝：無界緩衝會把一個卡住的客戶端變成整個服務的記憶體洩漏。512 條對互動式 PTY 是很大的餘量（正常情況下佇列長度是個位數），觸及上限基本可以斷定對端已經不再消費。

投遞一律用非阻塞的 `try_send`。路由路徑上持有著連線表的鎖，任何 `await` 都會把鎖的持有時間和對端的消費速度綁在一起，一個慢客戶端將直接拖住整張表。

兩種投遞失敗被區分對待：

| 結果 | 含義 | 處理 |
|---|---|---|
| `Lagging` | 佇列已滿 | 透過獨立關閉訊號中斷對應連線，清理該連線的會話；盡力通知另一端失敗 |
| `Closed` | 接收端已釋放 | 正常的連線結束，只記 debug 日誌 |

連線關閉訊號不經過傳送佇列，佇列滿或寫入阻塞時仍能觸發清理；裝置重連頂替和令牌輪換使用同一機制。

agent 側同樣有一個容量 256 的出站佇列。佇列滿說明網路已經完全送不出資料，此時繼續堆積沒有意義，應當斷開重連。PTY 的輸入佇列（64）滿時直接丟棄按鍵——互動式的小資料堆積起來沒有意義。
## Android 螢幕控制

定義位於 `crates/proto/src/android.rs`，使用現有雙空間會話路由、鑑權、併發限制和審計。`open_android` / `android_req` 的 `request` 是由 `action` 區分的 JSON 物件：

| `action` | 其它欄位 | 行為 |
|---|---|---|
| `status` | 無 | 查詢本地授權及 SDK 版本；無需開啟螢幕開關，但遠端服務必須執行 |
| `tree` | 無 | 當前活動應用的可訪問節點樹 |
| `screenshot` | 無 | 單張 JPEG 截圖 |
| `tap` | `x, y: u32` | 點選原始螢幕畫素座標 |
| `swipe` | `x1, y1, x2, y2, duration_ms: u32` | 滑動，時長 50–5000 ms |
| `swipe_path` | `points: [{x, y}]`（2–64 個點）, `duration_ms: u32` | 多段折線滑動：一次按下連續經過所有點，按長度均勻插值，時長 50–5000 ms |
| `global` | `key: string` | `back` / `home` / `recents` / `notifications` / `quick_settings` |
| `input_text` | `text: string` | 替換當前焦點可編輯欄位，最多 8192 UTF-8 位元組、無 NUL |

`AndroidResult` 由 `kind` 區分，欄位均為必填：

| `kind` | 欄位 |
|---|---|
| `status` | `controls_enabled, accessibility_enabled, screen_capture_enabled: bool`；`sdk: u32` |
| `tree` | `nodes: AndroidNode[]`；`screen_width, screen_height: u32`；`truncated: bool` |
| `screenshot` | `mime_type: "image/jpeg"`；`data_base64: string`；`width, height, screen_width, screen_height: u32` |
| `ok` | 無，表示輸入、手勢或系統動作完成 |

節點包含字串 `class_name, package, view_id, text, description`，`bounds: [left, top, right, bottom]`（有符號螢幕畫素座標），以及布林欄位 `clickable, editable, focused, password`。最多 200 個節點，每個字串最多 256 個 UTF-16 單元；`truncated` 僅標記節點上限。密碼值和描述為空，agent 自身配置子樹被省略。

截圖長邊最多 1600 畫素；`width/height` 是影象尺寸，`screen_width/screen_height` 是縮放前尺寸。手勢座標按縮放前尺寸處理。Java 橋接返回 JSON 最多 2 MiB，WebSocket 封裝額外允許 1024 位元組。沒有新增二進位制幀型別。

```json
{"type":"open_android","session_id":1,"device_id":"phone","request":{"action":"input_text","text":"Hello\nWorld"}}
{"type":"android_req","session_id":31,"request":{"action":"input_text","text":"Hello\nWorld"}}
{"type":"android_resp","session_id":31,"result":{"kind":"ok"}}
{"type":"android_resp","session_id":1,"result":{"kind":"ok"}}
```

`screen_capture_enabled` 表示當前有可用截圖授權（MediaProjection，或 Android 11+ 輔助功能）；所有實際螢幕操作仍要求 `controls_enabled`。許可權不足、視窗不可訪問或操作失敗由 agent 返回 `session_error`，Exchange 轉為 `session_failed`。非 Android agent 返回明確的不支援錯誤。

Agent 最多同時處理 4 個 Android 請求，同一時刻只編碼一張截圖。Java 操作等待最多 10 秒（等待螢幕幀最多 5 秒），agent 請求超時為 15 秒，CLI 為 20 秒。運維取消或斷開時 Exchange 下發 `android_cancel`；agent 關閉本地 socket，APK 取消尚未執行的排隊工作。已執行的手勢或文字修改不會回滾。

審計 action 為 `android`，detail 儲存動作及座標；`input_text` 只儲存 `characters`（Unicode 字元數），成功結果只記 `outcome: "completed"`。正文、介面樹和影象不寫入審計。`readonly` 賬號連 `status`、`tree` 和截圖也不能發起。

## 檔案會話擴充套件

支援檔案操作的 Agent 宣告 `files` 能力。`open_file {session_id, device_id, request}` 經 Exchange 翻譯為 `file_req {session_id, request}`；`request.action` 為 `list`、`download` 或 `upload`，欄位定義見 `crates/proto/src/files.rs`。

雙向控制訊息採用 JSON：控制端傳送 `file_input {session_id, input}`，裝置返回 `file_event {session_id, event}`。`input.kind` 為 `chunk {offset, data}`、`ack {offset}` 或 `finish`，`event.kind` 為 `ready {size}`、`chunk {offset, data}`、`progress {received}`、`complete {size}` 或 `list {path, entries, truncated}`。塊內容使用標準 Base64；每塊最多 64 KiB，傳送方等待確認後繼續。

下載的第一條 `ready` 需要 `ack {offset: 0}`，隨後每塊確認累計位元組數。上傳從 `ready` 開始，由每條 `progress` 驅動下一塊，全部傳送後用 `finish` 提交。`complete` 和 `list` 為終結訊息，錯誤使用現有 `session_error` / `session_failed`。`close_session` 下發 `file_cancel`，取消和斷線均移除會話路由。檔案訊息只在檔案會話上轉發，保持操作員會話 ID 與裝置會話 ID 的隔離。

協議版本仍為 1；新增訊息需要配套升級 Exchange 和 Agent。操作限制及 LAN 介面見 [檔案傳輸與區域網直連](/docs/zh-TW/file-transfer-and-lan.md)。

## 多平臺擴充套件

協議版本仍為 1。註冊的 `info` 新增可選 `platform`（`openwrt` / `linux` / `freebsd` / `netbsd` / `windows` / `android` / `macos`）、`os_version`、`capabilities`（`exec` / `pty` / `ubus` / `android_control` / `files` / `sensors` / `net`）。OpenWrt 上報 `openwrt_version`。

心跳 `metrics.unavailable` 是可選字串陣列，指出當前無法採集的 `uptime`、`load`、`memory`、`cpu`、`interfaces`。陣列缺失按舊格式解析；列出的指標所對應數值只是相容佔位，客戶端應顯示缺項。Windows 不提供 Unix 平均負載，Android 的部分 `/proc` 來源可能被系統許可權限制。

執行命令按目標裝置的 shell 解釋：Linux/OpenWrt/macOS 為 `/bin/sh`，Windows 為 PowerShell，Android 為 `/system/bin/sh`。PTY 幀格式與控制訊息在三個系統上相同，Windows 使用 ConPTY。非 OpenWrt 裝置收到 ubus 請求時返回 `session_error`。

## 端到端密封會話

協議版本仍為 1。新增訊息：OperatorUp `open_sealed {session_id, device_id, hello}`、`sealed_input {session_id, packet}`；DeviceDown `sealed_open {session_id, hello}`、`sealed_input`、`sealed_close {session_id, detach}`；DeviceUp / OperatorDown `sealed_accept {session_id, accept}`、`sealed_event {session_id, packet, last}`。新增二進位制幀型別 `4 = Sealed`（載荷 `counter ‖ AEAD(inner_kind ‖ bytes)`），`DeviceInfo.seal_key` 與能力 `sealed`。

Exchange 對密封會話只做編號翻譯、併發限制和路由：不解析、不審計載荷（審計只記公鑰、授權級別與到期、結束結果），不判定許可權（只檢查賬號能否看到該裝置），`last:true` 時釋放路由。密封路由只接受 `Sealed` 幀，普通路由不接受 `Sealed` 幀；agent 丟棄任何發往密封會話編號的明文。授權、握手、金鑰派生、威脅模型見 [端到端密封會話](/docs/zh-TW/sealed-sessions.md)。
