Align architecture, protocol, API, storage, security and deployment documentation with default mutual measurements and the Agent link board.
119 lines
12 KiB
Markdown
119 lines
12 KiB
Markdown
# 线协议
|
||
|
||
MoShiro 的两条控制链路都建立在 Fleet 之上:
|
||
|
||
- Agent ↔ Backend:Fleet 连接服务端(wss),见 [Fleet 文档](../../backend/fleet/README.md);
|
||
- 拨号方 ↔ Agent:Fleet 节点间点对点会话(TLS),见 [Fleet 文档](../../backend/fleet/README.md) 的「点对点会话」一节。
|
||
|
||
认证、心跳、重连、能力协商、可靠发件箱、期望状态同步、JSON-RPC 信封都由 Fleet 负责。本文只定义 MoShiro 自己的内容:`moshiro.*` 方法、状态文档、二进制通道,以及 Fleet 之外的数据面(QUIC Datagram 和 TCP 数据连接)。所有类型都放在 `MoShiro.Protocol` 中。
|
||
|
||
## 1. Fleet 能力的映射
|
||
|
||
| MoShiro 需要的能力 | 使用的 Fleet 机制 | MoShiro 提供的内容 |
|
||
| --- | --- | --- |
|
||
| 下发任务 | 期望状态 `fleet.state.sync` | 文档类型 `TaskSet`,包括 `tasks[]`、`peers`(允许连这个 Agent 的 拨号方 公钥,已按线路开关过滤) |
|
||
| 下发 Agent 列表给 拨号方 | 期望状态 `fleet.state.sync`(拨号方 直连面板) | 文档类型 `dial_targets`:`version`、`targets[]`(`agent_id`、`host`、`tcp_port`、`quic_ports`、`public_key`),`public_key` 是 Agent 面向 拨号方 的 TLS 证书公钥(SPKI,base64):拨号方 用它固定证书并派生敲门密钥;没有上报公钥的 Agent 不会下发。已按线路开关过滤;Agent 集合或线路开关变化时重新推送 |
|
||
| 定时重启 | 通知 `moshiro.schedule`(B→A、B→P) | `{restart_at}`,形如 `04:30+08:00`,空表示关闭;连上时补发、管理端改了设置就群发。节点本地写了 `restart_at` 或 `MOSHIRO_RESTART_AT` 时以本地为准;节点只是到点自己有序退出,由服务管理器拉起 |
|
||
| 更新 | 通知 `moshiro.update.offer` / `moshiro.update.apply`(B→A、B→P),心跳附加字段 `update` | `offer` 带 CI 签名的发布清单原文和签名,节点只写进数据目录;`apply` 请求更新;`update` 是宿主机更新器的状态(`state`、`current`、`available`、`error`、`at`),见 [deployment.md](../ops/deployment.md) |
|
||
| 上报原始块 | 发件箱,二进制通道 16 | `RawChunk`(自描述,见 [measurement.md](measurement.md) 第 4 节),`msg_id = chunk_id` |
|
||
| 拨号方 交付列块 | 点对点会话的发件箱,二进制通道 17 | 列块:`u32 stream_id \| u32 first_seq \| u32 count \| 列数据(Brotli)` |
|
||
| 节点自身状态 | `fleet.heartbeat` 的附加字段 | Agent:`sessions`、`dialers[]`、`pps_in/out`、`dropped_invalid`、`dropped_rate_limited`、`queue_bytes`、`update`;拨号方:只有 `update` |
|
||
| 版本与能力 | `fleet.hello` | capabilities 包括 `moshiro.kinds:<列表>`、`moshiro.blocks:<列表>`;Agent 附加 `platform`(系统、架构、版本、tag、提交、构建时间)、`tls_pubkey`(面向 拨号方 的证书公钥,面板自己算指纹)、`addresses`(本机公网地址)、`listen`、`local_limits` 摘要;拨号方 附加 `platform` |
|
||
|
||
## 2. `moshiro.*` 方法
|
||
|
||
每个方法的发起方、类型(请求或通知)和允许的角色声明在代码里的 `MoShiroMethodTable`(框架的 `fleet.*` 在 `FleetMethodTable`),路由器据此拒绝没有声明、方向不对或角色不符的消息,回 `fleet_protocol_violation`;下面各表的「方向」列由测试和这张声明表对账。设计见 [security.md](../security/security.md) 第 6 节。
|
||
|
||
### Agent ↔ Backend
|
||
|
||
| 方法 | 方向 | 类型 | 参数与结果 |
|
||
| --- | --- | --- | --- |
|
||
| `moshiro.link.retry` | B→P | 通知 | `agent_id`:拨号方 立即重试到该 Agent 的已配置 IP 路径;若列表尚未同步,拨号方 在列表更新后补发一次 |
|
||
| `moshiro.test.run` | B→A | 请求 | `test_id`、`dialer_id`(在哪个 拨号方 上跑)、`plan`、`phases[]` → `accepted`,或错误 `test_busy`、`dialer_offline` |
|
||
| `moshiro.dialer.disconnect` | B→A | 请求 | `dialer_id` → `accepted`(Agent 是否真的有这个会话);Agent 关闭该 拨号方 的会话 |
|
||
| `moshiro.test.cancel` | B→A | 请求 | `test_id` |
|
||
| `moshiro.test.status` | A→B | 通知 | `test_id`、`status`(`running`、`phase_done`、`succeeded`、`failed`、`aborted`)、`phase?`、`error_code?` |
|
||
| `moshiro.event` | A→B | 通知 | `kind`(`dialer_connected`(`detail` 是 `dialer_id`,`dialer` 里带版本、远端地址和连接时间)、`dialer_disconnected`、`endpoint_changed`、`session_rejected`、`clock_step`、`gap`、`cpu_saturated`(节点进程 CPU 饱和,可能影响测量,由 拨号方 或 Agent 上报))、`at_utc`、`detail` |
|
||
| `moshiro.host` | A、P→B | 通知 | `HostSnapshot`:主机资源占用的汇总数字(CPU、内存、根分区、网卡累计流量与网速、运行时长;没有型号、主机名、IP),每 `host_interval_s` 秒一次;`host_stats` 为 false 时不发,见 [enrollment.md](../security/enrollment.md) |
|
||
|
||
心跳的附加字段里有 `dialers[]`:Agent 当前连着的每个 拨号方(`id`、`version`、`remote`、`since_utc_ms`),每次都是完整快照,Backend 靠它纠正错过的连接事件。点对点握手的认证消息里可以带 拨号方 的 `version`(只用于展示)。块头里的 `dialer_id` 以 Agent 认证过的会话为准,拨号方 自报的值会被覆盖。
|
||
|
||
### 拨号方 ↔ Agent(点对点会话)
|
||
|
||
| 方法 | 方向 | 类型 | 参数与结果 |
|
||
| --- | --- | --- | --- |
|
||
| `fleet.state.sync`(文档类型 `line_task_set`) | A→P | 请求 | 这条线路的任务(`id`、`kind`、`params`、`stream_id`、`port`)和 Agent 的数据面端口 |
|
||
| `moshiro.clock` | P→A | 请求 | `dialer_mono_us` → `listener_mono_us`;拨号方 按往返中点估计两端单调时钟的偏移(取最小往返的一次),记入块头 `dialer_offset_us` |
|
||
| `moshiro.test.phase` | A→P | 请求 | 测试的一个阶段:`test_id`、`phase`、`kind`、`params`、`stream_id`、`duration_s`、`port` → `accepted`;拨号方 在回复之前准备好自己一侧的角色(单向数据流:先发几个探活包让 Agent 学到源地址) |
|
||
| `moshiro.test.phase_end` | A→P | 请求 | `sent_count`(Agent 发出的包数)→ `fragments`:拨号方 收尾,把列块交给 Agent,回复交出的个数;Agent 等全部列块合并上传之后才进入下一阶段 |
|
||
| `moshiro.test.abort` | A→P | 通知 | `test_id` |
|
||
| `fleet.outbox.ack` | 双向 | 通知 | 拨号方 的列块(通道 17)是至少一次投递,Agent 每收到一个就确认,重复到达的只处理一次 |
|
||
|
||
会话密钥不需要单独的方法:点对点握手(TLS 固定证书 → `fleet.peer.challenge` → 拨号方 用身份私钥签 `(peer_id, nonce, client_pub, server_pub)` → 两端各自 ECDH + HKDF)直接得到 `session_id` 和 `session_key`,`peer.auth` 的应答只回显 `session_id` 用来互相确认。
|
||
|
||
### 列块(拨号方 → Agent,通道 17)
|
||
|
||
拨号方 只记录自己一侧的列,Agent 在数据面记录另一侧,由 Agent 合并、盖章、上传(见 `measurement.md` 第 5 节)。合并由 Kind 的流声明驱动(`StreamSpec`:`Driver`、`Key`,每一列的归属由它的时钟决定),没有按 Kind 手写的模式:
|
||
|
||
| 流声明 | 例子 | 拨号方 的列块 | Agent 补充 |
|
||
| --- | --- | --- | --- |
|
||
| `Driver = 拨号方`,有 `Key`,Agent 一侧有列 | `udp_echo`、`tcp_echo` 的 `msg` | `seq`、`size`、`t1`、`t4` | 按 `seq` 补上 `t2`、`t3`(没有记录表示上行丢失) |
|
||
| `Driver = Agent`,有 `Key` | `udp_train` 的 `pkt` | `pkt(seq, t_recv)`,头部 `_seq_from` / `_seq_to` | 行由 Agent 发出的包决定(`size`、`t_send`),按 `seq` 取 `t_recv`(缺失表示丢失) |
|
||
| 没有 `Key`,或只有主导方一侧有列 | `connect`、`hold`、`step` | 完整的行 | —(按声明的列顺序原样透传) |
|
||
|
||
单块最多 `MaxRecordsPerChunk`(50 000)条记录:拨号方 必须把大的阶段按序号范围切成多个列块(例如 100 Mbit/s、1000 B 的 10 秒阶段是 125 000 个包 → 4 个列块),`phase_end` 的应答报告总块数。块头里 拨号方 必须带上 `agent_id`(它知道自己在和哪个 Agent 通信)。
|
||
|
||
新增方法在 `MoShiro.Protocol/Rpc` 中定义,并在节点组装处加一行 `.On(...)`。旧节点通过 capabilities 协商避开不支持的方法。
|
||
|
||
## 3. 数据面(不属于 Fleet)
|
||
|
||
### 3.1 UDP 包
|
||
|
||
```text
|
||
偏移 长度 字段
|
||
0 2 magic 0x4D4E ("MN")
|
||
2 1 version 1
|
||
3 1 type 1 = EchoRequest,2 = EchoReply,3 = Data(单向)
|
||
4 8 session_id
|
||
12 4 stream_id 标识任务或测试阶段
|
||
16 4 seq 在同一 stream 内单调递增
|
||
20 8 send_ts_us 发送方单调时钟(EchoReply 原样带回请求中的值)
|
||
28 N padding 补足到 Payload 积木决定的包大小
|
||
28+N 16 mac HMAC-SHA256(session_key, 前面全部字节),截断至 16 字节
|
||
```
|
||
|
||
接收方处理规则(任一步失败即静默丢弃并计数,不回复):
|
||
|
||
1. 长度、magic、version、type 合法;
|
||
2. `session_id` 和 `stream_id` 都处于活跃状态;
|
||
3. MAC 校验通过;
|
||
4. `seq` 不在重放窗口中;
|
||
5. 速率不超过声明速率的 1.2 倍,也不超过本地硬上限。
|
||
|
||
通过后先记录原始列。收到 `EchoRequest` 时,立即回复一个长度相同的包,发往请求的实际源地址(这样可以跟随 NAT 映射变化),并记录回复时刻。
|
||
|
||
### 3.2 TCP/TLS 数据连接
|
||
|
||
- 连接都由 拨号方 发起;首帧为 `session_id | stream_id | nonce | mac`,认证失败直接关闭连接。
|
||
- 应用层消息使用 `VarInt 长度 | 正文` 分帧;回显类消息的正文为 `seq | send_ts_us | padding`。
|
||
|
||
## 4. 版本演进
|
||
|
||
### IPv4 / IPv6 路径
|
||
|
||
- 一个 Agent 节点 可以同时建立 IPv4、IPv6 两个会话,共用身份公钥,各自使用独立的会话密钥、流、时钟估计与发件箱。重连只替换同地址族的会话。
|
||
- `dial_targets.targets[]` 保留 `host`(IPv4 地址或 A 记录域名),新增可选 `ipv6_host`(IPv6 地址或 AAAA 记录域名)。未配置时不建立 IPv6 会话;拨号方 按地址族解析,不跨地址族回退。
|
||
- Agent 在 hello 的元数据里上报 `ipv6_supported`,只在本机监听 IPv6 时为 true。Backend 只向支持它的 Agent 安排 IPv6 路径,旧 Agent 缺省为 false。
|
||
- Agent 会在 hello 与心跳中上报 IPv6 套接字、监听、全局地址、默认路由及网络命名空间状态。Podman/Docker 宿主网络部署里,采样反映宿主接口;桥接网络容器会分别报告容器网络和能读取到的宿主 IPv6。诊断只有布尔值和粗粒度状态,不包含地址和接口名称。
|
||
- `Connected拨号方`、`TestRunParams` 和 RawChunk 块头新增 `ip_version`,取值 `ipv4` / `ipv6`,缺省 `ipv4`。Agent 合并列块时以认证过的控制会话地址族为准,覆盖 拨号方 上报的值。
|
||
- IPv4 的线路 id `listener~dialer`、序列 `line:dialer` 保持不变;IPv6 使用 `agent~dialer~ipv6`、`line:dialer:ipv6`。指标、重算、SSE 和测试结果按路径隔离。
|
||
- 双栈是 JSON 字段扩展,二进制包、列编码和容器布局不变,`protocol_version` 与 `container_version` 不升级。双栈采集需要升级 Agent,旧节点继续提供 IPv4。
|
||
|
||
### 通用规则
|
||
|
||
- Fleet 层的兼容规则见 [Fleet 文档](../../backend/fleet/README.md)。
|
||
- `moshiro.*` 方法:新增方法或可选字段不需要升级版本,接收方忽略未知字段。
|
||
- RawChunk:块头自描述,新增列或新增 Kind 不需要升级 `container_version`;只有容器格式变化时才升级。
|
||
- QUIC Datagram 和 TCP 数据连接首帧的布局变化时,必须升级 `protocol_version`,并更新黄金字节测试。
|
||
- Agent 同时支持当前版本和上一个版本的 拨号方;Backend 能解码保留期内所有的 `container_version`。
|