# 线路协议

> HTML: https://remote.ivjn.us/docs/zh-CN/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-CN/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-CN/sealed-sessions.md)。
