chore: initial project import
This commit is contained in:
commit
26620707a0
347 files changed
+56172
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/Monitor.Backend.Analysis/ 依赖:Monitor.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/monitor-analyze`,对下载的原始块做实验。
|
||||
@@ -0,0 +1,141 @@
|
||||
# API 设计约束
|
||||
|
||||
本文是所有 Backend REST 端点都必须遵守的**规则**;具体端点见 [api.md](api.md)。规则参考了 [Microsoft Azure REST API Guidelines](https://github.com/microsoft/api-guidelines/blob/vNext/azure/Guidelines.md) 和 RFC 9457,并根据本项目做了取舍。内部协议(Probe↔Agent、Agent↔Backend)的兼容规则见 [protocol.md](protocol.md) 第 4 节。
|
||||
|
||||
## 1. 契约生成链路
|
||||
|
||||
```text
|
||||
Minimal API 端点(TypedResults 返回类型 + 显式元数据)
|
||||
│ dotnet build(Microsoft.Extensions.ApiDescription.Server)
|
||||
▼
|
||||
backend/openapi/v1.json(OpenAPI 3.1,提交到仓库)
|
||||
│ CI:git diff --exit-code,未提交的变更会导致失败
|
||||
▼
|
||||
web:openapi-typescript 生成类型,调用方的类型错误会直接暴露
|
||||
```
|
||||
|
||||
- 端点**必须**声明精确的返回类型,例如 `Results<Ok<AgentDto>, NotFound<ProblemDetails>>`,不能返回 `IResult` 或匿名对象,否则 OpenAPI 文档会不准确。
|
||||
- 每个端点都必须设置 `WithName("agents_list")`(即 operationId,snake_case、`资源_动作`)、`WithSummary`、`WithTags`。
|
||||
- 请求和响应 DTO 放在 `Monitor.Backend.Api/Contracts/`,并注册到 Backend 的 JSON 源生成上下文。**不要直接暴露 `Monitor.Protocol` 中的线协议类型**,因为 REST 与线协议的演进节奏不同。
|
||||
- **结构强类型,指标弱类型**:资源结构(Agent、任务、测试等)必须是强类型 DTO;而**指标值和评分**统一使用字典 `metrics: { "<metric_id>": number }`、`scores: { "<model_id>": number }`,键的含义、单位、版本由 `/api/public/catalog/*` 给出。这样新增指标或模型不需要改 OpenAPI,也不需要改前端类型。
|
||||
|
||||
## 2. 版本与兼容性
|
||||
|
||||
- 当前版本为 v1,路径前缀为 `/api/public`、`/api/admin`、`/api/agent/v1`。出现破坏性变更时,新增 `/api/v2/public`、`/api/v2/admin` 等路径。v1 内只允许**非破坏性变更**:
|
||||
|
||||
| 非破坏性变更(v1 内允许) | 破坏性变更(需要新建 v2) |
|
||||
| --- | --- |
|
||||
| 新增端点 | 删除或重命名端点、字段 |
|
||||
| 响应中新增字段 | 修改字段类型、单位或语义 |
|
||||
| 新增可选的请求字段或查询参数 | 可选字段改为必填 |
|
||||
| 枚举新增值 | 删除枚举值 |
|
||||
| 放宽校验 | 收紧校验、修改默认值 |
|
||||
| 新增错误码 | 修改已有错误码对应的 HTTP 状态 |
|
||||
|
||||
- 客户端必须忽略未知字段,也必须能处理未知的枚举值。
|
||||
- 需要 v2 时,v1 和 v2 至少并存一个发布周期;OpenAPI 文档按版本分文件(`v1.json`、`v2.json`)。
|
||||
|
||||
## 3. URL 与方法
|
||||
|
||||
- **按调用方分层**:`/api/public`(只读展示)、`/api/admin`(管理)、`/api/agent`(节点)。同一个资源在不同层中可以有不同的 DTO,例如 public 层不暴露 `pubkey`、`local_limits`。
|
||||
- 路径全部小写,资源名用复数名词,多个单词用 `-` 连接:`/api/admin/join-tokens`。
|
||||
- 嵌套最多一层;子资源一旦有全局唯一 ID,就提供顶层路径(`/probes/{probe_id}`)。
|
||||
- 查询参数和 JSON 字段使用 snake_case。
|
||||
|
||||
| 操作 | 方法 | 成功状态 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 列表 | GET 集合 | 200 | 分页 |
|
||||
| 读取 | GET 单个 | 200 | |
|
||||
| 创建 | POST 集合 | 201 + `Location` | 响应体返回完整资源 |
|
||||
| 部分更新 | PATCH | 200 | `application/merge-patch+json`(RFC 7396) |
|
||||
| 删除 | DELETE | 204 | 资源不存在时同样返回 204(幂等) |
|
||||
| 非 CRUD 动作 | POST `/{resource}/{id}/{verb}` | 200 或 204 | 例如 `revoke`、`rotate-token` |
|
||||
|
||||
- GET 请求不能产生副作用。PUT 不使用:配置类资源都通过 PATCH 修改。
|
||||
|
||||
## 4. 数据表示
|
||||
|
||||
| 类别 | 规则 | 示例 |
|
||||
| --- | --- | --- |
|
||||
| 时间点 | RFC 3339 UTC,以 `Z` 结尾,精确到毫秒。**唯一例外**:列式时序响应中的时间轴数组 `t` 使用 Unix 毫秒(见 [query.md](query.md)) | `2026-09-27T10:00:00.000Z` |
|
||||
| 时长 | 整数 + 单位后缀 | `interval_ms: 1000` |
|
||||
| 延迟 | 带小数的毫秒值(指标字典中的单位见目录) | `rtt_p95_ms: 58.93` |
|
||||
| 百分比 | 0–100 的数字 + `_pct` | `loss_pct: 0.5` |
|
||||
| 计数 | 整数,不使用浮点数 | `attempts: 600` |
|
||||
| ID | 带类型前缀的 ULID 字符串 | `prb_01J9Z...` |
|
||||
| 枚举 | snake_case 字符串 | `"udp_echo"` |
|
||||
| 缺失值 | 字段始终存在;未知值写 `null`,不省略字段 | `"last_seen_at": null` |
|
||||
| 布尔值 | 使用 `is_`/`has_` 前缀,或者用意义明确的形容词 | `online`、`enabled` |
|
||||
|
||||
## 5. 错误
|
||||
|
||||
所有 4xx/5xx 响应都使用 `application/problem+json`(RFC 9457),并附加扩展字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "about:blank",
|
||||
"title": "Validation failed",
|
||||
"status": 400,
|
||||
"code": "validation_failed",
|
||||
"detail": "interval_ms must be >= 200",
|
||||
"trace_id": "00-4bf92f...-01",
|
||||
"errors": { "interval_ms": ["must be >= 200"] }
|
||||
}
|
||||
```
|
||||
|
||||
- `code` 取自共享的 `ErrorCode` 枚举,是客户端做判断的**唯一**依据;`title`/`detail` 只供人阅读,内容可能变化。
|
||||
- `errors` 只在 400 `validation_failed` 中出现,键名是 JSON 字段路径。
|
||||
- 5xx 响应不能泄露异常信息或调用栈,只提供 `trace_id`,用于在日志中定位问题。
|
||||
|
||||
| 状态码 | 使用场景 |
|
||||
| --- | --- |
|
||||
| 400 | 请求格式错误或校验失败 |
|
||||
| 401 | 未认证 |
|
||||
| 404 | 资源不存在 |
|
||||
| 403 | 已认证但作用域不足 |
|
||||
| 409 | 名称冲突、状态冲突(例如 `probe_offline`) |
|
||||
| 410 | 资源曾经存在但已过保留期(例如 `raw_expired`) |
|
||||
| 412 | `If-Match` 不匹配 |
|
||||
| 413 | 请求体超过上限 |
|
||||
| 422 | 语义上无法满足(例如 `range_too_large`) |
|
||||
| 429 | 超过限流,需要带 `Retry-After` |
|
||||
| 503 | 存储不可用,或正在关闭 |
|
||||
|
||||
## 6. 集合与查询
|
||||
|
||||
- 分页由服务端驱动:`?limit=`(默认 50,最大 500)和 `?cursor=`(不透明字符串);响应格式为 `{ "items": [...], "next_cursor": string | null }`。排序必须稳定(按 ID 或时间 + ID)。
|
||||
- 时间范围参数统一使用 `from`/`to`,区间为左闭右开 `[from, to)`。缺省时 `to = now`,`from = to − 1h`。
|
||||
- 所有查询都有上限(点数、时间跨度、结果条数);超出时返回 422 并在 `detail` 中给出可行的替代方案,不能悄悄截断。
|
||||
|
||||
## 7. 并发控制
|
||||
|
||||
- 配置类资源(Agent、Probe、任务)在响应中返回 `ETag`,PATCH/DELETE 支持 `If-Match`,不匹配时返回 412。没有带 `If-Match` 的请求按“最后写入者获胜”处理。
|
||||
|
||||
## 8. 安全
|
||||
|
||||
- 只提供 HTTPS。认证方式有两种:
|
||||
- 浏览器使用 Cookie 会话:`HttpOnly; Secure; SameSite=Strict`;所有非 GET 请求都必须带 `X-CSRF-Token` 请求头;
|
||||
- 脚本使用 `Authorization: Bearer <api_token>`,作用域为 `read` 或 `admin`。
|
||||
- 凭据不能放在 URL 或查询参数中;SSE 依靠 Cookie 或同源令牌认证。
|
||||
- 登录接口需要限流(例如每个 IP 每分钟 10 次),失败时统一返回 401,不区分“用户不存在”和“密码错误”。
|
||||
- public 层根据站点设置和 `public_visible` 过滤数据:被隐藏的资源对匿名访客一律返回 404,而不是 403。
|
||||
- 令牌明文只在创建或轮换的响应中出现一次;这类响应带 `Cache-Control: no-store`。
|
||||
- 请求体上限为 64 KiB;查询接口有超时(默认 10 秒)。
|
||||
|
||||
## 9. 实时推送(SSE)
|
||||
|
||||
- 实时数据使用 `text/event-stream`。`event` 名称就是数据类型(`line`、`node`、`point`、`test`);`data` 是单行 JSON,遵守第 4 节的数据表示规则。
|
||||
- 每 15 秒发送一行注释作为心跳。重连后服务端先推送当前快照,再推送增量,不补发历史。
|
||||
- 订阅的主题数和连接数都有上限,超出时返回 429 `too_many_streams`。
|
||||
- 推送的内容必须能由对应的 REST 查询重建:SSE 只负责“更快地获得变化”,不承载独有的数据。
|
||||
|
||||
## 10. 新增端点检查清单
|
||||
|
||||
1. 符合第 3 节的路径与方法规则,并有 operationId、summary 和 tag;
|
||||
2. 返回类型精确(`TypedResults`),错误使用 problem+json,错误码来自 `ErrorCode`;
|
||||
3. DTO 符合第 4 节的数据表示规则,并注册到 JSON 源生成上下文;
|
||||
4. 列表支持分页,查询有上限;
|
||||
5. 选择正确的层(public / admin / agent),public 层只暴露可以公开的字段;
|
||||
6. 重新生成并提交 `openapi/v1.json`,在 PR 中说明是否属于破坏性变更;
|
||||
7. 测试覆盖:成功路径、校验失败、404、鉴权失败、隐藏资源;
|
||||
8. 同步更新 [api.md](api.md)。
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
# Backend API 规范(v1)
|
||||
|
||||
本文列出 Backend 的全部对外接口。通用规则见 [api-guidelines.md](api-guidelines.md);OpenAPI 3.1 文档在运行时由 `/openapi/v1.json` 提供,构建时同时生成快照 `backend/openapi/v1.json`。两者冲突时,以 OpenAPI 为准。
|
||||
|
||||
## 1. 分层
|
||||
|
||||
```text
|
||||
/api/public/* 只读。站点设为公开时匿名可访问,设为私有时需要登录或只读令牌;
|
||||
public_visible = false 的 Agent 对匿名访客隐藏
|
||||
/api/admin/* 管理。需要 Cookie 会话(含 CSRF 校验)或 admin 作用域的 API 令牌
|
||||
/api/agent/v1/* 节点专用:注册(使用加入令牌)和 RPC(使用签名认证)
|
||||
/healthz /metrics /openapi/v1.json /install.sh /install.ps1
|
||||
```
|
||||
|
||||
资源总览:
|
||||
|
||||
```text
|
||||
/api/public
|
||||
├─ overview 首屏快照:所有线路的在线状态、最新指标、评分
|
||||
├─ live SSE 实时推送
|
||||
├─ lines/{line_id} 线路详情(线路 = Agent 与 Probe 的一对,id 形如 agt_…~prb_…)
|
||||
├─ lines/{line_id}/series 历史曲线(指标与评分)
|
||||
├─ compare 多条线路对比
|
||||
├─ tests/{test_id} 测试结果
|
||||
└─ catalog/{kinds|plans|metrics|models} 目录(由代码注册表生成)
|
||||
|
||||
/api/admin
|
||||
├─ auth/{login|logout|me}
|
||||
├─ agents agents/{id} agents/{id}/{approve|disable|enable|takeover}
|
||||
├─ join-tokens
|
||||
├─ probes probes/{id} probes/{id}/{rotate|revoke} Probe 身份(后端生成、吊销、删除)
|
||||
├─ tasks tasks/{id} 线路模板、针对单个 Agent 的覆盖、目标任务
|
||||
├─ lines/{line_id}/tests 在某条线路(某个 Probe)上发起按需测试;tests/{id}/cancel
|
||||
├─ raw/chunks raw/chunks/{id} 原始数据检索与下载
|
||||
├─ releases 应用版本、灰度、批准(Fleet.Server.Releases)
|
||||
├─ events analysis/recompute api-tokens settings users
|
||||
```
|
||||
|
||||
## 2. Public API
|
||||
|
||||
> 展示数据的查询规则(时间段、步长、分层、指标分组、列式响应、实时跟随、缓存)以 [query.md](query.md) 为准。本节只列出端点。
|
||||
|
||||
### 2.1 首屏排行
|
||||
|
||||
`GET /api/public/overview?range=5m&preset=udp,tcp,scores&sort=score.mc&order=desc`
|
||||
|
||||
- 返回每条线路在该时间段内的精确汇总,以及在线状态、会话信息(源地址变化次数)、最近一次测试的摘要;
|
||||
- `range` 默认 `5m`,也可以写 `from/to`;
|
||||
- 汇总字段的格式见 [query.md](query.md) 第 6 节。
|
||||
|
||||
### 2.2 实时推送(SSE)
|
||||
|
||||
`GET /api/public/live?topics=nodes,overview:15m:udp,series:agt_…:2s:udp,tcp,test:tst_…`(`text/event-stream`)
|
||||
|
||||
| 事件 | 订阅主题 | 频率 | 内容 |
|
||||
| --- | --- | --- | --- |
|
||||
| `node` | `nodes` | 上下线时 | `{kind, id, online, at}` |
|
||||
| `overview` | `overview:{range}:{preset}` | 约每 10 s | 每条线路的最新汇总 |
|
||||
| `series` | `series:{line_id}:{step}:{preset}`(step ≤ 1m) | 约每 10 s | 新增的桶(列式)和最新的汇总 |
|
||||
| `host` | `hosts` | Agent 每次上报(约 2 s) | `{agent_id, online, host}`:Agent 主机资源占用的最新快照(连接时先推一份当前的) |
|
||||
| `test` | `test:{test_id}` | 状态变化时 | `{test_id, status, phase, phases}` |
|
||||
|
||||
- 每 15 秒发送一行注释作为心跳;断线后浏览器会自动重连,服务端先推送当前快照,再推送增量,不补发历史。
|
||||
- 单个连接最多订阅 32 个主题;每个客户端最多 4 个 SSE 连接。
|
||||
|
||||
### 2.3 时序、对比、分布
|
||||
|
||||
| 端点 | 说明 |
|
||||
| --- | --- |
|
||||
| `GET /api/public/lines/{line_id}/series?range=15m&preset=udp,tcp` | 单条线路的时序(列式),附带整个时间段的精确汇总、缺口和事件 |
|
||||
| `GET /api/public/compare?lines=a~p1,b~p1,c~p2&metric=loss.down&range=1h` | 多条线路的时间轴对齐对比 |
|
||||
| `GET /api/public/lines/{line_id}/distribution?metric=rtt&range=15m` | 该时间段内的直方图和 CDF |
|
||||
| `GET /api/public/lines/{line_id}/heatmap?metric=rtt&range=6h` | 时间 × 延迟桶的热力图 |
|
||||
|
||||
参数、响应示例和限额见 [query.md](query.md)。
|
||||
|
||||
线路详情 `GET /api/public/lines/{line_id}` 带 `data_since`:这条线路最早的数据是什么时候(没有数据是 null),前端据此只提供「有数据」的时间范围。**展示端的响应里没有 Probe 的版本和来源地址**(那些只在管理端)。
|
||||
|
||||
### 2.3.1 Agent 的主机资源占用
|
||||
|
||||
| 端点 | 内容 |
|
||||
| --- | --- |
|
||||
| `GET /api/public/hosts` | 每个对外可见的 Agent 的最新快照:`{agent_id, name, online, stale, last_seen_at, host}`;超过 15 s 没有更新 `stale` 为真(离线,或者 Agent 关了上报) |
|
||||
| `GET /api/public/agents/{agent_id}/host?range=1h` | 历史曲线(列式,按步长取平均;`range` 从 1 小时到 30 天):`cpu`、`mem`、`disk`(百分比)、`rx_bps`、`tx_bps` |
|
||||
|
||||
`host` 只有汇总的数字:CPU 占用、内存与根分区的已用和总量、网卡累计收发字节与瞬时网速、系统运行时长。**没有 CPU 型号、主机名、网卡名,也没有任何 IP**——Agent 不采集这些,公开接口当然也不会返回;有测试断言响应里不出现这类字段。实时更新走 SSE 的 `hosts` 主题,历史每个 Agent 每分钟存一个样本(`retention.host_days`,默认 7 天)。
|
||||
|
||||
### 2.4 测试结果与目录
|
||||
|
||||
- `GET /api/public/tests/{test_id}`:包括计划、参数、状态、阶段,`metrics` 与 `scores`,`series`(例如码率–丢包曲线),以及置信说明 `caveats`。
|
||||
- `GET /api/public/catalog/kinds`:每个 Kind 的积木组合与 `ParamSpec`(默认值和上下限)。
|
||||
- `GET /api/public/catalog/plans`、`/metrics`(id、单位、标签、说明、`better`、阈值、来源 Kind、版本)、`/presets`(指标分组)、`/models`(评分维度与所需指标)。
|
||||
|
||||
### 2.5 跨域访问
|
||||
|
||||
默认前端和 Backend 同源(Cookie 会话)。前端在别的来源时(比如本机跑前端、连线上的 Backend),Backend 设 `Monitor__CorsOrigins`(逗号分隔的来源,或 `*`;默认空 = 不允许)。只对 `/api/*` 生效,响应带 `Access-Control-Allow-Origin`,预检请求(`OPTIONS`)返回 204,允许的头是 `Authorization, Content-Type, X-CSRF-Token`。**不带凭据**,Cookie 不会跨域,所以跨域的调用只能用 `Authorization: Bearer <API 令牌>`:展示端用只读令牌(站点私有时需要),管理端用 admin 范围的令牌(`/api/admin/auth/me` 对令牌同样可用)。SSE 在跨域时前端用 fetch 读流,因为 `EventSource` 带不了请求头。
|
||||
|
||||
## 3. Admin API
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| POST | `/api/admin/auth/login` | `{username, password}` → 设置 `HttpOnly; Secure; SameSite=Strict` 的会话 Cookie,并返回 `csrf_token` |
|
||||
| POST | `/api/admin/auth/logout` | |
|
||||
| GET | `/api/admin/auth/me` | |
|
||||
| GET | `/api/admin/agents` | 全部 Agent(包括 pending/disabled)、版本、本地硬上限、心跳计数、任务同步状态 |
|
||||
| PATCH | `/api/admin/agents/{id}` | `name`、`labels`、`public_host`、`public_visible` |
|
||||
| POST | `/api/admin/agents/{id}/approve` `/disable` `/enable` `/takeover` | 见 [enrollment.md](enrollment.md) |
|
||||
| DELETE | `/api/admin/agents/{id}` | 删除;历史数据按保留策略自然过期 |
|
||||
| GET/POST | `/api/admin/join-tokens` | 创建时返回令牌明文和安装命令(仅此一次) |
|
||||
| DELETE | `/api/admin/join-tokens/{id}` | 吊销 |
|
||||
| GET/POST | `/api/admin/probes` | 列出所有 Probe(含它现在连着哪些 Agent:版本、远端 IP、起始时间);创建 Probe 身份,返回 `probe.json`(含私钥,仅此一次) |
|
||||
| GET/PATCH/DELETE | `/api/admin/probes/{id}` | 详情、改名、删除(历史数据按保留策略自然过期) |
|
||||
| POST | `/api/admin/probes/{id}/rotate` | 换新密钥(id 不变):旧密钥立即失效,所有 Agent 断开它的会话;返回新的 `probe.json`(仅此一次) |
|
||||
| POST | `/api/admin/probes/{id}/revoke` | 吊销:公钥从所有 Agent 的名单里移除,Agent 立即断开它;记录和历史保留 |
|
||||
| POST | `/api/admin/agents/{id}/probes/{probe_id}/disconnect` | 让这个 Agent 断开这个 Probe 的会话(Probe 会重连,除非已吊销);没有会话时返回 409 `probe_offline` |
|
||||
| GET/POST | `/api/admin/tasks` | 列表和创建:line 模板(不带 `agent_id`)、单个 Agent 的覆盖(带 `agent_id` + `base_task_id`)、target 任务 |
|
||||
| GET/PATCH/DELETE | `/api/admin/tasks/{id}` | 参数按 Kind 的 `ParamSpec` 校验,并检查带宽预算 |
|
||||
| POST | `/api/admin/lines/{line_id}/tests` | `{plan, params?}` → **202** + `Location: /api/public/tests/{id}`;Probe 离线时返回 409 `probe_offline`,节点繁忙时返回 409 `test_busy` |
|
||||
| POST | `/api/admin/tests/{id}/cancel` | |
|
||||
| GET | `/api/admin/raw/chunks?agent_id=&kind=&from=&to=&test_id=` | 原始块元数据 |
|
||||
| GET | `/api/admin/raw/chunks/{chunk_id}` | 下载原始块(`application/vnd.monitor.rawchunk`);过期返回 410 `raw_expired` |
|
||||
| GET | `/api/admin/events?agent_id=&kind=&from=&to=` | 事件 |
|
||||
| GET | `/api/admin/analysis/recompute` | 指标重算进度 |
|
||||
| GET/POST/DELETE | `/api/admin/api-tokens` | 作用域为 `read` 或 `admin`;明文只显示一次 |
|
||||
| GET | `/api/admin/releases` | 各应用、各通道的当前版本,以及节点的版本分布(由 `Fleet.Server.Releases` 提供) |
|
||||
| POST | `/api/admin/releases/{app}/{channel}/rollout` | `{version, batches:[{labels?, count?}], soak_minutes}`:分批灰度;只能选择已经签名的清单 |
|
||||
| POST | `/api/admin/releases/{app}/approve` | 对 `policy = notify` 的节点批准更新 `{version, agents?}` |
|
||||
| GET/PATCH | `/api/admin/settings` | 站点公开或私有、保留期等 |
|
||||
| GET/POST/PATCH | `/api/admin/users` | 首次启动时通过命令行或环境变量创建初始管理员 |
|
||||
|
||||
任务的请求体示例:
|
||||
|
||||
```json
|
||||
{ "scope": "line", "kind": "tcp_echo", "schedule": "continuous",
|
||||
"params": { "rate_hz": 50, "tcp_info_ms": 20, "port": 7444 } }
|
||||
```
|
||||
|
||||
## 4. Agent API
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| POST | `/api/agent/v1/enroll` | 由 `MapFleetEnrollment` 提供(使用加入令牌认证),见 [fleet/link.md](fleet/link.md) 第 3 节 |
|
||||
| GET | `/api/agent/v1/rpc` | 由 `MapFleetLink` 提供,升级为 WebSocket;Monitor 的方法见 [protocol.md](protocol.md) |
|
||||
| GET | `/fleet/releases/{app}/{channel}/latest.json`、`/fleet/releases/{app}/{version}/…` | 由 `MapFleetReleases` 提供的公开发布源(清单和制品),供 `fleet-updater` 轮询;也可以改用 GitHub Releases |
|
||||
|
||||
## 5. 运维端点
|
||||
|
||||
| 路径 | 认证 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `GET /healthz` | 无 | SQLite 与原始数据目录都可写 |
|
||||
| `GET /metrics` | admin 令牌 | Prometheus 格式:在线数、块入库速率、分析队列长度、重算进度、磁盘占用、SSE 连接数 |
|
||||
| `GET /openapi/v1.json` | 无 | |
|
||||
| `GET /install.sh` `/install.ps1` | 无 | 安装脚本(只下载二进制并写入配置) |
|
||||
|
||||
## 6. 错误码(节选,完整列表见共享的 `ErrorCode`)
|
||||
|
||||
| HTTP | code |
|
||||
| --- | --- |
|
||||
| 400 | `validation_failed`、`budget_exceeded` |
|
||||
| 401 | `unauthorized`、`csrf_failed` |
|
||||
| 403 | `forbidden`(令牌作用域不足)、`site_private` |
|
||||
| 404 | `agent_not_found`、`task_not_found`、`test_not_found`、`chunk_not_found`、`unknown_kind`、`unknown_plan` |
|
||||
| 409 | `name_conflict`、`probe_offline`、`test_busy`、`agent_not_active` |
|
||||
| 410 | `raw_expired`、`join_token_expired` |
|
||||
| 422 | `range_too_large`、`invalid_step`、`unknown_metric`、`unknown_preset`、`too_many_metrics` |
|
||||
| 429 | `too_many_requests`、`too_many_streams` |
|
||||
| 503 | `storage_unavailable` |
|
||||
@@ -0,0 +1,139 @@
|
||||
# 架构设计
|
||||
|
||||
## 1. 目标与定位
|
||||
|
||||
一个**只关注网络层**的线路质量面板:单体面板(Backend)+ 轻量节点(Probe、Agent)+ 长连接 RPC + 任务下发。核心能力:
|
||||
|
||||
- 在**一个家宽 Probe** 与**多台海外 VPS Agent**(10 几台)之间,采集**最原始的逐包、逐事件数据**;
|
||||
- 由 Backend 的分析层推算:**如果在某条线路上部署 REALITY 类、Hysteria2 类或 Minecraft 服务,体验会怎样**,并对多条线路排序和对比;
|
||||
- **主机层信息只上报只读的汇总数字**(2026-10 起,由用户确认放开):Agent 读 `/proc` 得到 CPU 占用、内存、根分区、网卡累计流量与瞬时网速、运行时长,不采集 CPU 型号、主机名、网卡名和任何 IP,不采集进程;Probe 不上报。展示页只能拿到这些数字。Agent 和 Probe 仍然是**纯采集器**,不具备 SSH、远程执行、终端、文件读写、远程更新等能力。
|
||||
|
||||
## 2. 拓扑
|
||||
|
||||
```text
|
||||
┌────────────────── Backend(面板)───────────────────┐
|
||||
前端(待开发)◀─────────┤ Api(public|admin) │ 注册中心 │ 任务编排 │ 结果管线 │
|
||||
REST + SSE 实时 │ Analysis(指标注册表) │ LiveHub │ 存储(段文件+SQLite) │
|
||||
└────▲──────▲──────▲───────────────▲─────────────────┘
|
||||
wss JSON-RPC │ │ │ │ 每个 Agent 一条长连接(由 Agent 发起)
|
||||
┌─────┴┐ ┌───┴──┐ ┌─┴────┐ ┌───┴──┐
|
||||
│ HK-1 │ │ JP-1 │ │ SG-1 │ ... │ US-3 │ 10+ 台 VPS Agent(有公网 IP)
|
||||
└──▲───┘ └──▲───┘ └──▲───┘ └──▲───┘
|
||||
│ │ │ │ 每个 Agent 一个会话:
|
||||
└────────┴───┬────┴─────────────┘ TLS 控制通道 + UDP/TCP 数据通道
|
||||
┌─────┴──────┐
|
||||
│ Probe │ 家宽,NAT/CGNAT 后,唯一的测量起点
|
||||
└────────────┘ 不认识 Backend,只连 Agent
|
||||
```
|
||||
|
||||
- **线路(line)** = 一对(Probe,Agent)。可以有多个 Probe(身份由 Backend 生成,可以吊销、删除),每个 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 | 连接所有启用的 Agent;执行线路任务中 Probe 一侧的角色;记录本端原始列并交给对应的 Agent | 认识 Backend;统计;落盘测量数据;任何主机操作 |
|
||||
| Agent | 注册;接受 Probe;执行 Agent 一侧的角色和目标任务;合并两侧的列并上传 | 统计;落盘测量数据;exec/终端/文件/自更新;采集主机信息 |
|
||||
| Backend | 注册与身份;任务编排;持久化原始数据;分析;实时汇聚;API | 连接 Probe |
|
||||
|
||||
## 4. 关键设计(详见各专题文档)
|
||||
|
||||
| 主题 | 要点 | 文档 |
|
||||
| --- | --- | --- |
|
||||
| 节点框架 | 注册、身份、连接、RPC、可靠上报、状态同步、宿主、更新都由与业务无关的 **Fleet** 提供;Monitor 只写业务,其他产品也能复用 | [fleet/overview.md](fleet/overview.md) |
|
||||
| 注册与身份 | 使用 `fleet-enroll` 和加入令牌自动注册,之后用签名认证;Probe 通过引导 Agent 同步 Agent 列表 | [enrollment.md](enrollment.md) |
|
||||
| 更新 | 独立的 `fleet-updater` 进程:只接受离线密钥签名的清单,更新策略由本地决定,失败自动回滚 | [fleet/update.md](fleet/update.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 + 二进制通道);Monitor 只定义 `monitor.*` 方法、`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.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. 安全边界:纯采集
|
||||
|
||||
```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](project-layout.md) 第 7 节。
|
||||
- **三进程权限分离**:注册(`fleet-enroll`,一次性运行)、采集(`monitor-agent` / `monitor-probe`)、更新(`fleet-updater`)是三个独立进程,使用不同的系统用户。采集进程无法改写自己的二进制;更新进程读不到节点身份私钥。
|
||||
- **最小权限运行**:非 root 用户;只在需要时授予 `CAP_NET_RAW`(ICMP);默认端口都高于 1024,不需要 `CAP_NET_BIND_SERVICE`,只有在 Agent 本地显式开启 443 时才授予。
|
||||
- **身份**:节点使用本地生成的 ECDSA P-256 密钥对签名认证,Backend 只存公钥,数据库泄露也无法冒充节点。
|
||||
- **防放大**:未认证的包静默丢弃;回复长度与请求相等;每个流按声明速率限速;使用重放窗口。
|
||||
- **自身状态**:节点上报进程内的计数(会话数、包速率、队列、丢弃数);Agent 另外每隔几秒用 `monitor.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,满时丢弃最旧的块,并通过 `monitor.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 同步 Agent 列表 | 新增 Agent 后自动覆盖,不需要改 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) | 可复用;`samples/EchoNode` 作为第二个使用者,防止过度设计 |
|
||||
| D15 | 注册独立为 `fleet-enroll` 工具;常驻进程不包含注册代码 | 常驻进程去掉 HttpClient,边界更纯 |
|
||||
| D16 | 更新独立为 `fleet-updater`:离线签名、多发布源、本地策略、健康确认与回滚(默认方案,待确认) | 面板被攻破时也无法推送恶意二进制;可以同时管理多个产品 |
|
||||
| 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 毫秒) | 由用户确认;体积小,可以直接交给图表库 |
|
||||
|
||||
## 9. 仍待确认
|
||||
|
||||
以下两项是按推荐方案写入的默认值(D16),等待确认:Fleet.Link 的通用范围(当前包含注册、认证、RPC、发件箱、状态同步、点对点会话);更新采用多发布源加离线签名。其余待确认事项:
|
||||
|
||||
1. **告警**:首版不包含,是否需要预留 Webhook?
|
||||
2. **与真实服务共用 443**(例如 SNI 分流):首版不做,默认使用非 443 端口;以后如有需要再评估。
|
||||
@@ -0,0 +1,111 @@
|
||||
# C# 代码风格
|
||||
|
||||
基线是 [dotnet/runtime 编码风格](https://github.com/dotnet/runtime/blob/main/docs/coding-guidelines/coding-style.md),再加上本项目的补充规则。所有能自动检查的规则都写在 `.editorconfig` 或 `BannedSymbols.txt` 里,**违反即编译失败**(`EnforceCodeStyleInBuild` + `TreatWarningsAsErrors`);无法自动检查的规则由代码评审把关。
|
||||
|
||||
## 1. 格式与命名(`.editorconfig` 强制)
|
||||
|
||||
- 使用 Allman 大括号(左大括号独占一行),四空格缩进,不使用 Tab;`if`/`for` 等语句必须加大括号。
|
||||
- 使用文件作用域 namespace;namespace 与目录一致(`Monitor.Agent.Sessions` 对应 `Sessions/`)。
|
||||
- `using` 放在文件顶部、namespace 之外,`System.*` 排在最前面,其余按字母排序。
|
||||
- 始终显式写出可见性,并且放在修饰符的第一位;`static readonly` 的顺序固定。
|
||||
|
||||
| 元素 | 规则 | 示例 |
|
||||
| --- | --- | --- |
|
||||
| 类型、方法、属性、常量、局部函数 | PascalCase | `SessionTable`、`MaxFrameBytes` |
|
||||
| 私有和内部实例字段 | `_camelCase` | `_sessions` |
|
||||
| 静态字段 | `s_camelCase` | `s_idleTimeout` |
|
||||
| 线程静态字段 | `t_camelCase` | `t_buffer` |
|
||||
| 局部变量、参数、主构造函数参数 | camelCase | `windowSeq` |
|
||||
| 接口 | `I` 前缀 | `IMeasurement` |
|
||||
| 异步方法 | `Async` 后缀 | `SendReportAsync` |
|
||||
|
||||
- `var` 只在右侧类型显而易见时使用(`new`、强制转换);类型名使用语言关键字(`int`,而不是 `Int32`)。
|
||||
- 用 `nameof(...)` 代替字符串字面量;不写多余的 `this.`。
|
||||
- 字段放在类型的最前面;一个文件只放一个主要类型,文件名与类型名相同。
|
||||
- 内部类和私有类默认使用 `sealed`(或 `static`),除非确实需要被继承。
|
||||
|
||||
## 2. 命名语义
|
||||
|
||||
- **名称里带单位**:`timeoutMs`、`rttUs`、`payloadBytes`。API 内部统一用 `TimeSpan`,只在边界(线协议、配置、JSON)使用带单位后缀的整数。
|
||||
- 不使用 `Manager`、`Helper`、`Util`、`Service`(ASP.NET 约定位置除外)之类的模糊名称,名称要说明职责:`UploadQueue`、`SessionTable`、`ResultWriter`。
|
||||
- 布尔值使用 `is`/`has`/`can` 前缀;返回“成功与否加输出值”的方法使用 `TryXxx(out ...)`。
|
||||
|
||||
## 3. 类型与数据
|
||||
|
||||
- 契约和领域值使用 `sealed record` 或 `readonly record struct`,并且不可变。
|
||||
- 热路径上的计数器和缓冲区使用可变 `struct` 或 `sealed class`,**所有权要写清楚**(谁创建、谁归还池)。
|
||||
- ID 使用强类型(`readonly record struct ProbeId(string Value)`),不直接传裸 `string`。
|
||||
- 枚举序列化成 snake_case 字符串(`JsonStringEnumConverter<T>` + `JsonStringEnumMemberName`);新增枚举值不属于破坏性变更,但所有消费方都必须能处理未知值。
|
||||
- 可空性:公共成员必须标注;使用 `!` 抑制警告时,同一行要写注释说明理由。
|
||||
|
||||
## 4. 错误处理
|
||||
|
||||
- **预期内的失败不抛异常**:超时、拒绝连接、认证失败、校验失败都返回结构化结果,其中包含 `ErrorCode`。
|
||||
- 异常只用于程序缺陷或无法继续运行的状态;只在边界(会话循环、请求管线、进程入口)捕获,并记录日志。
|
||||
- 禁止空 `catch`;捕获 `OperationCanceledException` 时必须确认它确实来自自己的取消令牌。
|
||||
|
||||
## 5. 异步与并发
|
||||
|
||||
- 所有 I/O 都是异步的,并且接受、传递 `CancellationToken`(作为最后一个参数)。
|
||||
- 禁止使用 `.Result`、`.Wait()`、`Thread.Sleep`、`async void`(事件处理函数除外),`BannedSymbols.txt` 强制执行。
|
||||
- 库代码中的 `await` 不需要 `ConfigureAwait(false)`,因为三个程序都没有同步上下文,统一省略即可。
|
||||
- 生产者和消费者之间使用有界 `Channel<T>`,明确指定 `BoundedChannelFullMode`。
|
||||
- 共享可变状态要么只由单个循环拥有(推荐),要么使用 `lock` 保护;不要混用多种方式。
|
||||
|
||||
## 6. 时间
|
||||
|
||||
- 时间来源一律通过 `TimeProvider` 获得:墙钟使用 `GetUtcNow()`,耗时使用 `GetTimestamp()`/`GetElapsedTime()`。
|
||||
- `DateTime.Now`、`DateTime.UtcNow`、`DateTimeOffset.Now`、`Stopwatch.StartNew` 列入禁用名单。测试使用 `FakeTimeProvider` 驱动窗口和超时(这个包只出现在测试项目中)。
|
||||
|
||||
## 7. NativeAOT 与轻量化
|
||||
|
||||
- 序列化只使用 `System.Text.Json` 源生成上下文(或手写的二进制编解码);禁用无上下文的 `JsonSerializer.Serialize<T>(T)` 重载。
|
||||
- 不做程序集扫描、`Activator.CreateInstance`、`Type.GetType(string)`、动态代理或 `Reflection.Emit`。
|
||||
- **Probe/Agent**:不使用 DI 容器,在 `Program.cs` 中手工组装对象,依赖关系一眼可见;不使用 LINQ;不使用 `HttpClient`(Agent 的 WebSocket 使用 `ClientWebSocket`)。
|
||||
- **Backend**:使用 `CreateSlimBuilder` + Minimal API;只能通过 `Microsoft.Data.Sqlite` 执行手写的参数化 SQL,禁止字符串拼接 SQL。
|
||||
- 热路径:使用 `Span<T>`、`stackalloc`(≤ 256 B)、`ArrayPool<byte>.Shared`;每个包的处理过程中不分配内存、不写日志。
|
||||
- 新增任何第三方包之前,需要先更新 [project-layout.md](project-layout.md) 第 3 节的依赖表,并在 PR 中说明理由。
|
||||
|
||||
## 8. 日志
|
||||
|
||||
- 统一使用 logfmt 字段名:`ts`、`level`、`msg`、`agent`、`probe`、`session`、`task`、`result`、`err`。
|
||||
- Backend 使用 `Microsoft.Extensions.Logging` 源生成的 `[LoggerMessage]`;Probe/Agent 使用 `NodeLog`,两者输出的字段名相同。
|
||||
- 令牌、`session_key` 和 MAC 不能出现在日志里;IP 地址可以记录。
|
||||
- 每个会话或连接的生命周期事件记录为 Info;逐包事件不记录,只累加计数。
|
||||
|
||||
## 9. 纯采集边界(Probe/Agent 与 Fleet 节点侧)
|
||||
|
||||
- 由 `BannedSymbols.Node.txt` 在编译期强制:禁止启动进程、动态加载程序集或本机库、使用 `HttpClient`、在配置目录以外写文件。唯一的豁免点是 `Fleet.Hosting.ConfigStore`,豁免必须写 `#pragma` 并附上理由注释。注册和更新分别在 `fleet-enroll` 和 `fleet-updater` 中完成,常驻进程不需要这些能力。
|
||||
- 节点不能新增任何“由远端触发的非采集动作”对应的 RPC 方法。凡是新增 RPC 方法的 PR,都要在描述中说明它不会扩大权限边界。
|
||||
- Backend 下发的任何参数都要先经过 `ParamSpec` 校验,再经过本地硬上限(`BudgetGuard`)检查,**两道校验都不能省略**。
|
||||
- 日志中不能出现私钥、签名、`session_key`、令牌或 MAC。
|
||||
|
||||
## 9.1 Fleet 的通用性
|
||||
|
||||
- `backend/fleet/**` 不能引用任何 `Monitor.*`,也不能出现产品相关的概念、命名或默认值(例如端口 7443、`monitor` 字样),由 `scripts/check-deps` 和代码评审把关。
|
||||
- Fleet 的每个公开 API 都要在 `samples/EchoNode` 或 `EchoServer` 中有用法。新增的公开 API 如果只有 Monitor 使用,应先放在 Monitor 中,等出现第二个使用者后再下沉到 Fleet。
|
||||
- Fleet 永远不提供 exec、shell、文件读写或远程下发代码的方法,这条规则不接受例外。
|
||||
- 公开 API 的变更记录在 `PublicAPI.Unshipped.txt` 中;PR 描述需要说明是否属于破坏性变更。
|
||||
|
||||
## 10. 声明式扩展的写法
|
||||
|
||||
- **Kind**:只放在 `Protocol/Catalog/Kinds`,一个 Kind 一个静态字段;参数一律使用 `P.Int`、`P.Enum` 等 `ParamSpec` 写出默认值和上下限,禁止写没有上下限的参数。
|
||||
- **积木**:实现对应的积木接口,在 `BlockRegistry` 中注册一行;积木内部不能读取全局配置,所有输入都来自参数。
|
||||
- **指标**:只放在 `Analysis/Metrics`,一行一个 `MetricDef`;指标 id 使用 `领域.名称`(如 `tcp.stall`),导出值为 `id.统计量`(如 `rtt.p95`)。已发布的 id 不能改名,只能新增。
|
||||
- **算子**:纯函数,输入列视图,输出累加器更新;每个算子都要有边界用例测试(空输入、单个样本、乱序、时钟回绕)。
|
||||
- **模型**:只读取指标,不读取原始列;评分函数需要有单调性测试(指标变差时,分数不能升高)。
|
||||
|
||||
## 11. 测试
|
||||
|
||||
- 使用 xUnit v3。测试项目与被测项目一一对应,放在同名目录下的 `tests/` 中。
|
||||
- 测试名采用 `方法_场景_期望` 格式,例如 `Merge_TwoHistograms_PreservesP95`。
|
||||
- 协议编解码必须有“黄金字节”测试:固定的输入对应固定的字节序列。布局发生变化时测试会失败,提醒升级协议版本。
|
||||
- 采集端必须覆盖:逐包记录的完整性(发送数 = 记录数)、丢失标记、重复、乱序、非法长度、重放、会话过期、版本不兼容。
|
||||
- 分析模型必须有“黄金原始块 → 期望输出”的测试;模型升版时,同时更新期望输出并说明原因。
|
||||
- e2e 测试在 Windows 和 Linux 上对 **AOT 发布产物**运行,而不是对 JIT 构建运行。
|
||||
|
||||
## 12. 提交与评审
|
||||
|
||||
- 提交信息使用 `<范围>: <摘要>` 格式,范围取值为 `probe`、`agent`、`backend`、`shared`、`web`、`docs`、`scripts`。
|
||||
- 修改 `backend/shared/Monitor.Protocol` 的 PR 必须说明兼容性影响(新增字段、行为变化、需要升级版本)。
|
||||
- 修改 `backend/openapi/v1.json` 的 PR 必须按照 [api-guidelines.md](api-guidelines.md) 第 2 节判断是否属于破坏性变更。
|
||||
@@ -0,0 +1,135 @@
|
||||
# 部署
|
||||
|
||||
节点(Agent、Probe)是单个 NativeAOT 可执行文件,没有运行时依赖,只需要系统的 OpenSSL 3(`libssl.so.3`)。启动时会自检,缺少时第一步就明确报错。
|
||||
|
||||
## 1. 构建与打包
|
||||
|
||||
```text
|
||||
scripts/publish.sh <agent|probe|backend|fleet-enroll> <rid> → backend/artifacts/publish/<project>/<rid>/
|
||||
scripts/package.sh <agent|probe> <rid> → backend/artifacts/packages/monitor-<role>-<version>-<rid>.tar.gz(附 .sha256)
|
||||
```
|
||||
|
||||
| rid | 目标 | 构建方式 |
|
||||
| --- | --- | --- |
|
||||
| `linux-musl-x64` | Alpine、OpenWrt x86 | Alpine 的 NativeAOT SDK 容器(任何有 Docker 的主机,包括 Windows) |
|
||||
| `linux-x64` | Debian、Ubuntu 等 glibc 发行版 | Ubuntu noble 的 NativeAOT SDK 容器 |
|
||||
| `win-x64` | Windows | 在 Windows 上直接 `dotnet publish -p:PublishAot=true`(需要 VS 的 C++ 工具链,`vswhere.exe` 所在目录要在 PATH 里) |
|
||||
| `linux-musl-arm64`、`linux-arm64` | 路由器、树莓派 | 需要在 arm64 主机(或 CI 的 arm64 runner)上运行同一个脚本;脚本在 x64 主机上会拒绝并说明 |
|
||||
|
||||
NativeAOT 不能交叉编译到别的操作系统;MIPS 不支持(见 `project-layout.md`)。
|
||||
|
||||
实测体积(`StackTraceSupport=false`、`IlcFoldIdenticalMethodBodies=true`、`OptimizationPreference=Size`):
|
||||
|
||||
| 程序 | linux-musl-x64 | linux-x64 | win-x64 |
|
||||
| --- | --- | --- | --- |
|
||||
| monitor-probe | 7.7 MB | 7.7 MB | 5.6 MB |
|
||||
| monitor-agent | 8.4 MB | 8.4 MB | 7.3 MB |
|
||||
| fleet-enroll | 6.4 MB | 6.4 MB | — |
|
||||
|
||||
Linux 上比 Windows 大,是因为 .NET 的加密、压缩、网络本机库被静态链接进来。tar.gz 压缩后 Probe 约 3.6 MB。
|
||||
|
||||
## 2. 安装
|
||||
|
||||
解压 tar.gz,在解压目录里运行 `install.sh`(POSIX sh,root 运行;Debian、Ubuntu、Alpine、OpenWrt 都测试过):
|
||||
|
||||
```sh
|
||||
# Agent(服务器):注册并启动
|
||||
./install.sh agent --server https://panel.example.com --join <加入令牌> --name hk-1
|
||||
|
||||
# Probe(家宽网络里的机器或 OpenWrt 路由器):先在面板生成身份(POST /api/admin/probes,只能下载一次)
|
||||
./install.sh probe --probe-json ./probe.json
|
||||
|
||||
./install.sh uninstall agent|probe [--purge] # --purge 同时删除身份(节点需要重新注册)
|
||||
```
|
||||
|
||||
| 位置 | 内容 | 所有者 |
|
||||
| --- | --- | --- |
|
||||
| `/usr/lib/monitor/` | 二进制 | root,0755:采集进程不能改写自己的可执行文件 |
|
||||
| `/etc/monitor/` | `agent.json` 或 `probe.json`、身份(`node.json`、`node.key`、`tls.pfx`)、`agents.json` | `monitor` 用户,0750 |
|
||||
|
||||
安装脚本会:检查 OpenSSL、创建 `monitor` 用户(`useradd`、busybox `adduser`,OpenWrt 没有这些时直接写 `/etc/passwd`)、安装二进制和服务文件;缺少配置时只安装、不启动,并打印下一步。
|
||||
|
||||
### systemd(`packaging/systemd/*.service`)
|
||||
|
||||
以 `monitor` 用户运行,沙箱:`NoNewPrivileges`、`ProtectSystem=strict`、`ProtectHome`、`PrivateTmp/Devices`、`ProtectKernel*`、`RestrictAddressFamilies=AF_INET AF_INET6`、`RestrictNamespaces`、`MemoryDenyWriteExecute`(NativeAOT 不 JIT)、`SystemCallArchitectures=native`,`MemoryMax`(Agent 128 MB、Probe 64 MB)。
|
||||
|
||||
- **Agent** 只保留 `CAP_NET_RAW`(ICMP 目标任务用),`/etc/monitor` 只读;Probe 没有任何 capability,只对 `agents.json` 所在的目录可写。
|
||||
- 监听 1024 以下的端口(例如把数据面放到 443)需要在 drop-in 里加 `AmbientCapabilities=CAP_NET_BIND_SERVICE` 和对应的 `CapabilityBoundingSet`。默认端口是 7443/tcp+udp(Probe 控制通道和 UDP 数据面)、7444/tcp。
|
||||
|
||||
### OpenWrt(`packaging/openwrt/`)
|
||||
|
||||
只支持 Probe(Agent 面向服务器)。`install.sh probe` 安装 `/etc/init.d/monitor-probe`(procd,崩溃自动重启,日志进 logd)并写入 `/lib/upgrade/keep.d/monitor`,sysupgrade 时保留 `/etc/monitor/`。
|
||||
|
||||
OpenWrt 默认不带 OpenSSL:`opkg install libopenssl3`(较新的快照和 24.10 之后用 `apk add libopenssl3`)。闪存很小时注意:二进制 7.7 MB,配置只在 Agent 列表变化时才写。路由器上建议把 `probe.json` 的 `limits.max_test_mbps` 调低。
|
||||
|
||||
## 3. 验证
|
||||
|
||||
`scripts/smoke-linux.sh` 在 Linux 容器里用发布的二进制跑完整链路(后端 → 注册 → Agent → Probe → 数据落库),`MODE=install` 用打包产物和 `install.sh` 安装、以 `monitor` 用户运行。`scripts/smoke-systemd.sh` 在 systemd 容器里验证服务单元。已经验证过:
|
||||
|
||||
- Alpine 3.22(musl)、Debian 12(glibc):直接运行和 `install.sh` 安装都通;
|
||||
- OpenWrt(官方 rootfs,busybox,无 `install`/`useradd`/`su`):安装、建用户、卸载、二进制启动和 OpenSSL 自检;
|
||||
- systemd(Debian 12):两个服务 active,Agent 只有 `CAP_NET_RAW`、Probe 没有 capability,`NoNewPrivs`、seccomp 生效,Agent 无法写 `/etc/monitor`,没有重启和错误日志。
|
||||
|
||||
没有验证的:arm64 构建、真正的 OpenWrt 设备(有 procd 和真实的内存/闪存限制)、跨公网的真实线路。
|
||||
|
||||
实测常驻内存(RSS,回环,默认模板,稳定后):Linux 上 Probe 约 21 MB、Agent 约 23 MB;Windows 上约 22 MB、25 MB。
|
||||
|
||||
### 跨机器的真实链路(Windows Probe → WSL2 里的 Agent 和 Backend)
|
||||
|
||||
`scripts/smoke-wsl.py` 用 Windows 上的 NativeAOT Probe 连 WSL2 里的 Agent(真实的虚拟网络,两端的单调时钟原点相差约 38 小时),并用 `tc netem` 在 WSL 里注入故障。结果(Probe 的 RTT 基线约 0.4 ms):
|
||||
|
||||
| 注入 | 期望 | 实测 |
|
||||
| --- | --- | --- |
|
||||
| Agent→Probe 方向 10% 丢包、30 ms ± 5 ms 延迟(egress netem,只匹配 7443/7444) | 丢包在下行,下行抖动变大 | `loss.down` 8.2%、`loss.up` 0;`jitter.down.p95` 8.7 ms、`jitter.up.p95` 0.18 ms;RTT +31 ms;TCP:`tcp.rtt.p99` 1.2 s、`tcp.slow` 28.8%、`tcp.retrans` 5.9%(Agent 一侧内核计数) |
|
||||
| Probe→Agent 方向 10% 丢包、20 ms ± 3 ms 延迟(ingress + ifb) | 丢包在上行 | `loss.up` 9.4%、`loss.down` 0;`jitter.up.p95` 5.2 ms、`jitter.down.p95` 0 |
|
||||
| 8 秒黑洞(tc ingress drop 7443/7444) | UDP 连续丢包 ~80 个;TCP 不丢包,只有几秒的长尾 | UDP 最长连续丢包 80(10 Hz × 8 s);`tcp.rtt.max` 9.5 s、`tcp.stall_max` 9.6 s |
|
||||
| `hy2_ladder`(10/25/50/100 Mbit/s) | 无丢包 | `train.loss` 0.006%、`train.jitter.p95` 0.64 ms、可持续 100 Mbit/s |
|
||||
| 杀掉 Agent / 杀掉 Probe 再启动 | 自动恢复 | Agent 重启后 2.1 s 恢复在线并重新连上 Probe;Probe 重启后 0.5 s 重新连上 |
|
||||
|
||||
验证中发现并修掉的两个问题:
|
||||
|
||||
- **Probe 重启后,Agent 沿用了上一个会话的记录**:Probe 重启之后序号从 0 开始,Agent 里同一个序号的旧 t2/t3 还在,于是真正的上行丢失被算成了下行丢失(黑洞测试里 `loss.up` 0、`loss.down` 7.4%)。现在新的 Probe 会话开始时 Agent 清空所有流的记录。
|
||||
- **TCP 回显的几秒中断被记成丢包**:Probe 像 UDP 那样等 2 秒就把没有回复的行交出去,TCP 随后才到的回复没有行可以回填,最严重的长尾反而在 RTT 里看不到(黑洞期间 `tcp.rtt.max` 只有 4 ms)。现在 TCP 的行等回复,30 秒仍然没有才当作连接已断(t4 缺失)。
|
||||
|
||||
没有覆盖的:Windows 上的 Agent(需要给 Windows 防火墙加入站规则)、NAT 重绑定。跨公网的线路见下一节。
|
||||
|
||||
### 跨公网的真实线路(广东移动家宽 → 日本 IIJ 的 VPS)
|
||||
|
||||
2026-10-03 在一条真实线路上试跑过:家宽机器(Debian 13,用户态运行,没有公网 IPv4 入站)跑 Probe 和 Backend,VPS(Debian 12,1 核 470 MB,上面本来就跑着 xray、hysteria2、caddy 等服务)跑 Agent。
|
||||
|
||||
```text
|
||||
浏览器 / 本机前端 ──► https://<公网域名> ──隧道(Cloudflare Tunnel 之类)──► 家宽机器: Backend (:29181)
|
||||
家宽机器: Probe ──TCP/UDP 7443、TCP 7444──► VPS: Agent
|
||||
VPS: Agent ──WSS 到 https://<公网域名>──► Backend
|
||||
```
|
||||
|
||||
做法和要点:
|
||||
|
||||
- **Backend 通过隧道暴露**:隧道把一个公网域名转到家宽机器的 `29181`;Backend 监听 `http://0.0.0.0:29181`(只写一个监听地址,同时写 `0.0.0.0` 和 `[::]` 会报地址已被占用),家宽路由器的防火墙不放行这个端口,公网只能经隧道进来。Agent 用 `fleet-enroll --server https://<公网域名>` 注册,之后用 WSS 连回 Backend。
|
||||
- **经隧道注册之后,检查 Agent 的 `public_host`**:Backend 看到的来源地址是隧道的出口,不是 VPS 的地址,要改成 VPS 的真实地址,Probe 才连得上。
|
||||
- **先注册 Agent,再创建 Probe**:`probe.json` 的 `bootstrap` 里才有这个 Agent;Probe 启动后会通过 Agent 同步列表。
|
||||
- **小 VPS 把 `limits.max_total_mbps` 调低**(试跑时用 100);Agent 常驻约 23 MB,VPS 上原有的服务不受影响,结束后删除 `/opt/…`、停掉进程即可,没有留下任何东西。
|
||||
- 家宽的 IPv6 前缀会在路由器重新拨号(比如停电)后变化,SSH 地址要跟着换;面板走隧道的域名,不受影响。
|
||||
- 本地电脑没有直连 VPS 的路径时,用家宽机器当跳板(`SSH_ASKPASS` + `SSH_ASKPASS_REQUIRE=force`,密码放环境变量,不写进文件)。
|
||||
|
||||
**常驻(开机自启)**:
|
||||
|
||||
- 家宽机器(没有 root 时):`~/.config/systemd/user/monitor-backend.service` 和 `monitor-probe.service`,`EnvironmentFile` 指向放配置的 `backend.env`,`Restart=always`,`systemctl --user enable --now …`,再用 `sudo loginctl enable-linger <用户>` 让它们不登录也能起来。Probe 的命令行是 `monitor-probe --config <probe.json 路径>`(默认路径是 `/etc/monitor/probe.json`)。
|
||||
- VPS:`/etc/systemd/system/monitor-agent.service`,`Restart=always`,加 `NoNewPrivileges`、`ProtectSystem=strict`、`ReadWritePaths=/opt/monitor`、`ProtectHome`、`PrivateTmp`。这是不走安装脚本的手动方式;正式安装用上面的 `packaging/systemd`。
|
||||
- 日志进 journal:`journalctl -u monitor-agent`、`journalctl --user -u monitor-probe`。重启 Backend 用 `systemctl` 而不是手动再起一个进程,否则会端口冲突。
|
||||
|
||||
**从别处看这个面板**:本机跑前端,在页面里把数据源指向这个域名,用 API 令牌访问,Backend 设 `Monitor__CorsOrigins` 放行前端的来源(见 [web/README.md](../web/README.md))。
|
||||
|
||||
实测(Probe 在广东移动家宽,Agent 在日本 IIJ):
|
||||
|
||||
| 项目 | 结果 |
|
||||
| --- | --- |
|
||||
| 延迟 p50 / p95 / p99 | 107 / 107 / 114 ms |
|
||||
| 丢包 | 上行 0%,下行 8.1%(`hy2_ladder` 在 10、25、50、100 Mbit/s 四档下的丢包都是 8~10%,与速率无关:随机丢包,不是限速) |
|
||||
| 抖动 p95(上 / 下) | 0.9 / 5.2 ms |
|
||||
| TCP | 重传 1.9%(批量传输时 5.8%),RTT 最大 412 ms |
|
||||
| `reality_session` | 建连成功率 100%,TCP 建连 p95 115 ms,TLS 握手 p95 117 ms,30 秒长连接无重置;4 秒批量传输峰值 133 Mbit/s、中位数 90 Mbit/s(Agent 上限 100) |
|
||||
| 评分 | REALITY 82(含按需测试)、Hy2 42 |
|
||||
| Agent 主机状态 | CPU 1.5%,内存 274 / 493 MB,已运行约 20.5 天 |
|
||||
|
||||
这次试跑发现并修掉的问题:`hy2_ladder` 的阶梯按「块」而不是按「阶段」计算,真实的 Probe 把一个阶段切成几块,4 个阶段出了 7 步(模拟数据不切块,所以之前的测试没有发现);现在同一阶段的块先合并。
|
||||
@@ -0,0 +1,104 @@
|
||||
# Monitor 的注册与身份
|
||||
|
||||
通用的注册、身份、认证机制由 Fleet 提供,见 [fleet/link.md](fleet/link.md) 第 2~3 节。本文只描述 **Monitor 在 Fleet 之上的约定**:Agent 注册后如何自动接入测量、Probe 的身份以及 Agent 列表同步,以及节点本地的硬上限。
|
||||
|
||||
## 1. 身份一览
|
||||
|
||||
| 主体 | 凭据 | 生成方 | Backend 保存 |
|
||||
| --- | --- | --- | --- |
|
||||
| Agent | Fleet 节点身份(ECDSA P-256)+ 面向 Probe 的 TLS 证书 | `fleet-enroll` | 公钥、SPKI 指纹 |
|
||||
| Probe | Fleet 节点身份 | 面板生成 `probe.json`(可以有多个 Probe,每个独立吊销) | 公钥 |
|
||||
| 面板用户 | 账号 + 密码(PBKDF2-SHA256) | 面板 | 密码哈希 |
|
||||
| API 令牌 | 随机高熵令牌,作用域为 `read` 或 `admin` | 面板 | SHA-256 |
|
||||
| 发布签名 | 离线的 ECDSA 发布密钥 | CI 或离线环境 | 只在节点的 `fleet-updater` 中配置公钥,**面板不保存** |
|
||||
|
||||
## 2. Agent 接入流程
|
||||
|
||||
```text
|
||||
管理员 ── 创建加入令牌 {标签, max_uses, 过期时间, auto_approve} ──> 面板(Fleet.Server.Enrollment)
|
||||
<── 安装命令:
|
||||
curl -fsSL https://panel/install.sh | sh -s -- --join jt_xxx --name jp-1
|
||||
|
||||
install.sh(只做安装,不包含任何远程控制功能)
|
||||
├─ 下载 monitor-agent、fleet-enroll、fleet-updater,并验证发布签名
|
||||
├─ 创建系统用户:monitor(运行 Agent)、fleet-update(运行更新器)
|
||||
├─ fleet-enroll --server https://panel --join jt_xxx --name jp-1 --identity-dir /etc/monitor
|
||||
│ └─ 注册元数据(Monitor 定义):{ probe_tcp_port, probe_udp_ports, public_host?, kinds[] }
|
||||
├─ 写入 agent.json(本地硬上限)和 /etc/fleet/updater.json
|
||||
└─ 启用 monitor-agent.service 和 fleet-updater.service
|
||||
|
||||
面板收到 OnEnrolled(Monitor 挂钩)
|
||||
├─ public_host 未提供时,取注册请求的来源 IP
|
||||
├─ 按“线路任务模板”为该 Agent 计算有效任务集(TaskSet)
|
||||
└─ 通知 Probe(通过 monitor.probe.agents_changed)→ Probe 自动连接新 Agent
|
||||
从安装到开始出数据,全程不需要手工配置
|
||||
```
|
||||
|
||||
名称冲突、审批、接管、禁用等行为都采用 Fleet 的默认规则。
|
||||
|
||||
## 3. Probe 身份与 Agent 列表同步
|
||||
|
||||
Probe 不认识 Backend,它需要的 Agent 列表经由任意一个已连接的 Agent 获取。
|
||||
|
||||
```text
|
||||
面板:POST /api/admin/probes {name}
|
||||
└─ 生成 Fleet 节点身份,返回 probe.json(只能下载一次;轮换密钥 /rotate 会使旧密钥失效,吊销 /revoke 会让所有 Agent 断开它)
|
||||
{ probe_id, private_key, bootstrap: [ {agent_id, host, tcp_port, spki}, ... ],
|
||||
limits: { max_test_mbps, max_total_pps } } ← 本地硬上限,面板无法修改;在路由器上建议调低
|
||||
|
||||
Probe 启动
|
||||
├─ 依次尝试 bootstrap 中的 Agent,与其中一个建立 Fleet 点对点会话(peer 用途签名)
|
||||
├─ monitor.probe.agents ──> Agent ──> Backend
|
||||
│ <── { version, agents: [ {agent_id, host, tcp_port, udp_ports, spki}... ] }
|
||||
├─ 调和:新增的 Agent → 建立会话;被移除或禁用的 → 断开
|
||||
└─ 之后列表的任何变化,都通过已连接的 Agent 推送 monitor.probe.agents_changed;
|
||||
Probe 用 Fleet.Hosting.ConfigStore 把最新列表写回 bootstrap(这是配置,不是测量数据)
|
||||
```
|
||||
|
||||
- 每个 Agent 通过自己的期望状态(`TaskSet.peers`)获得 Probe 的公钥,用来验证点对点会话的签名。
|
||||
- 吊销 Probe:在面板上重新生成身份,旧公钥立即从所有 Agent 的期望状态中移除,旧会话随之断开。
|
||||
|
||||
## 4. Agent 本地配置(`/etc/monitor/agent.json`,面板无法修改)
|
||||
|
||||
```json
|
||||
{
|
||||
"fleet": { "server": "https://panel.example.com", "identity_dir": "/etc/monitor" },
|
||||
"listen": { "control_port": 7443, "udp_ports": [7443], "tcp_ports": [7444] },
|
||||
"limits": {
|
||||
"max_total_mbps": 200,
|
||||
"max_total_pps": 20000,
|
||||
"allowed_target_cidrs": [],
|
||||
"max_concurrent_tests": 1
|
||||
},
|
||||
"fleet_policy": { "allow_methods": ["monitor.test.run", "monitor.test.cancel", "monitor.probe.agents_changed"] }
|
||||
}
|
||||
```
|
||||
|
||||
- `limits` 是**本地硬上限**:期望任务集超出这些范围时,调和器拒绝执行,并在 `rejected[]` 中给出原因。
|
||||
- `allowed_target_cidrs` 默认为空,即不允许执行任何目标任务;需要时由节点所有者在本机显式开启。
|
||||
- `listen` 默认不包含 443。要用 443 测量,需要在本机把它加入 `udp_ports` 或 `tcp_ports`(端口不能被真实服务占用),见 [measurement.md](measurement.md) 第 8 节。
|
||||
- `fleet_policy.allow_methods` 在 Fleet 默认值的基础上进一步收窄。
|
||||
|
||||
## 5. 相关 API
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| POST | `/api/agent/v1/enroll` | 由 `MapFleetEnrollment` 提供 |
|
||||
| GET | `/api/agent/v1/rpc` | 由 `MapFleetLink` 提供(升级为 WebSocket) |
|
||||
| GET/POST/DELETE | `/api/admin/join-tokens` | 管理加入令牌 |
|
||||
| POST | `/api/admin/agents/{id}/approve`、`/disable`、`/enable`、`/takeover` | 审批与启停 |
|
||||
| GET/POST/PATCH/DELETE | `/api/admin/probes`、`/probes/{id}` | Probe 身份的创建、列表、改名、删除 |
|
||||
| POST | `/api/admin/probes/{id}/rotate`、`/revoke` | 轮换密钥、吊销 |
|
||||
| POST | `/api/admin/agents/{id}/probes/{probe_id}/disconnect` | 让 Agent 断开某个 Probe |
|
||||
| GET | `/install.sh`、`/install.ps1` | 安装脚本 |
|
||||
|
||||
|
||||
## Agent 的本地配置:主机状态上报
|
||||
|
||||
`agent.json` 的 `reporting` 段(面板无法修改):
|
||||
|
||||
```json
|
||||
{ "reporting": { "host_stats": true, "host_interval_s": 2 } }
|
||||
```
|
||||
|
||||
`host_stats` 为 true(默认)时,Agent 每隔 `host_interval_s` 秒用 `monitor.host` 上报一份主机资源占用的汇总数字(CPU、内存、根分区、网卡累计流量与网速、运行时长;没有型号、主机名、IP);设为 false 就一个字也不上报。
|
||||
@@ -0,0 +1,137 @@
|
||||
# Fleet:身份、注册与连接
|
||||
|
||||
## 1. 概览
|
||||
|
||||
```text
|
||||
fleet-enroll(一次性运行)
|
||||
│ ① 用加入令牌注册,生成身份
|
||||
▼
|
||||
┌────────── 节点 ──────────┐ ┌──────────── 服务端 ────────────┐
|
||||
│ Fleet.Hosting │ ② wss 连接 │ Fleet.Server.Link │
|
||||
│ └─ Fleet.Link ─────────────── 签名认证 ────>│ 连接注册表 · 在线事件 │
|
||||
│ ├─ Rpc 路由 │<── state.sync ──│ 期望状态提供者(产品实现) │
|
||||
│ ├─ 期望状态调和 │── outbox.push ─>│ 收件箱(产品实现,持久化后 ACK) │
|
||||
│ └─ 可靠发件箱 │<── outbox.ack ──│ │
|
||||
│ │ │ Fleet.Server.Enrollment │
|
||||
│ Fleet.Link(点对点)──────── TLS + 签名 ────> 另一个节点(例如 Probe → Agent) │
|
||||
└──────────────────────────┘ └────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 2. 身份(Fleet.Identity)
|
||||
|
||||
| 对象 | 算法 | 保存位置 |
|
||||
| --- | --- | --- |
|
||||
| 节点身份密钥 | ECDSA P-256(BCL 自带;Ed25519 不在 BCL 中) | `<identity_dir>/node.key`(0600);服务端只保存公钥 |
|
||||
| 点对点 TLS 证书 | 自签名,ECDSA P-256 | `<identity_dir>/tls.pfx`;对端按 SPKI SHA-256 指纹校验 |
|
||||
| 签名串 | `"fleet|v1|<purpose>|" + 各字段` | 带用途前缀,防止同一个签名在不同场景下被复用 |
|
||||
|
||||
用途前缀的取值:`auth`(连接服务端)、`peer`(点对点)、`enroll`(注册时的持有证明)。产品可以追加自己的用途(例如 `myapp.xxx`),但不能占用 `fleet` 保留的用途。
|
||||
|
||||
## 3. 注册(Fleet.Enrollment)
|
||||
|
||||
```text
|
||||
管理员 ── 创建加入令牌 {labels, max_uses, expires_at, auto_approve, product_meta?} ──> 服务端
|
||||
<── 令牌明文(只返回一次)+ 安装命令
|
||||
|
||||
节点:fleet-enroll --server https://panel --join jt_xxx --name jp-1 --identity-dir /etc/myapp
|
||||
├─ 生成身份密钥和 TLS 证书
|
||||
├─ POST <enroll 端点>
|
||||
│ { join_token, name, pubkey, tls_spki, proof = 签名("enroll", join_token 哈希 + pubkey),
|
||||
│ node_version, meta(产品自定义的 JSON,经产品的 JsonTypeInfo 校验)}
|
||||
│ 服务端:校验令牌 → uses+1 → 创建节点(active 或 pending)→ 触发 OnEnrolled 事件(产品挂钩)
|
||||
└─ 写入 <identity_dir>/node.json { node_id, server, … },然后退出
|
||||
```
|
||||
|
||||
- **名称冲突**:同名节点已存在时,新注册进入 `pending`,由管理员选择“接管”(旧记录换成新公钥,历史数据保留)或“作为新节点”。
|
||||
- **节点状态**:`pending`(能连接,但不下发状态、不接收上报)、`active`、`disabled`(拒绝连接)。
|
||||
- 注册逻辑只存在于 `fleet-enroll` 中,**常驻的业务进程不包含注册代码,也不使用 HttpClient**。
|
||||
- 产品可以复用 `fleet-enroll` 本身,也可以把 `Fleet.Enrollment.Client` 嵌入自己的安装工具中。
|
||||
|
||||
## 4. RPC(Fleet.Rpc)
|
||||
|
||||
- 信封采用 JSON-RPC 2.0;方法名为 `领域.动作`。`fleet.*` 保留给框架,产品使用自己的前缀,例如 `monitor.*`。
|
||||
- 二进制帧:`u8 channel | 正文`。channel `0` 到 `15` 保留给框架,产品从 `16` 开始注册自己的二进制通道。
|
||||
- 错误:`error.data.code` 为 snake_case 字符串,框架的错误码以 `fleet_` 开头,产品可以扩展。
|
||||
- **与传输方式无关**:同一套路由既可以跑在 wss 上,也可以跑在 TLS 帧(`u32 长度 | u8 类型 | 正文`)上。
|
||||
|
||||
框架保留的方法:
|
||||
|
||||
| 方法 | 方向 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `fleet.challenge` / `fleet.auth` | 服务端→节点 / 节点→服务端 | 签名认证(`auth` 用途,nonce 30 秒内有效) |
|
||||
| `fleet.hello` | 节点→服务端 | 节点版本、能力列表(框架 + 产品)、本地策略摘要、当前时间 → 返回时钟偏差 |
|
||||
| `fleet.heartbeat` | 节点→服务端 | 每 10 s 一次;产品可以附加进程级计数 |
|
||||
| `fleet.state.sync` | 服务端→节点 | `{version, doc}` → `{applied_version, rejected[]}` |
|
||||
| `fleet.outbox.ack` | 服务端→节点 | `{ids[], duplicates[]}` |
|
||||
| `fleet.revoked` | 服务端→节点 | 节点被禁用或接管,随后断开 |
|
||||
|
||||
## 5. 连接生命周期(Fleet.Link)
|
||||
|
||||
```text
|
||||
connect(wss) → fleet.challenge → fleet.auth → fleet.hello → 状态同步 → 正常运行
|
||||
▲ │
|
||||
└──── 指数退避重连(1 s → 60 s,带随机抖动;401 或 revoked 时停止重试)◀─ 断开
|
||||
```
|
||||
|
||||
- 能力协商:双方交换 capabilities;调用对方不支持的方法时,在本地直接返回 `fleet_unsupported`,不会发出请求。
|
||||
- 节点本地策略:`allow_methods`(白名单,默认只包含本地已注册的方法)、`max_frame_bytes`、`max_inflight`。
|
||||
|
||||
## 6. 可靠发件箱(Outbox)
|
||||
|
||||
```text
|
||||
产品 → outbox.EnqueueAsync(msg) → 有界队列(按字节和条数限制;满时按策略:DropOldest 或 Reject)
|
||||
→ 发送(二进制通道或 JSON)→ 服务端收件箱处理并持久化 → fleet.outbox.ack → 从队列移除
|
||||
```
|
||||
|
||||
- 投递语义为**至少一次**:每条消息都带有 `msg_id`(UUIDv7),服务端按此去重。
|
||||
- 在途数据按字节限制(默认 4 MiB),保证服务端慢时不会把内存撑爆。
|
||||
- 丢弃消息时会累计缺口计数,通过 `OnGap` 回调通知产品,由产品决定如何上报。
|
||||
- 默认只保存在内存中,不落盘。产品如果需要持久化,可以自行实现 `IOutboxStore`(Monitor 不使用)。
|
||||
|
||||
## 7. 期望状态同步(State Sync)
|
||||
|
||||
```text
|
||||
服务端:IStateProvider<TDoc>.GetAsync(node) → {version, doc}
|
||||
发生变化时调用 NotifyChanged(node) → 推送 fleet.state.sync
|
||||
节点: IStateReconciler<TDoc>.ApplyAsync(doc) → {applied_version, rejected[]}
|
||||
重连后,服务端总会重新推送当前版本
|
||||
```
|
||||
|
||||
- 下发的是**完整快照**而不是命令,所以天然幂等,断线重连后也能自动收敛。
|
||||
- `rejected[]` 为结构化原因,例如 `unsupported`、`budget_exceeded`、`policy_denied`,服务端可以把它展示在面板上。
|
||||
|
||||
## 8. 节点间点对点会话
|
||||
|
||||
```text
|
||||
节点 A(发起方,例如 Probe)── TLS(按 SPKI 校验证书)──> 节点 B(监听方,例如 Agent)
|
||||
<── fleet.challenge ──
|
||||
── fleet.auth(peer 用途,节点 A 的私钥签名)──>
|
||||
节点 B 用服务端下发的对端公钥列表验签(通常经 state.sync 下发)
|
||||
之后使用同一套 Fleet.Rpc:方法路由、二进制通道、发件箱都可以复用
|
||||
```
|
||||
|
||||
- 对端的公钥和允许连接的节点列表,由产品通过期望状态下发给监听方。Fleet 只负责认证和会话,拓扑由产品决定。
|
||||
- 数据面的 UDP、原始 TCP 流等通道由产品自己实现。Fleet 只提供会话密钥派生(`session_key`)和首帧 MAC 的辅助函数。
|
||||
|
||||
## 9. 宿主(Fleet.Hosting)
|
||||
|
||||
- `ModuleHost`:按依赖顺序启动模块,按相反顺序停止;隔离故障,重启时退避。
|
||||
- `NodeConfig<T>`:源生成 JSON 读取,支持环境变量覆盖,启动时校验。
|
||||
- `ConfigStore`:业务进程唯一允许写文件的地方,只能写配置目录。
|
||||
- `ServiceShell`:处理 SIGTERM 和 Ctrl+C,支持 systemd `sd_notify`、OpenWrt procd(前台运行,由 procd 负责重启)和 Windows 服务。
|
||||
- `HealthBeacon`:进程完成启动并与服务端握手后,写出 `{app, version, pid, ok_at}` 到本地信标文件或命名管道,供 `fleet-updater` 确认新版本是否健康。
|
||||
|
||||
## 10. 服务端存储抽象
|
||||
|
||||
```csharp
|
||||
public interface IFleetStore
|
||||
{
|
||||
ValueTask<JoinToken?> FindJoinTokenAsync(ReadOnlyMemory<byte> tokenHash, CancellationToken ct);
|
||||
ValueTask<Result<NodeRecord>> CreateNodeAsync(NewNode node, CancellationToken ct);
|
||||
ValueTask<NodeRecord?> GetNodeAsync(NodeId id, CancellationToken ct);
|
||||
ValueTask UpdatePresenceAsync(NodeId id, Presence presence, CancellationToken ct);
|
||||
// 令牌、审批、接管、禁用等
|
||||
}
|
||||
```
|
||||
|
||||
产品可以使用 `Fleet.Server.Sqlite`,也可以把这些表放进自己的数据库中。Monitor 的做法是把它们放进自己的 `monitor.db`,并由同一个单写入者负责写入。
|
||||
@@ -0,0 +1,104 @@
|
||||
# Fleet:通用节点框架
|
||||
|
||||
> 名称暂定。Fleet 与业务无关,Monitor 是它的第一个使用者。
|
||||
|
||||
## 1. 定位
|
||||
|
||||
凡是“很多节点连到一个面板”的产品,都要解决同一批问题:节点怎么接入、怎么证明身份、怎么保持连接、怎么可靠上报、怎么接收配置、怎么更新自己。Fleet 把这些做成**通用库和工具**,产品只需要写业务。
|
||||
|
||||
```text
|
||||
┌──────────────────────────── 产品层(各产品自己写) ─────────────────────────────┐
|
||||
│ Monitor: Protocol(Kind/任务) · Measurement(积木) · Agent · Probe · Backend │
|
||||
│ 其他产品: YourApp.Node · YourApp.Server │
|
||||
└──────────────────────────────────┬─────────────────────────────────────────────┘
|
||||
│ 只依赖 Fleet;Fleet 不认识任何产品
|
||||
┌───────────────────────────────── Fleet ───────────────────────────────────────┐
|
||||
│ 节点侧(只依赖 BCL,支持 AOT) 服务端侧(ASP.NET Core) │
|
||||
│ Fleet.Core Result/退避/有界队列 Fleet.Server.Enrollment 注册与审批 │
|
||||
│ Fleet.Identity 密钥/签名/证书/存储 Fleet.Server.Link wss 网关/在线表 │
|
||||
│ Fleet.Rpc JSON-RPC/路由/二进制帧 Fleet.Server.Releases 通道与灰度(可选)│
|
||||
│ Fleet.Link 会话/认证/重连/能力/ Fleet.Server.Sqlite 存储实现(可选)│
|
||||
│ 发件箱/状态同步/点对点 IFleetStore 存储抽象 │
|
||||
│ Fleet.Hosting 模块宿主/配置/日志/服务外壳/健康信标 │
|
||||
│ Fleet.Update 清单/验签/安装/切换/回滚 │
|
||||
│ 工具:fleet-enroll(注册) fleet-updater(更新) fleet-release(CI 签名) │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 2. 设计原则
|
||||
|
||||
1. **与业务无关**:Fleet 中不出现任何产品概念。产品通过扩展点接入:元数据类型、RPC 方法、发件箱消息类型、期望状态文档类型。`scripts/check-deps` 强制规定 `backend/fleet/**` 不能引用 `Monitor.*`。
|
||||
2. **安全默认值**:
|
||||
- Fleet **永远不提供** exec、shell、文件读写、远程下发代码这类方法;
|
||||
- 节点只响应本地注册过的方法,本地策略 `allow_methods` 还可以进一步收窄;
|
||||
- 更新必须通过离线密钥签名验证,面板即使被攻破也推不出恶意二进制。
|
||||
3. **权限分离**:注册(`fleet-enroll`,一次性运行)、常驻业务进程、更新(`fleet-updater`)是三个独立的进程,使用不同的系统用户,各自只拥有最小权限。
|
||||
4. **NativeAOT 优先**:节点侧只依赖 BCL;泛型扩展点通过产品传入的 `JsonTypeInfo<T>` 实现序列化,不使用反射。
|
||||
5. **只抽取真正用到的**:每项能力都必须同时被 Monitor 和 `samples/EchoNode` 使用,防止过度设计。
|
||||
|
||||
## 3. 组件
|
||||
|
||||
| 组件 | 侧 | 职责 | 详细 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Fleet.Core` | 两侧 | `Result<T>`、错误码基类、退避、有界队列、ULID/UUIDv7 | — |
|
||||
| `Fleet.Identity` | 两侧 | ECDSA P-256 身份密钥、自签名证书、SPKI 指纹、带用途前缀的签名串、`KeyStore`(文件权限 0600) | [link.md](link.md) §2 |
|
||||
| `Fleet.Rpc` | 两侧 | JSON-RPC 2.0 信封、方法路由、请求与通知、二进制帧、错误映射;与传输方式无关 | [link.md](link.md) §4 |
|
||||
| `Fleet.Link` | 节点 | 连接服务端(wss)或其他节点(TLS);认证、心跳、重连、能力协商、可靠发件箱、期望状态同步 | [link.md](link.md) |
|
||||
| `Fleet.Enrollment.Client` | 节点 | 加入令牌注册流程(供 `fleet-enroll` 使用) | [link.md](link.md) §3 |
|
||||
| `Fleet.Hosting` | 节点 | 模块宿主、配置(源生成 JSON)、logfmt 日志、服务外壳(systemd/procd/Windows)、健康信标 | [link.md](link.md) §8 |
|
||||
| `Fleet.Update` | 节点 | 清单格式、验签、下载校验、多版本目录与原子切换、服务适配器、健康确认、回滚 | [update.md](update.md) |
|
||||
| `Fleet.Server.Enrollment` | 服务端 | 加入令牌、注册端点、审批与接管 | [link.md](link.md) §3 |
|
||||
| `Fleet.Server.Link` | 服务端 | wss 端点、签名认证、连接注册表、在线事件、状态下发、收件箱 ACK | [link.md](link.md) |
|
||||
| `Fleet.Server.Releases` | 服务端(可选) | 托管清单、管理通道、灰度发布、汇总各节点版本 | [update.md](update.md) §6 |
|
||||
| `Fleet.Server.Sqlite` | 服务端(可选) | `IFleetStore` 的 SQLite 实现(节点、令牌、公钥、状态版本) | — |
|
||||
| `fleet-enroll` | 工具 | 节点注册:生成密钥、调用注册端点、写入配置 | [link.md](link.md) §3 |
|
||||
| `fleet-updater` | 工具 | 节点更新守护进程,可以同时管理多个产品 | [update.md](update.md) |
|
||||
| `fleet-release` | 工具 | 在 CI 或离线环境中生成并签名发布清单 | [update.md](update.md) §3 |
|
||||
|
||||
## 4. 目录结构
|
||||
|
||||
```text
|
||||
fleet/ 以后可以原样拆成独立仓库并发布 NuGet 包
|
||||
├─ Directory.Build.props 导入仓库根目录的 props
|
||||
├─ src/
|
||||
│ ├─ Fleet.Core/ Fleet.Identity/ Fleet.Rpc/ Fleet.Link/ Fleet.Hosting/ Fleet.Update/
|
||||
│ ├─ Fleet.Enrollment.Client/
|
||||
│ └─ Fleet.Server.Enrollment/ Fleet.Server.Link/ Fleet.Server.Releases/ Fleet.Server.Sqlite/
|
||||
├─ tools/ fleet-enroll/ fleet-updater/ fleet-release/
|
||||
├─ samples/ EchoNode/ EchoServer/ 最小示例:不依赖 Monitor 的第二个使用者
|
||||
├─ tests/
|
||||
└─ docs → 仓库 docs/fleet/
|
||||
```
|
||||
|
||||
## 5. 演进策略
|
||||
|
||||
- **现阶段**(已确认,暂不独立):放在 monitor 仓库的 `backend/fleet/` 目录中,通过项目引用使用,一处修改两边同时编译。
|
||||
- **拆分条件**:公开 API 连续两个里程碑没有破坏性变更,并且 `samples` 能覆盖全部能力。满足后拆成独立仓库,按 semver 发布 NuGet 包,Monitor 改为包引用。
|
||||
- **公开 API 审查**:`backend/fleet/src/*` 的 public 类型使用 `PublicAPI.Shipped.txt` / `PublicAPI.Unshipped.txt` 跟踪(使用 `Microsoft.CodeAnalysis.PublicApiAnalyzers`,只在构建期生效),任何变更都会在 PR 中显示出来。
|
||||
|
||||
## 6. 产品接入一览
|
||||
|
||||
**节点侧**:
|
||||
|
||||
```csharp
|
||||
var identity = NodeIdentity.Load(config.IdentityDir); // 由 fleet-enroll 生成
|
||||
var link = FleetLink.ToServer(config.Server, identity, MyRpcContext.Default)
|
||||
.WithCapabilities(["myapp.v1"])
|
||||
.WithState<MyDesired>(MyRpcContext.Default.MyDesired, reconciler) // 期望状态同步(可选)
|
||||
.WithOutbox<MyReport>(MyReportCodec.Instance, maxBytes: 16 << 20) // 可靠上报(可选)
|
||||
.On<DoThingParams, DoThingResult>("myapp.do_thing", HandleAsync); // 业务方法
|
||||
var host = new ModuleHost(log).Add(link).Add(myModules);
|
||||
await ServiceShell.RunAsync(host, HealthBeacon.From(config)); // 同时提供更新所需的健康确认
|
||||
```
|
||||
|
||||
**服务端**:
|
||||
|
||||
```csharp
|
||||
builder.Services.AddFleetServer(o => o.UseStore<SqliteFleetStore>())
|
||||
.AddState<MyDesired>(desiredProvider) // 为每个节点计算期望状态
|
||||
.AddInbox<MyReport>(reportHandler) // 持久化之后再 ACK
|
||||
.OnNodeEvent(presenceHandler); // 上线、下线、版本变化
|
||||
app.MapFleetEnrollment("/fleet/enroll");
|
||||
app.MapFleetLink("/fleet/rpc");
|
||||
app.MapFleetReleases("/fleet/releases"); // 可选
|
||||
```
|
||||
@@ -0,0 +1,110 @@
|
||||
# Fleet:通用更新(Fleet.Update / fleet-updater)
|
||||
|
||||
## 1. 原则
|
||||
|
||||
- **与产品无关**:`fleet-updater` 通过本地配置声明它管理哪些应用,一台机器上可以同时管理多个产品。
|
||||
- **信任来自签名,而不是来源**:只接受由**离线发布密钥**签名的清单。发布源可以是面板、GitHub Releases、对象存储或任意 HTTP 服务。**面板即使被攻破,也推不出恶意二进制。**
|
||||
- **本地说了算**:更新策略(`auto`、`notify`、`pinned`)写在节点本地的配置中,面板只能“建议”目标版本。
|
||||
- **权限分离**:业务进程不能改写自己的二进制;更新进程读不到业务身份私钥,也不参与业务。
|
||||
|
||||
```text
|
||||
┌─ 节点 ─────────────────────────────────────────────────────────────────────┐
|
||||
│ myapp(业务进程) 用户 myapp:只读 /opt/myapp,只能写配置目录 │
|
||||
│ └─ HealthBeacon ──写──> /run/fleet/myapp.beacon │
|
||||
│ fleet-updater 用户 fleet-update:只能写 /opt/myapp/versions, │
|
||||
│ ├─ 读取信标 通过 polkit/sudoers 规则只允许重启 myapp.service │
|
||||
│ └─ 不读取 node.key │
|
||||
└────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 2. 本地配置(`/etc/fleet/updater.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"trust": [
|
||||
{ "id": "rel-2026a", "alg": "ecdsa-p256", "pubkey": "BASE64..." },
|
||||
{ "id": "rel-2027a", "alg": "ecdsa-p256", "pubkey": "BASE64..." }
|
||||
],
|
||||
"check_interval_s": 3600,
|
||||
"apps": [
|
||||
{
|
||||
"id": "monitor-agent",
|
||||
"sources": ["https://panel.example.com/fleet/releases", "https://github.com/org/monitor/releases/download"],
|
||||
"channel": "stable",
|
||||
"policy": "notify",
|
||||
"rid": "linux-x64",
|
||||
"install_dir": "/opt/monitor-agent",
|
||||
"keep_versions": 2,
|
||||
"service": { "kind": "systemd", "name": "monitor-agent" },
|
||||
"health": { "kind": "beacon", "path": "/run/fleet/monitor-agent.beacon", "timeout_s": 60 }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `trust` 可以配置多把公钥,用于轮换;清单中的 `key_id` 用来选择验证哪一把。
|
||||
- `policy` 取值:`auto`(验签通过即安装)、`notify`(只下载并上报“有可用更新”,等待面板上的人工批准)、`pinned:<version>`(锁定版本)。
|
||||
- 面板上的批准只对 `notify` 生效,并且**批准的版本同样必须通过签名验证**。
|
||||
|
||||
## 3. 发布清单(由 `fleet-release` 在 CI 或离线环境中生成)
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": 1,
|
||||
"app": "monitor-agent",
|
||||
"channel": "stable",
|
||||
"version": "0.4.2",
|
||||
"released_at": "2026-10-01T00:00:00Z",
|
||||
"min_from": "0.3.0",
|
||||
"artifacts": [
|
||||
{ "rid": "linux-x64", "url": "monitor-agent-0.4.2-linux-x64.tar.gz", "sha256": "…", "size": 4120334 },
|
||||
{ "rid": "linux-arm64", "url": "monitor-agent-0.4.2-linux-arm64.tar.gz", "sha256": "…", "size": 3987112 },
|
||||
{ "rid": "linux-musl-arm64", "url": "monitor-agent-0.4.2-linux-musl-arm64.tar.gz", "sha256": "…", "size": 3990021 },
|
||||
{ "rid": "win-x64", "url": "monitor-agent-0.4.2-win-x64.zip", "sha256": "…", "size": 4455010 }
|
||||
],
|
||||
"notes_url": "https://…/CHANGELOG#0.4.2",
|
||||
"key_id": "rel-2026a",
|
||||
"signature": "BASE64(ECDSA(规范化 JSON,不含 signature 字段))"
|
||||
}
|
||||
```
|
||||
|
||||
- 签名范围是**规范化后的 JSON**:键排序、无空白、不含 `signature` 字段。规范化规则由 `Fleet.Update` 实现,签名端和验签端使用同一份代码。
|
||||
- 发布源的目录约定:`<source>/<app>/<channel>/latest.json`(指向当前版本)和 `<source>/<app>/<version>/manifest.json`。
|
||||
- 防回滚:updater 在本地记录每个应用已安装过的最高版本;除非本地配置显式允许降级,否则拒绝安装更低的版本,防止旧的签名清单被重放。
|
||||
|
||||
## 4. 更新流程
|
||||
|
||||
```text
|
||||
轮询(间隔加随机抖动)
|
||||
└─ 拉取 latest.json → 验签 ✗ → 记录并忽略
|
||||
├─ 版本 ≤ 当前版本,或当前版本 < min_from → 跳过(后者需要人工介入)
|
||||
├─ policy = notify → 下载并校验 → 上报“可以更新” → 等待批准
|
||||
└─ policy = auto 或已批准:
|
||||
下载 → 校验 sha256 和大小 → 解包到 versions/<v>/ → 检查可执行文件
|
||||
→ 原子切换 current → versions/<v>(Linux 用符号链接,Windows 见 §5)
|
||||
→ 重启服务 → 等待健康信标(version == v,并且 ok_at 晚于重启时刻)
|
||||
✓ → 记录成功,按 keep_versions 清理旧版本
|
||||
✗ 超时 → 切回旧版本 → 重启 → 记录失败,该版本标记为坏版本,不再自动尝试
|
||||
```
|
||||
|
||||
## 5. 平台适配
|
||||
|
||||
| 平台 | 切换方式 | 服务管理 |
|
||||
| --- | --- | --- |
|
||||
| Linux | `current` 符号链接 + `rename(2)` 原子替换 | systemd:`systemctl restart <name>`,由 polkit 规则限定只能操作被管理的单元 |
|
||||
| Windows | 服务的 ImagePath 指向 `current\app.exe`;先停服务,再切换目录联结(junction),然后启动 | SCM:updater 的服务账户只被授予这几个服务的启动和停止权限 |
|
||||
| OpenWrt | 与 Linux 相同的符号链接切换;默认 `keep_versions = 1`,下载前检查闪存剩余空间 | procd:`/etc/init.d/<name> restart`(updater 以 root 运行,这是 OpenWrt 的常见做法;仍然不读取节点身份目录) |
|
||||
| 其他(以后) | launchd、OpenRC | 通过适配器接口 `IServiceAdapter` 扩展 |
|
||||
|
||||
## 6. 面板侧(`Fleet.Server.Releases`,可选)
|
||||
|
||||
- 托管清单和制品(也可以只做代理或索引,制品放在 GitHub 或对象存储)。
|
||||
- **通道与灰度**:每个通道可以指定目标版本,并按标签分批发布,例如先更新 1 台节点,观察 N 分钟没有失败后再更新其余节点。
|
||||
- **状态汇总**:节点在 `fleet.hello` 中上报 `{app, version}`,updater 上报 `{available, installing, failed, rolled_back}`。面板据此展示版本分布和更新进度。
|
||||
- updater 与面板通信时,**不使用业务进程的身份**。首版只轮询公开的通道清单,完全不需要身份,状态也由业务进程在 `fleet.hello` 中代为上报。给 updater 单独一对身份密钥是以后的扩展。
|
||||
|
||||
## 7. 发布密钥管理
|
||||
|
||||
- 私钥只存在于 CI 的密钥服务或离线环境中,**永远不上传到面板**。
|
||||
- 轮换方式:新旧两把公钥同时写进 `trust` → 用新密钥发布 → 所有节点都更新到信任新密钥的版本后 → 移除旧公钥。
|
||||
- 密钥泄露时:发布一个用新密钥签名、并从 `trust` 中移除旧公钥的版本;已经被恶意更新的节点需要人工重装。
|
||||
@@ -0,0 +1,184 @@
|
||||
# 任务与采集引擎
|
||||
|
||||
## 1. 原则
|
||||
|
||||
- **只采集原始数据**:逐包、逐消息、逐事件,TCP 另加周期性的内核采样;不计算任何统计,解释全部放在 [analysis.md](analysis.md)。
|
||||
- **一处声明,处处生效**:采集方法(Kind)由积木组合**声明**而成。Probe 和 Agent 共用同一套积木(`backend/shared/Monitor.Measurement`)。
|
||||
- **密度即参数**:频率、包大小、采样间隔全部是任务参数,由 API 下发,不需要改代码。
|
||||
|
||||
## 2. 任务模型
|
||||
|
||||
```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 积木(`Monitor.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 声明(`Monitor.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`(持续运行)。其余 Kind 可以按需开启,或者以 Plan 形式运行。
|
||||
|
||||
### 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)时提前切块。这样采集密度提高后,单块大小仍然有界,实时显示和持久化也共用同一条链路。
|
||||
- 按需测试按阶段切块,阶段内同样受记录数上限约束。
|
||||
- 编解码器位于 `Monitor.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/Monitor.Measurement`)
|
||||
|
||||
同一种采集方法的两端是**一对角色**写在同一个文件里:拨号方(Probe 永远是拨号方,适配 NAT)和监听方(Agent)。两端共用包的构造和校验、时钟、行表和合并;Probe 和 Agent 只负责组装自己那一侧。
|
||||
|
||||
```text
|
||||
Monitor.Measurement/ 依赖:Monitor.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,353 @@
|
||||
# 项目结构与工程约束
|
||||
|
||||
## 1. 目标
|
||||
|
||||
1. **三个独立项目加一个前端**:Probe、Agent、Backend 各自拥有解决方案,可以单独构建、测试和发布;Web 是独立的 Vite 项目(`web/`)。
|
||||
2. **通用能力只写一次**:TCP/UDP/TLS/ICMP 采集积木(类似 iperf3 的能力,但只记录原始数据)放在 `Monitor.Measurement` 中,Probe 和 Agent 共用;采集方法(Kind)由积木组合声明而成。
|
||||
3. **节点机制通用化**:注册、身份、连接、RPC、可靠上报、状态同步、宿主、更新,由与业务无关的 **Fleet** 框架提供(见 [fleet/overview.md](fleet/overview.md))。Monitor 只写业务,其他产品也可以复用 Fleet。
|
||||
4. **改一处,整条链路受益**:协议、原始数据格式、测试方案、指标、错误码、版本号、编译设置、代码风格都只有**一个真源**。改动之后,下游要么自动更新,要么在编译期或 CI 中直接报错。
|
||||
5. **模块化**:Probe/Agent 由可插拔的模块组成;Backend 按层分成多个项目,层之间的依赖在编译期强制。
|
||||
6. **Probe/Agent 轻量**:运行时只依赖 BCL 和仓库内的库,不引用任何 NuGet 包。
|
||||
|
||||
## 2. 仓库布局
|
||||
|
||||
```text
|
||||
monitor/
|
||||
├─ README.md .gitignore
|
||||
├─ docs/ docs/fleet/
|
||||
├─ scripts/ # publish / package / smoke / stability 等脚本(check-deps 等检查也放这里)
|
||||
├─ packaging/ # install.sh、systemd 单元、OpenWrt 文件
|
||||
├─ web/ # 前端:Vite + React + TypeScript(自写),只依赖 backend/openapi/v1.json
|
||||
│
|
||||
├─ global.json Directory.Build.props Directory.Packages.props Monitor.slnx
|
||||
├─ .editorconfig BannedSymbols.txt # 风格与禁用 API(构建期强制)
|
||||
├─ BannedSymbols.Node.txt # 仅用于常驻节点进程:纯采集边界
|
||||
│
|
||||
└─ backend/ # 所有 C# 代码(构建产物在 backend/artifacts/)
|
||||
├─ fleet/ # 通用节点框架(不认识 Monitor,以后可以拆成独立仓库)
|
||||
│ ├─ src/ Fleet.Core Fleet.Identity Fleet.Rpc Fleet.Link Fleet.Hosting Fleet.Update
|
||||
│ │ Fleet.Enrollment.Client Fleet.Server.{Enrollment,Link,Releases,Sqlite}
|
||||
│ ├─ tools/ fleet-enroll fleet-updater fleet-release
|
||||
│ ├─ samples/ EchoNode EchoServer
|
||||
│ └─ tests/
|
||||
├─ shared/ # Monitor 自己的共享库
|
||||
│ ├─ Monitor.Protocol/ # 契约真源:Kind/Plan 目录、任务集、monitor.* 方法、UDP 包、RawChunk、错误码
|
||||
│ ├─ Monitor.Measurement/ # 采集引擎:积木、Recorder、任务调和(见 measurement.md)
|
||||
│ └─ tests/
|
||||
├─ probe/ src/Monitor.Probe/ tests/ # 家宽端采集器
|
||||
├─ agent/ src/Monitor.Agent/ tests/ # VPS 端采集器
|
||||
├─ src/ # Backend(面板)
|
||||
│ ├─ Monitor.Backend/ # 宿主与组合根(Program.cs)
|
||||
│ ├─ Monitor.Backend.Api/ # API 层:HTTP 端点、DTO、错误映射
|
||||
│ ├─ Monitor.Backend.Gateway/ # 在 Fleet.Server.Link 上注册 Monitor 的方法、状态与收件箱
|
||||
│ ├─ Monitor.Backend.Application/ # 用例层 + 端口接口
|
||||
│ ├─ Monitor.Backend.Analysis/ # 分析(纯计算,见 analysis.md)
|
||||
│ └─ Monitor.Backend.Storage.Sqlite/ # 端口接口的实现,同时实现 IFleetStore
|
||||
├─ tests/
|
||||
├─ openapi/v1.json # 构建生成并提交
|
||||
└─ Monitor.Backend.slnx
|
||||
```
|
||||
|
||||
节点机器上的发布物是三个独立进程,使用不同的系统用户:`monitor-agent`(或 `monitor-probe`)、`fleet-enroll`(一次性运行)、`fleet-updater`(常驻或定时运行)。
|
||||
|
||||
## 3. 依赖关系
|
||||
|
||||
```text
|
||||
┌────────┐ ┌────────┐ ┌──────────────────────────────┐ ┌─────┐
|
||||
│ Probe │ │ Agent │ │ Backend (Api/Gateway/App/ │─openapi─>│ Web │
|
||||
└─┬──┬───┘ └─┬──┬───┘ │ Analysis/Storage) │ └─────┘
|
||||
│ └───┬───┘ │ └───┬───────────────┬──────────┘
|
||||
│ ▼ │ │ │
|
||||
│ ┌───────────┴┐ │ │
|
||||
│ │Measurement │ │ │
|
||||
│ └─────┬──────┘ │ │
|
||||
▼ ▼ ▼ ▼
|
||||
┌──────────────────────────────────┐ ┌─────────────────────────┐
|
||||
│ Monitor.Protocol │ │ Fleet.Server.* │
|
||||
└────────────────┬─────────────────┘ └───────────┬─────────────┘
|
||||
▼ ▼
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Fleet(节点侧):Core · Identity · Rpc · Link · Hosting │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
反向依赖一律禁止:fleet/** ✗→ Monitor.*
|
||||
```
|
||||
|
||||
简要来说:
|
||||
|
||||
```text
|
||||
Probe, Agent ──> Measurement ──> Monitor.Protocol ──> Fleet.Core / Fleet.Rpc
|
||||
Probe, Agent ──> Fleet.Link / Fleet.Hosting / Fleet.Identity
|
||||
Backend.* ──> Monitor.Protocol + Fleet.Server.* (不引用 Measurement)
|
||||
fleet-enroll ──> Fleet.Enrollment.Client ──> Fleet.Identity / Core
|
||||
fleet-updater ──> Fleet.Update ──> Fleet.Core (不引用 Link,也不读取节点身份)
|
||||
Web ──> openapi/v1.json
|
||||
```
|
||||
|
||||
| 项目 | 允许依赖 | 禁止 |
|
||||
| --- | --- | --- |
|
||||
| `Fleet.*`(节点侧) | BCL、其他节点侧 Fleet 库 | 任何 NuGet 运行时包;任何 `Monitor.*` |
|
||||
| `Fleet.Server.*` | ASP.NET Core、`Microsoft.Data.Sqlite`(仅 Sqlite 项目)、节点侧 Fleet 库 | 任何 `Monitor.*` |
|
||||
| `Monitor.Protocol` | Fleet.Core、Fleet.Rpc | 任何 I/O、套接字、日志 |
|
||||
| `Monitor.Measurement` | Monitor.Protocol、Fleet.Core | NuGet;日志(只通过计数接收器上报);读取配置;统计或解释逻辑 |
|
||||
| Probe / Agent | 上述库,以及 Fleet.Link / Hosting / Identity | NuGet 运行时包;彼此互相引用;Fleet.Update;Fleet.Enrollment.Client |
|
||||
| Backend.* | Monitor.Protocol、Fleet.Server.*,以及第 5.2 节列出的包 | Measurement、EF Core 等依赖反射的库 |
|
||||
|
||||
分析器类包(`PrivateAssets="all"`)不受限制。`scripts/check-deps` 在 CI 中检查上述规则。
|
||||
|
||||
## 4. 共享库
|
||||
|
||||
### 4.1 Fleet(通用节点框架)
|
||||
|
||||
组件、扩展点与接入方式见 [fleet/overview.md](fleet/overview.md),连接细节见 [fleet/link.md](fleet/link.md),更新见 [fleet/update.md](fleet/update.md)。原来的 `Monitor.Common` 和 `Monitor.NodeHost` 已经并入 Fleet:
|
||||
|
||||
| 原位置 | 现位置 |
|
||||
| --- | --- |
|
||||
| `Monitor.Common`(Result、缓冲区、VarInt、有界队列、重放窗口、退避、ID、令牌哈希) | `Fleet.Core` |
|
||||
| `Monitor.NodeHost`(模块宿主、日志、配置、ConfigStore、服务外壳) | `Fleet.Hosting` |
|
||||
| 节点密钥与签名 | `Fleet.Identity` |
|
||||
| JSON-RPC 信封与路由 | `Fleet.Rpc` |
|
||||
| Agent 注册 | `Fleet.Enrollment.Client` + `fleet-enroll` |
|
||||
|
||||
### 4.2 Monitor.Protocol(Monitor 的契约真源)
|
||||
|
||||
```text
|
||||
Monitor.Protocol/
|
||||
├─ ProtocolVersion ProtocolLimits MonitorErrorCode(在 Fleet 错误码基础上扩展)
|
||||
├─ Ids/ AgentId(= Fleet NodeId)、ProbeId、TaskId、TestId、ChunkId、StreamId
|
||||
├─ Catalog/
|
||||
│ ├─ Kinds/ KindSpec 声明(积木组合 + ParamSpec + 记录列 + 预算)
|
||||
│ ├─ Plans/ 多阶段测试方案
|
||||
│ └─ ParamSpec 参数定义(类型、默认值、上下限),节点与 Backend 共用同一套校验
|
||||
├─ Tasks/ TaskSet(Fleet 期望状态的文档类型)、调和结果
|
||||
├─ Rpc/ monitor.* 方法名、params/result、二进制通道号(从 16 开始)
|
||||
├─ Wire/ UDP 包、数据连接首帧
|
||||
├─ Raw/ 自描述 RawChunk:头部、列类型、列编解码、Brotli、crc32c
|
||||
└─ Json/ AgentRpcContext、ProbeRpcContext(按信道拆分,便于裁剪)
|
||||
```
|
||||
|
||||
累加器、直方图等统计类型**不在 Protocol 中**,只属于 `Backend.Analysis`。
|
||||
|
||||
### 4.3 Monitor.Measurement(采集引擎)
|
||||
|
||||
包含:
|
||||
|
||||
- 积木:Transport、Pacer、Payload、Behavior、Sampler;
|
||||
- 根据列定义自动生成的 Recorder;
|
||||
- `KindAssembler`;
|
||||
- `TaskReconciler`(实现 Fleet 的 `IStateReconciler<TaskSet>`);
|
||||
- `BudgetGuard`。
|
||||
|
||||
详见 [measurement.md](measurement.md) 第 6 节。
|
||||
|
||||
## 5. 模块化
|
||||
|
||||
### 5.1 Agent 与 Probe:模块组装
|
||||
|
||||
模块在 `Program.cs` 中**显式组装**,不使用 DI 容器,也不做反射扫描。连接、认证、重连、发件箱、状态同步都由 Fleet 提供,Monitor 只写业务模块:
|
||||
|
||||
```text
|
||||
Agent Program.cs
|
||||
identity = NodeIdentity.Load(dir) ← 由 fleet-enroll 生成
|
||||
link = FleetLink.ToServer(cfg, identity, AgentRpcContext.Default)
|
||||
.WithState<TaskSet>(…, taskReconciler) 期望任务集 → 调和
|
||||
.WithOutbox<RawChunk>(…, 64 MiB) 原始块可靠上报
|
||||
.On(monitor.test.run / monitor.test.cancel, …)
|
||||
peers = FleetLink.PeerListener(cfg.Listen, identity, ProbeRpcContext.Default) Probe 会话
|
||||
host = new ModuleHost(log)
|
||||
├─ link、peers (Fleet)
|
||||
├─ ProbeDirectoryRelay 转发 monitor.probe.agents / agents_changed
|
||||
├─ UdpMux / TcpAcceptor UDP/TCP/TLS 数据端口(Measurement 共享),按 (session, stream) 分发给监听方积木;AgentAdmission 提供重放、限速
|
||||
├─ ChunkModule 合并 Probe 与本端的列 → 切块 → link.Outbox
|
||||
└─ TestModule 按需测试的阶段编排
|
||||
|
||||
Probe Program.cs
|
||||
├─ AgentDirectoryModule 维护 Agent 列表(bootstrap → monitor.probe.agents → agents_changed)
|
||||
├─ PeerPool(Fleet) 每个 Agent 一个 FleetLink.ToPeer:认证、重连
|
||||
├─ TaskReconciler 每个会话各自的期望状态(与 Agent 相同的代码)
|
||||
├─ ColumnShipper 本端的列通过会话的 Outbox 发送(二进制通道 17)
|
||||
└─ TestModule
|
||||
```
|
||||
|
||||
模块之间通过构造函数传入的接口或 `Channel<T>` 通信,禁止通过静态全局状态共享数据。
|
||||
|
||||
**新增 RPC 方法**:在 `Monitor.Protocol/Rpc` 中定义 params/result,然后在组装处加一行 `.On(Methods.X, HandleX)`。
|
||||
|
||||
### 5.2 Backend:分层与 API 层
|
||||
|
||||
```text
|
||||
HTTP (public | admin) wss /api/agent/v1/rpc(Fleet.Server.Link) SSE /api/public/live
|
||||
│ │ ▲
|
||||
┌──────────────▼───────────────┐ ┌───────────▼──────────┐ │
|
||||
│ Backend.Api │ │ Backend.Gateway │ │
|
||||
│ Public/ Admin/ │ │ 注册 monitor.* 方法 │ │
|
||||
│ 认证(Cookie/CSRF/令牌) │ │ TaskSet 状态提供者 │ │
|
||||
│ 错误映射 · OpenAPI · SSE 端点 │ │ RawChunk 收件箱 │ │
|
||||
└──────────────┬───────────────┘ └───────────┬──────────┘ │
|
||||
└───────────────┬──────────────┘ │
|
||||
▼ │
|
||||
┌─────────────────────────────────────────────────────────┐ ┌───────────────────┴──┐
|
||||
│ Backend.Application │───>│ LiveHub(Application)│
|
||||
│ Agents · Probe · Tasks(有效任务集的计算与下发) │ │ 内存快照 + 主题订阅 │
|
||||
│ Tests · Ingest(chunk → 存储 → 分析队列)· SeriesQuery │ └──────────────────────┘
|
||||
│ AnalysisScheduler · Downsampler · Auth · Releases 视图 │ 调用 ─> Backend.Analysis
|
||||
└───────────────────────────▲─────────────────────────────┘ 纯计算:算子/指标/模型
|
||||
│ 实现端口
|
||||
┌───────────────────────────┴─────────────────────────────┐
|
||||
│ Backend.Storage.Sqlite:段文件 + SQLite(只做读写) │
|
||||
│ 同时实现 IFleetStore(Fleet 的节点和令牌表放在 monitor.db 中)│
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
|
||||
端口:IAgentStore、ITaskStore、IRawStore、IMetricStore、ITestStore、IAuthStore
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- **Api 不能引用 Storage**(编译期保证);Api 和 Gateway 只调用 Application。
|
||||
- Application 返回 `Result<T>`。**错误码到 HTTP 状态的映射只在 Api 层做一次**(`ErrorCodeHttpMap`);到 JSON-RPC 错误的映射由 Fleet.Rpc 统一完成。
|
||||
- Api 层按调用方分目录:`Public/`、`Admin/`,公共部分放在 `Common/`(分页、ETag、认证过滤器、ProblemFactory、`EndpointConventions`)。每类资源一个端点文件,新增资源时在 `ApiModule.Map` 中加一行。
|
||||
- 注册端点、签名认证、连接注册表、状态推送、收件箱 ACK、发布通道都来自 Fleet(`MapFleetEnrollment`、`MapFleetLink`、`MapFleetReleases`)。Monitor 只提供:
|
||||
- `IFleetStore` 的实现;
|
||||
- `TaskSet` 的状态提供者;
|
||||
- `RawChunk` 的收件箱;
|
||||
- `monitor.*` 方法。
|
||||
- public 层的 DTO 单独定义,只暴露可以公开的字段,并统一经过“可见性过滤器”。
|
||||
- 请求校验:简单字段约束使用 .NET 10 内置的 Minimal API 校验;任务参数统一用 `ParamSpec` 校验(与节点共用);跨字段规则放在 Application 层。
|
||||
- Backend 使用 ASP.NET Core 自带的 DI 容器,只在组合根显式注册。
|
||||
|
||||
Backend.Analysis 只依赖 Monitor.Protocol 和 Fleet.Core,没有 I/O,可以单独做成离线 CLI 使用。
|
||||
|
||||
Backend 允许的包:ASP.NET Core 共享框架、`Microsoft.Data.Sqlite`、`Microsoft.AspNetCore.OpenApi`、`Microsoft.Extensions.ApiDescription.Server`(仅构建期使用)。
|
||||
|
||||
## 6. “改一处,整条链路受益”的机制
|
||||
|
||||
| 改动 | 真源 | 链路上的效果 |
|
||||
| --- | --- | --- |
|
||||
| 采集密度(频率、包大小、采样间隔) | 任务参数(API) | 经 `fleet.state.sync` 下发后立即生效,无需发布 |
|
||||
| 新增采集方法(现有积木可以组合) | `Protocol/Catalog/Kinds` 中加 1 条 | 节点执行、Recorder 自动生成列、Backend 校验和目录、Analysis 按列名读取 |
|
||||
| 新增采集方法 | 在 `Kinds` 里加声明(含流的 `Driver`/`Key`),在 `Measurement/Blocks` 里实现它的拨号方和监听方并登记到 `BlockCatalog` | Probe 和 Agent 同时获得,合并不需要写代码 |
|
||||
| 原始数据格式 | `Protocol/Raw`(自描述) | 新增列不需要升级版本;四处使用同一份编解码器 |
|
||||
| 新增统计指标 | `Analysis/Metrics` 中加 1 行 `MetricDef` | 存储、降采样、API、SSE、目录全部自动处理;回填 30 天历史 |
|
||||
| 服务评分 | `Analysis/Models` | 查询时计算,立即对全部历史生效 |
|
||||
| 新增 RPC 能力 | `Monitor.Protocol/Rpc` + 组装处加 1 行 | 通过 Fleet 的 capabilities 协商,旧节点不受影响 |
|
||||
| 注册、认证、重连、发件箱、状态同步 | `backend/fleet/` | Monitor 和所有其他产品同时受益 |
|
||||
| 更新机制 | `Fleet.Update` + `fleet-updater` | 所有产品共用;产品只提供发布清单 |
|
||||
| REST DTO 与端点 | `Backend.Api` | 生成 OpenAPI,CI 检查 diff,前端的 TS 类型随之更新 |
|
||||
| 错误码 | Fleet 错误码 + `MonitorErrorCode` | REST、JSON-RPC、OpenAPI、TS 全部一致 |
|
||||
| 版本、编译设置、风格、禁用 API | `backend/` 下的 props、`.editorconfig`、`BannedSymbols*.txt` | 所有项目 |
|
||||
|
||||
## 7. 编译设置
|
||||
|
||||
`backend/Directory.Build.props`(所有项目):
|
||||
|
||||
```xml
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<AnalysisLevel>latest-recommended</AnalysisLevel>
|
||||
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||||
<IsAotCompatible>true</IsAotCompatible>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<UseArtifactsOutput>true</UseArtifactsOutput>
|
||||
<Deterministic>true</Deterministic>
|
||||
<Version>0.1.0</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" PrivateAssets="all" />
|
||||
<AdditionalFiles Include="$(MSBuildThisFileDirectory)BannedSymbols.txt" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
```
|
||||
|
||||
`backend/fleet/src/*` 另外启用 `Microsoft.CodeAnalysis.PublicApiAnalyzers`(`PublicAPI.Shipped.txt` / `PublicAPI.Unshipped.txt`),公开 API 的任何变更都会在 PR 中显示出来。
|
||||
|
||||
`backend/probe/`、`backend/agent/` 下的 `Directory.Build.props`(先导入根 props,再追加轻量设置):
|
||||
|
||||
```xml
|
||||
<PropertyGroup>
|
||||
<PublishAot>true</PublishAot>
|
||||
<OptimizationPreference>Size</OptimizationPreference>
|
||||
<UseSystemResourceKeys>true</UseSystemResourceKeys>
|
||||
<EventSourceSupport>false</EventSourceSupport>
|
||||
<DebuggerSupport>false</DebuggerSupport>
|
||||
<HttpActivityPropagationSupport>false</HttpActivityPropagationSupport>
|
||||
<ServerGarbageCollection>false</ServerGarbageCollection>
|
||||
<ConcurrentGarbageCollection>false</ConcurrentGarbageCollection>
|
||||
<!-- 保留 StackTraceSupport:线上崩溃需要可读的调用栈 -->
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<!-- 纯采集边界:编译期禁止 -->
|
||||
<AdditionalFiles Include="$(MSBuildThisFileDirectory)../BannedSymbols.Node.txt" />
|
||||
</ItemGroup>
|
||||
```
|
||||
|
||||
`BannedSymbols.Node.txt` 适用于常驻节点进程(Probe、Agent,以及 Fleet 的节点侧运行库),主要条目:
|
||||
|
||||
- `System.Diagnostics.Process`;
|
||||
- `System.Reflection.Assembly.Load*`;
|
||||
- `System.Runtime.InteropServices.NativeLibrary.Load*`;
|
||||
- `System.IO.File.Write*`、`File.Create`、`FileStream` 构造函数(只有 `Fleet.Hosting.ConfigStore` 可以通过 `#pragma` 豁免);
|
||||
- `System.Net.Http.HttpClient`(**不再有豁免**:注册已经移到 `fleet-enroll`)。
|
||||
|
||||
`fleet-enroll` 和 `fleet-updater` 是独立的工具,使用各自的禁用清单:
|
||||
|
||||
- `fleet-updater` 允许写安装目录、调用服务管理接口,但禁止读取节点身份目录;
|
||||
- `fleet-enroll` 允许 HTTP,允许写入身份目录。
|
||||
|
||||
## 8. 轻量预算
|
||||
|
||||
M0 阶段先测出基线,之后作为 CI 的回归门槛(超出 10% 即失败):
|
||||
|
||||
| 程序 | 发布体积 | 空闲常驻内存 | 负载 |
|
||||
| --- | --- | --- | --- |
|
||||
| Probe | ≤ 8 MB(Linux/OpenWrt,静态链接了 .NET 的加密、压缩、网络库;Windows 实测 5.6 MB) | ≤ 32 MB(Linux 实测约 21 MB) | 同时连接 15 个 Agent 并运行默认模板时 CPU < 2%;`hy2_ladder` 100 Mbps 时 CPU < 50%(单核) |
|
||||
| Agent | ≤ 10 MB(Linux 实测 8.4 MB) | ≤ 48 MB(Linux 实测约 23 MB) | 运行默认模板时 ≤ 64 MB;同时跑一个 200 Mbps 测试时 CPU < 60%(1 vCPU) |
|
||||
| fleet-enroll | ≤ 4 MB | — | 一次性运行 |
|
||||
| fleet-updater | ≤ 4 MB | ≤ 16 MB | 空闲时每小时轮询一次 |
|
||||
| Backend | 不设硬性限制 | ≤ 150 MB | — |
|
||||
|
||||
为满足预算,实现上需要遵守:
|
||||
|
||||
- 热路径使用 `Span<T>` 和 `ArrayPool<byte>`,不逐包分配;
|
||||
- UDP 使用 `SocketAddress` 系列非分配 API;
|
||||
- stream 和 bulk 发送使用预先填充的缓冲区,只统计计数,不复制数据;
|
||||
- 不使用 LINQ。
|
||||
|
||||
## 9. 发布
|
||||
|
||||
- 目标平台:
|
||||
|
||||
| 程序 | RID | 说明 |
|
||||
| --- | --- | --- |
|
||||
| Agent | `linux-x64`、`linux-arm64` | VPS |
|
||||
| Probe | `linux-x64`、`linux-arm64`、`win-x64` | 普通 PC 或服务器 |
|
||||
| Probe(OpenWrt) | `linux-musl-x64`、`linux-musl-arm64` | 正式支持;`linux-musl-arm`(armv7)视 M0 的工具链验证结果决定是否支持 |
|
||||
| fleet-enroll / fleet-updater | 与所在节点相同 | |
|
||||
| Backend | `linux-x64`、`linux-arm64`、`win-x64` | |
|
||||
|
||||
- **MIPS 不支持**(NativeAOT 没有 MIPS 目标)。使用 MIPS 路由器(例如 MT7621)时,把 Probe 运行在局域网内的其他设备上,比如 NAS、树莓派或 PC,测量结果同样代表这条家宽。
|
||||
- NativeAOT 需要目标平台的原生工具链:musl 目标在 Alpine 容器中构建,arm64 使用交叉编译镜像。CI 矩阵为 Windows、Linux glibc、Linux musl。
|
||||
- `scripts/publish.(sh|ps1) <probe|agent|backend|fleet-enroll|fleet-updater> <rid>` 输出到 `artifacts/publish/<project>/<rid>/`。
|
||||
- 发布时由 `fleet-release` 在 CI 中生成并签名清单(见 [fleet/update.md](fleet/update.md)),节点通过 `fleet-updater` 更新。
|
||||
- Linux 上 TLS 依赖系统 OpenSSL(`libssl.so.3`)。
|
||||
|
||||
### 9.1 OpenWrt 上的 Probe
|
||||
|
||||
| 事项 | 做法 |
|
||||
| --- | --- |
|
||||
| C 库 | musl:使用 `linux-musl-*` 目标发布 |
|
||||
| TLS | .NET 在 Linux 上依赖 OpenSSL,而 OpenWrt 默认使用 mbedTLS/wolfSSL。需要安装 `libopenssl3`(安装脚本自动检查并提示)。启动时自检,缺少时明确报错,不会静默降级 |
|
||||
| 全球化 | 已启用 `InvariantGlobalization`,不依赖 ICU |
|
||||
| 服务管理 | procd:提供 `/etc/init.d/monitor-probe`(`procd_set_param respawn`,前台运行,日志输出到 logd);`Fleet.Hosting.ServiceShell` 在 procd 下不需要 `sd_notify` |
|
||||
| 存储 | 闪存很小,而且需要避免频繁写入:二进制放在 `/usr/lib/monitor/`,配置和身份放在 `/etc/monitor/`(sysupgrade 时通过 `/lib/upgrade/keep.d/` 保留);测量数据本来就不落盘;`ConfigStore` 只在 Agent 列表变化时写入 |
|
||||
| 内存 | 预算 ≤ 32 MB,适合 ≥ 256 MB 内存的路由器;在本地配置中限制重负载测试的码率(`limits.max_test_mbps`),防止路由器 CPU 成为瓶颈,反过来影响测量结果 |
|
||||
| 测量偏差 | Probe 运行在网关上时,自己没有 NAT;一旦路由器 CPU 饱和,结果就会失真。因此 Probe 在 `monitor.event` 中上报自身的 CPU 繁忙标记(仅进程级别),分析层据此标注置信说明 |
|
||||
| 打包 | 提供 `tar.gz` 和 procd 脚本;包格式(ipk 或 apk)按目标 OpenWrt 版本提供 |
|
||||
| 更新 | `fleet-updater` 使用 procd 适配器;`keep_versions = 1`,下载前检查剩余空间,避免写满闪存 |
|
||||
|
||||
## 10. Web(待开发)
|
||||
|
||||
唯一输入是 `backend/openapi/v1.json`:用 `openapi-typescript` 生成类型,`openapi-fetch` 作为请求客户端;技术栈等前端立项时再决定。
|
||||
@@ -0,0 +1,103 @@
|
||||
# 线协议
|
||||
|
||||
Monitor 的两条控制链路都建立在 Fleet 之上:
|
||||
|
||||
- **Agent ↔ Backend**:Fleet 连接服务端(wss),见 [fleet/link.md](fleet/link.md);
|
||||
- **Probe ↔ Agent**:Fleet 节点间点对点会话(TLS),见 [fleet/link.md](fleet/link.md) 第 8 节。
|
||||
|
||||
认证、心跳、重连、能力协商、可靠发件箱、期望状态同步、JSON-RPC 信封都由 Fleet 负责。本文只定义 **Monitor 自己的内容**:`monitor.*` 方法、状态文档、二进制通道,以及 Fleet 之外的数据面(UDP 包和数据连接)。所有类型都放在 `Monitor.Protocol` 中。
|
||||
|
||||
## 1. Fleet 能力的映射
|
||||
|
||||
| Monitor 需要的能力 | 使用的 Fleet 机制 | Monitor 提供的内容 |
|
||||
| --- | --- | --- |
|
||||
| 下发任务 | 期望状态 `fleet.state.sync` | 文档类型 `TaskSet`,包括 `tasks[]`、`peers`(Probe 公钥),以及 `probe_agents` 的版本 |
|
||||
| 上报原始块 | 发件箱,二进制通道 **16** | `RawChunk`(自描述,见 [measurement.md](measurement.md) 第 4 节),`msg_id = chunk_id` |
|
||||
| Probe 交付列块 | 点对点会话的发件箱,二进制通道 **17** | 列块:`u32 stream_id \| u32 first_seq \| u32 count \| 列数据(Brotli)` |
|
||||
| 节点自身状态 | `fleet.heartbeat` 的附加字段 | `sessions`、`pps_in/out`、`dropped_invalid`、`dropped_rate_limited`、`queue_bytes` |
|
||||
| 版本与能力 | `fleet.hello` | capabilities 包括 `monitor.kinds:<列表>`、`monitor.blocks:<列表>`;附加 `tls_spki`、`probe_ports`、`local_limits` 摘要 |
|
||||
|
||||
## 2. `monitor.*` 方法
|
||||
|
||||
### Agent ↔ Backend
|
||||
|
||||
| 方法 | 方向 | 类型 | 参数与结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| `monitor.test.run` | B→A | 请求 | `test_id`、`probe_id`(在哪个 Probe 上跑)、`plan`、`phases[]` → `accepted`,或错误 `test_busy`、`probe_offline` |
|
||||
| `monitor.probe.disconnect` | B→A | 请求 | `probe_id` → `accepted`(Agent 是否真的有这个会话);Agent 关闭该 Probe 的会话 |
|
||||
| `monitor.test.cancel` | B→A | 请求 | `test_id` |
|
||||
| `monitor.test.status` | A→B | 通知 | `test_id`、`status`(`running`、`phase_done`、`succeeded`、`failed`、`aborted`)、`phase?`、`error_code?` |
|
||||
| `monitor.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` |
|
||||
|
||||
心跳的附加字段里有 `probes[]`:Agent 当前连着的每个 Probe(`id`、`version`、`remote`、`since_utc_ms`),每次都是完整快照,Backend 靠它纠正错过的连接事件。点对点握手的认证消息里可以带 Probe 的 `version`(只用于展示)。块头里的 `probe_id` 以 Agent 认证过的会话为准,Probe 自报的值会被覆盖。
|
||||
| `monitor.probe.agents` | A→B | 请求 | 代理 Probe 获取 Agent 列表 → `version`、`agents[]` |
|
||||
| `monitor.probe.agents_changed` | B→A | 通知 | Agent 列表发生变化,由 Agent 转发给 Probe |
|
||||
|
||||
### Probe ↔ Agent(点对点会话)
|
||||
|
||||
| 方法 | 方向 | 类型 | 参数与结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| `fleet.state.sync`(文档类型 `line_task_set`) | A→P | 请求 | 这条线路的任务(`id`、`kind`、`params`、`stream_id`、`port`)和 Agent 的数据面端口 |
|
||||
| `monitor.clock` | P→A | 请求 | `probe_mono_us` → `agent_mono_us`;Probe 按往返中点估计两端单调时钟的偏移(取最小往返的一次),记入块头 `probe_offset_us` |
|
||||
| `monitor.test.phase` | A→P | 请求 | 测试的一个阶段:`test_id`、`phase`、`kind`、`params`、`stream_id`、`duration_s`、`port` → `accepted`;Probe 在回复之前准备好自己一侧的角色(单向数据流:先发几个探活包让 Agent 学到源地址) |
|
||||
| `monitor.test.phase_end` | A→P | 请求 | `sent_count`(Agent 发出的包数)→ `fragments`:Probe 收尾,把列块交给 Agent,回复交出的个数;Agent 等全部列块合并上传之后才进入下一阶段 |
|
||||
| `monitor.test.abort` | A→P | 通知 | `test_id` |
|
||||
| `monitor.probe.agents` | P→A | 请求 | `have_version` → `version`、`agents[]`:Agent 代 Probe 向 Backend 查询(链路断开时 `fleet_node_offline`) |
|
||||
| `monitor.probe.agents_changed` | A→P | 通知 | `version`、`agents[]`(Agent 从 Backend 转发) |
|
||||
| `fleet.outbox.ack` | 双向 | 通知 | Probe 的列块(通道 17)是至少一次投递,Agent 每收到一个就确认,重复到达的只处理一次 |
|
||||
|
||||
会话密钥不需要单独的方法:点对点握手(TLS 固定证书 → `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 通信)。
|
||||
|
||||
新增方法只需要在 `Monitor.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. 版本演进
|
||||
|
||||
- Fleet 层的兼容规则见 [fleet/link.md](fleet/link.md)。
|
||||
- `monitor.*` 方法:新增方法或可选字段不需要升级版本,接收方忽略未知字段。
|
||||
- RawChunk:块头自描述,新增列或新增 Kind 不需要升级 `container_version`;只有容器格式变化时才升级。
|
||||
- UDP 包和数据连接首帧的布局变化时,必须升级 `protocol_version`,并更新黄金字节测试。
|
||||
- Agent 同时支持当前版本和上一个版本的 Probe;Backend 能解码保留期内所有的 `container_version`。
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
# 展示数据查询(前端取数)
|
||||
|
||||
本文定义前端从 Backend 拉取展示数据的方式:时间段、步长、指标选择、响应格式、实时跟随、缓存与限额。端点清单见 [api.md](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-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 读取累加器并导出值。
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
# 里程碑
|
||||
|
||||
每个里程碑都以“可运行、可验证”作为完成标准;可执行程序在各自的目标 RID 上(见 [project-layout.md](project-layout.md) 第 9 节,Probe 包括 `linux-musl-x64` 和 `linux-musl-arm64`)的 NativeAOT 发布必须通过,并且零 AOT/trim 警告。
|
||||
|
||||
## M0 骨架
|
||||
|
||||
- 按 [project-layout.md](project-layout.md) 建立仓库:`backend/` 下的 props、`.editorconfig`、`BannedSymbols.txt` 与 `BannedSymbols.Node.txt`、`backend/fleet/` 骨架、三个 `.slnx`、两个 Monitor 共享库、Backend 的六个项目。
|
||||
- CI:Windows/Linux 矩阵、路径过滤、`dotnet format --verify-no-changes`、`scripts/check-deps`、`scripts/check-openapi`、AOT 发布,并记录体积和内存基线。
|
||||
|
||||
**验收**:
|
||||
- 三个空程序以 AOT 方式发布并启动;
|
||||
- 在 Agent 中调用 `Process.Start` 时构建失败;
|
||||
- 空的 Probe 能在 OpenWrt aarch64 真机或 QEMU 上启动,并完成一次 TLS 握手(验证 musl 与 `libopenssl3`);
|
||||
- 给出 `linux-musl-arm`(armv7)工具链的验证结论。
|
||||
|
||||
## F1 Fleet 核心(与 M1 并行)
|
||||
|
||||
- `Fleet.Core`、`Fleet.Identity`、`Fleet.Rpc`、`Fleet.Link`(连接服务端、点对点会话、发件箱、期望状态同步)、`Fleet.Hosting`、`Fleet.Enrollment.Client`、`fleet-enroll`、`Fleet.Server.Enrollment`、`Fleet.Server.Link`、`Fleet.Server.Sqlite`。
|
||||
- `samples/EchoNode` 与 `EchoServer`:用不依赖 Monitor 的方式跑通注册 → 认证 → 状态同步 → 发件箱上报 → 断线重连。
|
||||
|
||||
**验收**:
|
||||
- `scripts/check-deps` 证明 `backend/fleet/**` 不引用 `Monitor.*`;
|
||||
- EchoNode 在 Backend 重启、网络中断 10 分钟后能恢复,上报的消息不重复也不丢失(队列未满时);
|
||||
- 伪造签名、重放 nonce、调用本地未允许的方法,全部被拒绝。
|
||||
|
||||
## M1 声明与积木(本机回环)
|
||||
|
||||
- `Protocol`:`ParamSpec`、`KindSpec`、自描述 RawChunk 编解码(附黄金字节测试)、JSON-RPC 信封。
|
||||
- `Measurement`:Transport(udp/tcp/tls)、Pacer(fixed/ladder)、Payload、Behavior(echo/sink/source/connect/hold)、Sampler(tcp_info),以及 Recorder、`KindAssembler`、`BudgetGuard`。
|
||||
- `tools/monitor-bench`:用两个本地进程运行任意 Kind,导出 RawChunk,用来调试引擎。
|
||||
- 测试:通过丢包注入中继验证逐包记录的完整性;100 Mbps 下发送节奏误差 < 1%;**只加一条 Kind 声明、不写代码,就能运行一种新的组合**。
|
||||
|
||||
## M2 Probe ↔ 多个 Agent
|
||||
|
||||
- Probe 与 Agent 之间的签名认证、会话、MAC、重放窗口、限速、源地址漫游;`TaskReconciler`;列的交付与合并;每 10 秒切块。
|
||||
- Probe 同时与多个 Agent 维持会话(先用静态列表)。
|
||||
- 开发模式:Agent 把 RawChunk 写入本地目录(正式运行时不落盘)。
|
||||
|
||||
**验收**:一个家宽 Probe 同时连接 3 台以上 VPS,连续运行 24 小时;原始块完整,内存稳定;宽带重拨后能自动恢复。
|
||||
|
||||
## M3 Backend:注册、RPC、存储
|
||||
|
||||
- 在 Fleet 之上接入:`IFleetStore` 实现、`TaskSet` 状态提供者(有效任务集的计算与下发)、`RawChunk` 收件箱、`monitor.*` 方法、`monitor.probe.agents` 同步。
|
||||
- 段文件与索引、发件箱 ACK、去重、缺口;管理员账号、Cookie 会话、API 令牌;`/api/admin` 中的节点、令牌、Probe 身份、任务管理。
|
||||
|
||||
**验收**:用一行安装命令部署一台新 VPS,自动注册 → 自动生成线路任务 → Probe 自动连接 → 1 分钟内在 `/api/admin` 中看到它的原始块。整个过程不需要手工配置。
|
||||
|
||||
## M4 分析层与实时
|
||||
|
||||
- 累加器、算子、首批 `MetricDef`、`base`/`mc`/`hy2`/`reality` 模型 v1;流式路径与持久路径;降采样;指标指纹与回填。
|
||||
- 查询([query.md](query.md)):`RangeResolver`、`SourcePlanner`、1s 热层(1 小时)、`metric_10s`、现场合并、精确汇总;`/overview?range=`、`/series`、`/compare`、`/distribution`、`/heatmap`、`/catalog/*`(包括 presets);SSE 的 `series` 和 `overview` 主题;按需测试与测试结果。
|
||||
- **查询验收**:近 5 分钟 1s 步长和近 15 分钟 2s 步长的响应时间 < 50 ms;7 天 15 分钟步长的响应时间 < 200 ms;同一时间段内,汇总值与“从原始块直接计算”的结果一致(直方图分位数误差 ≤ 4.5%)。
|
||||
|
||||
**验收**:
|
||||
- **扩展性**:新增一个指标只需一行 `MetricDef`,重启后通过 API 可以看到它,并且 30 天历史已经回填;
|
||||
- **校准**:在两条线路上实际部署 Hysteria2 和 REALITY 服务并实测,与推算结果对比,把偏差记录进 `docs/calibration.md`。
|
||||
|
||||
## M5 规模与保留
|
||||
|
||||
- 用模拟数据验证 15 个 Agent 运行 30 天的情况:存储量、查询延迟(30 天跨度 < 500 ms)、回填耗时、SSE 在 100 个连接下的表现。
|
||||
- 保留清理、磁盘占用指标、目标任务(`icmp_echo`、`connect`)与 `allowed_target_cidrs`。
|
||||
|
||||
## F2 Fleet 更新(在 M6 之前)
|
||||
|
||||
- `Fleet.Update`、`fleet-updater`(systemd 与 Windows 服务适配器、健康信标、回滚、防回滚)、`fleet-release`(CI 签名)、`Fleet.Server.Releases`(通道、灰度、版本汇总)。
|
||||
|
||||
**验收**:
|
||||
- 用 EchoNode 和 monitor-agent 两个应用验证“同一个 updater 管理多个产品”;
|
||||
- 验签失败、sha256 不符、新版本健康检查超时,这三种情况都能正确拒绝或回滚;
|
||||
- 面板被替换为恶意面板时,节点不会安装未签名的版本。
|
||||
|
||||
## M6 加固与部署
|
||||
|
||||
- OpenWrt 打包:procd 脚本、ipk/apk 包、sysupgrade 保留列表、`libopenssl3` 依赖检查;在 aarch64 路由器上跑满 24 小时,并运行一次 `hy2_ladder`(观察 CPU 饱和标记)。
|
||||
- 安装脚本的下载和验签(现在的 `install.sh` 从解压好的发布目录安装)、`install.ps1` 和 Windows 服务、`fleet-updater` 的配置。
|
||||
- 安全:对 UDP 包、控制帧、RawChunk、JSON-RPC 做模糊测试;伪造签名和重放测试;检查登录限流和 CSRF。
|
||||
|
||||
## 以后
|
||||
|
||||
- 告警(Webhook);真实 QUIC 握手积木(依赖 msquic,需要单独评估);pcap/eBPF 采集。
|
||||
|
||||
## 实现状态(后端与 Agent)
|
||||
|
||||
已实现并有自动化测试(287 个,另有 2 个跳过的测试,其中 1 个需要运行中 NativeAOT 产物):
|
||||
|
||||
- **多 Probe**:Probe 身份由 Backend 生成(创建、轮换密钥、吊销、删除),一条线路是一对(Probe,Agent),Agent 为每个 Probe 会话各起一组流,数据按 `line:<probe_id>` 隔离;Agent 上报连着的 Probe(id、版本、远端地址、时间),管理端可以断开某个 Probe;吊销立即生效。
|
||||
- **`tcp_bulk` 原语**:批量传输(fwd / rev、本地码率上限、两端的 tcp_info),评分用持续 1 秒的吞吐峰值。
|
||||
- **Agent 主机状态**:只上报汇总数字(没有型号和 IP),实时推给展示页,每分钟存一个样本。
|
||||
- **前端**:`web/display`(Agent 卡片总览、线路详情、按 Probe 的线路对比,拖动放大、只提供有数据的时间范围)和 `web/admin`(Agent、Probe、任务、按需测试、系统),共享「纸墨」主题;页面里可以把数据源指到别的后端(API 令牌 + 可选的 CORS 白名单 `Monitor__CorsOrigins`)。
|
||||
- **真实线路试跑**:广东移动家宽 → 日本 IIJ 的 VPS(见 deployment.md)。
|
||||
|
||||
- **F1 的服务端部分**:`Fleet.Core/Identity/Rpc`、`Fleet.Server.Abstractions/Enrollment/Link`(注册、签名认证、期望状态同步、发件箱收件箱、服务端发起的调用)。
|
||||
- **M3**:注册、RPC、段文件与 SQLite、任务集下发、管理 API。
|
||||
- **M4(除校准外)**:累加器、算子、指标注册制、四个模型、流式与持久两条路径、降采样、指标指纹与回填、查询层(范围、步长、数据分层、精确汇总、列式响应)、SSE、按需测试。
|
||||
- **M5 的一部分**:保留清理。
|
||||
- **F1 的节点部分**:`Fleet.Link`(链路、发件箱、点对点监听与连接)、`Fleet.Hosting`、`Fleet.Enrollment.Client` 与 `fleet-enroll`。
|
||||
- **Agent(M1 的一部分)**:`Monitor.Agent`(NativeAOT 可发布)与 `Monitor.Measurement`(列块合并、预算守卫)。UDP/TCP 数据面(认证、重放窗口、限速、端点学习)、线路任务调和、目标任务(`icmp_echo`、`connect`)、按需测试编排(`udp_train` 由 Agent 发包)。测试里的假 Probe(`ProbeSimulator`)通过真实的点对点握手连接真实的 Agent 与后端,端到端覆盖。详见 `backend/agent/README.md`。
|
||||
- **Probe(M0/M2 的一部分)**:`Monitor.Probe`(NativeAOT,5.6 MB,常驻约 22 MB)——Agent 列表(bootstrap → 由 Agent 代为查询 → 推送,写回 `agents.json`)、每个 Agent 一个自动重连的会话、`udp_echo`/`tcp_echo`/`connect`/`hold` 角色、`udp_train` 接收端(按序号范围切块)、时钟偏移估计、有界发件箱。真实 Probe + 真实 Agent + 真实后端的端到端测试,以及用发布的 AOT 二进制做过的冒烟(内存稳定、CPU < 1%)。详见 `backend/probe/README.md`。
|
||||
- **采集积木统一(重构)**:Probe 和 Agent 的数据面合并成 `Monitor.Measurement` 里的一份实现(每种采集方法一个文件、拨号方和监听方一对角色,`UdpMux`/`Tcp`/`RowStore` 共用);合并改成由 Kind 的流声明(`Driver`、`Key`)驱动;测试方案移回 Backend;架构测试禁止采集层出现业务概念。
|
||||
- **TCP 内核采样与长尾**:`tcp_echo` 两端各自采样套接字的 `TCP_INFO`(Linux,`union` 流拼接);`tcp.rtt` 增加 `p999`、`max`、长尾比 `tail`,新增 `tcp.slow`(块内基线的慢回显占比);序列接口增加 `anchor=latest`。
|
||||
- **打包与部署(M6 的一部分)**:`scripts/publish.sh`(linux-musl-x64、linux-x64 在 NativeAOT SDK 容器里构建)、`scripts/package.sh`、`packaging/`(`install.sh`、systemd 单元、OpenWrt procd 脚本)、节点启动时的 OpenSSL 自检。在 Alpine、Debian、OpenWrt 官方 rootfs、带 systemd 的 Debian 容器里验证过。详见 `docs/deployment.md`。
|
||||
|
||||
未实现:
|
||||
|
||||
- `monitor-bench`(M0/M2);arm64 构建(需要 arm64 主机或 CI)、真正的 OpenWrt 设备;`udp_train` 只支持 `direction=rev`;跨公网线路只试跑过一条(几分钟),还没有长期试跑。
|
||||
- 告警;定时调度和按需测试的长期峰值趋势;Windows Probe 没有 tcp_info;Probe 会话结束时最后 ≤10 秒的数据(不修,极端情况)。多个 Probe 同时连时,码率和包速率的预算按单个 Probe 会话计算,总负载是它的倍数,靠会话数上限(默认 8)约束。
|
||||
- `fleet-updater`、`fleet-release`、`Fleet.Update`、`Fleet.Server.Releases`、`Fleet.Server.Sqlite`(F2;Monitor 自己实现 `IFleetStore`)。
|
||||
- `/api/admin/releases`、`/install.sh`、`/install.ps1`(安装脚本返回 501,依赖打包 M6)。
|
||||
- M4 的校准(需要在真实线路上部署 Hysteria2 与 REALITY 对比)和 M5 的 30 天规模验证;模型评分曲线是 v1 的经验值。
|
||||
- 查询的 `tz` 只支持 UTC(其他值返回 400);`probe.endpoint_changes` 指标未进入注册表(计数在 Agent 运行时状态里)。
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
# Backend 存储
|
||||
|
||||
实现位于 `Monitor.Backend.Storage.Sqlite`,负责实现 `Backend.Application` 中定义的端口接口,**只做读写,不含业务逻辑**。降采样和分析由 Application 调用 Analysis 完成。
|
||||
|
||||
## 1. 目录布局
|
||||
|
||||
```text
|
||||
data/
|
||||
├─ monitor.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` 存储(由 `Monitor.Backend.Storage.Sqlite` 实现),和 Monitor 的其他数据放在同一个 `monitor.db` 中,由同一个单写入者写入。发布与灰度相关的表(`fleet_releases`、`fleet_rollouts`)由 `Fleet.Server.Releases` 自带的迁移脚本创建(职责见 [fleet/update.md](fleet/update.md) 第 6 节)。
|
||||
|
||||
```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 的证书指纹
|
||||
public_host TEXT NOT NULL, -- 默认取注册请求的来源 IP,可以修改
|
||||
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 天 |
|
||||
|
||||
降采样由 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