docs: reorganize into design, security, api, ops and dev; drop stale designs; refresh the diagrams [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
ci/woodpecker/push/main-amd64-build Pipeline was successful
- Group docs by category with an index (docs/README.md); fix every link, code comment and the protocol conformance test that read docs/protocol.md. - access.md and security.md rewritten to what is implemented: removed the unadopted member-proof chain, roles and recovery key that do not exist, the old implementation order and the budgets; gaps now carry a status. - enrollment, deployment, api: the install flow is the static, hash-checked script with environment variables; no default admin. - README and architecture: new topology and update-flow diagrams (operator passkey, CI signing, registry feed, auth and trust modules).
This commit is contained in:
1 parent
636a784043
commit
e014a19554
45 files changed
+592
-966
No files matched your search
@@ -0,0 +1,147 @@
|
||||
# 分析层(Backend.Analysis)
|
||||
|
||||
## 1. 结构:算子 → 指标 → 模型
|
||||
|
||||
```text
|
||||
原始块(自描述列)
|
||||
│ 解码为列视图(按列名读取)
|
||||
▼
|
||||
┌────────────┐ ┌──────────────────────┐ ┌───────────────────────┐
|
||||
│ 算子 Op │──>│ 指标 MetricDef │──>│ 模型 Model(评分) │
|
||||
│ 可复用计算 │ │ 一行声明 = 一个指标 │ │ 指标的评分函数 │
|
||||
│ rtt/loss/… │ │ 输入 + 算子 + 累加器 │ │ base/mc/hy2/reality │
|
||||
└────────────┘ └──────────┬───────────┘ └──────────┬────────────┘
|
||||
│ 累加器状态(可合并) │ 查询时计算
|
||||
▼ ▼
|
||||
指标窗口(1m) → 5m → 1h API:metrics{} + scores{}
|
||||
```
|
||||
|
||||
三层都采用**注册制**,由代码中的注册表驱动;API 的 `/catalog/metrics`、`/catalog/models` 也直接由注册表生成。
|
||||
|
||||
## 2. 累加器(固定的少数几种)
|
||||
|
||||
所有指标的中间状态只能是下面几种累加器之一。**合并规则只写一次**,存储、降采样、按时间范围聚合都通用处理:
|
||||
|
||||
| 累加器 | 状态 | 合并 | 可导出的值 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Counter` | u64 | 相加 | 值;除以时长得到速率(例如每分钟) |
|
||||
| `Ratio` | 分子、分母 | 分别相加 | 百分比 |
|
||||
| `Sum` / `Min` / `Max` | f64 | 相加 / 取极值 | 值;平均值 = Sum / Counter |
|
||||
| `Histogram` | 固定对数桶(100 µs~30 s,每 2 倍 8 个桶,相对误差 ≤ 4.5%)| 逐桶相加 | p50 / p90 / p95 / p99、均值、最值 |
|
||||
| `RunLength` | 连续段长度的直方图 | 逐桶相加 | 平均或最大连续丢包长度 |
|
||||
| `Series` | 固定分辨率的时间序列(例如每秒一个点) | 拼接 | 曲线(仅用于测试结果) |
|
||||
|
||||
状态的二进制编码由 `AccumulatorCodec` 统一实现,存储层只把它当作不透明的字节处理。
|
||||
|
||||
## 3. 算子(可复用的计算,新增时才需要写代码)
|
||||
|
||||
| 算子 | 输入列 | 作用 |
|
||||
| --- | --- | --- |
|
||||
| `rtt` | 回显流的 `t1..t4` | `(t4−t1)−(t3−t2)` |
|
||||
| `loss(dir)` | 回显流或包列 | 按方向判断丢失 |
|
||||
| `owd_var(dir)` | `t1,t2` 或 `t3,t4` | 去除偏移和线性漂移后的单向时延变化(抖动) |
|
||||
| `loss_runs` | 丢失标记 | 连续丢失段的长度 |
|
||||
| `reorder` / `dup` | `seq` | 乱序与重复 |
|
||||
| `gap(expected, threshold)` | 到达时间 | 卡顿(到达间隔超过期望值 + 阈值) |
|
||||
| `kernel_delta(field)` | `tcp_info` 流 | 两次采样之间的差值(重传、确认字节数) |
|
||||
| `rate(dir)` | 字节数或包数 + 时间 | 每秒吞吐 |
|
||||
| `phase_ms(a, b)` | `connect` 事件 | 阶段耗时(TCP 建连、TLS 握手) |
|
||||
| `outcome` | 事件 | 按错误类别计数(超时、RST、TLS alert) |
|
||||
| `change_point` | 逐秒序列 | 变点检测(QoS 开始限速的时刻) |
|
||||
|
||||
算子都是纯函数,每个都有单元测试。
|
||||
|
||||
## 4. 指标定义(一行声明)
|
||||
|
||||
```csharp
|
||||
// Backend.Analysis/Metrics/LinkMetrics.cs
|
||||
public static class LinkMetrics
|
||||
{
|
||||
public static readonly MetricDef Rtt =
|
||||
Metric.Histogram("rtt", from: "*.echo", op: Ops.Rtt, unit: Unit.Ms,
|
||||
exports: [Stat.P50, Stat.P95, Stat.P99, Stat.Mean]);
|
||||
|
||||
public static readonly MetricDef LossUp =
|
||||
Metric.Ratio("loss.up", from: "udp_echo.msg", op: Ops.Loss(Dir.Up), unit: Unit.Pct);
|
||||
|
||||
public static readonly MetricDef JitterDown =
|
||||
Metric.Histogram("jitter.down", from: "udp_echo.msg", op: Ops.OwdVar(Dir.Down), unit: Unit.Ms,
|
||||
exports: [Stat.P95]);
|
||||
|
||||
public static readonly MetricDef TcpStall =
|
||||
Metric.Counter("tcp.stall", from: "tcp_echo.msg", op: Ops.Gap(expectedFrom: "rate_hz", thresholdMs: 100),
|
||||
unit: Unit.PerMin);
|
||||
|
||||
public static readonly MetricDef TcpRetrans =
|
||||
Metric.Ratio("tcp.retrans", from: "tcp_echo.tcp_info", op: Ops.KernelDelta("retrans_total", over: "segs_out"),
|
||||
unit: Unit.Pct);
|
||||
}
|
||||
```
|
||||
|
||||
- `from` 写成 `kind.stream`,`*` 表示所有具有该 stream 的 Kind;某个窗口里没有对应的输入时,该指标在这个窗口就不存在。
|
||||
- **TCP 长尾**:均值和 p95 看不到偶发的重传尖峰(一次丢包后的 RTT 聚在 RTO 附近),所以 `tcp.rtt` 还导出 `p999`、`max` 和长尾比 `tail`(p99 / p50,从同一个直方图导出,窗口合并后仍然精确);`tcp.slow` 是慢回显的占比——阈值 `max(1.8 × 基线, 基线 + max(15 ms, 0.35 × 基线))`,基线是**同一个 10 秒块**里 RTT 的中位数(所以每个观测只依赖自己所在的块,窗口合并仍然精确;块里少于 20 个样本不产生观测)。把它和 `tcp.retrans`(内核计数,Agent 一侧反映下行)、`tcp.stall` 对照,可以区分"重传造成的尖峰"和"队头阻塞"。
|
||||
- 导出值的名字为 `指标id.统计量`,例如 `rtt.p95`、`loss.up`、`tcp.stall.per_min`。
|
||||
|
||||
**新增一个统计 = 新增一行 `MetricDef`**,如果需要新的算子,再加一个算子类。存储、降采样、API 输出、目录说明都不需要改。新指标上线后,会自动对保留期内(30 天)的原始数据回填(见第 7 节)。
|
||||
|
||||
## 5. 模型(服务体验评分)
|
||||
|
||||
模型是一组指标的评分函数,**在查询时计算**,不存储。改动评分公式后,立即对全部历史生效。
|
||||
|
||||
```csharp
|
||||
public sealed class McModel : IServiceModel
|
||||
{
|
||||
public string Id => "mc";
|
||||
public int Version => 1;
|
||||
public IReadOnlyList<string> Needs => ["rtt.p95", "tcp.stall.per_min", "tcp.stall_max", "tcp.retrans"];
|
||||
public ModelScore Score(MetricView m) => /* 分段打分 + 加权,返回 0..100 与各维度得分和说明 */;
|
||||
}
|
||||
```
|
||||
|
||||
| 模型 | 主要指标 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `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 | 握手、稳定性、首字节时间估算、吞吐 |
|
||||
|
||||
## 6. 两种处理路径
|
||||
|
||||
```text
|
||||
chunk 入库 ─┬─> 流式路径:按 1s 桶计算累加器 → 热层环形缓冲(最近 1 小时)
|
||||
│ → 每满 10 s 写入 metric_10s → 推送 SSE(series / overview)
|
||||
└─> 持久路径:分钟窗口就绪(该分钟的块都已到达,或已过窗口结束时间 + 1 分钟)
|
||||
→ 计算完整窗口 → 写入 metric_1m → 标记降采样脏桶(5m、1h)
|
||||
按需测试:所有阶段的块到达后 → 计算测试结果(包含 Series 曲线)→ 写入 test_results
|
||||
```
|
||||
|
||||
两条路径调用同一份 `MetricDef` 和算子代码,所以实时值与历史值使用的是同一套口径。迟到的块(例如 Agent 重连后补传)会同时更新热层(如果仍在 1 小时内)、`metric_10s`,并重新进入持久路径。
|
||||
|
||||
**指标分组(preset)** 与指标一样采用注册制:`Presets.Udp = [LinkMetrics.LossUp, …]`,由 `/api/public/catalog/presets` 下发。前端按分组展示,新增指标只需要把它加进分组。查询规则见 [query.md](query.md)。
|
||||
|
||||
## 7. 版本与重算
|
||||
|
||||
- 每个 `MetricDef` 都有一个指纹(由声明内容和算子版本计算得出)。启动时与 `metric_versions` 表比对,发现新增或变化的指标时,就把保留期内的相关窗口加入重算队列,由后台以低优先级、限速的方式执行,进度可以通过 API 查询。
|
||||
- 超出原始数据保留期的历史保留旧口径,查询结果中会标注指标的版本。
|
||||
- 模型只在查询时计算,不参与重算。
|
||||
|
||||
## 8. 置信说明
|
||||
|
||||
每个模型的输出都附带:各指标的样本量、`estimated` 标记,以及局限说明,例如:
|
||||
|
||||
- 模拟的只是流量特征,不是真实协议;
|
||||
- 测量端口与真实服务端口不同(`port_differs_from_service`,默认端口 7443/7444 时总会出现)。
|
||||
|
||||
## 9. 项目结构
|
||||
|
||||
```text
|
||||
backend/src/MoShiro.Backend.Analysis/ 依赖:MoShiro.Protocol、Fleet.Core(纯计算,没有 I/O)
|
||||
├─ Accumulators/ Counter Ratio Sum Min Max Histogram RunLength Series + AccumulatorCodec
|
||||
├─ Ops/ Rtt Loss OwdVar LossRuns Reorder Gap KernelDelta Rate PhaseMs Outcome ChangePoint
|
||||
├─ Metrics/ LinkMetrics TcpMetrics UdpTrainMetrics ConnectMetrics …(只包含声明)
|
||||
├─ Models/ BaseModel McModel Hy2Model RealityModel
|
||||
├─ Engine/ ChunkView(列视图) WindowEvaluator TestEvaluator MetricRegistry ModelRegistry
|
||||
└─ Clock/ ClockDriftFit(估计两端的时钟偏移和漂移)
|
||||
```
|
||||
|
||||
分析库没有 I/O,可以直接用于离线 CLI `tools/moshiro-analyze`,对下载的原始块做实验。
|
||||
@@ -0,0 +1,172 @@
|
||||
# 架构设计
|
||||
|
||||
## 1. 目标与定位
|
||||
|
||||
一个**只关注网络层**的线路质量面板:单体面板(Backend)+ 轻量节点(Probe、Agent)+ 长连接 RPC + 任务下发。核心能力:
|
||||
|
||||
- 在**若干 Probe**(家宽等需要观测的出口,可以有多个)与**多台海外 VPS Agent**(10 几台)之间,采集**最原始的逐包、逐事件数据**;哪些 Probe 连哪些 Agent 由面板决定(自动全连,也可以手动选择);
|
||||
- 由 Backend 的分析层推算:**如果在某条线路上部署 REALITY 类、Hysteria2 类或 Minecraft 服务,体验会怎样**,并对多条线路排序和对比;
|
||||
- **主机层信息只上报只读的汇总数字**(2026-10 起,由用户确认放开):Agent 读 `/proc` 得到 CPU 占用、内存、根分区、网卡累计流量与瞬时网速、运行时长,不采集 CPU 型号、主机名、网卡名和任何 IP,不采集进程;Probe 不上报主机资源。节点只在连接面板时自报粗粒度的系统、架构和构建信息(版本、tag、提交、构建时间),用于展示和更新;国家和运营商由 Backend 用节点的来源地址查询,节点自己不访问任何外部服务。展示页看不到任何 IP。Agent 和 Probe 仍然是**纯采集器**,不具备 SSH、远程执行、终端、文件读写、自更新等能力:更新由节点宿主机上的更新器完成,节点进程只往数据目录放面板转来的发布清单和更新请求。
|
||||
|
||||
## 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 控制通道 + UDP/TCP 数据通道,由 Probe 主动拨出
|
||||
宿主机更新器(容器节点):用本机公钥验签 → 按 digest 拉镜像 → 重启,失败回滚
|
||||
```
|
||||
|
||||
- **线路(line)** = 一对(Probe,Agent)。可以有多个 Probe(身份由 Probe 自己生成并用加入令牌注册,可以吊销、删除),每个 Probe 连接多个 Agent,每个 Agent 同时服务多个 Probe;一条线路的数据按 `line:<probe_id>` 单独存放,互不混合。对外的线路 id 是 `<agent_id>~<probe_id>`。
|
||||
- **目标任务(target)** = 某个 Agent → 指定的 IP:端口(ICMP、TCP/TLS 建连),只能访问 Agent 本地允许的网段。
|
||||
|
||||
## 3. 数据链路
|
||||
|
||||
```text
|
||||
采集(只记录) 传输(有界、至少一次) 分析(注册制、可重算) 展示
|
||||
┌───────┐ 原始列(10s) ┌────────┐ RawChunk(10s) ┌──────────────────────────────────────────┐
|
||||
│ Probe │───────────>│ Agent │──outbox(16)──>│ 段文件(30天) → Analysis → 指标窗口(1m) │──> REST
|
||||
│ 积木 │<══════════>│ 积木 │ wss │ ↓ 流式处理未满窗口 ↓ 降采样 5m/1h │──> SSE
|
||||
└───────┘ 数据通道 └────────┘ │ LiveHub(内存快照) │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| 角色 | 职责 | 明确不做 |
|
||||
| --- | --- | --- |
|
||||
| Probe | 连接 Backend(身份、Agent 列表、系统与构建信息、更新);连接面板指定的 Agent;执行线路任务中 Probe 一侧的角色;记录本端原始列并交给对应的 Agent | 统计;落盘测量数据;任何主机操作 |
|
||||
| Agent | 注册;接受 Probe;执行 Agent 一侧的角色和目标任务;合并两侧的列并上传;上报主机资源的汇总数字 | 统计;落盘测量数据;exec/终端/文件/自更新(更新由宿主机上的更新器完成);采集主机的详细信息 |
|
||||
| Backend | 注册与身份;任务编排;决定哪些 Probe 连哪些 Agent;持久化原始数据;分析;实时汇聚;API;分发 CI 签名的发布清单 | 主动连接 Probe 或 Agent(都是节点拨进来);验签和安装更新(在节点宿主机上) |
|
||||
|
||||
## 4. 关键设计(详见各专题文档)
|
||||
|
||||
| 主题 | 要点 | 文档 |
|
||||
| --- | --- | --- |
|
||||
| 节点框架 | 注册、身份、连接、RPC、可靠上报、状态同步、宿主都由与业务无关的 **Fleet** 提供;MoShiro 只写业务,其他产品也能复用 | [../backend/fleet/README.md](../../backend/fleet/README.md) |
|
||||
| 注册与身份 | 使用 `fleet-enroll` 和加入令牌自动注册,之后用签名认证;Probe 用自己的身份直连面板,Agent 列表由面板下发 | [enrollment.md](../security/enrollment.md) |
|
||||
| 更新 | 只接受发布密钥签名的清单,更新策略由节点本地决定,失败自动回滚。容器节点(Agent、Probe)已实现:CI 对镜像 digest 签名,宿主机上的 `moshiro-update` 验签、按 digest 拉取、重启;纯二进制节点的更新是待办([TODO.md](../../TODO.md)) | [deployment.md](../ops/deployment.md) |
|
||||
| 任务模型 | `Task = {kind, target, params, schedule}`;持续采集、按需测试、目标任务统一建模;下发“期望任务集”,由节点自行调和 | [measurement.md](measurement.md) |
|
||||
| 采集扩展 | Kind 由积木组合声明;采集密度靠参数调整;原始块自描述 | [measurement.md](measurement.md) |
|
||||
| 统计扩展 | 指标注册制:少数几种累加器 + 统计算子;一行 `MetricDef` 就是一个新指标 | [analysis.md](analysis.md) |
|
||||
| 协议 | 控制链路使用 Fleet(JSON-RPC 2.0 + 二进制通道);MoShiro 只定义 `moshiro.*` 方法、`TaskSet` 状态文档和 UDP/TCP 数据面 | [protocol.md](protocol.md) |
|
||||
| 存储 | 原始块追加写入段文件,SQLite 记录偏移量;指标窗口通用存储并降采样 | [storage.md](storage.md) |
|
||||
| 展示查询 | 任意时间段(相对或绝对);自动步长(每条曲线最多约 720 个点);1s 内存热层(1 小时)加上 10s/1m/5m/1h 各层;累加器现场合并,汇总精确;列式响应;SSE 实时跟随 | [query.md](query.md) |
|
||||
| API | `/api/public`(只读,可设为公开)、`/api/admin`(登录)、`/api/agent`(注册与 RPC);实时数据用 SSE | [api.md](../api/api.md) |
|
||||
|
||||
### 4.0 容器节点的更新
|
||||
|
||||
```text
|
||||
CI ──签名的发布清单(镜像 digest)──▶ 镜像仓库(<仓库>-manifest 小镜像的标签里)
|
||||
│ │
|
||||
Backend 定时取、验签后登记(也接受 CI 直接 POST /api/release/offer)
|
||||
▼ │
|
||||
Backend ──原样转发(offer)──▶ Agent / Probe 进程 │ 宿主机更新器也可以直接读
|
||||
│ 只写进数据目录的 update/ │
|
||||
▼ ▼
|
||||
宿主机 moshiro-update(root):用本机公钥验签 → 核对组件、仓库和 seq → podman pull 仓库@digest → 重启 → 不健康就回滚
|
||||
│ 状态写进只读的 update-status/
|
||||
▼
|
||||
节点随心跳上报更新状态 ──▶ Backend ──▶ 管理端(有新版本时点「更新」)
|
||||
```
|
||||
|
||||
细节见 [deployment.md](../ops/deployment.md)「容器节点的更新」。
|
||||
|
||||
### 4.1 回显的四个时间戳
|
||||
|
||||
回显类 Kind 记录 `t1..t4`(Probe 发出、Agent 收到、Agent 回复、Probe 收到)。借此可以扣除 Agent 的处理耗时得到 RTT,可以分别判断上行和下行的丢包,并在去除时钟偏移和漂移后,得到**每个方向各自的抖动**,不需要两端时钟同步。
|
||||
|
||||
### 4.2 NAT / CGNAT 适配
|
||||
|
||||
所有连接和 UDP 流都由 Probe 发起;Agent 把回复发往请求的**实际源地址**。会话以 `session_id` 和 MAC 认证,不绑定五元组,NAT 映射变化后自动跟随新地址。持续的 UDP 回显本身就起到保活作用。
|
||||
|
||||
## 5. 安全边界:纯采集
|
||||
|
||||
> 这一节是现状。「Backend 被攻破、节点被入侵」之后的威胁模型、现有的几处缺口(例如 Probe 会拨 Backend 给的任意地址、测量数据没有产生者签名)和每项的状态,见 [security.md](../security/security.md);预握手敲门、Probe 自己生成密钥、操作员集合与服务器身份已经实现。
|
||||
|
||||
```text
|
||||
Agent / Probe 能做的 执行编译在二进制中的 Kind;监听和访问本地配置允许的端口与网段;受本地硬上限约束
|
||||
不能做的 exec / shell / 终端 / 文件读写(配置目录除外)/ 自更新 / 加载外部代码 / 采集主机标识(型号、主机名、IP、进程)
|
||||
即使 Backend 被攻破 攻击者最多能在本地硬上限内发测量流量,拿不到主机权限,也无法把 Agent 当成扫描器或放大器;
|
||||
也推不出恶意更新(更新清单必须由发布密钥签名,节点宿主机上的更新器用本机公钥验签,并且只认本机配置的镜像仓库)
|
||||
```
|
||||
|
||||
- **本地硬上限**写在节点本机的配置文件中,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/`;采集进程改写不了自己的二进制,更新器也读不到节点身份私钥。纯二进制节点的更新是待办(见 [TODO.md](../../TODO.md))。
|
||||
- **容器运行**:Agent、Probe 的镜像基于 Alpine(musl,NativeAOT),宿主机用 rootful podman 加 systemd 运行:UID/GID 1654、只读文件系统、`--cap-drop all`、`no-new-privileges`、`/tmp` 用 tmpfs;配置和身份放在挂载的数据目录里。
|
||||
- **最小权限运行**:非 root 用户;只在需要时授予 `CAP_NET_RAW`(ICMP);默认端口都高于 1024,不需要 `CAP_NET_BIND_SERVICE`,只有在 Agent 本地显式开启 443 时才授予。
|
||||
- **身份**:节点使用本地生成的 ECDSA P-256 密钥对签名认证(Agent 和 Probe 都自己生成;Probe 有两把独立的密钥,一把连面板、一把连 Agent),Backend 只存公钥,数据库泄露也无法冒充节点。
|
||||
- **防放大**:未认证的包静默丢弃;回复长度与请求相等;每个流按声明速率限速;使用重放窗口。
|
||||
- **容器里的主机指标**:容器里的 Agent 通过只读挂载宿主机的 `/proc`、`/sys`、根目录和 `/etc/os-release`(环境变量 `HOST_PROC`、`HOST_SYS`、`HOST_ROOT`)读取宿主机的资源占用和系统名;读不到的项返回空值,不会退回去读容器自己的数据。Probe 只需要挂载 `/etc/os-release`。
|
||||
- **自身状态**:节点上报进程内的计数(会话数、包速率、队列、丢弃数);Agent 另外每隔几秒用 `moshiro.host` 上报一份主机资源占用的汇总数字(可以在 `agent.json` 里用 `reporting.host_stats = false` 整个关掉),最新值实时推给展示页,Backend 每分钟存一个样本(默认保留 7 天,`retention.host_days`)。
|
||||
|
||||
## 6. 可扩展性目标
|
||||
|
||||
| 变更 | 需要改动的地方 |
|
||||
| --- | --- |
|
||||
| 调整采集密度(例如 TCP 20 Hz → 100 Hz,`tcp_info` 100 ms → 10 ms) | 在 API 中修改任务参数,立即下发生效 |
|
||||
| 新增采集方法(现有积木可以组合出来) | 在 Kind 目录中加 1 条声明,然后更新 Probe/Agent 版本 |
|
||||
| 新增采集方法(需要新积木) | 1 个积木类 + 1 条 Kind 声明 |
|
||||
| 新增统计指标 | 1 行 `MetricDef`(如需新算子,再加 1 个算子类);自动回填 30 天历史 |
|
||||
| 调整服务评分 | 修改模型的评分函数;评分在查询时计算,立即对全部历史生效 |
|
||||
|
||||
Kind 目录编译在二进制中,节点不接受远程下发代码(安全边界)。节点在 `fleet.hello` 的 capabilities 中上报自己支持的 Kind,Backend 只下发它支持的类型。
|
||||
|
||||
## 7. 可靠性与资源
|
||||
|
||||
- Probe:每个 Agent 未交付的原始列 ≤ 8 MB;Agent:上传队列 ≤ 64 MB,满时丢弃最旧的块,并通过 `moshiro.event(gap)` 上报缺口。
|
||||
- 至少一次投递:Backend 持久化之后才回复 ACK,按 `chunk_id` 去重。
|
||||
- 节点重启会丢失未上传的数据,并体现为数据缺口。
|
||||
- 窗口时间以 Agent 时钟为准;时钟偏差超过 5 秒时记录告警事件。
|
||||
- 规模:10+ 个 Agent × 2~4 种持续 Kind × 每 10 秒一块,总计约每秒 2~6 块。
|
||||
|
||||
## 8. 设计决策记录
|
||||
|
||||
| # | 决策 | 理由 |
|
||||
| --- | --- | --- |
|
||||
| D1 | 只关注网络层;节点是纯采集器,没有远程执行能力 | 由用户确认;防止权限泄露 |
|
||||
| D2 | 拓扑为多个 Probe 连接多个 Agent(10+);线路 = (Probe, Agent),数据按 Probe 隔离 | 由用户确认 |
|
||||
| D3 | Agent 用加入令牌自动注册,之后用密钥对签名认证 | 批量部署省事;Backend 不保存可用于冒充节点的秘密 |
|
||||
| D4 | Probe 直连 Backend:身份、Agent 列表(期望状态 `probe_agents`)、状态和更新都走这条链路,测量仍然只走 Probe → Agent(2026-10 由「Probe 不认识 Backend、经 Agent 同步列表」改成直连,因为面板本来就要对外暴露,直连让更新和管理少一跳)。哪些 Probe 连哪些 Agent 由面板决定,`fleet.auto_link` 打开时两两连,也可以手动选择,Probe 始终主动拨出 | 新增 Agent 后自动覆盖,不需要改 Probe 配置;Probe 的版本、更新和线路都能在面板上管理 |
|
||||
| D5 | 只采集逐包、逐事件的原始数据,解释全部放在 Backend | 模型和指标可以事后重算;采集端最轻 |
|
||||
| D6 | 统一的任务模型 + 期望任务集调和 | 新增任务类型时协议不变 |
|
||||
| D7 | Kind 由积木组合声明;原始块自描述 | 调整密度或新增采集方法只改一处 |
|
||||
| D8 | 指标注册制,使用固定的少数几种累加器 | 新增统计只改一行;存储、降采样、API 全部通用 |
|
||||
| D9 | JSON-RPC 2.0 信封 + 二进制帧 | 新增能力只需要加方法 |
|
||||
| D10 | 原始数据每 10 秒切一块,追加写入段文件,保留 30 天;指标长期保留 | 同一条链路同时服务实时和历史;避免海量小文件 |
|
||||
| D11 | API 分为 public / admin / agent 三层;实时数据用 SSE | 前端只读场景足够;反向代理和 CDN 更友好 |
|
||||
| D12 | 单仓库,三个独立项目加 Web(展示端、管理端);契约以 C# 代码为真源 | 改一处,编译期即可发现不一致 |
|
||||
| D13 | Probe/Agent 运行时只依赖 BCL;.NET 10 NativeAOT | 轻量 |
|
||||
| D14 | 节点机制抽成与业务无关的 Fleet 框架,放在同一仓库的 `backend/fleet/` 目录,暂不独立(由用户确认;不设拆分时间表,需要时再评估;不使用 PublicApiAnalyzers) | 可复用;不引用任何 `MoShiro.*` 的约束由 `ArchitectureTests` 在构建时检查 |
|
||||
| D15 | 注册独立为 `fleet-enroll` 工具;常驻进程不包含注册代码 | 常驻进程去掉 HttpClient,边界更纯 |
|
||||
| D16 | 更新与业务进程分离:发布密钥签名、本地策略、健康确认与回滚。容器节点已实现(宿主机 `moshiro-update`,清单里是镜像 digest);纯二进制节点的更新见 [TODO.md](../../TODO.md) | 面板被攻破时也无法推送恶意二进制 |
|
||||
| D17 | Probe 支持 OpenWrt:正式支持 `linux-musl-x64`、`linux-musl-arm64`;armv7 视工具链验证结果决定;MIPS 不支持,改为在局域网设备上运行 Probe | 由用户确认;NativeAOT 不支持 MIPS |
|
||||
| D18 | 默认测量端口不使用 443:控制 7443/tcp,UDP 数据 7443/udp,TCP 数据 7444/tcp;443 只在 Agent 本地显式开启时才允许使用 | 由用户确认;避免与 VPS 上的真实服务冲突;结果会记录实际端口 |
|
||||
| D19 | 展示查询最细 1s:最近 1 小时来自内存热层,更早的数据来自 10s/1m/5m/1h 各层,现场合并;时间段内的汇总由累加器精确合并得出 | 由用户确认;5 分钟、15 分钟这类视图足够精细且快 |
|
||||
| D20 | 时序响应采用列式格式(`t[]` + `values{}`,时间轴使用 Unix 毫秒) | 由用户确认;体积小,可以直接交给图表库 |
|
||||
| D21 | 国家和运营商只由 Backend 向 IP 归属服务查询(缺省 `https://ip.007995.xyz`,可关闭),只查公网地址;节点不访问任何外部服务;公开接口不返回 IP、城市、ASN | 客户的防火墙常常要审计节点的出站连接;访客拿不到敏感信息(有测试保证) |
|
||||
| D22 | 构建信息(版本、tag、提交、构建时间)由节点自报,访客是否可见由设置 `site.show_build` 控制(默认关);提交号只在管理端 | 展示页可以选择不暴露版本;管理员能看出哪个节点没升级 |
|
||||
| D23 | 零信任方向:信任锚点从 Backend 移到操作员 passkey(操作员集合,节点安装时固定创世指纹和属主);Probe 自己生成密钥;Agent 的 TCP 端口预握手敲门;敏感能力以后放进独立 extension,用属主 passkey 签名的能力令牌授权。不做进程沙箱(节点是 1 核 1 GB 的小机器),代码风险靠 PR 评审 | 由用户确认;做法和各缺口的状态见 [security.md](../security/security.md)、[access.md](../security/access.md) |
|
||||
|
||||
## 9. 仍待确认
|
||||
|
||||
待确认事项:
|
||||
|
||||
1. **告警**:首版不包含,是否需要预留 Webhook?
|
||||
2. **与真实服务共用 443**(例如 SNI 分流):首版不做,默认使用非 443 端口;以后如有需要再评估。
|
||||
@@ -0,0 +1,186 @@
|
||||
# 任务与采集引擎
|
||||
|
||||
## 1. 原则
|
||||
|
||||
- **只采集原始数据**:逐包、逐消息、逐事件,TCP 另加周期性的内核采样;不计算任何统计,解释全部放在 [analysis.md](analysis.md)。
|
||||
- **一处声明,处处生效**:采集方法(Kind)由积木组合**声明**而成。Probe 和 Agent 共用同一套积木(`backend/shared/MoShiro.Measurement`)。
|
||||
- **密度即参数**:频率、包大小、采样间隔全部是任务参数,由 API 下发,不需要改代码。
|
||||
|
||||
## 2. 任务模型
|
||||
|
||||
同一个 Probe 与 Agent 的 IPv4、IPv6 路径并行执行相同的持续线路任务模板,各自记录、上传、分析。IPv6 只在管理端配置了地址且 Agent 上报支持时建立;两端的控制和数据通道固定使用同一地址族。按需测试通过所选线路 id 指定地址族,重负载并发限制仍按 Agent 和 Probe 的本机策略执行。两个地址族分别使用有界发件箱(Probe 每路径 8 MB,Agent 的共同上传队列 64 MB)。
|
||||
|
||||
```text
|
||||
Task = { id, scope, kind, target, params, schedule, enabled }
|
||||
|
||||
scope line : Probe ↔ 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 校验(默认值、上下限);未指定的参数取默认值
|
||||
schedule continuous 持续运行,按时间切块
|
||||
every(N) 每 N 分钟运行一次(N ≥ 60,适用于重负载 Kind)
|
||||
once 按需测试,由 API 触发,也就是一次 Test
|
||||
```
|
||||
|
||||
- **线路任务模板**:`agent_id` 为空的 `line` 任务会自动应用到所有 `active` 的 Agent。新 Agent 注册后自动获得这些任务;也可以针对单个 Agent 覆盖参数或停用。
|
||||
- **测试方案(Plan)**:多个阶段的 `once` 任务组合,例如 `hy2_ladder` 是 `udp_train` 在 10、25、50、100 Mbps 下各运行 10 秒。Plan 定义在 `Protocol/Catalog/Plans`。
|
||||
- **期望任务集**:Backend 为每个 Agent 计算“有效任务集”(模板 + 覆盖 + 目标任务),带版本号,作为 Fleet 的期望状态(`fleet.state.sync`,文档类型 `TaskSet`)下发。Agent 的 `TaskReconciler` 与当前运行的任务对比后,启动、停止或修改参数;其中线路任务再经过控制通道同步给 Probe。**新增任务类型时,协议不需要任何改动。**
|
||||
|
||||
## 3. Kind 目录:积木组合
|
||||
|
||||
### 3.1 积木(`MoShiro.Measurement`,只有新增积木才需要写代码)
|
||||
|
||||
| 类别 | 积木 | 参数示例 |
|
||||
| --- | --- | --- |
|
||||
| Transport | `udp`、`tcp`、`tls`、`icmp` | `port`、`sni`、`nodelay`、`sndbuf` |
|
||||
| Pacer(发送节奏) | `fixed`、`poisson`、`burst`、`ladder` | `rate_hz`、`mbps`、`burst_len`、`steps[]` |
|
||||
| Payload(负载形态) | `fixed`、`uniform`、`choice` | `size`、`min`/`max`、`sizes[]` |
|
||||
| Behavior(对端行为) | `echo`、`sink`、`source`、`connect`、`hold` | `direction`(`fwd`/`rev`/`bidir`)、`streams` |
|
||||
| Sampler(采样器,可挂到任意连接上) | `tcp_info`、`socket_counters` | `every_ms` |
|
||||
| Recorder(记录器) | 根据 Kind 声明的列**自动生成**的列式记录器 | — |
|
||||
|
||||
**两种角色互相独立**:连接角色(Probe 永远是拨号方,适配 NAT)与数据角色(发送端、接收端、回显端,由 `direction` 决定)无关。每种积木只实现一次,Probe 和 Agent 共用。
|
||||
|
||||
### 3.2 Kind 声明(`MoShiro.Protocol/Catalog/Kinds`)
|
||||
|
||||
```csharp
|
||||
public static readonly KindSpec TcpEcho = new(
|
||||
Id: "tcp_echo",
|
||||
Scopes: Scope.Line,
|
||||
Transport: Blocks.Tcp(nodelay: true),
|
||||
Pacer: Blocks.Fixed(rateHz: P.Int("rate_hz", @default: 20, min: 1, max: 200)),
|
||||
Payload: Blocks.Uniform(P.Int("min", 32, 8, 1400), P.Int("max", 64, 8, 1400)),
|
||||
Behavior: Blocks.Echo(),
|
||||
Samplers: [Blocks.TcpInfo(everyMs: P.Int("tcp_info_ms", @default: 100, min: 10, max: 1000))],
|
||||
Records:
|
||||
[
|
||||
Rec.Stream("msg", Col.U32("seq"), Col.U16("size"),
|
||||
Col.TsProbe("t1"), Col.TsAgent("t2"), Col.TsAgent("t3"), Col.TsProbe("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")),
|
||||
],
|
||||
Budget: p => Bandwidth.Echo(p.RateHz, p.Max)); // 用于校验上限和估算带宽
|
||||
```
|
||||
|
||||
这一条声明会被四个地方使用:
|
||||
|
||||
- **Probe/Agent**:组装积木执行任务;
|
||||
- **Recorder**:根据列定义记录数据;
|
||||
- **Backend**:校验参数和预算,并在 `/catalog/kinds` 中展示;
|
||||
- **Analysis**:按列名读取数据。
|
||||
|
||||
### 3.3 内置 Kind
|
||||
|
||||
| Kind | 组合 | 默认参数 | 主要服务于 |
|
||||
| --- | --- | --- | --- |
|
||||
| `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 |
|
||||
| `connect` | tcp/tls + fixed + connect | 每分钟 6 次 | reality,以及目标任务 |
|
||||
| `hold` | tls + hold | 每 30 s 一次心跳 | reality |
|
||||
| `icmp_echo` | icmp + fixed + fixed + echo | 1 Hz × 64 B | 目标任务 |
|
||||
|
||||
默认的线路任务模板:`udp_echo`、`tcp_echo`、`connect`(持续运行;`connect` 每分钟 6 次,记录 TCP 建连和 TLS 握手耗时,约 0.4 kbps)。其余 Kind 可以按需开启,或者以 Plan 形式运行。每一批默认任务只种入一次,在设置里用标记记住,所以删掉的默认模板不会被重新建出来;后来新增的默认任务(`connect`)在已有部署升级后会补建一次。
|
||||
|
||||
### 3.4 回显的四个时间戳
|
||||
|
||||
```text
|
||||
Probe 单调时钟 Agent 单调时钟
|
||||
t1 发送 ─────────────> t2 收到
|
||||
t4 收到 <───────────── t3 回复
|
||||
```
|
||||
|
||||
RTT = `(t4 − t1) − (t3 − t2)`。缺少 `t2` 表示上行丢失;有 `t2`/`t3` 但缺少 `t4` 表示下行丢失。`t2 − t1` 和 `t4 − t3` 的**变化量**在去除偏移和漂移后,就是每个方向各自的时延抖动。
|
||||
|
||||
## 4. 原始块(自描述)
|
||||
|
||||
```text
|
||||
RawChunk
|
||||
├─ header(JSON,自描述)
|
||||
│ chunk_id(UUIDv7) container_version kind task_id test_id? phase?
|
||||
│ agent_id probe_id? target? params(完整快照) port direction
|
||||
│ start_utc_ms duration_ms
|
||||
│ clock:agent 单调时钟 ↔ UTC 锚点,probe 时钟偏移估计
|
||||
│ streams: [ { name: "msg", columns: [ {name:"seq", type:"u32", enc:"delta"}, … ], count } … ]
|
||||
├─ 各列数据(delta / zigzag / varint,按列定义编码)
|
||||
├─ Brotli 压缩(BCL 自带)
|
||||
└─ crc32c
|
||||
```
|
||||
|
||||
- **列定义写在块头中**:新增列或新增 Kind 时,**不需要升级 `container_version`**。只有容器格式本身变化时才升级。Backend 可以通用地解码任何块,不认识的列会被保留,但会被分析层忽略。
|
||||
- **切块规则**:持续任务每 **10 秒**切一块,或者记录数达到上限(默认 50 000)时提前切块。这样采集密度提高后,单块大小仍然有界,实时显示和持久化也共用同一条链路。
|
||||
- 按需测试按阶段切块,阶段内同样受记录数上限约束。
|
||||
- 编解码器位于 `MoShiro.Protocol/Raw`,Probe、Agent、Backend、离线工具使用同一份代码。
|
||||
|
||||
## 5. 列的归属与合并
|
||||
|
||||
> **两侧各自记录完整行的流**(`StreamSpec.Union`,例如 `tcp_info`):Probe 和 Agent 各自采样自己的套接字(Linux `TCP_INFO`,通过 `Socket.GetRawSocketOption`,不需要 P/Invoke),每一行带 `side` 标明属于哪一侧;合并时直接拼接,不连接,Agent 一侧的行每行只交出一次。Probe 的重传反映上行方向,Agent 的重传反映下行方向(代理场景里大流量走这个方向)。非 Linux 平台没有这类采样,对应的指标在窗口里就是没有数据。
|
||||
|
||||
采集层只产生**原始列**(`seq`、`size`、`t1…t4`、`t_send`/`t_recv`、`outcome`……),不做任何统计,也不认识上层的方案或协议;丢包率、RTT、抖动由 Backend 的分析层从这些列推算。
|
||||
|
||||
每个记录流(`StreamSpec`)声明:
|
||||
|
||||
- `Driver`:行的主导方——行由它发出的包或事件决定;
|
||||
- `Key`:连接键(通常是 `seq`);
|
||||
- 每一列属于哪一侧由它的时钟决定:`TsProbe` 属于 Probe,`TsAgent` 属于 Agent,其余列属于主导方。
|
||||
|
||||
两端各自把**本侧的列**记在一张按键索引的环形行表里(`RowStore`),Probe 每 10 秒把已完成的行切成列块经控制通道交给**对应的 Agent**,Agent 按声明合并成完整的 RawChunk 后上传:
|
||||
|
||||
- `Driver = Probe`(回显):行取自列块,Agent 的列按键补上,缺失就是缺失(上行丢失);
|
||||
- `Driver = Agent`(单向数据流):行取自 Agent 的记录(按列块头里的序号范围),Probe 的列按键补上,缺失就是缺失(丢失);
|
||||
- 没有 `Key`,或只有主导方一侧有列:原样透传。
|
||||
|
||||
新增一种记录流只需要写声明,不需要写合并代码。Probe 的列在规定时间内没有送达时,目前 Agent 带着已有的数据继续(不上传只有 Agent 一侧的 `partial` 块,见已知缺口)。
|
||||
|
||||
## 6. 代码结构(`backend/shared/MoShiro.Measurement`)
|
||||
|
||||
同一种采集方法的两端是**一对角色**写在同一个文件里:拨号方(Probe 永远是拨号方,适配 NAT)和监听方(Agent)。两端共用包的构造和校验、时钟、行表和合并;Probe 和 Agent 只负责组装自己那一侧。
|
||||
|
||||
```text
|
||||
MoShiro.Measurement/ 依赖:MoShiro.Protocol(仅 BCL,零 NuGet)
|
||||
├─ Core/
|
||||
│ ├─ Contracts.cs IBlock、BlockSpec、BlockEnv、DataSession、IAdmission、IFragmentSink……
|
||||
│ ├─ RowStore.cs 本侧记录的行:只含本侧拥有的列,按键索引的环形缓冲,热路径不分配
|
||||
│ ├─ MonoClock.cs 单调时钟(微秒)与 UTC 锚点
|
||||
│ └─ RunningBlock.cs 一个运行中的积木(取消令牌 + 后台任务)
|
||||
├─ Net/
|
||||
│ ├─ UdpMux.cs UDP 数据面:按 stream 分发;校验顺序固定;准入策略(重放、限速)通过 IAdmission 注入
|
||||
│ └─ Tcp.cs TcpLink(拨号:TLS 按指纹固定 + 认证首帧)、TcpAcceptor(监听:TLS 自动识别 + 认证首帧)
|
||||
├─ Blocks/ 一个文件 = 一种采集方法的两个角色
|
||||
│ ├─ Echo.cs UdpEchoDialer/Listener、TcpEchoDialer/Listener
|
||||
│ ├─ Session.cs ConnectDialer、HoldDialer、IcmpEchoSolo(没有对端)、IdleListener
|
||||
│ ├─ Train.cs TrainDialer(接收,记 t_recv)、TrainListener(按码率发包,记发送行)
|
||||
│ └─ BlockCatalog.cs Kind → (拨号方, 监听方);capabilities、本地预算的支持列表都从这里推出来
|
||||
├─ Merging/ FragmentMerger(声明驱动的按键合并)、ChunkFinalizer(盖章:块头、时钟锚点、起止时间)
|
||||
└─ Execution/ BudgetGuard(参数校验 + 本地硬上限,超出即拒绝)
|
||||
```
|
||||
|
||||
**边界**(由架构测试强制):采集层的任何代码里不能出现 REALITY、Hysteria2、Minecraft 这些业务概念,也没有测试方案——方案(例如 `hy2_ladder`、`reality_session`)只是若干采集原语阶段的编排,只存在于 Backend。监听方的准入策略(重放窗口、令牌桶、源地址学习)是安全边界,通过 `IAdmission` 由 Agent 实现,积木本身不含策略。
|
||||
|
||||
## 7. 资源与安全上限
|
||||
|
||||
| 项目 | 默认值 | 由谁约束 |
|
||||
| --- | --- | --- |
|
||||
| 同时运行的重负载测试 | 每个 Agent 1 个;Probe 全局 1 个 | Backend 编排 + 节点本地 |
|
||||
| 单次测试时长 / 码率 | ≤ 120 s;≤ 本地 `max_total_mbps` | 节点本地硬上限 |
|
||||
| 单块记录数 | ≤ 50 000 | Recorder |
|
||||
| Probe 上未交付的列数据 | 每个 Agent ≤ 8 MB,超出则丢弃最旧的数据并计入缺口 | Probe |
|
||||
| 持续任务的总带宽 | 每条线路 ≤ 100 kbps(默认模板约 40 kbps) | Backend 校验 |
|
||||
| 接收端速率校验 | 实际速率 > 声明速率 × 1.2 时中止 | 节点 |
|
||||
|
||||
## 8. 端口
|
||||
|
||||
| 用途 | 默认端口 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| Probe 控制通道(TLS,Fleet 点对点会话) | 7443/tcp | |
|
||||
| UDP 数据(`udp_echo`、`udp_train`) | 7443/udp | |
|
||||
| TCP/TLS 数据(`tcp_echo`、`tcp_bulk`、`connect`、`hold`) | 7444/tcp | 按首帧中的 `stream_id` 分发 |
|
||||
|
||||
- **默认不使用 443**,避免与 VPS 上已有的 REALITY 或 Hysteria2 服务冲突,也不需要特权端口权限。
|
||||
- 任务参数中的 `port` 只能取 Agent 本地配置 `listen` 列表中的端口。要用 443 测量,需要节点所有者在本机把 443 加入 `listen`(并授予 `CAP_NET_BIND_SERVICE`),然后在任务中指定 `port: 443`。
|
||||
- 每个原始块的头部都记录实际使用的端口。当测量端口与被推算服务的常用端口(443)不同时,分析结果附带 `port_differs_from_service` 置信说明,因为运营商的 QoS 可能按端口区别对待。
|
||||
|
||||
## 9. 边界
|
||||
|
||||
采集端只模拟流量特征(端口、包大小、速率、握手、方向),**不实现** REALITY 或 Hysteria2 协议本身,分析结果会附带置信说明。用户态看不到 TCP 的逐段收发,所以 TCP 能拿到的最原始数据是“逐消息时间戳 + 内核采样”;pcap/eBPF 不在首版范围内。
|
||||
@@ -0,0 +1,117 @@
|
||||
# 线协议
|
||||
|
||||
MoShiro 的两条控制链路都建立在 Fleet 之上:
|
||||
|
||||
- **Agent ↔ Backend**:Fleet 连接服务端(wss),见 [../backend/fleet/README.md](../../backend/fleet/README.md);
|
||||
- **Probe ↔ Agent**:Fleet 节点间点对点会话(TLS),见 [../backend/fleet/README.md](../../backend/fleet/README.md) 的「点对点会话」一节。
|
||||
|
||||
认证、心跳、重连、能力协商、可靠发件箱、期望状态同步、JSON-RPC 信封都由 Fleet 负责。本文只定义 **MoShiro 自己的内容**:`moshiro.*` 方法、状态文档、二进制通道,以及 Fleet 之外的数据面(UDP 包和数据连接)。所有类型都放在 `MoShiro.Protocol` 中。
|
||||
|
||||
## 1. 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`、`udp_ports`、`public_key`),`public_key` 是 Agent 面向 Probe 的 TLS 证书公钥(SPKI,base64):Probe 用它固定证书并派生敲门密钥;没有上报公钥的 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` |
|
||||
|
||||
## 2. `moshiro.*` 方法
|
||||
|
||||
每个方法的发起方、类型(请求或通知)和允许的角色声明在代码里的 `MoShiroMethodTable`(框架的 `fleet.*` 在 `FleetMethodTable`),路由器据此拒绝没有声明、方向不对或角色不符的消息,回 `fleet_protocol_violation`;下面各表的「方向」列由测试和这张声明表对账。设计见 [security.md](../security/security.md) 第 6 节。
|
||||
|
||||
### Agent ↔ Backend
|
||||
|
||||
| 方法 | 方向 | 类型 | 参数与结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| `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.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.host` | A→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 自报的值会被覆盖。
|
||||
|
||||
### Probe ↔ 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.test.abort` | A→P | 通知 | `test_id` |
|
||||
| `fleet.outbox.ack` | 双向 | 通知 | Probe 的列块(通道 17)是至少一次投递,Agent 每收到一个就确认,重复到达的只处理一次 |
|
||||
|
||||
会话密钥不需要单独的方法:点对点握手(TLS 固定证书 → `fleet.peer.challenge` → Probe 用身份私钥签 `(peer_id, nonce, client_pub, server_pub)` → 两端各自 ECDH + HKDF)直接得到 `session_id` 和 `session_key`,`peer.auth` 的应答只回显 `session_id` 用来互相确认。
|
||||
|
||||
### 列块(Probe → Agent,通道 17)
|
||||
|
||||
Probe 只记录自己一侧的列,Agent 在数据面记录另一侧,由 Agent 合并、盖章、上传(见 `measurement.md` 第 5 节)。合并**由 Kind 的流声明驱动**(`StreamSpec`:`Driver`、`Key`,每一列的归属由它的时钟决定),没有按 Kind 手写的模式:
|
||||
|
||||
| 流声明 | 例子 | Probe 的列块 | Agent 补充 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Driver = Probe`,有 `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 通信)。
|
||||
|
||||
新增方法只需要在 `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 数据连接
|
||||
|
||||
- 连接都由 Probe 发起;首帧为 `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 在 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 字段扩展,二进制包、列编码和容器布局没有变化,协议与 `container_version` 保持原版本。双栈采集需要升级 Agent 和 Probe;旧节点继续提供 IPv4。
|
||||
|
||||
### 通用规则
|
||||
|
||||
- Fleet 层的兼容规则见 [../backend/fleet/README.md](../../backend/fleet/README.md)。
|
||||
- `moshiro.*` 方法:新增方法或可选字段不需要升级版本,接收方忽略未知字段。
|
||||
- RawChunk:块头自描述,新增列或新增 Kind 不需要升级 `container_version`;只有容器格式变化时才升级。
|
||||
- UDP 包和数据连接首帧的布局变化时,必须升级 `protocol_version`,并更新黄金字节测试。
|
||||
- Agent 同时支持当前版本和上一个版本的 Probe;Backend 能解码保留期内所有的 `container_version`。
|
||||
@@ -0,0 +1,209 @@
|
||||
# 展示数据查询(前端取数)
|
||||
|
||||
本文定义前端从 Backend 拉取展示数据的方式:时间段、步长、指标选择、响应格式、实时跟随、缓存与限额。端点清单见 [api.md](../api/api.md) 第 2 节;指标与累加器见 [analysis.md](analysis.md)。
|
||||
|
||||
## 1. 设计目标
|
||||
|
||||
1. **任意时间段**:支持 5 分钟、15 分钟这类相对时间段,也支持任意绝对区间。
|
||||
2. **短时间段足够精细**:近 5 分钟精确到 1 秒,近 1 小时精确到 5 秒。
|
||||
3. **汇总精确**:“这段时间内的丢包率、p95”由累加器精确合并得出,**不是各点的平均**。
|
||||
4. **快**:常用的短时间段直接从内存读取;较长的时间段读取预聚合层,现场合并。
|
||||
5. **新增指标时前端不用改**:指标按 id 选取,按分组(preset)展示,含义和单位来自目录。
|
||||
|
||||
## 2. 时间段与步长
|
||||
|
||||
### 2.1 参数
|
||||
|
||||
| 参数 | 取值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `range` | `5m`、`15m`、`30m`、`1h`、`3h`、`6h`、`12h`、`24h`、`7d`、`30d`、`90d`、`365d`,或任意 `<数字><s\|m\|h\|d>` | 相对时间段,`to = now`,可以跟随实时数据 |
|
||||
| `from` + `to` | RFC 3339 UTC | 绝对区间,左闭右开 `[from, to)`;与 `range` 互斥 |
|
||||
| `step` | `auto`(默认),或下列常用步长之一 | 指定的步长比数据源所能提供的更细时,自动降级,并在响应中标记 `degraded` |
|
||||
| `anchor` | `now`(默认)或 `latest` | 只用于序列接口的相对时间段(`range=`):`latest` 以这条线路最后一个样本为终点(最后样本在最近 5 秒内就仍以当前时间为终点)。离线的线路因此显示它最后一段数据,而不是空图;响应里的 `from/to` 是实际使用的窗口,`live` 为 false |
|
||||
| `tz` | IANA 时区,默认 `UTC` | 只影响 ≥ 1d 的分桶对齐,比如“按天”统计时从本地零点切分 |
|
||||
|
||||
常用步长:`1s 2s 5s 10s 15s 30s 1m 2m 5m 10m 15m 30m 1h 2h 3h 6h 12h 1d`
|
||||
|
||||
### 2.2 自动步长
|
||||
|
||||
`step = 常用步长中 ≥ 区间长度 / 720 的最小值`,即每条曲线最多约 720 个点。
|
||||
|
||||
| 时间段 | 步长 | 点数 | 数据源 |
|
||||
| --- | --- | --- | --- |
|
||||
| 5m | 1s | 300 | 内存热层 |
|
||||
| 15m | 2s | 450 | 内存热层 |
|
||||
| 30m | 5s | 360 | 内存热层 |
|
||||
| 1h | 5s | 720 | 内存热层 |
|
||||
| 6h | 30s | 720 | 10s 层 |
|
||||
| 24h | 2m | 720 | 1m 层 |
|
||||
| 7d | 15m | 672 | 5m 层 |
|
||||
| 30d | 1h | 720 | 1h 层 |
|
||||
| 90d | 3h | 720 | 1h 层 |
|
||||
|
||||
- 桶对齐到 UTC 的整数倍步长。
|
||||
- 起点不对齐的绝对区间:第一个桶和最后一个桶可能不完整,会在响应的 `edge_partial` 中标出。
|
||||
|
||||
## 3. 数据分层
|
||||
|
||||
```text
|
||||
精细 ◀────────────────────────────────────────────────────▶ 粗略
|
||||
┌─────────────────┬──────────────┬──────────────┬─────────────┬─────────────┐
|
||||
│ 热层(内存) │ metric_10s │ metric_1m │ metric_5m │ metric_1h │
|
||||
│ 1s 累加器 │ 保留 48 小时 │ 保留 30 天 │ 保留 365 天 │ 永久 │
|
||||
│ 最近 1 小时(环形缓冲)│ SQLite │ SQLite │ SQLite │ SQLite │
|
||||
└────────┬────────┴──────────────┴──────────────┴─────────────┴─────────────┘
|
||||
│ 重启后热层为空 → 按需从原始块回填(最近 1 小时,后台以低优先级执行)
|
||||
原始段文件(30 天)── 超出热层时间范围的 1s~5s 精度查询 → 从原始块现算(区间 ≤ 15 分钟)
|
||||
```
|
||||
|
||||
**选择数据源的规则**:在“覆盖该时间段”并且“粒度能整除步长”的各层中,选择**最粗**的一层,然后把累加器按步长现场合并。
|
||||
|
||||
示例:
|
||||
|
||||
- `step = 30s` 时,取 10s 层,每 3 个桶合并一次;
|
||||
- `step = 2m` 时,取 1m 层,每 2 个桶合并一次。
|
||||
|
||||
合并方式是计数相加、直方图逐桶相加,所以结果精确。
|
||||
|
||||
| 情况 | 处理方式 |
|
||||
| --- | --- |
|
||||
| 时间段在最近 1 小时内,步长 ≥ 1s | 热层 |
|
||||
| 时间段在 1 小时之前、30 天之内,步长 < 10s,区间 ≤ 15 分钟 | 从原始块现算(全局最多同时 2 个查询) |
|
||||
| 同上,但区间 > 15 分钟 | 降级为 10s 层或 1m 层,并标记 `degraded: true` |
|
||||
| 所需的层已经过期 | 降级为下一个更粗的可用层,并标记 `degraded: true` |
|
||||
|
||||
**热层内存估算**:15 条线路 × 3600 秒 × 约 300 B(稀疏直方图 + 计数)≈ 16 MB。热层也是 LiveHub 实时推送的数据来源。
|
||||
|
||||
## 4. 指标选择
|
||||
|
||||
### 4.1 按 id 选取
|
||||
|
||||
`metrics=loss.down,tcp.retrans,rtt.p95`
|
||||
|
||||
id 的格式为 `指标.统计量`,详见 [analysis.md](analysis.md) 第 4 节;所有可用的 id 可以从 `/api/public/catalog/metrics` 获取。
|
||||
|
||||
### 4.2 按分组(preset)选取
|
||||
|
||||
`preset=udp,tcp`
|
||||
|
||||
分组在 `Backend.Analysis` 的注册表中声明,并通过 `/api/public/catalog/presets` 下发给前端。新增指标后,只要把它加进某个分组,前端就会自动展示。
|
||||
|
||||
| 分组 | 指标 |
|
||||
| --- | --- |
|
||||
| `udp` | `loss.up`、`loss.down`、`loss.total`、`rtt.p50`、`rtt.p95`、`rtt.p99`、`jitter.up.p95`、`jitter.down.p95`、`loss.run_max`、`reorder`、`dup` |
|
||||
| `tcp` | `tcp.rtt.p50`、`tcp.rtt.p95`、`tcp.rtt.p99`、`tcp.rtt.p999`、`tcp.rtt.max`、`tcp.rtt.tail`、`tcp.slow`、`tcp.stall.per_min`、`tcp.stall_max`、`tcp.retrans`、`tcp.srtt.p95` |
|
||||
| `connect` | `connect.success`、`connect.tcp_ms.p95`、`connect.tls_ms.p95`、`connect.fail.timeout`、`connect.fail.rst`、`connect.fail.tls` |
|
||||
| `session` | `hold.resets.per_h`、`hold.lifetime_max`、`probe.endpoint_changes` |
|
||||
| `scores` | `score.base`、`score.mc`、`score.hy2`、`score.reality` |
|
||||
|
||||
目录中每个 id 都附带 `unit`、`label`、`description`、`better`(`lower` 或 `higher`,用于前端配色),以及 `thresholds`(优、良、差三档阈值,由模型给出)。
|
||||
|
||||
## 5. 响应格式(列式)
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_id": "agt_…",
|
||||
"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],
|
||||
"values": {
|
||||
"loss.down": [0.0, 0.0, 10.0],
|
||||
"rtt.p95": [48.1, 49.0, 61.2],
|
||||
"tcp.retrans": [0.0, 0.1, null]
|
||||
},
|
||||
"summary": {
|
||||
"metrics": { "loss.down": 0.42, "rtt.p95": 61.3, "tcp.retrans": 0.08 },
|
||||
"samples": { "udp_echo": 8990, "tcp_echo": 17980 },
|
||||
"coverage": 0.998
|
||||
},
|
||||
"edge_partial": { "first": false, "last": true },
|
||||
"gaps": [{ "from": "…", "to": "…", "reason": "probe_disconnected" }],
|
||||
"events": [{ "at": "…", "kind": "endpoint_changed", "detail": "…" }],
|
||||
"versions": { "loss.down": "a1f3…" }
|
||||
}
|
||||
```
|
||||
|
||||
- 时间轴 `t` 使用 Unix 毫秒,只出现一次;`values` 中每个数组的长度都与 `t` 相同,可以直接交给 uPlot 或 ECharts。**这是 [api-guidelines.md](../api/api-guidelines.md) 中“时间用 RFC 3339”规则的明确例外**,理由是减小体积、方便绘图;其余时间字段仍然使用 RFC 3339。
|
||||
- 某个桶没有数据时填 `null`(不填 0),前端据此绘制断线。
|
||||
- `summary` 是**整个时间段**的精确合并结果。`coverage` = 实际数据量 / 期望数据量,用来提示前端“数据是否完整”。
|
||||
- `gaps` 和 `events` 用于在曲线上做标注(断线、端点变化、时钟跳变等)。
|
||||
|
||||
## 6. 端点
|
||||
|
||||
| 端点 | 用途 | 主要参数 |
|
||||
| --- | --- | --- |
|
||||
| `GET /api/public/overview` | 首页排行:每条线路在该时间段内的汇总,并支持排序 | `range`(默认 `5m`)、`preset` 或 `metrics`、`sort`、`order` |
|
||||
| `GET /api/public/lines/{line_id}/series` | 单条线路的时序 | `range` 或 `from/to`、`step`、`preset` 或 `metrics` |
|
||||
| `GET /api/public/compare` | 多条线路对比(时间轴对齐) | `agents`、`metric`(单个)、`range` 或 `from/to`、`step` |
|
||||
| `GET /api/public/lines/{line_id}/distribution` | 该时间段内的分布(直方图、CDF) | `metric`(直方图类,如 `rtt`、`jitter.down`、`tcp.rtt`)、`range` 或 `from/to` |
|
||||
| `GET /api/public/lines/{line_id}/heatmap` | 时间 × 延迟桶的热力图(类似 smokeping) | `metric`、`range` 或 `from/to`、`step` |
|
||||
| `GET /api/public/catalog/presets` | 分组定义 | — |
|
||||
|
||||
`overview` 的响应示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"range": "15m", "from": "…", "to": "…",
|
||||
"lines": [
|
||||
{ "agent_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 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`compare` 的响应示例:`{ "t":[…], "values":{ "agt_a":[…], "agt_b":[…] }, "summary":{ "agt_a":0.1, "agt_b":0.4 } }`
|
||||
|
||||
`distribution` 的响应示例:`{ "metric":"rtt", "unit":"ms", "buckets":[{"le":40.0,"count":812}, …], "p50":44.2, "p95":61.0, "p99":140.2, "count":8990 }`
|
||||
|
||||
`heatmap` 的响应示例:`{ "t":[…], "edges":[…], "counts":[[…],[…]] }`,其中 `counts[i]` 是时间桶 `t[i]` 在各个延迟桶中的计数。
|
||||
|
||||
## 7. 实时跟随
|
||||
|
||||
```text
|
||||
前端选择“近 15 分钟”
|
||||
① GET /lines/{id}/series?range=15m&preset=udp,tcp → 一次性获取 450 个点和汇总
|
||||
② SSE /api/public/live?topics=series:{id}:2s:udp,tcp → 每当原始块到达(约每 10 s),
|
||||
推送一批新的 2s 桶和最新的汇总
|
||||
event: series
|
||||
data: {"agent_id":"…","step":"2s","t":[…5 个],"values":{…},"summary":{…}}
|
||||
③ 前端把新点追加到末尾,并丢弃超出窗口的旧点;用推送来的汇总替换原来的汇总
|
||||
|
||||
前端切换为“自定义 09:00–10:00”→ 只执行 ①(live=false)
|
||||
```
|
||||
|
||||
- SSE 的订阅主题格式为 `series:{line_id}:{step}:{preset}`,只允许 ≤ 1m 的步长;更粗的步长由前端定时重新拉取即可。
|
||||
- `overview` 也可以跟随实时:订阅 `overview:{range}:{preset}`,每 10 s 推送一次最新汇总。
|
||||
- 实时延迟:取决于原始块的切块节奏(10 s)加上处理时间,**典型值为 10~12 s**。1s 步长的点会按批次到达,而不是每秒一个。
|
||||
|
||||
## 8. 缓存与限额
|
||||
|
||||
| 请求类型 | 缓存策略 |
|
||||
| --- | --- |
|
||||
| 已经结束的绝对区间(`to` 早于当前时间 − 2 分钟) | `Cache-Control: public, max-age=31536000, immutable`,并带 ETag(由参数和指标版本计算) |
|
||||
| 相对时间段(`to = now`) | `Cache-Control: no-store` |
|
||||
| 目录(catalog) | `max-age=300`,并带 ETag |
|
||||
|
||||
| 限额 | 默认值 | 超出时 |
|
||||
| --- | --- | --- |
|
||||
| 单次响应的点数 | 每条曲线 ≤ 2000 | 422 `range_too_large`,并给出建议的步长 |
|
||||
| 单次请求的指标数 | ≤ 32 | 422 `too_many_metrics` |
|
||||
| `compare` 的线路数 | ≤ 20 | 422 |
|
||||
| 从原始块现算的查询 | 全局最多同时 2 个,区间 ≤ 15 分钟 | 429 `too_many_requests`(带 `Retry-After`)或者降级 |
|
||||
| 查询超时 | 10 s | 503 |
|
||||
|
||||
## 9. 实现位置
|
||||
|
||||
```text
|
||||
Backend.Api/Public/SeriesEndpoints.cs 参数解析、校验、缓存头(不含业务)
|
||||
Backend.Application/Query/
|
||||
├─ RangeResolver range 或 from/to 与 step → 对齐后的桶序列
|
||||
├─ SourcePlanner 选择数据源(热层 / 10s / 1m / 5m / 1h / 原始块现算),判断是否降级
|
||||
├─ SeriesQuery 读取累加器 → 按步长合并 → 导出统计量 → 列式结果
|
||||
├─ SummaryQuery 整个时间段的精确合并(overview 和 series 共用)
|
||||
└─ DistributionQuery 合并直方图 → 分桶、CDF、热力图
|
||||
Backend.Application/Live/LiveHub 热层环形缓冲(1s,1 小时)+ SSE 主题
|
||||
Backend.Analysis 累加器的合并与导出(与持久化路径共用同一份代码)
|
||||
```
|
||||
|
||||
新增指标时,这套查询代码不需要任何改动:`SeriesQuery` 只通过注册表按 id 读取累加器并导出值。
|
||||
@@ -0,0 +1,193 @@
|
||||
# Backend 存储
|
||||
|
||||
实现位于 `MoShiro.Backend.Storage.Sqlite`,负责实现 `Backend.Application` 中定义的端口接口,**只做读写,不含业务逻辑**。降采样和分析由 Application 调用 Analysis 完成。
|
||||
|
||||
## 1. 目录布局
|
||||
|
||||
```text
|
||||
data/
|
||||
├─ moshiro.db # SQLite:配置、身份、原始块索引、指标、测试结果
|
||||
└─ raw/
|
||||
└─ 2026-09-27/ # 按 UTC 日期分目录,保留期满整目录删除
|
||||
├─ agt_01J…/ # 每条线路或每个 Agent 一个目录
|
||||
│ ├─ udp_echo.seg # 段文件:同一天、同一 Kind 的块依次追加
|
||||
│ ├─ tcp_echo.seg
|
||||
│ └─ tsk_…_icmp_echo.seg # 目标任务
|
||||
└─ tests/tst_01J…/phase0.seg …
|
||||
```
|
||||
|
||||
- **段文件**:把收到的 RawChunk 原样追加写入(块本身已经带 crc,并且自描述),SQLite 只记录 `(segment, offset, length)`。每 10 秒一块也不会产生海量小文件。
|
||||
- **写入顺序**:追加写入 → `fsync`(每批合并执行一次)→ 插入索引 → 由收件箱确认(Fleet 回复 `fleet.outbox.ack`)。启动时截断没有索引记录的段文件尾部。
|
||||
- **去重**:`chunk_id` 已存在时,直接回复 ACK。
|
||||
|
||||
## 2. SQLite 配置
|
||||
|
||||
- `journal_mode=WAL`、`synchronous=NORMAL`、`foreign_keys=ON`、`busy_timeout=5000`。
|
||||
- **单写入者**:所有写操作经过一个有界 `Channel`,由一个后台循环执行;读操作使用独立的只读连接池。
|
||||
- 时间统一存储为 Unix 毫秒 `INTEGER`(UTC);表结构版本记录在 `PRAGMA user_version`,迁移脚本为内置 SQL。
|
||||
|
||||
## 3. 表结构
|
||||
|
||||
`agents`、`join_tokens` 以及公钥和状态版本字段,同时作为 Fleet 的 `IFleetStore` 存储(由 `MoShiro.Backend.Storage.Sqlite` 实现),和 MoShiro 的其他数据放在同一个 `moshiro.db` 中,由同一个单写入者写入。CI 签名的发布清单存在 `settings` 表里(`release.<组件>.manifest`、`release.<组件>.signature`),面板只原样保存和转发。
|
||||
|
||||
```sql
|
||||
-- 身份与注册 -------------------------------------------------------------
|
||||
CREATE TABLE agents (
|
||||
id TEXT PRIMARY KEY, -- agt_<ULID>
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
status TEXT NOT NULL, -- pending | active | disabled
|
||||
pubkey BLOB NOT NULL, -- ECDSA P-256 身份公钥
|
||||
tls_spki BLOB NOT NULL, -- 面向 Probe 的证书指纹(由 tls_pubkey 算出)
|
||||
tls_pubkey BLOB, -- 面向 Probe 的证书公钥,Agent 在 hello 里上报;Probe 用它固定证书、派生敲门密钥
|
||||
public_host TEXT NOT NULL, -- 自动识别(Agent 上报的公网地址),手填后标记 public_host_manual,不再被覆盖
|
||||
probe_tcp_port INTEGER NOT NULL, probe_udp_ports TEXT NOT NULL,
|
||||
labels TEXT NOT NULL DEFAULT '[]',
|
||||
public_visible INTEGER NOT NULL DEFAULT 1, -- 是否在 public API 中展示
|
||||
kinds TEXT, agent_version TEXT, local_limits TEXT, -- 来自 fleet.hello
|
||||
task_version INTEGER NOT NULL DEFAULT 0, applied_task_version INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL, last_seen_at INTEGER, row_version INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE join_tokens (
|
||||
id TEXT PRIMARY KEY, token_sha256 BLOB NOT NULL UNIQUE, note TEXT,
|
||||
labels TEXT NOT NULL DEFAULT '[]', auto_approve INTEGER NOT NULL,
|
||||
max_uses INTEGER NOT NULL, uses INTEGER NOT NULL DEFAULT 0,
|
||||
expires_at INTEGER NOT NULL, revoked_at INTEGER, created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE host_samples ( -- 主机资源占用,每个 Agent 每分钟一行(schema v3)
|
||||
agent_id TEXT NOT NULL, at INTEGER NOT NULL,
|
||||
cpu REAL, mem_used INTEGER, mem_total INTEGER, disk_used INTEGER, disk_total INTEGER,
|
||||
net_rx_bps INTEGER, net_tx_bps INTEGER, uptime INTEGER,
|
||||
PRIMARY KEY (agent_id, at)
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE TABLE probes ( -- 每个 Probe 身份一行(吊销只设 revoked_at)
|
||||
id TEXT PRIMARY KEY, name TEXT NOT NULL, pubkey BLOB NOT NULL,
|
||||
created_at INTEGER NOT NULL, revoked_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE,
|
||||
password_hash BLOB NOT NULL, password_salt BLOB NOT NULL, iterations INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE sessions (
|
||||
id_sha256 BLOB PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
csrf_token BLOB NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE api_tokens (
|
||||
id TEXT PRIMARY KEY, name TEXT NOT NULL, scope TEXT NOT NULL, -- read | admin
|
||||
token_sha256 BLOB NOT NULL UNIQUE, created_at INTEGER NOT NULL, last_used_at INTEGER, revoked_at INTEGER
|
||||
);
|
||||
CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL); -- 如 site.public = true
|
||||
|
||||
-- 任务 ------------------------------------------------------------------
|
||||
CREATE TABLE tasks (
|
||||
id TEXT PRIMARY KEY, -- tsk_<ULID>
|
||||
scope TEXT NOT NULL, -- line | target
|
||||
agent_id TEXT REFERENCES agents(id) ON DELETE CASCADE, -- 为 NULL 时,是适用于所有 Agent 的 line 模板
|
||||
base_task_id TEXT REFERENCES tasks(id) ON DELETE CASCADE, -- 针对某个 Agent 覆盖模板时,指向模板
|
||||
kind TEXT NOT NULL,
|
||||
target TEXT, -- 仅 target 任务
|
||||
params TEXT NOT NULL DEFAULT '{}',
|
||||
schedule TEXT NOT NULL, -- continuous | every:<分钟>
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at INTEGER NOT NULL, row_version INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE tests ( -- 按需测试(once)
|
||||
id TEXT PRIMARY KEY, agent_id TEXT NOT NULL, plan TEXT NOT NULL, params TEXT NOT NULL,
|
||||
trigger TEXT NOT NULL, -- api | schedule:<task_id>
|
||||
status TEXT NOT NULL, -- queued | running | succeeded | failed | aborted
|
||||
error_code TEXT, requested_at INTEGER NOT NULL, started_at INTEGER, finished_at INTEGER
|
||||
);
|
||||
CREATE INDEX ix_tests_agent ON tests (agent_id, requested_at);
|
||||
|
||||
-- 原始数据索引 ------------------------------------------------------------
|
||||
CREATE TABLE raw_segments (
|
||||
id INTEGER PRIMARY KEY, day TEXT NOT NULL, path TEXT NOT NULL UNIQUE, bytes INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE raw_chunks (
|
||||
chunk_id TEXT PRIMARY KEY, segment_id INTEGER NOT NULL, offset INTEGER NOT NULL, length INTEGER NOT NULL,
|
||||
agent_id TEXT NOT NULL, series TEXT NOT NULL, -- line | target:<task_id>
|
||||
kind TEXT NOT NULL, task_id TEXT, test_id TEXT, phase INTEGER,
|
||||
start_ts INTEGER NOT NULL, duration_ms INTEGER NOT NULL, partial INTEGER NOT NULL
|
||||
) WITHOUT ROWID;
|
||||
CREATE INDEX ix_raw_series ON raw_chunks (agent_id, series, start_ts);
|
||||
CREATE INDEX ix_raw_test ON raw_chunks (test_id) WHERE test_id IS NOT NULL;
|
||||
|
||||
-- 指标(通用存储:每个桶一行,内容为 指标id → 累加器状态 的映射)----------------
|
||||
-- 分层:metric_10s(48 小时)、metric_1m(30 天)、metric_5m(365 天)、metric_1h(永久)
|
||||
-- 另有 1s 精度的内存热层(最近 1 小时,不落盘),见 query.md 第 3 节
|
||||
CREATE TABLE metric_1m ( -- 60 s
|
||||
agent_id TEXT NOT NULL, series TEXT NOT NULL, window_start INTEGER NOT NULL,
|
||||
partial INTEGER NOT NULL,
|
||||
metrics BLOB NOT NULL, -- AccumulatorCodec 编码的 {metric_id: state}
|
||||
PRIMARY KEY (agent_id, series, window_start)
|
||||
) WITHOUT ROWID;
|
||||
-- metric_10s 结构相同(由流式路径直接写入,不经过降采样)
|
||||
-- metric_5m / metric_1h 结构相同(window_start 换成 bucket_start,另加 windows 计数)
|
||||
|
||||
CREATE TABLE test_results (
|
||||
test_id TEXT PRIMARY KEY, metrics BLOB NOT NULL, series BLOB -- series 为逐秒或逐档的曲线
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE TABLE metric_versions (metric_id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL,
|
||||
recompute_from INTEGER, recompute_cursor INTEGER);
|
||||
|
||||
CREATE TABLE analysis_queue (
|
||||
agent_id TEXT NOT NULL, series TEXT NOT NULL, window_start INTEGER NOT NULL,
|
||||
not_before INTEGER NOT NULL, reason TEXT NOT NULL, -- new | late_chunk | recompute
|
||||
PRIMARY KEY (agent_id, series, window_start)
|
||||
) WITHOUT ROWID;
|
||||
CREATE TABLE rollup_dirty (level INTEGER NOT NULL, agent_id TEXT NOT NULL, series TEXT NOT NULL,
|
||||
bucket_start INTEGER NOT NULL, PRIMARY KEY (level, agent_id, series, bucket_start)) WITHOUT ROWID;
|
||||
|
||||
-- 事件 ------------------------------------------------------------------
|
||||
CREATE TABLE events (id INTEGER PRIMARY KEY, agent_id TEXT, kind TEXT NOT NULL,
|
||||
at INTEGER NOT NULL, detail TEXT);
|
||||
CREATE INDEX ix_events_time ON events (at);
|
||||
```
|
||||
|
||||
**要点:新增 Kind 或指标都不需要改表结构。** 指标是 `metric_*.metrics` 里的一个键;Kind 只是 `raw_chunks.kind` 的一个取值。
|
||||
|
||||
## 4. 保留
|
||||
|
||||
| 数据 | 默认保留 |
|
||||
| --- | --- |
|
||||
| 持续任务的原始段文件 | **30 天** |
|
||||
| 按需测试的原始段文件 | 90 天 |
|
||||
| 热层(1s,内存) | 最近 1 小时 |
|
||||
| `metric_10s` | 48 小时 |
|
||||
| `metric_1m` | 30 天 |
|
||||
| `metric_5m` | 365 天 |
|
||||
| `metric_1h`、`tests`、`test_results` | 永久 |
|
||||
| `events` | 365 天 |
|
||||
|
||||
保留期在管理端的设置里改,对应的键:`retention.raw_days`、`retention.test_raw_days`、`retention.metric_10s_hours`、`retention.metric_1m_days`、`retention.metric_5m_days`、`retention.events_days`。
|
||||
|
||||
降采样由 Application 中的 `Downsampler` 负责:读取下一级的累加器状态,用 `AccumulatorCodec.Merge` 合并后写回。所有保留期都可以配置。
|
||||
|
||||
## 5. 容量估算
|
||||
|
||||
以 15 个 Agent、默认模板(`udp_echo` 10 Hz + `tcp_echo` 20 Hz)为例:
|
||||
|
||||
| 数据 | 估算 |
|
||||
| --- | --- |
|
||||
| 原始数据 | 每条线路约 7 MB/天(udp_echo)+ 14 MB/天(tcp_echo 及内核采样)≈ 21 MB/天;15 条线路约 0.3 GB/天,30 天约 **9.5 GB** |
|
||||
| 按需测试 | 一次 `hy2_ladder` 约 1.5 MB;每条线路每天 4 次,90 天约 8 GB |
|
||||
| SQLite | 指标窗口和降采样约 1~2 GB/年 |
|
||||
|
||||
写入量约每秒 3 块,单写入者绰绰有余。M5 阶段会用模拟数据验证这些估算。
|
||||
|
||||
## 6. 查询
|
||||
|
||||
时间段、自动步长、数据源选择、现场合并和降级规则见 [query.md](query.md)。
|
||||
|
||||
## 7. 与实现的差异(以 `Schema.cs` 为准)
|
||||
|
||||
- `agents`:`name` 不再 `UNIQUE`,改为“已建立(非 pending)节点之间唯一”的部分唯一索引,pending 节点可以重名;新增 `task_hash`(有效任务集的哈希,用来判断版本是否要递增)、`rejected`(节点上报的拒绝原因)、`meta`(注册元数据)。
|
||||
- `tests`:新增 `phase`、`phase_count`;`test_results` 的 `metrics` / `series` 保存 JSON。
|
||||
- `analysis_queue`:新增 `seq`,每次入队递增;完成时只删除序号没变的项,处理期间到达的迟到块不会丢更新。
|
||||
- `rollup_dirty`:先处理低层(5 分钟)再处理高层(1 小时)。
|
||||
Reference in new issue
Block a user