docs: describe the unified Agent topology and link controls

Align architecture, protocol, API, storage, security and deployment documentation with default mutual measurements and the Agent link board.
This commit is contained in:
justme committed 2026-10-21 11:55:55 +08:00
1 parent 15f0023614
commit c9beeee8e3
16 files changed
+363 -383

No files matched your search

+1 -1
View File
@@ -103,7 +103,7 @@ public sealed class McModel : IServiceModel
| `base` | `rtt.*`、`loss.up/down`、`jitter.up/down`、`loss.run_max` | 线路基础质量 |
| `mc` | `tcp_echo` 的 `rtt.p95/p99`、`tcp.stall.per_min`、`tcp.stall_max`、`tcp.retrans` | TCP 上不会丢包,丢包表现为重传和卡顿(队头阻塞)。没有 `tcp_echo` 数据时,用 `udp_echo` 的丢包序列代入 TCP 恢复模型估算,并标记为 `estimated` |
| `hy2` | 测试中 `udp_train` 各档的 `loss`、`jitter`,以及变点检测得到的 `qos_onset`;持续的 `udp_echo` 作为趋势参考 | Brutal 固定码率:计算可持续码率(丢包 ≤ 5% 或 ≤ 10%)、有效吞吐、限速开始的时刻 |
| `reality` | `connect` 的成功率和阶段耗时、`hold` 的重置次数和存活时长、`tcp_bulk` 的吞吐(`tcp.bulk.peak.max`:最好的持续 1 秒窗口,评分用它;`tcp.bulk.mbps.*`:每个采样间隔)和重传。`hy2_ladder` 的阶梯里一步是一个阶段:Probe 会把一个阶段切成几块(一块最多 50000 条记录,100 Mbit/s 的 10 秒要切 3~4 块),评估时同一阶段的块先合并,再算丢包、抖动和实际吞吐;实际吞吐的时长取发送时间戳(Agent 时钟,按每块的时钟锚点换算成 UTC)的跨度,不用块头里的时长(Probe 切块时块头带的时长可能是整个阶段的)、`tcp_echo` 的 RTT | 握手、稳定性、首字节时间估算、吞吐 |
| `reality` | `connect` 的成功率和阶段耗时、`hold` 的重置次数和存活时长、`tcp_bulk` 的吞吐(`tcp.bulk.peak.max`:最好的持续 1 秒窗口,评分用它;`tcp.bulk.mbps.*`:每个采样间隔)和重传。`hy2_ladder` 的阶梯里一步是一个阶段:拨号方 会把一个阶段切成几块(一块最多 50000 条记录,100 Mbit/s 的 10 秒要切 3~4 块),评估时同一阶段的块先合并,再算丢包、抖动和实际吞吐;实际吞吐的时长取发送时间戳(Agent 时钟,按每块的时钟锚点换算成 UTC)的跨度,不用块头里的时长(拨号方 切块时块头带的时长可能是整个阶段的)、`tcp_echo` 的 RTT | 握手、稳定性、首字节时间估算、吞吐 |
## 6. 两种处理路径
+68 -47
View File
@@ -2,40 +2,33 @@
## 1. 目标与定位
只关注网络层的线路质量面板:单体面板(Backend)、轻量节点(Probe、Agent)、长连接 RPC 与任务下发。
只关注网络层的线路质量面板:单体面板(Backend)、轻量节点(Agent)、长连接 RPC 与任务下发。
- 采集:多个 Probe(家宽等需要观测的出口)与多台海外 VPS Agent 之间记录逐包、逐事件的原始数据。哪些 Probe 连哪些 Agent 由面板决定,默认全连,也可以手动选择。
- 采集:多个 拨号方(家宽等需要观测的出口)与多台海外 VPS Agent 之间记录逐包、逐事件的原始数据。哪些 拨号方 连哪些 Agent 由面板决定,默认全连,也可以手动选择。
- 分析:Backend 的分析层推算在某条线路上部署 REALITY 类、Hysteria2 类或 Minecraft 服务的体验,并对多条线路排序和对比。
- 主机状态:Agent 和 Probe 读取 `/proc`,上报 CPU 占用、内存、根分区、网卡累计流量与瞬时网速、运行时长这类汇总数字。不采集 CPU 型号、主机名、网卡名、IP 和进程。Probe 的上报可在 `probe.json` 中用 `reporting.host_stats: false` 关闭。展示页以卡片呈现每个节点的主机状态,线路在卡片下方的面板中。
- 主机状态:Agent 读取 `/proc`,上报 CPU 占用、内存、根分区、网卡累计流量与瞬时网速、运行时长这类汇总数字。不采集 CPU 型号、主机名、网卡名、IP 和进程。拨号方 的上报可在 `agent.json` 中用 `reporting.host_stats: false` 关闭。展示页以卡片呈现每个节点的主机状态,线路在卡片下方的面板中。
- 构建信息:节点连接面板时自报系统、架构和构建信息(版本、tag、提交、构建时间),用于展示和更新。国家和运营商由 Backend 依据节点来源地址查询,节点不访问任何外部服务,展示页不含任何 IP。
- 节点能力:当前 Agent 和 Probe 只包含测量与上报,不提供 SSH、远程执行、终端和文件读写;更新由节点宿主机上的更新器或服务管理器完成,节点进程只把面板转来的发布清单和更新请求写入数据目录。后续能力的规划见 [security.md](../security/security.md) 第 9 节。
- 节点能力:当前 Agent 只包含测量与上报,不提供 SSH、远程执行、终端和文件读写;更新由节点宿主机上的更新器或服务管理器完成,节点进程只把面板转来的发布清单和更新请求写入数据目录。后续能力的规划见 [security.md](../security/security.md) 第 9 节。
## 2. 拓扑
```text
操作员 passkey ──签名「操作员集合」──┐ CI 发布私钥 ──签名「镜像 digest 清单」──▶ 镜像仓库(清单 + 镜像)
▼ ▲
Web(展示端 · 管理端) │ 定时取清单
▲ REST + SSE │
┌──────────────────────────────────────────────────────────────┐
│ Backend(面板) │──────────────────────────────┘
│ Api · Auth(会话 · passkey · API 令牌 · 审计) │
│ Trust(操作员集合 · 服务器身份)· Registry · Planner · Links │
│ Releases(发布清单 · 可信发布密钥) │
│ Ingest · Analysis · LiveHub │
│ Store(段文件 + SQLite) │
└──────────────────────────────────────────────────────────────┘
▲ ▲
│ wss 长连接(节点发起,应用层加密) │ wss 长连接(节点发起,应用层加密)
│ 任务、数据、状态、更新通知 │ 身份、Agent 列表、状态、更新通知
┌────────────────────┐ ┌──────────────────────┐
│ Agent × 10+ │ ◀── 测量会话 ── │ Probe × N │
│ 有公网 IP,监听 │ 先敲门再 TLS │ 家宽等出口,NAT 后 │
└────────────────────┘ └──────────────────────┘
测量会话:TLS 控制通道 + QUIC Datagram/TCP 数据通道,由 Probe 主动拨出(Agent 固定 QUIC 端口,Probe 本地源端口轮换)
更新(Agent、Probe、Backend):面板登记 CI 签名的清单 → 管理端确认 → 节点(Backend 先备份数据库)有序退出 → systemd Pull=always 拉最新镜像重启
Web
|
REST / SSE
|
Backend
^ ^
| wss | 注册、任务、拨号目标、状态、数据
| |
Agent A <=====> Agent B
直接测量
```
所有节点都是 Agent,默认同时监听和拨号。Backend 为每个节点下发拨号目标以及允许接入的公钥名册,自动连接开启时,所有 Agent 拨其他可拨号 Agent;A→B 和 B→A 分别编排、存储和展示。单方向的手动设置优先于自动连接开关。可在本地关闭监听,但不产生另一种节点角色。
Agent → Backend 的心跳包含运行状态、拨出线路的控制层与任务层状态、失败原因、重试次数与等待时间,以及当前接入会话。管理端的 Agent 详情展示拨出与接入连接,线路画板汇总所有方向并提供开关和立即重试。Backend 不转发节点之间的测量流量。
各组件的代码位置:
| 组件 | 代码 |
@@ -44,20 +37,20 @@
| Api(REST、SSE、OpenAPI) | [`MoShiro.Backend.Api`](../../backend/src/MoShiro.Backend.Api),实时推送 [`LiveEndpoints.cs`](../../backend/src/MoShiro.Backend.Api/Public/LiveEndpoints.cs)
| Auth(会话、passkey、API 令牌、审计) | [`Application/Auth`](../../backend/src/MoShiro.Backend.Application/Auth)、[`Application/Audit`](../../backend/src/MoShiro.Backend.Application/Audit) |
| Trust(操作员集合、服务器身份) | [`TrustService.cs`](../../backend/src/MoShiro.Backend.Application/Auth/TrustService.cs)、[`ServerIdentityService.cs`](../../backend/src/MoShiro.Backend.Application/Auth/ServerIdentityService.cs);节点侧验证 [`OperatorTrust.cs`](../../backend/fleet/src/Fleet.Identity/OperatorTrust.cs)、[`TrustAnchor.cs`](../../backend/fleet/src/Fleet.Hosting/TrustAnchor.cs)
| Registry(Agent、Probe 注册与状态) | [`AgentService.cs`](../../backend/src/MoShiro.Backend.Application/Agents/AgentService.cs)、[`ProbeService.cs`](../../backend/src/MoShiro.Backend.Application/Agents/ProbeService.cs)、[`MoShiroFleetHooks.cs`](../../backend/src/MoShiro.Backend.Application/Agents/MoShiroFleetHooks.cs)
| Registry(Agent 注册与状态) | [`AgentService.cs`](../../backend/src/MoShiro.Backend.Application/Agents/AgentService.cs)、[`AgentService.cs`](../../backend/src/MoShiro.Backend.Application/Agents/AgentService.cs)、[`MoShiroFleetHooks.cs`](../../backend/src/MoShiro.Backend.Application/Agents/MoShiroFleetHooks.cs)
| Planner(有效任务集) | [`TaskPlanner.cs`](../../backend/src/MoShiro.Backend.Application/Tasks/TaskPlanner.cs)、[`EffectiveTasks.cs`](../../backend/src/MoShiro.Backend.Application/Tasks/EffectiveTasks.cs)
| Links(哪些 Probe 连哪些 Agent) | [`LinkService.cs`](../../backend/src/MoShiro.Backend.Application/Agents/LinkService.cs)
| Links(哪些 拨号方 连哪些 Agent) | [`LinkService.cs`](../../backend/src/MoShiro.Backend.Application/Agents/LinkService.cs)
| Releases(发布清单、可信发布密钥) | [`Application/Updates`](../../backend/src/MoShiro.Backend.Application/Updates),轮询 [`ReleasePoller.cs`](../../backend/src/MoShiro.Backend.Application/Updates/ReleasePoller.cs)
| Gateway(Fleet 网关、状态提供者、收件箱) | [`MoShiro.Backend.Gateway`](../../backend/src/MoShiro.Backend.Gateway),入库 [`RawChunkInbox.cs`](../../backend/src/MoShiro.Backend.Gateway/RawChunkInbox.cs)、[`IngestService.cs`](../../backend/src/MoShiro.Backend.Application/Ingest/IngestService.cs)
| Analysis(算子、指标、模型) | [`MoShiro.Backend.Analysis`](../../backend/src/MoShiro.Backend.Analysis),调度 [`AnalysisScheduler.cs`](../../backend/src/MoShiro.Backend.Application/Analysis/AnalysisScheduler.cs)
| LiveHub(热层与 SSE 主题) | [`LiveHub.cs`](../../backend/src/MoShiro.Backend.Application/Live/LiveHub.cs)
| Store(段文件 + SQLite) | [`MoShiro.Backend.Storage.Sqlite`](../../backend/src/MoShiro.Backend.Storage.Sqlite),原始块 [`SqliteRawStore.cs`](../../backend/src/MoShiro.Backend.Storage.Sqlite/SqliteRawStore.cs)
| Agent | [`AgentApp.cs`](../../backend/agent/src/MoShiro.Agent/AgentApp.cs)、任务 [`TaskManager.cs`](../../backend/agent/src/MoShiro.Agent/Tasks/TaskManager.cs)、Probe 会话 [`PeerHub.cs`](../../backend/agent/src/MoShiro.Agent/Peer/PeerHub.cs)
| Probe | [`ProbeApp.cs`](../../backend/probe/src/MoShiro.Probe/ProbeApp.cs)、Agent 连接 [`Link/AgentConnection.cs`](../../backend/probe/src/MoShiro.Probe/Link/AgentConnection.cs)
| Agent | [`AgentApp.cs`](../../backend/agent/src/MoShiro.Agent/AgentApp.cs)、任务 [`TaskManager.cs`](../../backend/agent/src/MoShiro.Agent/Tasks/TaskManager.cs)、拨号方 会话 [`PeerHub.cs`](../../backend/agent/src/MoShiro.Agent/Peer/PeerHub.cs)
| 拨号方 | [`AgentApp.cs`](../../backend/agent/src/MoShiro.Agent/AgentApp.cs)、Agent 连接 [`Link/DialConnection.cs`](../../backend/agent/src/MoShiro.Agent/Dial/DialConnection.cs)(控制层)、[`Link/TaskLink.cs`](../../backend/agent/src/MoShiro.Agent/Dial/TaskLink.cs)(QUIC 数据通道)
| 采集引擎与协议 | [`MoShiro.Measurement`](../../backend/shared/MoShiro.Measurement)、[`MoShiro.Protocol`](../../backend/shared/MoShiro.Protocol) |
| Fleet 节点框架 | [`backend/fleet`](../../backend/fleet),注册工具 [`fleet-enroll`](../../backend/fleet/tools/fleet-enroll) |
- 线路(line):一对(Probe,Agent)。可以有多个 Probe(身份由 Probe 自己生成并用加入令牌注册,可以吊销、删除),每个 Probe 连接多个 Agent,每个 Agent 同时服务多个 Probe;一条线路的数据按 `line:<probe_id>` 单独存放,互不混合。对外的线路 id 是 `<agent_id>~<probe_id>`。
- 线路(line):一对(拨号方,Agent)。可以有多个 拨号方(身份由 拨号方 自己生成并用加入令牌注册,可以吊销、删除),每个 拨号方 连接多个 Agent,每个 Agent 同时服务多个 拨号方;一条线路的数据按 `line:<dialer_id>` 单独存放,互不混合。对外的线路 id 是 `<agent_id>~<dialer_id>`。
- 目标任务(target):某个 Agent → 指定的 IP:端口(ICMP、TCP/TLS 建连),只能访问 Agent 本地允许的网段。
## 3. 数据链路
@@ -65,7 +58,7 @@
```text
采集(只记录) 传输(有界、至少一次) 分析(注册制、可重算) 展示
┌───────┐ 原始列(10s) ┌────────┐ RawChunk(10s) ┌──────────────────────────────────────────┐
│ Probe │───────────>│ Agent │──outbox(16)──>│ 段文件(30天) → Analysis → 指标窗口(1m) │──> REST
│ 拨号方 │───────────>│ Agent │──outbox(16)──>│ 段文件(30天) → Analysis → 指标窗口(1m) │──> REST
│ 积木 │<══════════>│ 积木 │ wss │ ↓ 流式处理未满窗口 ↓ 降采样 5m/1h │──> SSE
└───────┘ 数据通道 └────────┘ │ LiveHub(内存快照) │
└──────────────────────────────────────────┘
@@ -73,19 +66,19 @@
| 角色 | 职责 | 明确不做 |
| --- | --- | --- |
| Probe | 连接 Backend(身份、Agent 列表、系统与构建信息、更新);连接面板指定的 Agent;执行线路任务中 Probe 一侧的角色;记录本端原始列并交给对应的 Agent | 统计;落盘测量数据;任何主机操作 |
| Agent | 注册;接受 Probe;执行 Agent 一侧的角色和目标任务;合并两侧的列并上传;上报主机资源的汇总数字 | 统计;落盘测量数据;exec / 终端 / 文件操作(当前未提供);更新由宿主机上的更新器完成;采集主机的详细信息 |
| Backend | 注册与身份;任务编排;决定哪些 Probe 连哪些 Agent;持久化原始数据;分析;实时汇聚;API;分发 CI 签名的发布清单 | 主动连接 Probe 或 Agent(都是节点拨进来);验签和安装更新(在节点宿主机上) |
| 拨号方 | 连接 Backend(身份、Agent 列表、系统与构建信息、更新);连接面板指定的 Agent;执行线路任务中 拨号方 一侧的角色;记录本端原始列并交给对应的 Agent | 统计;落盘测量数据;任何主机操作 |
| Agent | 注册;接受 拨号方;执行 Agent 一侧的角色和目标任务;合并两侧的列并上传;上报主机资源的汇总数字 | 统计;落盘测量数据;exec / 终端 / 文件操作(当前未提供);更新由宿主机上的更新器完成;采集主机的详细信息 |
| Backend | 注册与身份;任务编排;决定哪些 拨号方 连哪些 Agent;持久化原始数据;分析;实时汇聚;API;分发 CI 签名的发布清单 | 主动连接 拨号方 或 Agent(都是节点拨进来);验签和安装更新(在节点宿主机上) |
角色对应的代码:Probe 见 [`ProbeApp.cs`](../../backend/probe/src/MoShiro.Probe/ProbeApp.cs);Agent 见 [`AgentApp.cs`](../../backend/agent/src/MoShiro.Agent/AgentApp.cs),上传块由 [`ChunkAssembler.cs`](../../backend/agent/src/MoShiro.Agent/Chunks/ChunkAssembler.cs)合并;Backend 接收见 [`FleetGateway.cs`](../../backend/src/MoShiro.Backend.Gateway/FleetGateway.cs)。
角色对应的代码:拨号方 见 [`AgentApp.cs`](../../backend/agent/src/MoShiro.Agent/AgentApp.cs);Agent 见 [`AgentApp.cs`](../../backend/agent/src/MoShiro.Agent/AgentApp.cs),上传块由 [`ChunkAssembler.cs`](../../backend/agent/src/MoShiro.Agent/Chunks/ChunkAssembler.cs)合并;Backend 接收见 [`FleetGateway.cs`](../../backend/src/MoShiro.Backend.Gateway/FleetGateway.cs)。
## 4. 关键设计(详见各专题文档)
| 主题 | 要点 | 文档 | 代码 |
| --- | --- | --- | --- |
| 节点框架 | 注册、身份、连接、RPC、可靠上报、状态同步、宿主都由与业务无关的 Fleet 提供;MoShiro 只写业务,其他产品也能复用 | [Fleet 文档](../../backend/fleet/README.md) | [`backend/fleet`](../../backend/fleet) |
| 注册与身份 | 使用 `fleet-enroll` 和加入令牌自动注册,之后用签名认证;Probe 用自己的身份直连面板,Agent 列表由面板下发 | [enrollment.md](../security/enrollment.md) | [`NodeIdentity.cs`](../../backend/fleet/src/Fleet.Identity/NodeIdentity.cs)、[`fleet-enroll`](../../backend/fleet/tools/fleet-enroll) |
| 更新 | 只接受发布密钥签名的清单,更新策略由节点本地决定,失败自动回滚。CI 对镜像 digest 签名;Backend 和一行脚本装的节点由宿主机上的 `moshiro-update` 验签、按 digest 拉取、失败回滚;Podman Quadlet 装的 Agent、Probe 更简单:节点有序退出,systemd 用 `--pull always` 重新拉最新镜像,节点自己不下载、不验证清单;纯二进制节点的更新见[路线图](../roadmap.md) | [deployment.md](../ops/deployment.md) | [`moshiro-update.sh`](../../packaging/update/moshiro-update.sh)、[`RestartUpdater.cs`](../../backend/shared/MoShiro.Measurement/Update/RestartUpdater.cs)、[`packaging/quadlet`](../../packaging/quadlet) |
| 注册与身份 | 使用 `fleet-enroll` 和加入令牌自动注册,之后用签名认证;拨号方 用自己的身份直连面板,Agent 列表由面板下发 | [enrollment.md](../security/enrollment.md) | [`NodeIdentity.cs`](../../backend/fleet/src/Fleet.Identity/NodeIdentity.cs)、[`fleet-enroll`](../../backend/fleet/tools/fleet-enroll) |
| 更新 | 只接受发布密钥签名的清单,更新策略由节点本地决定,失败自动回滚。CI 对镜像 digest 签名;Backend 和一行脚本装的节点由宿主机上的 `moshiro-update` 验签、按 digest 拉取、失败回滚;Podman Quadlet 装的 Agent 更简单:节点有序退出,systemd 用 `--pull always` 重新拉最新镜像,节点自己不下载、不验证清单;纯二进制节点的更新见[路线图](../roadmap.md) | [deployment.md](../ops/deployment.md) | [`moshiro-update.sh`](../../packaging/update/moshiro-update.sh)、[`RestartUpdater.cs`](../../backend/shared/MoShiro.Measurement/Update/RestartUpdater.cs)、[`packaging/quadlet`](../../packaging/quadlet) |
| 任务模型 | `Task = {kind, target, params, schedule}`;持续采集、按需测试、目标任务统一建模;下发“期望任务集”,由节点自行调和 | [measurement.md](measurement.md) | [`TaskSet.cs`](../../backend/shared/MoShiro.Protocol/Tasks/TaskSet.cs)、[`TaskPlanner.cs`](../../backend/src/MoShiro.Backend.Application/Tasks/TaskPlanner.cs) |
| 采集扩展 | Kind 由积木组合声明;采集密度靠参数调整;原始块自描述 | [measurement.md](measurement.md) | [`Kinds.cs`](../../backend/shared/MoShiro.Protocol/Catalog/Kinds/Kinds.cs)、[`BlockCatalog.cs`](../../backend/shared/MoShiro.Measurement/Blocks/BlockCatalog.cs) |
| 统计扩展 | 指标注册制:少数几种累加器 + 统计算子;一行 `MetricDef` 就是一个新指标 | [analysis.md](analysis.md) | [`MetricRegistry.cs`](../../backend/src/MoShiro.Backend.Analysis/Metrics/MetricRegistry.cs) |
@@ -98,10 +91,10 @@ Kind 目录编译在二进制中,节点不接受远程下发代码。节点在
### 4.0 节点的更新
发布清单(镜像 digest)由 CI 签名,放进镜像仓库(`<仓库>-manifest` 小镜像的标签里);Backend 定时取、验签后登记(也接受 CI 直接 `POST /api/release/offer`),再原样转发给在线的 Agent / Probe。之后分两条路:
发布清单(镜像 digest)由 CI 签名,放进镜像仓库(`<仓库>-manifest` 小镜像的标签里);Backend 定时取、验签后登记(也接受 CI 直接 `POST /api/release/offer`),再原样转发给在线的 Agent。之后分两条路:
```text
Podman Quadlet(Agent、Probe、Backend,推荐)
Podman Quadlet(Agent、Backend,推荐)
节点比对清单和自己的版本 ──▶ 心跳报「有新版本」──▶ 管理端显示
自动(默认,运行满 10 分钟)或管理员点「更新」──▶ 节点有序退出
──▶ systemd 重启单元(Pull=always)──▶ 新镜像、新容器
@@ -124,14 +117,42 @@ Backend:不自动,只有管理员点「更新并重启」;退出之前先
### 4.2 NAT / CGNAT 适配
所有连接和 UDP 流都由 Probe 发起;Agent 把回复发往请求的实际源地址。会话以 `session_id` 和 MAC 认证,不绑定五元组,NAT 映射变化后自动跟随新地址。持续的 UDP 回显本身就起到保活作用。准入逻辑(重放窗口、令牌桶、源地址学习)见 [`Registries.cs`](../../backend/agent/src/MoShiro.Agent/DataPlane/Registries.cs),数据报路由见 [`MeasurementDatagramRouter.cs`](../../backend/shared/MoShiro.Measurement/Net/MeasurementDatagramRouter.cs)。
所有连接和 UDP 流都由 拨号方 发起;Agent 把回复发往请求的实际源地址。会话以 `session_id` 和 MAC 认证,不绑定五元组,NAT 映射变化后自动跟随新地址。持续的 UDP 回显本身就起到保活作用。准入逻辑(重放窗口、令牌桶、源地址学习)见 [`Registries.cs`](../../backend/agent/src/MoShiro.Agent/DataPlane/Registries.cs),数据报路由见 [`MeasurementDatagramRouter.cs`](../../backend/shared/MoShiro.Measurement/Net/MeasurementDatagramRouter.cs)。
### 4.3 线路连接:控制层与任务层
拨号方 到每个 Agent 的连接分两层,各有一个状态机和独立的重试节奏([`LinkStatus.cs`](../../backend/agent/src/MoShiro.Agent/Dial/LinkStatus.cs))。
```text
控制层 TCP+TLS 长连接:控制 RPC(时钟、任务集、测试阶段、OutboxAck)+ 回传数据(发件箱)
控制层断开,Agent 节点 才算失联。
dialing ─▶ authenticating ─▶ syncing ─▶ up
▲ │ RPC 关闭 / 120 s 无应答 / keepalive 失败
└─────────── backoff ◀────────────────┘
任务层 测量链路,控制层在线期间运行
├─ tcp 各 TCP 类积木(tcp_echo / connect / hold / tcp_bulk)自带套接字,积木内部断线重拨,不依赖 QUIC
└─ quic QUIC datagram(udp_echo / udp_train)
connecting ─▶ ready ─▶ retrying ─▶ connecting … 控制层不在线时为 idle
```
| | 控制层 | 任务层 QUIC |
| --- | --- | --- |
| 断开的含义 | 失联;数据面凭据随控制会话失效,所有积木停止 | 仅 UDP 类积木受影响,控制连接和 TCP 类积木继续 |
| 重试节奏 | 1、2、4、8、16 s,32 s × 5,之后每 60 s | 1、2、3、5 s,之后固定 5 s,带 ±20% 抖动;连接稳定 10 s 后节奏复位 |
| 数据面静默 | 120 s 没有收到任何控制帧即断开重拨 | 发送中 30 s 没有收到任何报文即关闭并重拨 |
| 手动「立即重试」 | 跳过退避 | 跳过重拨等待 |
回传数据(`RawChunk` 列块)进入属于这条连接的发件箱,经控制连接发送;控制层重连后重发未确认的块(Backend 按 `chunk_id` 去重)。会话结束前,积木先把已稳定的行交给发件箱。
状态随心跳上报(`LinkHealth`:`control`、`task.tcp`、`task.quic`),失败原因带阶段和分类(`phase`、`kind`:`timeout`、`refused`、`reset`、`dns`、`tls`、`auth`、`closed`、`silent`)。Backend 据此给出线路状态:`control_connecting`、`control_down`(失联)、`task_connecting`、`task_retrying`、`connected`([`LinkEndpoints.cs`](../../backend/src/MoShiro.Backend.Api/Admin/LinkEndpoints.cs))。日志中控制层的每次迁移写一条 `link state`,QUIC 只在可用与不可用的边界上各写一条,重拨过程中的失败只计入握手统计。
## 5. 安全边界
> 本节描述现状。Backend 或节点被攻破后的威胁模型、现有缺口(例如 Probe 会拨 Backend 给的任意地址、测量数据没有产生者签名)及状态见 [security.md](../security/security.md)。
> 本节描述现状。Backend 或节点被攻破后的威胁模型、现有缺口(例如 拨号方 会拨 Backend 给的任意地址、测量数据没有产生者签名)及状态见 [security.md](../security/security.md)。
```text
Agent / Probe 能做的 执行编译在二进制中的 Kind;监听和访问本地配置允许的端口与网段;受本地硬上限约束
Agent 能做的 执行编译在二进制中的 Kind;监听和访问本地配置允许的端口与网段;受本地硬上限约束
当前未提供的能力 exec / shell / 终端 / 文件读写(配置目录除外)/ 加载外部代码 / 采集主机标识(型号、主机名、IP、进程);更新在节点进程之外完成
即使 Backend 被攻破 攻击者最多能在本地硬上限内发测量流量,拿不到主机权限,也无法把 Agent 当成扫描器或放大器;
也推不出恶意更新(更新清单必须由发布密钥签名,节点宿主机上的更新器用本机公钥验签,并且只认本机配置的镜像仓库)
@@ -139,18 +160,18 @@ Agent / Probe 能做的 执行编译在二进制中的 Kind;监听和访问
- 本地硬上限写在节点本机的配置文件中([`AgentConfig.cs`](../../backend/agent/src/MoShiro.Agent/AgentConfig.cs)),由 [`BudgetGuard.cs`](../../backend/shared/MoShiro.Measurement/Execution/BudgetGuard.cs) 检查,Backend 下发的任务如果超出,节点直接拒绝:允许访问的目标网段(默认为空,即不开放目标任务)、总码率和包速率上限、允许监听的端口。
- 构建期强制:常驻节点进程使用 [`BannedSymbols.Node.txt`](../../backend/BannedSymbols.Node.txt),禁用 `System.Diagnostics.Process`、`Assembly.Load*`、`NativeLibrary.Load`、`HttpClient`,以及 `ConfigStore` 以外的文件写入 API,详见 [project-layout.md](../dev/project-layout.md) 第 7 节。
- 权限分离:注册(`fleet-enroll`,一次性运行)、采集(`moshiro-agent` / `moshiro-probe`)、更新是不同的进程。容器节点里,采集进程在容器内以 UID 1654 运行(只读文件系统、不带额外能力),更新由宿主机上 root 运行的 `moshiro-update` 完成:它只读取节点放进 `update/` 的普通文件、复制到自己的临时目录后验签,状态写在节点只读的 `update-status/`;采集进程改写不了自己的二进制,更新器也读不到节点身份私钥。纯二进制节点的更新见[路线图](../roadmap.md)。
- 容器运行:Agent、Probe 的镜像基于 Alpine(musl,NativeAOT),宿主机用 rootful podman 加 systemd 运行:UID/GID 1654、只读文件系统、`--cap-drop all`、`no-new-privileges`、`/tmp` 用 tmpfs;配置和身份放在挂载的数据目录里。
- 权限分离:注册(`fleet-enroll`,一次性运行)、采集(`moshiro-agent` / `moshiro-agent`)、更新是不同的进程。容器节点里,采集进程在容器内以 UID 1654 运行(只读文件系统、不带额外能力),更新由宿主机上 root 运行的 `moshiro-update` 完成:它只读取节点放进 `update/` 的普通文件、复制到自己的临时目录后验签,状态写在节点只读的 `update-status/`;采集进程改写不了自己的二进制,更新器也读不到节点身份私钥。纯二进制节点的更新见[路线图](../roadmap.md)。
- 容器运行:Agent 的镜像基于 Alpine(musl,NativeAOT),宿主机用 rootful podman 加 systemd 运行:UID/GID 1654、只读文件系统、`--cap-drop all`、`no-new-privileges`、`/tmp` 用 tmpfs;配置和身份放在挂载的数据目录里。
- 属主检查:[`OwnerGuard.cs`](../../backend/fleet/src/Fleet.Hosting/OwnerGuard.cs);预握手敲门:[`KnockGate.cs`](../../backend/fleet/src/Fleet.Identity/KnockGate.cs)。
- 最小权限运行:非 root 用户;只在需要时授予 `CAP_NET_RAW`(ICMP);默认端口都高于 1024,不需要 `CAP_NET_BIND_SERVICE`,只有在 Agent 本地显式开启 443 时才授予。
- 身份([`NodeIdentity.cs`](../../backend/fleet/src/Fleet.Identity/NodeIdentity.cs)):节点使用本地生成的 ECDSA P-256 密钥对签名认证(Agent 和 Probe 都自己生成;Probe 有两把独立的密钥,一把连面板、一把连 Agent),Backend 只存公钥,数据库泄露也无法冒充节点。
- 身份([`NodeIdentity.cs`](../../backend/fleet/src/Fleet.Identity/NodeIdentity.cs)):节点使用本地生成的 ECDSA P-256 密钥对签名认证(Agent 都自己生成;拨号方 有两把独立的密钥,一把连面板、一把连 Agent),Backend 只存公钥,数据库泄露也无法冒充节点。
- 防放大([`Registries.cs`](../../backend/agent/src/MoShiro.Agent/DataPlane/Registries.cs)):未认证的包静默丢弃;回复长度与请求相等;每个流按声明速率限速;使用重放窗口。
- 容器里的主机指标:容器里的 Agent 通过只读挂载宿主机的 `/proc`、`/sys`、根目录和 `/etc/os-release`(环境变量 `HOST_PROC`、`HOST_SYS`、`HOST_ROOT`)读取宿主机的资源占用和系统名(采样见 [`HostSampler.cs`](../../backend/shared/MoShiro.Measurement/Platform/HostSampler.cs));读不到的项返回空值,不会退回去读容器自己的数据。Probe 的 compose 也一样挂载这几项。
- 容器里的主机指标:容器里的 Agent 通过只读挂载宿主机的 `/proc`、`/sys`、根目录和 `/etc/os-release`(环境变量 `HOST_PROC`、`HOST_SYS`、`HOST_ROOT`)读取宿主机的资源占用和系统名(采样见 [`HostSampler.cs`](../../backend/shared/MoShiro.Measurement/Platform/HostSampler.cs));读不到的项返回空值,不会退回去读容器自己的数据。拨号方 的 compose 也一样挂载这几项。
- 自身状态:节点上报进程内的计数(会话数、包速率、队列、丢弃数);Agent 另外每隔几秒用 `moshiro.host` 上报一份主机资源占用的汇总数字(可以在 `agent.json` 里用 `reporting.host_stats = false` 整个关掉),最新值实时推给展示页,Backend 每分钟存一个样本(默认保留 7 天,`retention.host_days`),见 [`HostStatusService.cs`](../../backend/src/MoShiro.Backend.Application/Agents/HostStatusService.cs)。
## 6. 可靠性与资源
- Probe:每个 Agent 未交付的原始列 ≤ 8 MB;Agent:上传队列 ≤ 64 MB,满时丢弃最旧的块,并通过 `moshiro.event(gap)` 上报缺口。
- 拨号方:每个 Agent 未交付的原始列 ≤ 8 MB;Agent:上传队列 ≤ 64 MB,满时丢弃最旧的块,并通过 `moshiro.event(gap)` 上报缺口。
- 至少一次投递:Backend 持久化之后才回复 ACK,按 `chunk_id` 去重([`RawChunkInbox.cs`](../../backend/src/MoShiro.Backend.Gateway/RawChunkInbox.cs))。
- 节点重启会丢失未上传的数据,并体现为数据缺口。
- 窗口时间以 Agent 时钟为准;时钟偏差超过 5 秒时记录告警事件。
+24 -24
View File
@@ -3,17 +3,17 @@
## 1. 原则
- 只采集原始数据:逐包、逐消息、逐事件,TCP 另加周期性的内核采样;不计算任何统计,解释全部放在 [analysis.md](analysis.md)。
- 一处声明,处处生效:采集方法(Kind)由积木组合声明而成。Probe 和 Agent 共用同一套积木(`backend/shared/MoShiro.Measurement`)。
- 一处声明,处处生效:采集方法(Kind)由积木组合声明而成。Agent 共用同一套积木(`backend/shared/MoShiro.Measurement`)。
- 密度即参数:频率、包大小、采样间隔全部是任务参数,由 API 下发,不需要改代码。
## 2. 任务模型
同一个 Probe 与 Agent 的 IPv4、IPv6 路径并行执行相同的持续线路任务模板,各自记录、上传、分析。IPv6 只在管理端配置了地址且 Agent 上报支持时建立;两端的控制和数据通道固定使用同一地址族。按需测试通过所选线路 id 指定地址族,重负载并发限制仍按 Agent 和 Probe 的本机策略执行。两个地址族分别使用有界发件箱(Probe 每路径 8 MB,Agent 的共同上传队列 64 MB)。
同一个 Agent 节点 的 IPv4、IPv6 路径并行执行相同的持续线路任务模板,各自记录、上传、分析。IPv6 只在管理端配置了地址且 Agent 上报支持时建立;两端的控制和数据通道固定使用同一地址族。按需测试通过所选线路 id 指定地址族,重负载并发限制仍按 Agent 的本机策略执行。两个地址族分别使用有界发件箱(拨号方 每路径 8 MB,Agent 的共同上传队列 64 MB)。
```text
Task = { id, scope, kind, target, params, schedule, enabled }
scope line : Probe ↔ Agent 线路任务 target 为空(agent_id 为空时表示对所有 Agent 生效的模板)
scope line : 拨号方 ↔ Agent 线路任务 target 为空(agent_id 为空时表示对所有 Agent 生效的模板)
target : Agent → 目标 IP 的单边任务 target = ip[:port](受 Agent 本地 allowed_target_cidrs 限制)
kind Kind 目录中的 id,例如 udp_echo / tcp_echo / udp_train …
params 按 Kind 的 ParamSpec 校验(默认值、上下限);未指定的参数取默认值
@@ -24,7 +24,7 @@ schedule continuous 持续运行,按时间切块
- 线路任务模板:`agent_id` 为空的 `line` 任务会自动应用到所有 `active` 的 Agent。新 Agent 注册后自动获得这些任务;也可以针对单个 Agent 覆盖参数或停用。
- 测试方案(Plan):多个阶段的 `once` 任务组合,例如 `hy2_ladder` 是 `udp_train` 在 10、25、50、100 Mbps 下各运行 10 秒。Plan 定义在 `backend/src/MoShiro.Backend.Application/Tests/Plans.cs`。
- 期望任务集:Backend 为每个 Agent 计算“有效任务集”(模板 + 覆盖 + 目标任务),带版本号,作为 Fleet 的期望状态(`fleet.state.sync`,文档类型 `TaskSet`)下发。Agent 的 `TaskManager` 与当前运行的任务对比后,启动、停止或修改参数;其中线路任务再经过控制通道同步给 Probe。新增任务类型时协议不变。
- 期望任务集:Backend 为每个 Agent 计算“有效任务集”(模板 + 覆盖 + 目标任务),带版本号,作为 Fleet 的期望状态(`fleet.state.sync`,文档类型 `TaskSet`)下发。Agent 的 `TaskManager` 与当前运行的任务对比后,启动、停止或修改参数;其中线路任务再经过控制通道同步给 拨号方。新增任务类型时协议不变。
## 3. Kind 目录:积木组合
@@ -39,7 +39,7 @@ schedule continuous 持续运行,按时间切块
| Sampler(采样器,可挂到任意连接上) | `tcp_info`、`socket_counters` | `every_ms` |
| Recorder(记录器) | 根据 Kind 声明的列自动生成的列式记录器 | — |
两种角色互相独立:连接角色(Probe 永远是拨号方,适配 NAT)与数据角色(发送端、接收端、回显端,由 `direction` 决定)无关。每种积木只实现一次,Probe 和 Agent 共用。
两种角色互相独立:连接角色(拨号方 永远是拨号方,适配 NAT)与数据角色(发送端、接收端、回显端,由 `direction` 决定)无关。每种积木只实现一次,Agent 共用。
### 3.2 Kind 声明(`MoShiro.Protocol/Catalog/Kinds`)
@@ -55,7 +55,7 @@ public static readonly KindSpec TcpEcho = new(
Records:
[
Rec.Stream("msg", Col.U32("seq"), Col.U16("size"),
Col.TsProbe("t1"), Col.TsAgent("t2"), Col.TsAgent("t3"), Col.TsProbe("t4")),
Col.Ts拨号方("t1"), Col.TsAgent("t2"), Col.TsAgent("t3"), Col.Ts拨号方("t4")),
Rec.Stream("tcp_info", Col.TsLocal("ts"), Col.U8("side"), Col.U32("srtt_us"),
Col.U32("rttvar_us"), Col.U32("cwnd"), Col.U32("retrans_total"), Col.U64("bytes_acked")),
],
@@ -64,7 +64,7 @@ public static readonly KindSpec TcpEcho = new(
这一条声明会被四个地方使用:
- Probe/Agent:组装积木执行任务;
- Agent:组装积木执行任务;
- Recorder:根据列定义记录数据;
- Backend:校验参数和预算,并在 `/catalog/kinds` 中展示;
- Analysis:按列名读取数据。
@@ -76,7 +76,7 @@ public static readonly KindSpec TcpEcho = new(
| `udp_echo` | udp + fixed + fixed + echo | 10 Hz × 64 B | 基础指标、mc、hy2 |
| `udp_train` | udp + fixed/ladder + fixed + sink | 1250 B,`rev` | hy2 |
| `tcp_echo` | tcp/tls + fixed + uniform + echo + tcp_info | 20 Hz × 32–64 B,`tcp_info` 每 100 ms | mc、reality |
| `tcp_bulk` | tcp/tls + source/sink + tcp_info | `streams` 条连接并行传输(`direction` = fwd Probe 发 / rev Agent 发),Probe 每 `sample_ms` 记录每条连接的累计字节数(`bytes` 流);两端各自只采样第一条连接的内核 tcp_info;发送端受本地 `MaxBulkMbps` 约束;只用于按需测试,默认 4 秒(够量到峰值,省流量) | reality |
| `tcp_bulk` | tcp/tls + source/sink + tcp_info | `streams` 条连接并行传输(`direction` = fwd 拨号方 发 / rev Agent 发),拨号方 每 `sample_ms` 记录每条连接的累计字节数(`bytes` 流);两端各自只采样第一条连接的内核 tcp_info;发送端受本地 `MaxBulkMbps` 约束;只用于按需测试,默认 4 秒(够量到峰值,省流量) | reality |
| `connect` | tcp/tls + fixed + connect | 每分钟 6 次 | reality,以及目标任务 |
| `hold` | tls + hold | 每 30 s 一次心跳 | reality |
| `icmp_echo` | icmp + fixed + fixed + echo | 1 Hz × 64 B | 目标任务 |
@@ -86,7 +86,7 @@ public static readonly KindSpec TcpEcho = new(
### 3.4 回显的四个时间戳
```text
Probe 单调时钟 Agent 单调时钟
拨号方 单调时钟 Agent 单调时钟
t1 发送 ─────────────> t2 收到
t4 收到 <───────────── t3 回复
```
@@ -99,9 +99,9 @@ RTT = `(t4 − t1) − (t3 − t2)`。缺少 `t2` 表示上行丢失;有 `t2`/
RawChunk
├─ header(JSON,自描述)
│ chunk_id(UUIDv7) container_version kind task_id test_id? phase?
│ agent_id probe_id? target? params(完整快照) port direction
│ agent_id dialer_id? target? params(完整快照) port direction
│ start_utc_ms duration_ms
│ clock:agent 单调时钟 ↔ UTC 锚点,probe 时钟偏移估计
│ clock:agent 单调时钟 ↔ UTC 锚点,拨号方时钟偏移估计
│ streams: [ { name: "msg", columns: [ {name:"seq", type:"u32", enc:"delta"}, … ], count } … ]
├─ 各列数据(delta / zigzag / varint,按列定义编码)
├─ Brotli 压缩(BCL 自带)
@@ -111,13 +111,13 @@ RawChunk
- 列定义写在块头中:新增列或新增 Kind 时不需要升级 `container_version`。只有容器格式本身变化时才升级。Backend 可以通用地解码任何块,不认识的列会被保留,但会被分析层忽略。
- 切块规则:持续任务每 10 秒切一块,或者记录数达到上限(默认 50 000)时提前切块。这样采集密度提高后,单块大小仍然有界,实时显示和持久化也共用同一条链路。
- 按需测试按阶段切块,阶段内同样受记录数上限约束。
- 编解码器位于 `MoShiro.Protocol/Raw`,Probe、Agent、Backend、离线工具使用同一份代码。
- 编解码器位于 `MoShiro.Protocol/Raw`,Agent、Backend、离线工具使用同一份代码。
## 5. 列的归属与合并
两侧各自记录完整行的流(`StreamSpec.Union`,例如 `tcp_info`):Probe 和 Agent 各自采样自己的套接字(Linux `TCP_INFO`,通过 `Socket.GetRawSocketOption`,不需要 P/Invoke),每一行带 `side` 标明属于哪一侧;合并时直接拼接,不连接,Agent 一侧的行每行只交出一次。Probe 的重传反映上行方向,Agent 的重传反映下行方向(代理场景里大流量走这个方向)。非 Linux 平台没有这类采样,对应的指标在窗口里就是没有数据。
两侧各自记录完整行的流(`StreamSpec.Union`,例如 `tcp_info`):Agent 各自采样自己的套接字(Linux `TCP_INFO`,通过 `Socket.GetRawSocketOption`,不需要 P/Invoke),每一行带 `side` 标明属于哪一侧;合并时直接拼接,不连接,Agent 一侧的行每行只交出一次。拨号方 的重传反映上行方向,Agent 的重传反映下行方向(代理场景里大流量走这个方向)。非 Linux 平台没有这类采样,对应的指标在窗口里就是没有数据。
`tcp_info` 的可空 `conn` 列标识当前记录器内的一条 TCP 连接,不包含地址。Backend 按 `(side, conn)` 分别求累计计数差,避免把重连前后或同时存活的套接字混为一个序列;时间不递增的样本被忽略,任一计数器回退时重新建立基线。旧数据没有 `conn`,仍按 `side` 分组,无法事后恢复丢失的连接归属。升级时先更新 Backend 和 Agent,再更新 Probe;旧 Agent 合并采样时会丢弃尚不认识的列。
`tcp_info` 的可空 `conn` 列标识当前记录器内的一条 TCP 连接,不包含地址。Backend 按 `(side, conn)` 分别求累计计数差,避免把重连前后或同时存活的套接字混为一个序列;时间不递增的样本被忽略,任一计数器回退时重新建立基线。旧数据没有 `conn`,仍按 `side` 分组,无法事后恢复丢失的连接归属。升级时先更新 Backend 和 Agent,再更新 拨号方;旧 Agent 合并采样时会丢弃尚不认识的列。
TCP 回显的长度头和正文合为一次写入,避免在启用 `TCP_NODELAY` 时为长度头单独发送一个小段。`tcp.retrans` 仍按内核的重传段数与发送段数求比,不由应用消息数估算。
@@ -127,19 +127,19 @@ TCP 回显的长度头和正文合为一次写入,避免在启用 `TCP_NODELAY
- `Driver`:行的主导方——行由它发出的包或事件决定;
- `Key`:连接键(通常是 `seq`);
- 每一列属于哪一侧由它的时钟决定:`TsProbe` 属于 Probe,`TsAgent` 属于 Agent,其余列属于主导方。
- 每一列属于哪一侧由它的时钟决定:`Ts拨号方` 属于 拨号方,`TsAgent` 属于 Agent,其余列属于主导方。
两端各自把本侧的列记在一张按键索引的环形行表里(`RowStore`),Probe 每 10 秒把已完成的行切成列块经控制通道交给对应的 Agent,Agent 按声明合并成完整的 RawChunk 后上传:
两端各自把本侧的列记在一张按键索引的环形行表里(`RowStore`),拨号方 每 10 秒把已完成的行切成列块经控制通道交给对应的 Agent,Agent 按声明合并成完整的 RawChunk 后上传:
- `Driver = Probe`(回显):行取自列块,Agent 的列按键补上,缺失就是缺失(上行丢失);
- `Driver = Agent`(单向数据流):行取自 Agent 的记录(按列块头里的序号范围),Probe 的列按键补上,缺失就是缺失(丢失);
- `Driver = 拨号方`(回显):行取自列块,Agent 的列按键补上,缺失就是缺失(上行丢失);
- `Driver = Agent`(单向数据流):行取自 Agent 的记录(按列块头里的序号范围),拨号方 的列按键补上,缺失就是缺失(丢失);
- 没有 `Key`,或只有主导方一侧有列:原样透传。
新增一种记录流只需要写声明,不需要写合并代码。Probe 的列在规定时间内没有送达时,Agent 带着已有的数据继续,不上传只有 Agent 一侧的块。
新增一种记录流只需要写声明,不需要写合并代码。拨号方 的列在规定时间内没有送达时,Agent 带着已有的数据继续,不上传只有 Agent 一侧的块。
## 6. 代码结构(`backend/shared/MoShiro.Measurement`)
同一种采集方法的两端是一对角色写在同一个文件里:拨号方(Probe 永远是拨号方,适配 NAT)和监听方(Agent)。两端共用包的构造和校验、时钟、行表和合并;Probe 和 Agent 只负责组装自己那一侧。
同一种采集方法的两端是一对角色写在同一个文件里:拨号方(拨号方 永远是拨号方,适配 NAT)和监听方(Agent)。两端共用包的构造和校验、时钟、行表和合并;Agent 只负责组装自己那一侧。
```text
MoShiro.Measurement/ 依赖:MoShiro.Protocol(仅 BCL,零 NuGet)
@@ -171,10 +171,10 @@ MoShiro.Measurement/ 依赖:MoShiro.Protocol(仅 BCL,
| 项目 | 默认值 | 由谁约束 |
| --- | --- | --- |
| 同时运行的重负载测试 | 每个 Agent 1 个;Probe 全局 1 个 | Backend 编排 + 节点本地 |
| 同时运行的重负载测试 | 每个 Agent 1 个;拨号方 全局 1 个 | Backend 编排 + 节点本地 |
| 单次测试时长 / 码率 | ≤ 120 s;≤ 本地 `max_total_mbps` | 节点本地硬上限 |
| 单块记录数 | ≤ 50 000 | Recorder |
| Probe 上未交付的列数据 | 每个 Agent ≤ 8 MB,超出则丢弃最旧的数据并计入缺口 | Probe |
| 拨号方 上未交付的列数据 | 每个 Agent ≤ 8 MB,超出则丢弃最旧的数据并计入缺口 | 拨号方 |
| 持续任务的总带宽 | 每条线路 ≤ 100 kbps(默认模板约 40 kbps) | Backend 校验 |
| 接收端速率校验 | 实际速率 > 声明速率 × 1.2 时中止 | 节点 |
@@ -182,8 +182,8 @@ MoShiro.Measurement/ 依赖:MoShiro.Protocol(仅 BCL,
| 用途 | 默认端口 | 说明 |
| --- | --- | --- |
| Probe 控制通道(TLS,Fleet 点对点会话) | 7443/tcp | |
| QUIC Datagram 数据(`udp_echo`、`udp_train`) | 7443/udp | Agent 固定监听;Probe 每次新连接轮换本地源端口 |
| 拨号方 控制通道(TLS,Fleet 点对点会话) | 7443/tcp | |
| QUIC Datagram 数据(`udp_echo`、`udp_train`) | 7443/udp | Agent 固定监听;拨号方 每次新连接轮换本地源端口 |
| TCP/TLS 数据(`tcp_echo`、`tcp_bulk`、`connect`、`hold`) | 7444/tcp | 按首帧中的 `stream_id` 分发 |
- 默认不使用 443,避免与 VPS 上已有的 REALITY 或 Hysteria2 服务冲突,也不需要特权端口权限。
+30 -30
View File
@@ -3,7 +3,7 @@
MoShiro 的两条控制链路都建立在 Fleet 之上:
- Agent ↔ Backend:Fleet 连接服务端(wss),见 [Fleet 文档](../../backend/fleet/README.md);
- Probe ↔ Agent:Fleet 节点间点对点会话(TLS),见 [Fleet 文档](../../backend/fleet/README.md) 的「点对点会话」一节。
- 拨号方 ↔ Agent:Fleet 节点间点对点会话(TLS),见 [Fleet 文档](../../backend/fleet/README.md) 的「点对点会话」一节。
认证、心跳、重连、能力协商、可靠发件箱、期望状态同步、JSON-RPC 信封都由 Fleet 负责。本文只定义 MoShiro 自己的内容:`moshiro.*` 方法、状态文档、二进制通道,以及 Fleet 之外的数据面(QUIC Datagram 和 TCP 数据连接)。所有类型都放在 `MoShiro.Protocol` 中。
@@ -11,14 +11,14 @@ MoShiro 的两条控制链路都建立在 Fleet 之上:
| MoShiro 需要的能力 | 使用的 Fleet 机制 | MoShiro 提供的内容 |
| --- | --- | --- |
| 下发任务 | 期望状态 `fleet.state.sync` | 文档类型 `TaskSet`,包括 `tasks[]`、`peers`(允许连这个 Agent 的 Probe 公钥,已按线路开关过滤) |
| 下发 Agent 列表给 Probe | 期望状态 `fleet.state.sync`(Probe 直连面板) | 文档类型 `probe_agents`:`version`、`agents[]`(`agent_id`、`host`、`tcp_port`、`quic_ports`、`public_key`),`public_key` 是 Agent 面向 Probe 的 TLS 证书公钥(SPKI,base64):Probe 用它固定证书并派生敲门密钥;没有上报公钥的 Agent 不会下发。已按线路开关过滤;Agent 集合或线路开关变化时重新推送 |
| 下发任务 | 期望状态 `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` |
| Probe 交付列块 | 点对点会话的发件箱,二进制通道 17 | 列块:`u32 stream_id \| u32 first_seq \| u32 count \| 列数据(Brotli)` |
| 节点自身状态 | `fleet.heartbeat` 的附加字段 | Agent:`sessions`、`probes[]`、`pps_in/out`、`dropped_invalid`、`dropped_rate_limited`、`queue_bytes`、`update`;Probe:只有 `update` |
| 版本与能力 | `fleet.hello` | capabilities 包括 `moshiro.kinds:<列表>`、`moshiro.blocks:<列表>`;Agent 附加 `platform`(系统、架构、版本、tag、提交、构建时间)、`tls_pubkey`(面向 Probe 的证书公钥,面板自己算指纹)、`addresses`(本机公网地址)、`probe_ports`、`local_limits` 摘要;Probe 附加 `platform` |
| 拨号方 交付列块 | 点对点会话的发件箱,二进制通道 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.*` 方法
@@ -28,40 +28,40 @@ MoShiro 的两条控制链路都建立在 Fleet 之上:
| 方法 | 方向 | 类型 | 参数与结果 |
| --- | --- | --- | --- |
| `moshiro.probe.link_retry` | B→P | 通知 | `agent_id`:Probe 立即重试到该 Agent 的已配置 IP 路径;若列表尚未同步,Probe 在列表更新后补发一次 |
| `moshiro.test.run` | B→A | 请求 | `test_id`、`probe_id`(在哪个 Probe 上跑)、`plan`、`phases[]` → `accepted`,或错误 `test_busy`、`probe_offline` |
| `moshiro.probe.disconnect` | B→A | 请求 | `probe_id` → `accepted`(Agent 是否真的有这个会话);Agent 关闭该 Probe 的会话 |
| `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`(`probe_connected`(`detail` 是 `probe_id`,`probe` 里带版本、远端地址和连接时间)、`probe_disconnected`、`endpoint_changed`、`session_rejected`、`clock_step`、`gap`、`cpu_saturated`(节点进程 CPU 饱和,可能影响测量,由 Probe 或 Agent 上报))、`at_utc`、`detail` |
| `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) |
心跳的附加字段里有 `probes[]`:Agent 当前连着的每个 Probe(`id`、`version`、`remote`、`since_utc_ms`),每次都是完整快照,Backend 靠它纠正错过的连接事件。点对点握手的认证消息里可以带 Probe 的 `version`(只用于展示)。块头里的 `probe_id` 以 Agent 认证过的会话为准,Probe 自报的值会被覆盖。
心跳的附加字段里有 `dialers[]`:Agent 当前连着的每个 拨号方(`id`、`version`、`remote`、`since_utc_ms`),每次都是完整快照,Backend 靠它纠正错过的连接事件。点对点握手的认证消息里可以带 拨号方 的 `version`(只用于展示)。块头里的 `dialer_id` 以 Agent 认证过的会话为准,拨号方 自报的值会被覆盖。
### Probe ↔ Agent(点对点会话)
### 拨号方 ↔ Agent(点对点会话)
| 方法 | 方向 | 类型 | 参数与结果 |
| --- | --- | --- | --- |
| `fleet.state.sync`(文档类型 `line_task_set`) | A→P | 请求 | 这条线路的任务(`id`、`kind`、`params`、`stream_id`、`port`)和 Agent 的数据面端口 |
| `moshiro.clock` | P→A | 请求 | `probe_mono_us` → `agent_mono_us`;Probe 按往返中点估计两端单调时钟的偏移(取最小往返的一次),记入块头 `probe_offset_us` |
| `moshiro.test.phase` | A→P | 请求 | 测试的一个阶段:`test_id`、`phase`、`kind`、`params`、`stream_id`、`duration_s`、`port` → `accepted`;Probe 在回复之前准备好自己一侧的角色(单向数据流:先发几个探活包让 Agent 学到源地址) |
| `moshiro.test.phase_end` | A→P | 请求 | `sent_count`(Agent 发出的包数)→ `fragments`:Probe 收尾,把列块交给 Agent,回复交出的个数;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` | 双向 | 通知 | Probe 的列块(通道 17)是至少一次投递,Agent 每收到一个就确认,重复到达的只处理一次 |
| `fleet.outbox.ack` | 双向 | 通知 | 拨号方 的列块(通道 17)是至少一次投递,Agent 每收到一个就确认,重复到达的只处理一次 |
会话密钥不需要单独的方法:点对点握手(TLS 固定证书 → `fleet.peer.challenge` → Probe 用身份私钥签 `(peer_id, nonce, client_pub, server_pub)` → 两端各自 ECDH + HKDF)直接得到 `session_id` 和 `session_key`,`peer.auth` 的应答只回显 `session_id` 用来互相确认。
会话密钥不需要单独的方法:点对点握手(TLS 固定证书 → `fleet.peer.challenge` → 拨号方 用身份私钥签 `(peer_id, nonce, client_pub, server_pub)` → 两端各自 ECDH + HKDF)直接得到 `session_id` 和 `session_key`,`peer.auth` 的应答只回显 `session_id` 用来互相确认。
### 列块(Probe → Agent,通道 17)
### 列块(拨号方 → Agent,通道 17)
Probe 只记录自己一侧的列,Agent 在数据面记录另一侧,由 Agent 合并、盖章、上传(见 `measurement.md` 第 5 节)。合并由 Kind 的流声明驱动(`StreamSpec`:`Driver`、`Key`,每一列的归属由它的时钟决定),没有按 Kind 手写的模式:
拨号方 只记录自己一侧的列,Agent 在数据面记录另一侧,由 Agent 合并、盖章、上传(见 `measurement.md` 第 5 节)。合并由 Kind 的流声明驱动(`StreamSpec`:`Driver`、`Key`,每一列的归属由它的时钟决定),没有按 Kind 手写的模式:
| 流声明 | 例子 | Probe 的列块 | Agent 补充 |
| 流声明 | 例子 | 拨号方 的列块 | Agent 补充 |
| --- | --- | --- | --- |
| `Driver = Probe`,有 `Key`,Agent 一侧有列 | `udp_echo`、`tcp_echo` 的 `msg` | `seq`、`size`、`t1`、`t4` | 按 `seq` 补上 `t2`、`t3`(没有记录表示上行丢失) |
| `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)条记录:Probe 必须把大的阶段按序号范围切成多个列块(例如 100 Mbit/s、1000 B 的 10 秒阶段是 125 000 个包 → 4 个列块),`phase_end` 的应答报告总块数。块头里 Probe 必须带上 `agent_id`(它知道自己在和哪个 Agent 通信)。
单块最多 `MaxRecordsPerChunk`(50 000)条记录:拨号方 必须把大的阶段按序号范围切成多个列块(例如 100 Mbit/s、1000 B 的 10 秒阶段是 125 000 个包 → 4 个列块),`phase_end` 的应答报告总块数。块头里 拨号方 必须带上 `agent_id`(它知道自己在和哪个 Agent 通信)。
新增方法在 `MoShiro.Protocol/Rpc` 中定义,并在节点组装处加一行 `.On(...)`。旧节点通过 capabilities 协商避开不支持的方法。
@@ -94,20 +94,20 @@ Probe 只记录自己一侧的列,Agent 在数据面记录另一侧,由 Agen
### 3.2 TCP/TLS 数据连接
- 连接都由 Probe 发起;首帧为 `session_id | stream_id | nonce | mac`,认证失败直接关闭连接。
- 连接都由 拨号方 发起;首帧为 `session_id | stream_id | nonce | mac`,认证失败直接关闭连接。
- 应用层消息使用 `VarInt 长度 | 正文` 分帧;回显类消息的正文为 `seq | send_ts_us | padding`。
## 4. 版本演进
### IPv4 / IPv6 路径
- 一个 Probe 与 Agent 可以同时建立 IPv4、IPv6 两个会话,共用身份公钥,各自使用独立的会话密钥、流、时钟估计与发件箱。重连只替换同地址族的会话。
- `probe_agents.agents[]` 保留 `host`(IPv4 地址或 A 记录域名),新增可选 `ipv6_host`(IPv6 地址或 AAAA 记录域名)。未配置时不建立 IPv6 会话;Probe 按地址族解析,不跨地址族回退。
- 一个 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 和 Probe 会在 hello 与心跳中上报 IPv6 套接字、监听、全局地址、默认路由及网络命名空间状态。Podman/Docker 宿主网络部署里,采样反映宿主接口;桥接网络容器会分别报告容器网络和能读取到的宿主 IPv6。诊断只有布尔值和粗粒度状态,不包含地址和接口名称。
- `ConnectedProbe`、`TestRunParams` 和 RawChunk 块头新增 `ip_version`,取值 `ipv4` / `ipv6`,缺省 `ipv4`。Agent 合并列块时以认证过的控制会话地址族为准,覆盖 Probe 上报的值。
- IPv4 的线路 id `agent~probe`、序列 `line:probe` 保持不变;IPv6 使用 `agent~probe~ipv6`、`line:probe:ipv6`。指标、重算、SSE 和测试结果按路径隔离。
- 双栈是 JSON 字段扩展,二进制包、列编码和容器布局不变,`protocol_version` 与 `container_version` 不升级。双栈采集需要升级 Agent 和 Probe,旧节点继续提供 IPv4。
- 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。
### 通用规则
@@ -115,4 +115,4 @@ Probe 只记录自己一侧的列,Agent 在数据面记录另一侧,由 Agen
- `moshiro.*` 方法:新增方法或可选字段不需要升级版本,接收方忽略未知字段。
- RawChunk:块头自描述,新增列或新增 Kind 不需要升级 `container_version`;只有容器格式变化时才升级。
- QUIC Datagram 和 TCP 数据连接首帧的布局变化时,必须升级 `protocol_version`,并更新黄金字节测试。
- Agent 同时支持当前版本和上一个版本的 Probe;Backend 能解码保留期内所有的 `container_version`。
- Agent 同时支持当前版本和上一个版本的 拨号方;Backend 能解码保留期内所有的 `container_version`。
+3 -3
View File
@@ -103,7 +103,7 @@ id 的格式为 `指标.统计量`,详见 [analysis.md](analysis.md) 第 4 节
```json
{
"line_id": "agt_…~prb_…", "agent_id": "agt_…", "probe_id": "prb_…", "ip_version": "ipv4",
"line_id": "agt_…~agt_…", "agent_id": "agt_…", "dialer_id": "agt_…", "ip_version": "ipv4",
"from": "2026-09-28T10:00:00.000Z", "to": "2026-09-28T10:15:00.000Z",
"step": "2s", "source": "hot", "live": true, "degraded": false,
"t": [1759053600000, 1759053602000, 1759053604000],
@@ -118,7 +118,7 @@ id 的格式为 `指标.统计量`,详见 [analysis.md](analysis.md) 第 4 节
"coverage": 0.998
},
"edge_partial": { "first": false, "last": true },
"gaps": [{ "from": "…", "to": "…", "reason": "probe_disconnected" }],
"gaps": [{ "from": "…", "to": "…", "reason": "dialer_disconnected" }],
"events": [{ "at": "…", "kind": "endpoint_changed", "detail": "…" }],
"versions": { "loss.down": "a1f3…" }
}
@@ -146,7 +146,7 @@ id 的格式为 `指标.统计量`,详见 [analysis.md](analysis.md) 第 4 节
{
"range": "15m", "from": "…", "to": "…",
"lines": [
{ "id": "agt_…~prb_…", "agent_id": "agt_…", "probe_id": "prb_…", "name": "jp-1", "online": true,
{ "id": "agt_…~agt_…", "agent_id": "agt_…", "dialer_id": "agt_…", "name": "jp-1", "online": true,
"summary": { "loss.down": 0.1, "loss.up": 0.0, "rtt.p95": 48.0, "tcp.retrans": 0.1, "score.mc": 94 },
"coverage": 1.0 }
]
+2 -2
View File
@@ -33,10 +33,10 @@ data/
| 类别 | 表 | 说明 |
| --- | --- | --- |
| 身份与注册 | `agents`、`probes`、`join_tokens`、`node_meta` | 节点公钥、状态、地址(含手填标记)、IPv6 诊断、`task_version` / `applied_task_version`、`row_version`(ETag);`node_meta` 保存系统、构建信息与国家 / 运营商;`agents.name` 仅在非 pending 节点间唯一 |
| 身份与注册 | `agents`、`join_tokens`、`node_meta` | 节点公钥、状态、地址(含手填标记)、IPv6 诊断、`task_version` / `applied_task_version`、`row_version`(ETag);`node_meta` 保存系统、构建信息与国家 / 运营商;`agents.name` 仅在非 pending 节点间唯一 |
| 账户与信任 | `users`、`sessions`、`api_tokens`、`passkeys`、`operator_sets`、`audit_log`、`settings` | 会话与 CSRF、能力化 API 令牌、passkey、操作员集合链、审计日志、键值设置 |
| 任务 | `tasks`、`tests`、`test_results` | `tasks.scope` 为 `line` 或 `target`,`agent_id` 为空表示全局模板,`base_task_id` 指向被覆盖的模板;`tests` 记录按需测试及其阶段、地址族、线路;`test_results` 保存指标与曲线 |
| 原始数据索引 | `raw_segments`、`raw_chunks` | `raw_chunks` 记录 `(segment, offset, length)`、`series`(`line:<probe_id>[:ipv6]` 或 `target:<task_id>`)、Kind、任务、测试、起止时间 |
| 原始数据索引 | `raw_segments`、`raw_chunks` | `raw_chunks` 记录 `(segment, offset, length)`、`series`(`line:<dialer_id>[:ipv6]` 或 `target:<task_id>`)、Kind、任务、测试、起止时间 |
| 指标 | `metric_10s`、`metric_1m`、`metric_5m`、`metric_1h`、`metric_versions` | 每个桶一行,`metrics` 为 `AccumulatorCodec` 编码的 `{metric_id: 累加器状态}`;主键 `(agent_id, series, window_start)`;`metric_versions` 保存指标指纹与重算进度 |
| 分析调度 | `analysis_queue`、`rollup_dirty` | 待计算窗口(`seq` 递增,完成时只删除序号未变的项,处理期间到达的迟到块不丢更新);待降采样的桶(先低层 5 分钟,再高层 1 小时) |
| 观测 | `events`、`host_samples` | 节点事件;每个节点每分钟一行的主机资源汇总 |