Files
justme fb3a9c260f fleet: send log counters in heartbeats and pull the text on demand
Heartbeats carry per-level counts, the most frequent log types (a stable hash of the message) and the newest id, no text. The node keeps its last 2048 lines; moshiro.logs.fetch lets the backend pull them. The log page pulls when it is open, and the backend pulls warnings and errors once a minute after a node reports an error.
2026-10-22 05:28:29 +08:00

12 KiB
Raw Permalink Blame History

线协议

MoShiro 的两条控制链路都建立在 Fleet 之上:

  • Agent ↔ Backend:Fleet 连接服务端(wss),见 Fleet 文档;
  • 拨号方 ↔ Agent:Fleet 节点间点对点会话(TLS),见 Fleet 文档 的「点对点会话」一节。

认证、心跳、重连、能力协商、可靠发件箱、期望状态同步、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
上报原始块 发件箱,二进制通道 16 RawChunk(自描述,见 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、log_summary(自上次心跳以来各级别日志的条数、出现最多的几个类型 type(消息文本的稳定哈希)和最新一条的编号,不带文本);拨号方:只有 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 第 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.logs.fetch B→A 请求 after_id、limit(≤ 200)、min_level → items[](id、type、at_utc_ms、level、message、fields)、last_id:按需拉取节点本地保留的日志(最近 2048 条),最旧的在前;last_id 比 after_id 小说明节点重启过。管理端打开日志页时拉,节点报告了错误时 Backend 自动拉一次警告以上的
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

心跳的附加字段里有 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 包

偏移  长度  字段
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 文档。
  • moshiro.* 方法:新增方法或可选字段不需要升级版本,接收方忽略未知字段。
  • RawChunk:块头自描述,新增列或新增 Kind 不需要升级 container_version;只有容器格式变化时才升级。
  • QUIC Datagram 和 TCP 数据连接首帧的布局变化时,必须升级 protocol_version,并更新黄金字节测试。
  • Agent 同时支持当前版本和上一个版本的 拨号方;Backend 能解码保留期内所有的 container_version。