# 任务与采集引擎 ## 1. 原则 - **只采集原始数据**:逐包、逐消息、逐事件,TCP 另加周期性的内核采样;不计算任何统计,解释全部放在 [analysis.md](analysis.md)。 - **一处声明,处处生效**:采集方法(Kind)由积木组合**声明**而成。Probe 和 Agent 共用同一套积木(`backend/shared/MoShiro.Measurement`)。 - **密度即参数**:频率、包大小、采样间隔全部是任务参数,由 API 下发,不需要改代码。 ## 2. 任务模型 同一个 Probe 与 Agent 的 IPv4、IPv6 路径并行执行相同的持续线路任务模板,各自记录、上传、分析。IPv6 只在管理端配置了地址且 Agent 上报支持时建立;两端的控制和数据通道固定使用同一地址族。按需测试通过所选线路 id 指定地址族,重负载并发限制仍按 Agent 和 Probe 的本机策略执行。两个地址族分别使用有界发件箱(Probe 每路径 8 MB,Agent 的共同上传队列 64 MB)。 ```text Task = { id, scope, kind, target, params, schedule, enabled } scope line : Probe ↔ Agent 线路任务 target 为空(agent_id 为空时表示对所有 Agent 生效的模板) target : Agent → 目标 IP 的单边任务 target = ip[:port](受 Agent 本地 allowed_target_cidrs 限制) kind Kind 目录中的 id,例如 udp_echo / tcp_echo / udp_train … params 按 Kind 的 ParamSpec 校验(默认值、上下限);未指定的参数取默认值 schedule continuous 持续运行,按时间切块 every(N) 每 N 分钟运行一次(N ≥ 60,适用于重负载 Kind) once 按需测试,由 API 触发,也就是一次 Test ``` - **线路任务模板**:`agent_id` 为空的 `line` 任务会自动应用到所有 `active` 的 Agent。新 Agent 注册后自动获得这些任务;也可以针对单个 Agent 覆盖参数或停用。 - **测试方案(Plan)**:多个阶段的 `once` 任务组合,例如 `hy2_ladder` 是 `udp_train` 在 10、25、50、100 Mbps 下各运行 10 秒。Plan 定义在 `Protocol/Catalog/Plans`。 - **期望任务集**:Backend 为每个 Agent 计算“有效任务集”(模板 + 覆盖 + 目标任务),带版本号,作为 Fleet 的期望状态(`fleet.state.sync`,文档类型 `TaskSet`)下发。Agent 的 `TaskReconciler` 与当前运行的任务对比后,启动、停止或修改参数;其中线路任务再经过控制通道同步给 Probe。**新增任务类型时,协议不需要任何改动。** ## 3. Kind 目录:积木组合 ### 3.1 积木(`MoShiro.Measurement`,只有新增积木才需要写代码) | 类别 | 积木 | 参数示例 | | --- | --- | --- | | Transport | `udp`、`tcp`、`tls`、`icmp` | `port`、`sni`、`nodelay`、`sndbuf` | | Pacer(发送节奏) | `fixed`、`poisson`、`burst`、`ladder` | `rate_hz`、`mbps`、`burst_len`、`steps[]` | | Payload(负载形态) | `fixed`、`uniform`、`choice` | `size`、`min`/`max`、`sizes[]` | | Behavior(对端行为) | `echo`、`sink`、`source`、`connect`、`hold` | `direction`(`fwd`/`rev`/`bidir`)、`streams` | | Sampler(采样器,可挂到任意连接上) | `tcp_info`、`socket_counters` | `every_ms` | | Recorder(记录器) | 根据 Kind 声明的列**自动生成**的列式记录器 | — | **两种角色互相独立**:连接角色(Probe 永远是拨号方,适配 NAT)与数据角色(发送端、接收端、回显端,由 `direction` 决定)无关。每种积木只实现一次,Probe 和 Agent 共用。 ### 3.2 Kind 声明(`MoShiro.Protocol/Catalog/Kinds`) ```csharp public static readonly KindSpec TcpEcho = new( Id: "tcp_echo", Scopes: Scope.Line, Transport: Blocks.Tcp(nodelay: true), Pacer: Blocks.Fixed(rateHz: P.Int("rate_hz", @default: 20, min: 1, max: 200)), Payload: Blocks.Uniform(P.Int("min", 32, 8, 1400), P.Int("max", 64, 8, 1400)), Behavior: Blocks.Echo(), Samplers: [Blocks.TcpInfo(everyMs: P.Int("tcp_info_ms", @default: 100, min: 10, max: 1000))], Records: [ Rec.Stream("msg", Col.U32("seq"), Col.U16("size"), Col.TsProbe("t1"), Col.TsAgent("t2"), Col.TsAgent("t3"), Col.TsProbe("t4")), Rec.Stream("tcp_info", Col.TsLocal("ts"), Col.U8("side"), Col.U32("srtt_us"), Col.U32("rttvar_us"), Col.U32("cwnd"), Col.U32("retrans_total"), Col.U64("bytes_acked")), ], Budget: p => Bandwidth.Echo(p.RateHz, p.Max)); // 用于校验上限和估算带宽 ``` 这一条声明会被四个地方使用: - **Probe/Agent**:组装积木执行任务; - **Recorder**:根据列定义记录数据; - **Backend**:校验参数和预算,并在 `/catalog/kinds` 中展示; - **Analysis**:按列名读取数据。 ### 3.3 内置 Kind | Kind | 组合 | 默认参数 | 主要服务于 | | --- | --- | --- | --- | | `udp_echo` | udp + fixed + fixed + echo | 10 Hz × 64 B | 基础指标、mc、hy2 | | `udp_train` | udp + fixed/ladder + fixed + sink | 1250 B,`rev` | hy2 | | `tcp_echo` | tcp/tls + fixed + uniform + echo + tcp_info | 20 Hz × 32–64 B,`tcp_info` 每 100 ms | mc、reality | | `tcp_bulk` | tcp/tls + source/sink + tcp_info | `streams` 条连接并行传输(`direction` = fwd Probe 发 / rev Agent 发),Probe 每 `sample_ms` 记录每条连接的累计字节数(`bytes` 流);两端各自只采样第一条连接的内核 tcp_info;发送端受本地 `MaxBulkMbps` 约束;只用于按需测试,默认 4 秒(够量到峰值,省流量) | reality | | `connect` | tcp/tls + fixed + connect | 每分钟 6 次 | reality,以及目标任务 | | `hold` | tls + hold | 每 30 s 一次心跳 | reality | | `icmp_echo` | icmp + fixed + fixed + echo | 1 Hz × 64 B | 目标任务 | 默认的线路任务模板:`udp_echo`、`tcp_echo`、`connect`(持续运行;`connect` 每分钟 6 次,记录 TCP 建连和 TLS 握手耗时,约 0.4 kbps)。其余 Kind 可以按需开启,或者以 Plan 形式运行。每一批默认任务只种入一次,在设置里用标记记住,所以删掉的默认模板不会被重新建出来;后来新增的默认任务(`connect`)在已有部署升级后会补建一次。 ### 3.4 回显的四个时间戳 ```text Probe 单调时钟 Agent 单调时钟 t1 发送 ─────────────> t2 收到 t4 收到 <───────────── t3 回复 ``` RTT = `(t4 − t1) − (t3 − t2)`。缺少 `t2` 表示上行丢失;有 `t2`/`t3` 但缺少 `t4` 表示下行丢失。`t2 − t1` 和 `t4 − t3` 的**变化量**在去除偏移和漂移后,就是每个方向各自的时延抖动。 ## 4. 原始块(自描述) ```text RawChunk ├─ header(JSON,自描述) │ chunk_id(UUIDv7) container_version kind task_id test_id? phase? │ agent_id probe_id? target? params(完整快照) port direction │ start_utc_ms duration_ms │ clock:agent 单调时钟 ↔ UTC 锚点,probe 时钟偏移估计 │ streams: [ { name: "msg", columns: [ {name:"seq", type:"u32", enc:"delta"}, … ], count } … ] ├─ 各列数据(delta / zigzag / varint,按列定义编码) ├─ Brotli 压缩(BCL 自带) └─ crc32c ``` - **列定义写在块头中**:新增列或新增 Kind 时,**不需要升级 `container_version`**。只有容器格式本身变化时才升级。Backend 可以通用地解码任何块,不认识的列会被保留,但会被分析层忽略。 - **切块规则**:持续任务每 **10 秒**切一块,或者记录数达到上限(默认 50 000)时提前切块。这样采集密度提高后,单块大小仍然有界,实时显示和持久化也共用同一条链路。 - 按需测试按阶段切块,阶段内同样受记录数上限约束。 - 编解码器位于 `MoShiro.Protocol/Raw`,Probe、Agent、Backend、离线工具使用同一份代码。 ## 5. 列的归属与合并 > **两侧各自记录完整行的流**(`StreamSpec.Union`,例如 `tcp_info`):Probe 和 Agent 各自采样自己的套接字(Linux `TCP_INFO`,通过 `Socket.GetRawSocketOption`,不需要 P/Invoke),每一行带 `side` 标明属于哪一侧;合并时直接拼接,不连接,Agent 一侧的行每行只交出一次。Probe 的重传反映上行方向,Agent 的重传反映下行方向(代理场景里大流量走这个方向)。非 Linux 平台没有这类采样,对应的指标在窗口里就是没有数据。 采集层只产生**原始列**(`seq`、`size`、`t1…t4`、`t_send`/`t_recv`、`outcome`……),不做任何统计,也不认识上层的方案或协议;丢包率、RTT、抖动由 Backend 的分析层从这些列推算。 每个记录流(`StreamSpec`)声明: - `Driver`:行的主导方——行由它发出的包或事件决定; - `Key`:连接键(通常是 `seq`); - 每一列属于哪一侧由它的时钟决定:`TsProbe` 属于 Probe,`TsAgent` 属于 Agent,其余列属于主导方。 两端各自把**本侧的列**记在一张按键索引的环形行表里(`RowStore`),Probe 每 10 秒把已完成的行切成列块经控制通道交给**对应的 Agent**,Agent 按声明合并成完整的 RawChunk 后上传: - `Driver = Probe`(回显):行取自列块,Agent 的列按键补上,缺失就是缺失(上行丢失); - `Driver = Agent`(单向数据流):行取自 Agent 的记录(按列块头里的序号范围),Probe 的列按键补上,缺失就是缺失(丢失); - 没有 `Key`,或只有主导方一侧有列:原样透传。 新增一种记录流只需要写声明,不需要写合并代码。Probe 的列在规定时间内没有送达时,目前 Agent 带着已有的数据继续(不上传只有 Agent 一侧的 `partial` 块,见已知缺口)。 ## 6. 代码结构(`backend/shared/MoShiro.Measurement`) 同一种采集方法的两端是**一对角色**写在同一个文件里:拨号方(Probe 永远是拨号方,适配 NAT)和监听方(Agent)。两端共用包的构造和校验、时钟、行表和合并;Probe 和 Agent 只负责组装自己那一侧。 ```text MoShiro.Measurement/ 依赖:MoShiro.Protocol(仅 BCL,零 NuGet) ├─ Core/ │ ├─ Contracts.cs IBlock、BlockSpec、BlockEnv、DataSession、IAdmission、IFragmentSink…… │ ├─ RowStore.cs 本侧记录的行:只含本侧拥有的列,按键索引的环形缓冲,热路径不分配 │ ├─ MonoClock.cs 单调时钟(微秒)与 UTC 锚点 │ └─ RunningBlock.cs 一个运行中的积木(取消令牌 + 后台任务) ├─ Net/ │ ├─ UdpMux.cs UDP 数据面:按 stream 分发;校验顺序固定;准入策略(重放、限速)通过 IAdmission 注入 │ └─ Tcp.cs TcpLink(拨号:TLS 按指纹固定 + 认证首帧)、TcpAcceptor(监听:TLS 自动识别 + 认证首帧) ├─ Blocks/ 一个文件 = 一种采集方法的两个角色 │ ├─ Echo.cs UdpEchoDialer/Listener、TcpEchoDialer/Listener │ ├─ Session.cs ConnectDialer、HoldDialer、IcmpEchoSolo(没有对端)、IdleListener │ ├─ Train.cs TrainDialer(接收,记 t_recv)、TrainListener(按码率发包,记发送行) │ └─ BlockCatalog.cs Kind → (拨号方, 监听方);capabilities、本地预算的支持列表都从这里推出来 ├─ Merging/ FragmentMerger(声明驱动的按键合并)、ChunkFinalizer(盖章:块头、时钟锚点、起止时间) └─ Execution/ BudgetGuard(参数校验 + 本地硬上限,超出即拒绝) ``` **边界**(由架构测试强制):采集层的任何代码里不能出现 REALITY、Hysteria2、Minecraft 这些业务概念,也没有测试方案——方案(例如 `hy2_ladder`、`reality_session`)只是若干采集原语阶段的编排,只存在于 Backend。监听方的准入策略(重放窗口、令牌桶、源地址学习)是安全边界,通过 `IAdmission` 由 Agent 实现,积木本身不含策略。 ## 7. 资源与安全上限 | 项目 | 默认值 | 由谁约束 | | --- | --- | --- | | 同时运行的重负载测试 | 每个 Agent 1 个;Probe 全局 1 个 | Backend 编排 + 节点本地 | | 单次测试时长 / 码率 | ≤ 120 s;≤ 本地 `max_total_mbps` | 节点本地硬上限 | | 单块记录数 | ≤ 50 000 | Recorder | | Probe 上未交付的列数据 | 每个 Agent ≤ 8 MB,超出则丢弃最旧的数据并计入缺口 | Probe | | 持续任务的总带宽 | 每条线路 ≤ 100 kbps(默认模板约 40 kbps) | Backend 校验 | | 接收端速率校验 | 实际速率 > 声明速率 × 1.2 时中止 | 节点 | ## 8. 端口 | 用途 | 默认端口 | 说明 | | --- | --- | --- | | Probe 控制通道(TLS,Fleet 点对点会话) | 7443/tcp | | | UDP 数据(`udp_echo`、`udp_train`) | 7443/udp | | | TCP/TLS 数据(`tcp_echo`、`tcp_bulk`、`connect`、`hold`) | 7444/tcp | 按首帧中的 `stream_id` 分发 | - **默认不使用 443**,避免与 VPS 上已有的 REALITY 或 Hysteria2 服务冲突,也不需要特权端口权限。 - 任务参数中的 `port` 只能取 Agent 本地配置 `listen` 列表中的端口。要用 443 测量,需要节点所有者在本机把 443 加入 `listen`(并授予 `CAP_NET_BIND_SERVICE`),然后在任务中指定 `port: 443`。 - 每个原始块的头部都记录实际使用的端口。当测量端口与被推算服务的常用端口(443)不同时,分析结果附带 `port_differs_from_service` 置信说明,因为运营商的 QoS 可能按端口区别对待。 ## 9. 边界 采集端只模拟流量特征(端口、包大小、速率、握手、方向),**不实现** REALITY 或 Hysteria2 协议本身,分析结果会附带置信说明。用户态看不到 TCP 的逐段收发,所以 TCP 能拿到的最原始数据是“逐消息时间戳 + 内核采样”;pcap/eBPF 不在首版范围内。