线路协议
本文是 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 丢弃任何发往密封会话编号的明文。授权、握手、密钥派生、威胁模型见 端到端密封会话。