remote.文件首頁

線路協議

本文是 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: u16
device_id: string
token: string
info: DeviceInfo
連線後的第一條訊息,必須是它。服務端在收到之前不接受任何其它訊息,且限時 15 秒
heartbeat metrics: Metrics 週期性業務指標上報。鏈路存活性由 WebSocket 原生 ping/pong 負責,與本訊息職責分開——心跳丟失不代表鏈路斷了,反之亦然
exec_started session_id
job_id
hold
timeout_secs
命令已啟動;job_id 用於 hold 列表與重連附加
exec_exit session_id
code: i32 | null
timed_out: bool
命令執行結束。code 在程序被訊號終止時為 null;timed_out 表示因超時被強制終止
exec_list_resp session_id
jobs: ExecHoldInfo[]
hold 作業列表
ubus_resp session_id
code: i32
data: any
ubus 呼叫返回。code 是 ubus 自身的返回碼,0 為成功
android_resp session_id
result: AndroidResult
Android 螢幕操作完成;此訊息結束會話
pty_closed session_id
code: i32 | null
PTY 會話結束,code 是 shell 的退出碼
session_error session_id
message: 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: bool
error: string | null
heartbeat_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_id
command: string
timeout_secs: u64
hold: 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_id
job_id
把 hold 作業重新附加到本會話並回放緩衝輸出
exec_hold_kill session_id
job_id
按 job_id 終止 hold 作業
exec_hold_tail session_id
job_id
只跟隨新輸出(不回放緩衝)
exec_hold_cat session_id
job_id
轉儲當前緩衝後以 exec_hold_cat_done 結束
ubus_req session_id
object: string
method: string
params: any
發起一次 ubus 呼叫。params 為 null 時不向 ubus 傳第三個位置引數
pty_open session_id
rows: u16
cols: u16
開啟 PTY 會話。行列為 0 時兜底成 24×80
pty_resize session_id
rows: u16
cols: u16
終端視窗尺寸變化
pty_close session_id 關閉 PTY 會話
android_req session_id
request: AndroidAction
單次 Android 螢幕操作
android_cancel session_id 取消尚未完成的 Android 請求

OperatorUp(cli → exchange)

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

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

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

OperatorDown(exchange → cli)

type 欄位 用途
welcome protocol_version: u16
server_version: string
連線建立後服務端主動傳送,用於確認協議版本一致
device_sessions session_id
sessions: DeviceSessionInfo[]
limit
count
Exchange 當前裝置側會話列表
device_session_killed session_id
target_session_id
released: bool
已本地釋放;不存在時為 false
exec_exit session_id
code: i32 | null
timed_out: bool
命令結束,由裝置側同名訊息翻譯而來
ubus_resp session_id
code: i32
data: any
ubus 呼叫返回
android_resp session_id
result: AndroidResult
Android 返回值,已翻譯為運維側會話編號
pty_closed session_id
code: i32 | null
PTY 會話結束
session_failed session_id
reason: SessionFailure
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 }


二進位制資料幀

 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 丟棄任何發往密封會話編號的明文。授權、握手、金鑰派生、威脅模型見 端到端密封會話。