線路協議
本文是 agent、exchange、cli 三端之間線路格式的完整規格。定義位於 crates/proto,三端共享同一份程式碼——任何欄位增刪只有一處定義,不一致會在編譯期而非某次線上會話中暴露。
面向兩類讀者:需要抓包排查問題的運維人員,以及要改協議或寫第三方客戶端的開發者。
目錄
總覽
協議分兩層,複用同一條 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://:兩種憑證都以明文出現在握手階段。
協議版本
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: u16device_id: stringtoken: stringinfo: DeviceInfo |
連線後的第一條訊息,必須是它。服務端在收到之前不接受任何其它訊息,且限時 15 秒 |
heartbeat |
metrics: Metrics |
週期性業務指標上報。鏈路存活性由 WebSocket 原生 ping/pong 負責,與本訊息職責分開——心跳丟失不代表鏈路斷了,反之亦然 |
exec_started |
session_idjob_idholdtimeout_secs |
命令已啟動;job_id 用於 hold 列表與重連附加 |
exec_exit |
session_idcode: i32 | nulltimed_out: bool |
命令執行結束。code 在程序被訊號終止時為 null;timed_out 表示因超時被強制終止 |
exec_list_resp |
session_idjobs: ExecHoldInfo[] |
hold 作業列表 |
ubus_resp |
session_idcode: i32data: any |
ubus 呼叫返回。code 是 ubus 自身的返回碼,0 為成功 |
android_resp |
session_idresult: AndroidResult |
Android 螢幕操作完成;此訊息結束會話 |
pty_closed |
session_idcode: i32 | null |
PTY 會話結束,code 是 shell 的退出碼 |
session_error |
session_idmessage: string |
會話級錯誤。裝置無法完成某個請求時回這條,而不是靜默丟棄 |
服務端收到已註冊連線上的第二條 register 會視為協議誤用並記錄告警。
示例:
{"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: boolerror: string | nullheartbeat_interval_secs: u64 |
註冊結果。ok: false 時 error 給出原因,隨後服務端關閉連線。心跳間隔由服務端指定,便於裝置規模變大後統一調稀而無需逐臺改配置。agent 會把 0 夾到至少 1 秒,避免定時器空轉打滿 CPU |
device_access |
managers: DeviceManager[]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_idcommand: stringtimeout_secs: u64hold: bool(預設 true)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_idjob_id |
把 hold 作業重新附加到本會話並回放緩衝輸出 |
exec_hold_kill |
session_idjob_id |
按 job_id 終止 hold 作業 |
exec_hold_tail |
session_idjob_id |
只跟隨新輸出(不回放緩衝) |
exec_hold_cat |
session_idjob_id |
轉儲當前緩衝後以 exec_hold_cat_done 結束 |
ubus_req |
session_idobject: stringmethod: stringparams: any |
發起一次 ubus 呼叫。params 為 null 時不向 ubus 傳第三個位置引數 |
pty_open |
session_idrows: u16cols: u16 |
開啟 PTY 會話。行列為 0 時兜底成 24×80 |
pty_resize |
session_idrows: u16cols: u16 |
終端視窗尺寸變化 |
pty_close |
session_id |
關閉 PTY 會話 |
android_req |
session_idrequest: AndroidAction |
單次 Android 螢幕操作 |
android_cancel |
session_id |
取消尚未完成的 Android 請求 |
OperatorUp(cli → exchange)
鑑權在 WebSocket 握手階段完成,因此沒有登入類訊息。
type |
欄位 | 用途 |
|---|---|---|
open_exec |
session_iddevice_idcommandtimeout_secshold(預設 true)hold_timeout_secs? |
在目標裝置上執行命令。hold 預設開啟;hold=true 且超時 0 → 3600s。hold=false 且超時 0 → Exchange 預設 60s |
list_exec_holds |
session_iddevice_id |
查詢 exec 作業和進行中的下載、上傳、目錄查詢 |
list_device_sessions |
session_iddevice_id |
直接查詢 Exchange 路由表,不佔裝置會話槽位 |
kill_device_session |
session_iddevice_idtarget_session_id |
按裝置側 target_session_id 取消並本地釋放路由;包括 hold exec,不佔額外槽位 |
attach_exec |
session_iddevice_idjob_id |
附加到 hold 作業並繼續收輸出 |
exec_hold_kill / exec_hold_tail / exec_hold_cat |
session_iddevice_idjob_id |
hold 作業 kill / tail / cat |
detach_session |
session_id |
結束前臺等待並 detach(不殺 hold 程序) |
open_ubus |
session_iddevice_id: stringobject: stringmethod: stringparams: any |
發起一次 ubus 呼叫 |
open_pty |
session_iddevice_id: stringrows: u16cols: u16 |
開啟互動式 PTY 會話 |
open_android |
session_iddevice_id: stringrequest: AndroidAction |
發起 Android 螢幕操作;只讀賬號不允許 |
pty_resize |
session_idrows: u16cols: u16 |
終端視窗尺寸變化。不帶 device_id——會話已經建立,服務端能查到目標裝置 |
close_session |
session_id |
主動結束會話:PTY 正常退出,或使用者中斷命令執行 |
四條 open_* 訊息裡的 session_id 由 CLI 自己分配(見會話編號的雙空間翻譯),因此發出請求後可以立刻發後續幀,不必等編號回執。
OperatorDown(exchange → cli)
type |
欄位 | 用途 |
|---|---|---|
welcome |
protocol_version: u16server_version: string |
連線建立後服務端主動傳送,用於確認協議版本一致 |
device_sessions |
session_idsessions: DeviceSessionInfo[]limitcount |
Exchange 當前裝置側會話列表 |
device_session_killed |
session_idtarget_session_idreleased: bool |
已本地釋放;不存在時為 false |
exec_exit |
session_idcode: i32 | nulltimed_out: bool |
命令結束,由裝置側同名訊息翻譯而來 |
ubus_resp |
session_idcode: i32data: any |
ubus 呼叫返回 |
android_resp |
session_idresult: AndroidResult |
Android 返回值,已翻譯為運維側會話編號 |
pty_closed |
session_idcode: i32 | null |
PTY 會話結束 |
session_failed |
session_idreason: SessionFailuremessage: 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 }
二進位制資料幀
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 在中間做翻譯。同一個邏輯會話在兩側有兩個不同的編號。
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 與自己一致時才真的移除。少了這道校驗,剛上線的裝置會被舊連線的清理邏輯立刻標記為離線。
握手時序
裝置側
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 一建立就復位。否則遇到「連上即被拒」的情況會退化成毫秒級的密集重試。
抖動不是錦上添花。 服務端重啟時成百上千臺裝置會在同一瞬間檢測到斷連,若都按同一條確定性曲線重連,它們會保持同步、成批次衝擊剛起來的服務端,把一次重啟放大成持續的雪崩。
運維側
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 位元組。沒有新增二進位制幀型別。
{"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 介面見 檔案傳輸與區域網直連。
多平臺擴充套件
協議版本仍為 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 丟棄任何發往密封會話編號的明文。授權、握手、金鑰派生、威脅模型見 端到端密封會話。