docs: describe the unified Agent topology and link controls

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

No files matched your search

+5 -4
View File
@@ -1,13 +1,13 @@
# Agent Instructions
MoShiro is a self-hosted network path quality monitor. Probes (home broadband) and Agents (public VPS) run active measurements and record raw per-packet and per-event data. The Backend stores it, derives metrics, scores lines per service model, and serves a REST/SSE API to two web apps. Documentation is in Chinese under [`docs/`](docs/); source comments are mostly Chinese too. [`README.md`](README.md) is the entry point.
MoShiro is a self-hosted network path quality monitor. Agents on home broadband, NAT networks, or public VPS hosts run active measurements and record raw per-packet and per-event data. The Backend stores it, derives metrics, scores lines per service model, and serves a REST/SSE API to two web apps. Documentation is in Chinese under [`docs/`](docs/); source comments are mostly Chinese too. [`README.md`](README.md) is the entry point.
## Mental model
| Term | Meaning |
| --- | --- |
| Probe, Agent | Collector processes (NativeAOT, BCL only). The Probe always dials the Agent; both dial the Backend over `wss` |
| Line | One (Probe, Agent) pair, per IP family. Id `<agent_id>~<probe_id>`, IPv6 adds `~ipv6`; series key `line:<probe_id>` |
| Agent | Unified collector process (NativeAOT, BCL only), listening and dialing by default. Each Agent connects to the Backend over `wss` and reports outgoing link health and incoming sessions. |
| Line | One directed (dialer, listener) Agent pair, per IP family. Id `<agent_id>~<dialer_id>`, IPv6 adds `~ipv6`; series key `line:<dialer_id>` |
| Kind | A declared measurement method (`udp_echo`, `tcp_echo`, `connect`, ...) composed from building blocks. Defined once in `MoShiro.Protocol/Catalog/Kinds` |
| RawChunk | Self-describing columnar block, cut every 10 s, appended to segment files. The Backend never trusts node-reported statistics |
| Metric, Model | `MetricDef` is a one-line declaration over accumulators; models score metrics at query time. Both are recomputable from raw data |
@@ -32,10 +32,11 @@ MoShiro is a self-hosted network path quality monitor. Probes (home broadband) a
| New measurement method | Kind declaration in `backend/shared/MoShiro.Protocol/Catalog/Kinds`; new building block only if needed in `MoShiro.Measurement/Blocks` plus `BlockCatalog` |
| New metric | `MetricDef` in `backend/src/MoShiro.Backend.Analysis/Metrics`, add to `MetricRegistry.All` and a preset if shown |
| New service model | `Analysis/Models` |
| New RPC method | `MoShiro.Protocol/Rpc` (params, `MoShiroMethodTable`), then `router.On(...)` in `AgentApp` or `ProbeApp`; Backend-side handlers register in `Backend.Gateway` (`FleetGateway`) |
| New RPC method | `MoShiro.Protocol/Rpc` (params, `MoShiroMethodTable`), then `router.On(...)` in `AgentApp`; Backend-side handlers register in `Backend.Gateway` (`FleetGateway`) |
| New REST endpoint | `Backend.Api/Public` or `Admin` (typed results, operationId, capability), contract DTO in `Api/Contracts`, then regenerate `backend/openapi/v1.json` and web types |
| New capability | `Capability` constants in `Backend.Application/Auth/Capabilities.cs`; every `/api/admin` endpoint must declare one |
| Schema change | Append a migration in `Storage.Sqlite/Schema.cs` and bump `CurrentVersion` |
| Agent-to-Agent connection states, retry cadence, health reporting | `backend/agent/src/MoShiro.Agent/Dial` (`DialConnection` control layer, `TaskLink` QUIC channel, `LinkStatus`), `LinkHealth` in `MoShiro.Protocol/Rpc`, [`docs/design/architecture.md`](docs/design/architecture.md) §4.3 |
| Install or update behaviour | `packaging/` (install scripts, Quadlet, compose, updater) and `docs/ops/deployment.md` |
| Web UI | `web/display` (anonymous read-only), `web/admin`, shared code in `web/shared`. Colors only in `web/shared/theme/tokens.css` |
+19 -18
View File
@@ -5,29 +5,30 @@ MoShiro 旨在解决跨网络线路质量难以量化的问题:
- 家宽到海外 VPS 的延迟、丢包、抖动随时段波动,单点 ping 无法反映真实体验;
- 不同代理、游戏、流媒体服务对线路的敏感度各异,缺少统一的评价口径。
它通过 Probe 与 Agent 之间的主动测量采集原始数据,由 Backend 推算指标,并按服务模型(REALITY、Hysteria2、Minecraft 等)对线路评分与排序。
它通过 Agent 之间的主动测量采集原始数据,由 Backend 推算指标,并按服务模型(REALITY、Hysteria2、Minecraft 等)对线路评分与排序。
## 角色
## 组件
| 角色 | 定位 | 部署位置 |
| --- | --- | --- |
| Probe | 测量发起端,向 Agent 拨出测量会话 | 家宽等出口,可位于 NAT 后 |
| Agent | 测量接收端,响应 Probe 并汇总两端数据上报 | 具备公网地址的 VPS 等节点 |
| Backend | 控制面与数据服务:注册、任务编排、存储、分析、REST / SSE | 自托管服务器 |
| Web | `display` 展示端(可匿名只读)与 `admin` 管理端 | 与 Backend 同源部署 |
| 组件 | 职责 |
| --- | --- |
| Agent | 统一采集节点,默认同时监听和拨号;可部署在 VPS、家宽或 NAT 后 |
| Backend | 注册、线路编排、存储、分析和 REST / SSE |
| Web | 匿名只读展示端 `display` 与管理端 `admin` |
```text
┌───────────── Backend ─────────────┐
│ 注册 · 编排 · 存储 · 分析 · API │──▶ Web
└───────▲───────────────────▲───────┘
│ wss(节点发起) │
┌───────┴────────┐ ┌──────┴───────┐
│ Agent × N │◀──│ Probe × N │
│ 公网可达 │ │ NAT 后 │
└────────────────┘ └──────────────┘
测量会话:Probe → Agent
Backend ---- REST / SSE ---- Web
^ ^
| | 每个 Agent 独立建立 wss,汇报自身及线路状态
| |
Agent A <=====> Agent B
直连测量
```
Agent 默认向其他可拨号 Agent 建立测量线路,A→B 和 B→A 分别记录;节点不连接自身。
管理端统一展示 Agent,在线路画板中按方向开关测量、查看连接状态和立即重试。
家宽或 NAT 后的 Agent 仍可拨出;入站测量需要其他节点能够访问其监听地址和端口。
可在本地 `agent.json` 中显式设置 `listen.enabled: false` 关闭监听,节点身份和程序保持统一。
## 快速开始
环境:.NET SDK 10(版本见 `global.json`)、Node.js 与 npm。
@@ -60,7 +61,7 @@ npm run build --prefix web
| 构建、发布、CI、部署与更新 | [deployment](docs/ops/deployment.md) |
| 仓库结构与代码风格 | [project-layout](docs/dev/project-layout.md) · [coding-style](docs/dev/coding-style.md) |
| 路线图 | [roadmap](docs/roadmap.md) |
| 子项目 | [backend](backend/README.md) · [fleet](backend/fleet/README.md) · [agent](backend/agent/README.md) · [probe](backend/probe/README.md) · [web](web/README.md) |
| 子项目 | [backend](backend/README.md) · [fleet](backend/fleet/README.md) · [agent](backend/agent/README.md) · [web](web/README.md) |
贡献与自动化代理的工作约定见 [`AGENTS.md`](AGENTS.md)。
+25 -29
View File
@@ -8,7 +8,7 @@
- `PATCH /api/admin/agents/{id}` 新增可选 `ipv6_host`:IPv6 地址或域名;空字符串关闭 IPv6,缺省或 null 不修改。原 `public_host` 用于 IPv4。
- `GET /api/public/overview` 新增可选 `ip_version=ipv4|ipv6`,缺省 IPv4。SSE `/api/public/live` 的同名参数选择 overview 主题的地址族;series 主题仍按线路 id 定位。
- IPv4 线路 id 保持 `agent~probe`,IPv6 为 `agent~probe~ipv6`。series、distribution、heatmap、compare 和管理端测试端点共用这个标识。测试记录与评分叠加独立。
- IPv4 线路 id 保持 `listener~dialer`,IPv6 为 `agent~dialer~ipv6`。series、distribution、heatmap、compare 和管理端测试端点共用这个标识。测试记录与评分叠加独立。
- 线路、时序、测试和连接 DTO 新增 `ip_version`;管理端 Agent DTO 新增 `ipv6_host`、`ipv6_supported`;线路详情新增 `ipv6_available`(是否配置了 IPv6 地址)。公开 DTO 不包含任一种 IP 地址。
- API v1 采用新增字段和可选参数,现有 IPv4 请求及历史链接继续有效。前端类型从 OpenAPI 重新生成。
@@ -27,7 +27,7 @@
/api/public
├─ overview 首屏快照:所有线路的在线状态、最新指标、评分
├─ live SSE 实时推送
├─ lines/{line_id} 线路详情(线路 = Agent 与 Probe 的一对,id 形如 agt_…~prb_…)
├─ lines/{line_id} 线路详情(线路 = Agent 的一对,id 形如 agt_…~agt_…)
├─ lines/{line_id}/series 历史曲线(指标与评分)
├─ nodes about ipv6 节点的展示信息(系统、国家、运营商,开启后带版本和 tag);后端的版本信息;IPv6 配置与采集诊断(不含地址)
├─ compare 多条线路对比
@@ -40,10 +40,9 @@
├─ audit 管理操作审计日志
├─ agents agents/{id} agents/{id}/{approve|disable|enable|takeover}
├─ join-tokens
├─ probes probes/{id} probes/{id}/revoke Probe(用 probe 角色的加入令牌注册、吊销、删除)
├─ links links/{probe_id}/{agent_id}[/retry] 线路开关:哪些(Probe,Agent)连成线路(自动连接 + 手动选择),立即重试
├─ links links/{dialer_id}/{agent_id}[/retry] 线路开关:哪些(拨号方,Agent)连成线路(自动连接 + 手动选择),立即重试
├─ tasks tasks/{id} 线路模板、针对单个 Agent 的覆盖、目标任务
├─ lines/{line_id}/tests 在某条线路(某个 Probe)上发起按需测试、列出近期测试;tests/{id}/cancel
├─ lines/{line_id}/tests 在某条线路(某个 拨号方)上发起按需测试、列出近期测试;tests/{id}/cancel
├─ raw/chunks raw/chunks/{id} 原始数据检索与下载
├─ build-info 后端和每个节点的版本、tag、提交、构建时间、更新状态
├─ releases release-key nodes/{id}/update CI 签名的发布清单、可信发布密钥;请求节点更新
@@ -90,25 +89,25 @@
参数、响应示例和限额见 [query.md](../design/query.md)。
线路详情 `GET /api/public/lines/{line_id}` 带 `data_since`:这条线路最早的数据是什么时候(没有数据是 null),前端据此只提供「有数据」的时间范围。展示端的响应里没有 Probe 的版本和来源地址(那些只在管理端)。
线路详情 `GET /api/public/lines/{line_id}` 带 `data_since`:这条线路最早的数据是什么时候(没有数据是 null),前端据此只提供「有数据」的时间范围。展示端的响应里没有 拨号方 的版本和来源地址(那些只在管理端)。
### 2.3.1 Agent 的主机资源占用
| 端点 | 内容 |
| --- | --- |
| `GET /api/public/hosts` | 每个对外可见的 Agent 和没被吊销的 Probe 的最新快照:`{id, kind, name, online, stale, last_seen_at, host}`(`kind` 是 `agent` 或 `probe`);超过 15 s 没有更新 `stale` 为真(离线,或者节点关了上报) |
| `GET /api/public/hosts/{node_id}?range=1h` | Agent 或 Probe 的历史曲线(列式,按步长取平均;`range` 从 1 小时到 30 天):`cpu`、`mem`、`disk`(百分比)、`rx_bps`、`tx_bps` |
| `GET /api/public/hosts` | 每个对外可见的 Agent 和没被吊销的 拨号方 的最新快照:`{id, name, online, stale, last_seen_at, host}`;超过 15 s 没有更新 `stale` 为真(离线,或者节点关了上报) |
| `GET /api/public/hosts/{node_id}?range=1h` | Agent 或 拨号方 的历史曲线(列式,按步长取平均;`range` 从 1 小时到 30 天):`cpu`、`mem`、`disk`(百分比)、`rx_bps`、`tx_bps` |
`host` 只有汇总的数字:CPU 占用、内存与根分区的已用和总量、网卡累计收发字节与瞬时网速、系统运行时长。没有 CPU 型号、主机名、网卡名,也没有任何 IP——Agent 不采集这些,公开接口当然也不会返回;有测试断言响应里不出现这类字段。实时更新走 SSE 的 `hosts` 主题,历史每个 Agent 每分钟存一个样本(`retention.host_days`,默认 7 天)。
### 2.3.2 节点的展示信息
`GET /api/public/nodes`:看得见的 Agent 和没被吊销的 Probe 的展示信息,`{id, kind, os, os_name, arch, country, isp_key, isp_name, isp_manual}`——系统(`linux` / `windows` / `macos`)、「Debian 12」这样的系统名、架构(`x64` / `arm64`…)、国家代码(`CN`)、运营商类别(`mobile` / `telecom` / `unicom` / `broadnet` / `other`)和名字。没有 IP 地址、城市、ASN、经纬度,有测试保证:公开接口的整个响应里不出现 IP 形状的字符串,也没有 `ip`、`city`、`asn` 这些字段。管理员在设置里打开 `site.show_build` 之后,每项还有 `version` 和 `tag`(节点自己上报的程序版本和 tag);关着时这两个字段是 `null`。
`GET /api/public/nodes`:看得见的 Agent 和没被吊销的 拨号方 的展示信息,`{id, kind, os, os_name, arch, country, isp_key, isp_name, isp_manual}`——系统(`linux` / `windows` / `macos`)、「Debian 12」这样的系统名、架构(`x64` / `arm64`…)、国家代码(`CN`)、运营商类别(`mobile` / `telecom` / `unicom` / `broadnet` / `other`)和名字。没有 IP 地址、城市、ASN、经纬度,有测试保证:公开接口的整个响应里不出现 IP 形状的字符串,也没有 `ip`、`city`、`asn` 这些字段。管理员在设置里打开 `site.show_build` 之后,每项还有 `version` 和 `tag`(节点自己上报的程序版本和 tag);关着时这两个字段是 `null`。
`GET /api/public/about`:`{show_build, backend}`。`show_build` 关着时 `backend` 是 `null`;打开后 `backend` 是 `{version, tag, built_at}`(后端自己的版本、tag、构建时间,不含提交号)。
- 系统和架构是节点自报的:Agent 和 Probe 都在各自连面板时的 `fleet.hello` 里自报。只有粗粒度的名字,没有主机名、内核版本、CPU 型号。
- 国家和运营商由 Backend 用节点的来源地址向 IP 归属服务查一次(`MoShiro__GeoLookupUrl`,缺省 `https://ip.007995.xyz`,留空就不查):Probe 用 Agent 看到的来源 IP,Agent 用它的公开地址。Probe 和 Agent 不访问任何外部服务。只查公网地址(回环、内网、CGNAT、链路本地一律不发出去);同一个地址一周内只查一次,失败 10 分钟后再试;查到的只留国家代码和运营商,地址只存在 Backend 内部的 `node_meta.geo_host`,不对外。
- 系统和架构是节点自报的:Agent 都在各自连面板时的 `fleet.hello` 里自报。只有粗粒度的名字,没有主机名、内核版本、CPU 型号。
- 国家和运营商由 Backend 用节点的来源地址向 IP 归属服务查一次(`MoShiro__GeoLookupUrl`,缺省 `https://ip.007995.xyz`,留空就不查):拨号方 用 Agent 看到的来源 IP,Agent 用它的公开地址。Agent 不访问任何外部服务。只查公网地址(回环、内网、CGNAT、链路本地一律不发出去);同一个地址一周内只查一次,失败 10 分钟后再试;查到的只留国家代码和运营商,地址只存在 Backend 内部的 `node_meta.geo_host`,不对外。
### 2.4 测试结果与目录
@@ -131,15 +130,12 @@
| PATCH | `/api/admin/agents/{id}` | `name`、`labels`、`public_host`、`ipv6_host`、`public_visible`;`public_host_auto` / `ipv6_host_auto` 为 true 时撤销手填、回到自动识别。手填就是手动,Agent 上报的地址不再覆盖;DTO 的 `public_host_auto` / `ipv6_host_auto` 表示当前是不是自动 |
| POST | `/api/admin/agents/{id}/approve` `/disable` `/enable` `/takeover` | 见 [enrollment.md](../security/enrollment.md) |
| DELETE | `/api/admin/agents/{id}` | 删除;历史数据按保留策略自然过期 |
| GET/POST | `/api/admin/join-tokens` | 创建时返回令牌明文和安装命令(仅此一次);`role` 是 `agent`(默认)或 `probe`,决定用它注册出来的是哪种节点 |
| GET/POST | `/api/admin/join-tokens` | 创建时返回令牌明文和安装命令(仅此一次);`role` 统一为 `agent`(默认) |
| DELETE | `/api/admin/join-tokens/{id}` | 吊销 |
| GET | `/api/admin/probes` | 列出所有 Probe(含它现在连着哪些 Agent:版本、远端 IP、起始时间);Probe 由 `role=probe` 的加入令牌注册,密钥由它自己生成 |
| GET/PATCH/DELETE | `/api/admin/probes/{id}` | 详情、改名、删除(历史数据按保留策略自然过期) |
| POST | `/api/admin/probes/{id}/revoke` | 吊销:公钥从所有 Agent 的名单里移除,Agent 立即断开它;记录和历史保留 |
| POST | `/api/admin/agents/{id}/probes/{probe_id}/disconnect` | 让这个 Agent 断开这个 Probe 的会话(Probe 会重连,除非已吊销);没有会话时返回 409 `probe_offline` |
| POST | `/api/admin/agents/{id}/dialers/{dialer_id}/disconnect` | 让这个 Agent 断开这个 拨号方 的会话(拨号方 会重连,除非已吊销);没有会话时返回 409 `dialer_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/lines/{line_id}/tests` | `{plan, params?}` → 202 + `Location: /api/public/tests/{id}`;拨号方 离线时返回 409 `dialer_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.moshiro.rawchunk`);过期返回 410 `raw_expired` |
@@ -150,19 +146,19 @@
| GET/POST/DELETE | `/api/admin/auth/passkeys`、`/auth/passkeys/options`、`/auth/passkeys/{id}` | 已登录用户管理自己的 passkey:列出、取注册挑战、注册、删除(API key 不能用) |
| GET | `/api/admin/capabilities` | 能力目录:分组、哪些改变信任、哪些是预留、依赖关系和预设(只读、运维、接入、CI 发布、全部) |
| GET/PATCH | `/api/admin/settings` | 站点公开或私有、是否对访客显示构建信息(`site.show_build`)、保留期、节点定时重启(`fleet.restart_enabled`、`fleet.restart_hour`、`fleet.restart_minute`、`fleet.restart_utc_offset_minutes`,默认开、04:30、UTC+8;改完立即通知在线节点)等 |
| GET | `/api/admin/build-info` | 后端和每个 Agent、Probe 的版本、tag、提交、构建时间、系统,以及节点宿主机更新器报告的更新状态(管理端「系统 → 版本」) |
| GET | `/api/admin/build-info` | 后端和每个 Agent 的版本、tag、提交、构建时间、系统,以及节点宿主机更新器报告的更新状态(管理端「系统 → 版本」) |
| GET | `/api/admin/releases` | 每个组件最新的一份签名发布清单 |
| GET/PUT/DELETE | `/api/admin/release-key` | 可信资产来源:GET 看官方发布密钥和(如果有)自有发布公钥的指纹;PUT `{public_key}` 另外信任自己的发布公钥(P-256 的 PEM,拒绝私钥);DELETE 停用它,只有它签过的已登记清单会被丢掉。官方密钥始终信任。PUT/DELETE 需要 `update.confirm`,写审计 |
| POST | `/api/release/offer` | CI 登记发布清单的入口,不需要令牌:`{manifest, signature}` 的签名必须能用 Backend 内嵌的官方发布公钥或已开启的自有发布公钥验过(ECDSA P-256 / SHA-256,DER),否则 403;`seq` 必须比已登记的新,否则 409。登记不依赖面板里的任何凭证,清库、重装、还没有 passkey 时照常工作 |
| GET | `/api/admin/links` | 每一对(Probe,Agent)的线路开关:`linked`(最终结果)、`override`(手动设置,`null` 跟随自动连接)、`connected`,以及 `auto_link` |
| PUT | `/api/admin/links/{probe_id}/{agent_id}` | `{enabled: true|false|null}`:手动强制连或不连,`null` 取消手动设置;改完立即重新下发 |
| POST | `/api/admin/nodes/{id}/update` | 请求一个在线的 Agent 或 Probe 更新到它收到的清单(节点不在线返回 409) |
| GET | `/api/admin/links` | 每一对(拨号方,Agent)的线路开关:`linked`(最终结果)、`override`(手动设置,`null` 跟随自动连接)、`connected`、`connection_state`(`disabled`、`dialer_unavailable`、`listener_unavailable`、`waiting_report`、`control_connecting`、`control_down`、`task_connecting`、`task_retrying`、`connected`,含义见 [architecture.md](../design/architecture.md) 第 4.3 节),以及 `auto_link` |
| PUT | `/api/admin/links/{dialer_id}/{agent_id}` | `{enabled: true|false|null}`:手动强制连或不连,`null` 取消手动设置;改完立即重新下发 |
| POST | `/api/admin/nodes/{id}/update` | 请求一个在线的 Agent 或 拨号方 更新到它收到的清单(节点不在线返回 409) |
| GET/POST/PATCH | `/api/admin/users`、`/api/admin/users/{id}` | 用户列表、创建、修改密码 |
| GET/POST | `/api/admin/auth/setup` | 首次认领:GET 返回面板是否尚无用户;POST 凭后端日志中的认领码创建第一个管理员,响应同密码登录,已有用户后关闭 |
| GET/POST | `/api/admin/trust` | 操作员集合(信任根):GET 返回创世指纹、版本、与已注册 passkey 是否一致;POST 提交 passkey 签名的下一版集合 |
| GET | `/api/admin/audit` | 管理操作审计日志,按时间倒序 |
| GET | `/api/admin/lines/{line_id}/tests` | 该线路的近期测试 |
| POST | `/api/admin/links/{probe_id}/{agent_id}/retry` | 启用该线路并立即让 Probe 重试连接 |
| POST | `/api/admin/links/{dialer_id}/{agent_id}/retry` | 启用该线路并立即让 拨号方 重试连接 |
| GET | `/api/admin/nodes/{id}/logs` | 节点近期运行日志(经心跳上传) |
| GET/POST | `/api/admin/backend/update`、GET `/backend/update/live` | Backend 自身的更新状态、管理员确认更新,以及 SSE 通知 |
@@ -189,7 +185,7 @@
| `GET /metrics` | admin 令牌 | Prometheus 格式:在线数、块入库速率、分析队列长度、重算进度、磁盘占用、SSE 连接数 |
| `GET /openapi/v1.json` | 无 | |
| `GET /install`(也接受 `/install.sh`)、`GET /install.ps1`、`GET /install/openwrt.sh` | 无 | 静态安装脚本,原样返回、不替换任何值,所以哈希在一个版本内固定;角色、面板地址、代码和属主走环境变量 |
| `GET /install/compose/{file}`、`GET /install/quadlet/{file}` | 无 | 静态的 compose 文件与 Quadlet 单元(`agent`、`probe`、`backend`),所有人拿到的字节相同 |
| `GET /install/compose/{file}`、`GET /install/quadlet/{file}` | 无 | 静态的 compose 文件与 Quadlet 单元(`agent`、`backend`),所有人拿到的字节相同 |
| `GET /install/manifest?code=…`、`GET /install/manifest.sig?code=…` | 无(凭一次性代码) | 该角色已签名的发布清单(原样)和签名,安装脚本据此验签、按 digest 拉镜像;代码不能用就是 404 |
## 6. 错误码(节选,完整列表见共享的 `ErrorCode`)
@@ -200,7 +196,7 @@
| 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` |
| 409 | `name_conflict`、`dialer_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` |
@@ -208,7 +204,7 @@
## 7. 设计约束
本文是所有 Backend REST 端点都必须遵守的规则;具体端点见上面第 1–6 节。规则参考了 [Microsoft Azure REST API Guidelines](https://github.com/microsoft/api-guidelines/blob/vNext/azure/Guidelines.md) 和 RFC 9457,并根据本项目做了取舍。内部协议(Probe↔Agent、Agent↔Backend)的兼容规则见 [protocol.md](../design/protocol.md) 第 4 节。
本文是所有 Backend REST 端点都必须遵守的规则;具体端点见上面第 1–6 节。规则参考了 [Microsoft Azure REST API Guidelines](https://github.com/microsoft/api-guidelines/blob/vNext/azure/Guidelines.md) 和 RFC 9457,并根据本项目做了取舍。内部协议(拨号方↔Agent、Agent↔Backend)的兼容规则见 [protocol.md](../design/protocol.md) 第 4 节。
### 7.1 契约生成链路
@@ -247,7 +243,7 @@ web:openapi-typescript 生成类型,调用方的类型错误会直接暴露
- 按调用方分层:`/api/public`(只读展示)、`/api/admin`(管理)、`/api/agent`(节点)。同一个资源在不同层中可以有不同的 DTO,例如 public 层不暴露 `pubkey`、`local_limits`。
- 路径全部小写,资源名用复数名词,多个单词用 `-` 连接:`/api/admin/join-tokens`。
- 嵌套最多一层;子资源一旦有全局唯一 ID,就提供顶层路径(`/probes/{probe_id}`)。
- 嵌套最多一层;子资源一旦有全局唯一 ID,就提供顶层路径(`/agents/{dialer_id}`)。
- 查询参数和 JSON 字段使用 snake_case。
| 操作 | 方法 | 成功状态 | 说明 |
@@ -270,7 +266,7 @@ web:openapi-typescript 生成类型,调用方的类型错误会直接暴露
| 延迟 | 带小数的毫秒值(指标字典中的单位见目录) | `rtt_p95_ms: 58.93` |
| 百分比 | 0–100 的数字 + `_pct` | `loss_pct: 0.5` |
| 计数 | 整数,不使用浮点数 | `attempts: 600` |
| ID | 带类型前缀的 ULID 字符串 | `prb_01J9Z...` |
| ID | 带类型前缀的 ULID 字符串 | `agt_01J9Z...` |
| 枚举 | snake_case 字符串 | `"udp_echo"` |
| 缺失值 | 字段始终存在;未知值写 `null`,不省略字段 | `"last_seen_at": null` |
| 布尔值 | 使用 `is_`/`has_` 前缀,或者用意义明确的形容词 | `online`、`enabled` |
@@ -301,7 +297,7 @@ web:openapi-typescript 生成类型,调用方的类型错误会直接暴露
| 401 | 未认证 |
| 404 | 资源不存在 |
| 403 | 已认证但作用域不足 |
| 409 | 名称冲突、状态冲突(例如 `probe_offline`) |
| 409 | 名称冲突、状态冲突(例如 `dialer_offline`) |
| 410 | 资源曾经存在但已过保留期(例如 `raw_expired`) |
| 412 | `If-Match` 不匹配 |
| 413 | 请求体超过上限 |
@@ -317,7 +313,7 @@ web:openapi-typescript 生成类型,调用方的类型错误会直接暴露
### 7.7 并发控制
- 配置类资源(Agent、Probe、任务)在响应中返回 `ETag`,PATCH/DELETE 支持 `If-Match`,不匹配时返回 412。没有带 `If-Match` 的请求按“最后写入者获胜”处理。
- 配置类资源(Agent、任务)在响应中返回 `ETag`,PATCH/DELETE 支持 `If-Match`,不匹配时返回 412。没有带 `If-Match` 的请求按“最后写入者获胜”处理。
### 7.8 安全
+1 -1
View File
@@ -103,7 +103,7 @@ public sealed class McModel : IServiceModel
| `base` | `rtt.*`、`loss.up/down`、`jitter.up/down`、`loss.run_max` | 线路基础质量 |
| `mc` | `tcp_echo` 的 `rtt.p95/p99`、`tcp.stall.per_min`、`tcp.stall_max`、`tcp.retrans` | TCP 上不会丢包,丢包表现为重传和卡顿(队头阻塞)。没有 `tcp_echo` 数据时,用 `udp_echo` 的丢包序列代入 TCP 恢复模型估算,并标记为 `estimated` |
| `hy2` | 测试中 `udp_train` 各档的 `loss`、`jitter`,以及变点检测得到的 `qos_onset`;持续的 `udp_echo` 作为趋势参考 | Brutal 固定码率:计算可持续码率(丢包 ≤ 5% 或 ≤ 10%)、有效吞吐、限速开始的时刻 |
| `reality` | `connect` 的成功率和阶段耗时、`hold` 的重置次数和存活时长、`tcp_bulk` 的吞吐(`tcp.bulk.peak.max`:最好的持续 1 秒窗口,评分用它;`tcp.bulk.mbps.*`:每个采样间隔)和重传。`hy2_ladder` 的阶梯里一步是一个阶段:Probe 会把一个阶段切成几块(一块最多 50000 条记录,100 Mbit/s 的 10 秒要切 3~4 块),评估时同一阶段的块先合并,再算丢包、抖动和实际吞吐;实际吞吐的时长取发送时间戳(Agent 时钟,按每块的时钟锚点换算成 UTC)的跨度,不用块头里的时长(Probe 切块时块头带的时长可能是整个阶段的)、`tcp_echo` 的 RTT | 握手、稳定性、首字节时间估算、吞吐 |
| `reality` | `connect` 的成功率和阶段耗时、`hold` 的重置次数和存活时长、`tcp_bulk` 的吞吐(`tcp.bulk.peak.max`:最好的持续 1 秒窗口,评分用它;`tcp.bulk.mbps.*`:每个采样间隔)和重传。`hy2_ladder` 的阶梯里一步是一个阶段:拨号方 会把一个阶段切成几块(一块最多 50000 条记录,100 Mbit/s 的 10 秒要切 3~4 块),评估时同一阶段的块先合并,再算丢包、抖动和实际吞吐;实际吞吐的时长取发送时间戳(Agent 时钟,按每块的时钟锚点换算成 UTC)的跨度,不用块头里的时长(拨号方 切块时块头带的时长可能是整个阶段的)、`tcp_echo` 的 RTT | 握手、稳定性、首字节时间估算、吞吐 |
## 6. 两种处理路径
+68 -47
View File
@@ -2,40 +2,33 @@
## 1. 目标与定位
只关注网络层的线路质量面板:单体面板(Backend)、轻量节点(Probe、Agent)、长连接 RPC 与任务下发。
只关注网络层的线路质量面板:单体面板(Backend)、轻量节点(Agent)、长连接 RPC 与任务下发。
- 采集:多个 Probe(家宽等需要观测的出口)与多台海外 VPS Agent 之间记录逐包、逐事件的原始数据。哪些 Probe 连哪些 Agent 由面板决定,默认全连,也可以手动选择。
- 采集:多个 拨号方(家宽等需要观测的出口)与多台海外 VPS Agent 之间记录逐包、逐事件的原始数据。哪些 拨号方 连哪些 Agent 由面板决定,默认全连,也可以手动选择。
- 分析:Backend 的分析层推算在某条线路上部署 REALITY 类、Hysteria2 类或 Minecraft 服务的体验,并对多条线路排序和对比。
- 主机状态:Agent 和 Probe 读取 `/proc`,上报 CPU 占用、内存、根分区、网卡累计流量与瞬时网速、运行时长这类汇总数字。不采集 CPU 型号、主机名、网卡名、IP 和进程。Probe 的上报可在 `probe.json` 中用 `reporting.host_stats: false` 关闭。展示页以卡片呈现每个节点的主机状态,线路在卡片下方的面板中。
- 主机状态:Agent 读取 `/proc`,上报 CPU 占用、内存、根分区、网卡累计流量与瞬时网速、运行时长这类汇总数字。不采集 CPU 型号、主机名、网卡名、IP 和进程。拨号方 的上报可在 `agent.json` 中用 `reporting.host_stats: false` 关闭。展示页以卡片呈现每个节点的主机状态,线路在卡片下方的面板中。
- 构建信息:节点连接面板时自报系统、架构和构建信息(版本、tag、提交、构建时间),用于展示和更新。国家和运营商由 Backend 依据节点来源地址查询,节点不访问任何外部服务,展示页不含任何 IP。
- 节点能力:当前 Agent 和 Probe 只包含测量与上报,不提供 SSH、远程执行、终端和文件读写;更新由节点宿主机上的更新器或服务管理器完成,节点进程只把面板转来的发布清单和更新请求写入数据目录。后续能力的规划见 [security.md](../security/security.md) 第 9 节。
- 节点能力:当前 Agent 只包含测量与上报,不提供 SSH、远程执行、终端和文件读写;更新由节点宿主机上的更新器或服务管理器完成,节点进程只把面板转来的发布清单和更新请求写入数据目录。后续能力的规划见 [security.md](../security/security.md) 第 9 节。
## 2. 拓扑
```text
操作员 passkey ──签名「操作员集合」──┐ CI 发布私钥 ──签名「镜像 digest 清单」──▶ 镜像仓库(清单 + 镜像)
▼ ▲
Web(展示端 · 管理端) │ 定时取清单
▲ REST + SSE │
┌──────────────────────────────────────────────────────────────┐
│ Backend(面板) │──────────────────────────────┘
│ Api · Auth(会话 · passkey · API 令牌 · 审计) │
│ Trust(操作员集合 · 服务器身份)· Registry · Planner · Links │
│ Releases(发布清单 · 可信发布密钥) │
│ Ingest · Analysis · LiveHub │
│ Store(段文件 + SQLite) │
└──────────────────────────────────────────────────────────────┘
▲ ▲
│ wss 长连接(节点发起,应用层加密) │ wss 长连接(节点发起,应用层加密)
│ 任务、数据、状态、更新通知 │ 身份、Agent 列表、状态、更新通知
┌────────────────────┐ ┌──────────────────────┐
│ Agent × 10+ │ ◀── 测量会话 ── │ Probe × N │
│ 有公网 IP,监听 │ 先敲门再 TLS │ 家宽等出口,NAT 后 │
└────────────────────┘ └──────────────────────┘
测量会话:TLS 控制通道 + QUIC Datagram/TCP 数据通道,由 Probe 主动拨出(Agent 固定 QUIC 端口,Probe 本地源端口轮换)
更新(Agent、Probe、Backend):面板登记 CI 签名的清单 → 管理端确认 → 节点(Backend 先备份数据库)有序退出 → systemd Pull=always 拉最新镜像重启
Web
|
REST / SSE
|
Backend
^ ^
| wss | 注册、任务、拨号目标、状态、数据
| |
Agent A <=====> Agent B
直接测量
```
所有节点都是 Agent,默认同时监听和拨号。Backend 为每个节点下发拨号目标以及允许接入的公钥名册,自动连接开启时,所有 Agent 拨其他可拨号 Agent;A→B 和 B→A 分别编排、存储和展示。单方向的手动设置优先于自动连接开关。可在本地关闭监听,但不产生另一种节点角色。
Agent → Backend 的心跳包含运行状态、拨出线路的控制层与任务层状态、失败原因、重试次数与等待时间,以及当前接入会话。管理端的 Agent 详情展示拨出与接入连接,线路画板汇总所有方向并提供开关和立即重试。Backend 不转发节点之间的测量流量。
各组件的代码位置:
| 组件 | 代码 |
@@ -44,20 +37,20 @@
| Api(REST、SSE、OpenAPI) | [`MoShiro.Backend.Api`](../../backend/src/MoShiro.Backend.Api),实时推送 [`LiveEndpoints.cs`](../../backend/src/MoShiro.Backend.Api/Public/LiveEndpoints.cs)
| Auth(会话、passkey、API 令牌、审计) | [`Application/Auth`](../../backend/src/MoShiro.Backend.Application/Auth)、[`Application/Audit`](../../backend/src/MoShiro.Backend.Application/Audit) |
| Trust(操作员集合、服务器身份) | [`TrustService.cs`](../../backend/src/MoShiro.Backend.Application/Auth/TrustService.cs)、[`ServerIdentityService.cs`](../../backend/src/MoShiro.Backend.Application/Auth/ServerIdentityService.cs);节点侧验证 [`OperatorTrust.cs`](../../backend/fleet/src/Fleet.Identity/OperatorTrust.cs)、[`TrustAnchor.cs`](../../backend/fleet/src/Fleet.Hosting/TrustAnchor.cs)
| Registry(Agent、Probe 注册与状态) | [`AgentService.cs`](../../backend/src/MoShiro.Backend.Application/Agents/AgentService.cs)、[`ProbeService.cs`](../../backend/src/MoShiro.Backend.Application/Agents/ProbeService.cs)、[`MoShiroFleetHooks.cs`](../../backend/src/MoShiro.Backend.Application/Agents/MoShiroFleetHooks.cs)
| Registry(Agent 注册与状态) | [`AgentService.cs`](../../backend/src/MoShiro.Backend.Application/Agents/AgentService.cs)、[`AgentService.cs`](../../backend/src/MoShiro.Backend.Application/Agents/AgentService.cs)、[`MoShiroFleetHooks.cs`](../../backend/src/MoShiro.Backend.Application/Agents/MoShiroFleetHooks.cs)
| Planner(有效任务集) | [`TaskPlanner.cs`](../../backend/src/MoShiro.Backend.Application/Tasks/TaskPlanner.cs)、[`EffectiveTasks.cs`](../../backend/src/MoShiro.Backend.Application/Tasks/EffectiveTasks.cs)
| Links(哪些 Probe 连哪些 Agent) | [`LinkService.cs`](../../backend/src/MoShiro.Backend.Application/Agents/LinkService.cs)
| Links(哪些 拨号方 连哪些 Agent) | [`LinkService.cs`](../../backend/src/MoShiro.Backend.Application/Agents/LinkService.cs)
| Releases(发布清单、可信发布密钥) | [`Application/Updates`](../../backend/src/MoShiro.Backend.Application/Updates),轮询 [`ReleasePoller.cs`](../../backend/src/MoShiro.Backend.Application/Updates/ReleasePoller.cs)
| Gateway(Fleet 网关、状态提供者、收件箱) | [`MoShiro.Backend.Gateway`](../../backend/src/MoShiro.Backend.Gateway),入库 [`RawChunkInbox.cs`](../../backend/src/MoShiro.Backend.Gateway/RawChunkInbox.cs)、[`IngestService.cs`](../../backend/src/MoShiro.Backend.Application/Ingest/IngestService.cs)
| Analysis(算子、指标、模型) | [`MoShiro.Backend.Analysis`](../../backend/src/MoShiro.Backend.Analysis),调度 [`AnalysisScheduler.cs`](../../backend/src/MoShiro.Backend.Application/Analysis/AnalysisScheduler.cs)
| LiveHub(热层与 SSE 主题) | [`LiveHub.cs`](../../backend/src/MoShiro.Backend.Application/Live/LiveHub.cs)
| Store(段文件 + SQLite) | [`MoShiro.Backend.Storage.Sqlite`](../../backend/src/MoShiro.Backend.Storage.Sqlite),原始块 [`SqliteRawStore.cs`](../../backend/src/MoShiro.Backend.Storage.Sqlite/SqliteRawStore.cs)
| Agent | [`AgentApp.cs`](../../backend/agent/src/MoShiro.Agent/AgentApp.cs)、任务 [`TaskManager.cs`](../../backend/agent/src/MoShiro.Agent/Tasks/TaskManager.cs)、Probe 会话 [`PeerHub.cs`](../../backend/agent/src/MoShiro.Agent/Peer/PeerHub.cs)
| Probe | [`ProbeApp.cs`](../../backend/probe/src/MoShiro.Probe/ProbeApp.cs)、Agent 连接 [`Link/AgentConnection.cs`](../../backend/probe/src/MoShiro.Probe/Link/AgentConnection.cs)
| Agent | [`AgentApp.cs`](../../backend/agent/src/MoShiro.Agent/AgentApp.cs)、任务 [`TaskManager.cs`](../../backend/agent/src/MoShiro.Agent/Tasks/TaskManager.cs)、拨号方 会话 [`PeerHub.cs`](../../backend/agent/src/MoShiro.Agent/Peer/PeerHub.cs)
| 拨号方 | [`AgentApp.cs`](../../backend/agent/src/MoShiro.Agent/AgentApp.cs)、Agent 连接 [`Link/DialConnection.cs`](../../backend/agent/src/MoShiro.Agent/Dial/DialConnection.cs)(控制层)、[`Link/TaskLink.cs`](../../backend/agent/src/MoShiro.Agent/Dial/TaskLink.cs)(QUIC 数据通道)
| 采集引擎与协议 | [`MoShiro.Measurement`](../../backend/shared/MoShiro.Measurement)、[`MoShiro.Protocol`](../../backend/shared/MoShiro.Protocol) |
| Fleet 节点框架 | [`backend/fleet`](../../backend/fleet),注册工具 [`fleet-enroll`](../../backend/fleet/tools/fleet-enroll) |
- 线路(line):一对(Probe,Agent)。可以有多个 Probe(身份由 Probe 自己生成并用加入令牌注册,可以吊销、删除),每个 Probe 连接多个 Agent,每个 Agent 同时服务多个 Probe;一条线路的数据按 `line:<probe_id>` 单独存放,互不混合。对外的线路 id 是 `<agent_id>~<probe_id>`。
- 线路(line):一对(拨号方,Agent)。可以有多个 拨号方(身份由 拨号方 自己生成并用加入令牌注册,可以吊销、删除),每个 拨号方 连接多个 Agent,每个 Agent 同时服务多个 拨号方;一条线路的数据按 `line:<dialer_id>` 单独存放,互不混合。对外的线路 id 是 `<agent_id>~<dialer_id>`。
- 目标任务(target):某个 Agent → 指定的 IP:端口(ICMP、TCP/TLS 建连),只能访问 Agent 本地允许的网段。
## 3. 数据链路
@@ -65,7 +58,7 @@
```text
采集(只记录) 传输(有界、至少一次) 分析(注册制、可重算) 展示
┌───────┐ 原始列(10s) ┌────────┐ RawChunk(10s) ┌──────────────────────────────────────────┐
│ Probe │───────────>│ Agent │──outbox(16)──>│ 段文件(30天) → Analysis → 指标窗口(1m) │──> REST
│ 拨号方 │───────────>│ Agent │──outbox(16)──>│ 段文件(30天) → Analysis → 指标窗口(1m) │──> REST
│ 积木 │<══════════>│ 积木 │ wss │ ↓ 流式处理未满窗口 ↓ 降采样 5m/1h │──> SSE
└───────┘ 数据通道 └────────┘ │ LiveHub(内存快照) │
└──────────────────────────────────────────┘
@@ -73,19 +66,19 @@
| 角色 | 职责 | 明确不做 |
| --- | --- | --- |
| Probe | 连接 Backend(身份、Agent 列表、系统与构建信息、更新);连接面板指定的 Agent;执行线路任务中 Probe 一侧的角色;记录本端原始列并交给对应的 Agent | 统计;落盘测量数据;任何主机操作 |
| Agent | 注册;接受 Probe;执行 Agent 一侧的角色和目标任务;合并两侧的列并上传;上报主机资源的汇总数字 | 统计;落盘测量数据;exec / 终端 / 文件操作(当前未提供);更新由宿主机上的更新器完成;采集主机的详细信息 |
| Backend | 注册与身份;任务编排;决定哪些 Probe 连哪些 Agent;持久化原始数据;分析;实时汇聚;API;分发 CI 签名的发布清单 | 主动连接 Probe 或 Agent(都是节点拨进来);验签和安装更新(在节点宿主机上) |
| 拨号方 | 连接 Backend(身份、Agent 列表、系统与构建信息、更新);连接面板指定的 Agent;执行线路任务中 拨号方 一侧的角色;记录本端原始列并交给对应的 Agent | 统计;落盘测量数据;任何主机操作 |
| Agent | 注册;接受 拨号方;执行 Agent 一侧的角色和目标任务;合并两侧的列并上传;上报主机资源的汇总数字 | 统计;落盘测量数据;exec / 终端 / 文件操作(当前未提供);更新由宿主机上的更新器完成;采集主机的详细信息 |
| Backend | 注册与身份;任务编排;决定哪些 拨号方 连哪些 Agent;持久化原始数据;分析;实时汇聚;API;分发 CI 签名的发布清单 | 主动连接 拨号方 或 Agent(都是节点拨进来);验签和安装更新(在节点宿主机上) |
角色对应的代码:Probe 见 [`ProbeApp.cs`](../../backend/probe/src/MoShiro.Probe/ProbeApp.cs);Agent 见 [`AgentApp.cs`](../../backend/agent/src/MoShiro.Agent/AgentApp.cs),上传块由 [`ChunkAssembler.cs`](../../backend/agent/src/MoShiro.Agent/Chunks/ChunkAssembler.cs)合并;Backend 接收见 [`FleetGateway.cs`](../../backend/src/MoShiro.Backend.Gateway/FleetGateway.cs)。
角色对应的代码:拨号方 见 [`AgentApp.cs`](../../backend/agent/src/MoShiro.Agent/AgentApp.cs);Agent 见 [`AgentApp.cs`](../../backend/agent/src/MoShiro.Agent/AgentApp.cs),上传块由 [`ChunkAssembler.cs`](../../backend/agent/src/MoShiro.Agent/Chunks/ChunkAssembler.cs)合并;Backend 接收见 [`FleetGateway.cs`](../../backend/src/MoShiro.Backend.Gateway/FleetGateway.cs)。
## 4. 关键设计(详见各专题文档)
| 主题 | 要点 | 文档 | 代码 |
| --- | --- | --- | --- |
| 节点框架 | 注册、身份、连接、RPC、可靠上报、状态同步、宿主都由与业务无关的 Fleet 提供;MoShiro 只写业务,其他产品也能复用 | [Fleet 文档](../../backend/fleet/README.md) | [`backend/fleet`](../../backend/fleet) |
| 注册与身份 | 使用 `fleet-enroll` 和加入令牌自动注册,之后用签名认证;Probe 用自己的身份直连面板,Agent 列表由面板下发 | [enrollment.md](../security/enrollment.md) | [`NodeIdentity.cs`](../../backend/fleet/src/Fleet.Identity/NodeIdentity.cs)、[`fleet-enroll`](../../backend/fleet/tools/fleet-enroll) |
| 更新 | 只接受发布密钥签名的清单,更新策略由节点本地决定,失败自动回滚。CI 对镜像 digest 签名;Backend 和一行脚本装的节点由宿主机上的 `moshiro-update` 验签、按 digest 拉取、失败回滚;Podman Quadlet 装的 Agent、Probe 更简单:节点有序退出,systemd 用 `--pull always` 重新拉最新镜像,节点自己不下载、不验证清单;纯二进制节点的更新见[路线图](../roadmap.md) | [deployment.md](../ops/deployment.md) | [`moshiro-update.sh`](../../packaging/update/moshiro-update.sh)、[`RestartUpdater.cs`](../../backend/shared/MoShiro.Measurement/Update/RestartUpdater.cs)、[`packaging/quadlet`](../../packaging/quadlet) |
| 注册与身份 | 使用 `fleet-enroll` 和加入令牌自动注册,之后用签名认证;拨号方 用自己的身份直连面板,Agent 列表由面板下发 | [enrollment.md](../security/enrollment.md) | [`NodeIdentity.cs`](../../backend/fleet/src/Fleet.Identity/NodeIdentity.cs)、[`fleet-enroll`](../../backend/fleet/tools/fleet-enroll) |
| 更新 | 只接受发布密钥签名的清单,更新策略由节点本地决定,失败自动回滚。CI 对镜像 digest 签名;Backend 和一行脚本装的节点由宿主机上的 `moshiro-update` 验签、按 digest 拉取、失败回滚;Podman Quadlet 装的 Agent 更简单:节点有序退出,systemd 用 `--pull always` 重新拉最新镜像,节点自己不下载、不验证清单;纯二进制节点的更新见[路线图](../roadmap.md) | [deployment.md](../ops/deployment.md) | [`moshiro-update.sh`](../../packaging/update/moshiro-update.sh)、[`RestartUpdater.cs`](../../backend/shared/MoShiro.Measurement/Update/RestartUpdater.cs)、[`packaging/quadlet`](../../packaging/quadlet) |
| 任务模型 | `Task = {kind, target, params, schedule}`;持续采集、按需测试、目标任务统一建模;下发“期望任务集”,由节点自行调和 | [measurement.md](measurement.md) | [`TaskSet.cs`](../../backend/shared/MoShiro.Protocol/Tasks/TaskSet.cs)、[`TaskPlanner.cs`](../../backend/src/MoShiro.Backend.Application/Tasks/TaskPlanner.cs) |
| 采集扩展 | Kind 由积木组合声明;采集密度靠参数调整;原始块自描述 | [measurement.md](measurement.md) | [`Kinds.cs`](../../backend/shared/MoShiro.Protocol/Catalog/Kinds/Kinds.cs)、[`BlockCatalog.cs`](../../backend/shared/MoShiro.Measurement/Blocks/BlockCatalog.cs) |
| 统计扩展 | 指标注册制:少数几种累加器 + 统计算子;一行 `MetricDef` 就是一个新指标 | [analysis.md](analysis.md) | [`MetricRegistry.cs`](../../backend/src/MoShiro.Backend.Analysis/Metrics/MetricRegistry.cs) |
@@ -98,10 +91,10 @@ Kind 目录编译在二进制中,节点不接受远程下发代码。节点在
### 4.0 节点的更新
发布清单(镜像 digest)由 CI 签名,放进镜像仓库(`<仓库>-manifest` 小镜像的标签里);Backend 定时取、验签后登记(也接受 CI 直接 `POST /api/release/offer`),再原样转发给在线的 Agent / Probe。之后分两条路:
发布清单(镜像 digest)由 CI 签名,放进镜像仓库(`<仓库>-manifest` 小镜像的标签里);Backend 定时取、验签后登记(也接受 CI 直接 `POST /api/release/offer`),再原样转发给在线的 Agent。之后分两条路:
```text
Podman Quadlet(Agent、Probe、Backend,推荐)
Podman Quadlet(Agent、Backend,推荐)
节点比对清单和自己的版本 ──▶ 心跳报「有新版本」──▶ 管理端显示
自动(默认,运行满 10 分钟)或管理员点「更新」──▶ 节点有序退出
──▶ systemd 重启单元(Pull=always)──▶ 新镜像、新容器
@@ -124,14 +117,42 @@ Backend:不自动,只有管理员点「更新并重启」;退出之前先
### 4.2 NAT / CGNAT 适配
所有连接和 UDP 流都由 Probe 发起;Agent 把回复发往请求的实际源地址。会话以 `session_id` 和 MAC 认证,不绑定五元组,NAT 映射变化后自动跟随新地址。持续的 UDP 回显本身就起到保活作用。准入逻辑(重放窗口、令牌桶、源地址学习)见 [`Registries.cs`](../../backend/agent/src/MoShiro.Agent/DataPlane/Registries.cs),数据报路由见 [`MeasurementDatagramRouter.cs`](../../backend/shared/MoShiro.Measurement/Net/MeasurementDatagramRouter.cs)。
所有连接和 UDP 流都由 拨号方 发起;Agent 把回复发往请求的实际源地址。会话以 `session_id` 和 MAC 认证,不绑定五元组,NAT 映射变化后自动跟随新地址。持续的 UDP 回显本身就起到保活作用。准入逻辑(重放窗口、令牌桶、源地址学习)见 [`Registries.cs`](../../backend/agent/src/MoShiro.Agent/DataPlane/Registries.cs),数据报路由见 [`MeasurementDatagramRouter.cs`](../../backend/shared/MoShiro.Measurement/Net/MeasurementDatagramRouter.cs)。
### 4.3 线路连接:控制层与任务层
拨号方 到每个 Agent 的连接分两层,各有一个状态机和独立的重试节奏([`LinkStatus.cs`](../../backend/agent/src/MoShiro.Agent/Dial/LinkStatus.cs))。
```text
控制层 TCP+TLS 长连接:控制 RPC(时钟、任务集、测试阶段、OutboxAck)+ 回传数据(发件箱)
控制层断开,Agent 节点 才算失联。
dialing ─▶ authenticating ─▶ syncing ─▶ up
▲ │ RPC 关闭 / 120 s 无应答 / keepalive 失败
└─────────── backoff ◀────────────────┘
任务层 测量链路,控制层在线期间运行
├─ tcp 各 TCP 类积木(tcp_echo / connect / hold / tcp_bulk)自带套接字,积木内部断线重拨,不依赖 QUIC
└─ quic QUIC datagram(udp_echo / udp_train)
connecting ─▶ ready ─▶ retrying ─▶ connecting … 控制层不在线时为 idle
```
| | 控制层 | 任务层 QUIC |
| --- | --- | --- |
| 断开的含义 | 失联;数据面凭据随控制会话失效,所有积木停止 | 仅 UDP 类积木受影响,控制连接和 TCP 类积木继续 |
| 重试节奏 | 1、2、4、8、16 s,32 s × 5,之后每 60 s | 1、2、3、5 s,之后固定 5 s,带 ±20% 抖动;连接稳定 10 s 后节奏复位 |
| 数据面静默 | 120 s 没有收到任何控制帧即断开重拨 | 发送中 30 s 没有收到任何报文即关闭并重拨 |
| 手动「立即重试」 | 跳过退避 | 跳过重拨等待 |
回传数据(`RawChunk` 列块)进入属于这条连接的发件箱,经控制连接发送;控制层重连后重发未确认的块(Backend 按 `chunk_id` 去重)。会话结束前,积木先把已稳定的行交给发件箱。
状态随心跳上报(`LinkHealth`:`control`、`task.tcp`、`task.quic`),失败原因带阶段和分类(`phase`、`kind`:`timeout`、`refused`、`reset`、`dns`、`tls`、`auth`、`closed`、`silent`)。Backend 据此给出线路状态:`control_connecting`、`control_down`(失联)、`task_connecting`、`task_retrying`、`connected`([`LinkEndpoints.cs`](../../backend/src/MoShiro.Backend.Api/Admin/LinkEndpoints.cs))。日志中控制层的每次迁移写一条 `link state`,QUIC 只在可用与不可用的边界上各写一条,重拨过程中的失败只计入握手统计。
## 5. 安全边界
> 本节描述现状。Backend 或节点被攻破后的威胁模型、现有缺口(例如 Probe 会拨 Backend 给的任意地址、测量数据没有产生者签名)及状态见 [security.md](../security/security.md)。
> 本节描述现状。Backend 或节点被攻破后的威胁模型、现有缺口(例如 拨号方 会拨 Backend 给的任意地址、测量数据没有产生者签名)及状态见 [security.md](../security/security.md)。
```text
Agent / Probe 能做的 执行编译在二进制中的 Kind;监听和访问本地配置允许的端口与网段;受本地硬上限约束
Agent 能做的 执行编译在二进制中的 Kind;监听和访问本地配置允许的端口与网段;受本地硬上限约束
当前未提供的能力 exec / shell / 终端 / 文件读写(配置目录除外)/ 加载外部代码 / 采集主机标识(型号、主机名、IP、进程);更新在节点进程之外完成
即使 Backend 被攻破 攻击者最多能在本地硬上限内发测量流量,拿不到主机权限,也无法把 Agent 当成扫描器或放大器;
也推不出恶意更新(更新清单必须由发布密钥签名,节点宿主机上的更新器用本机公钥验签,并且只认本机配置的镜像仓库)
@@ -139,18 +160,18 @@ Agent / Probe 能做的 执行编译在二进制中的 Kind;监听和访问
- 本地硬上限写在节点本机的配置文件中([`AgentConfig.cs`](../../backend/agent/src/MoShiro.Agent/AgentConfig.cs)),由 [`BudgetGuard.cs`](../../backend/shared/MoShiro.Measurement/Execution/BudgetGuard.cs) 检查,Backend 下发的任务如果超出,节点直接拒绝:允许访问的目标网段(默认为空,即不开放目标任务)、总码率和包速率上限、允许监听的端口。
- 构建期强制:常驻节点进程使用 [`BannedSymbols.Node.txt`](../../backend/BannedSymbols.Node.txt),禁用 `System.Diagnostics.Process`、`Assembly.Load*`、`NativeLibrary.Load`、`HttpClient`,以及 `ConfigStore` 以外的文件写入 API,详见 [project-layout.md](../dev/project-layout.md) 第 7 节。
- 权限分离:注册(`fleet-enroll`,一次性运行)、采集(`moshiro-agent` / `moshiro-probe`)、更新是不同的进程。容器节点里,采集进程在容器内以 UID 1654 运行(只读文件系统、不带额外能力),更新由宿主机上 root 运行的 `moshiro-update` 完成:它只读取节点放进 `update/` 的普通文件、复制到自己的临时目录后验签,状态写在节点只读的 `update-status/`;采集进程改写不了自己的二进制,更新器也读不到节点身份私钥。纯二进制节点的更新见[路线图](../roadmap.md)。
- 容器运行:Agent、Probe 的镜像基于 Alpine(musl,NativeAOT),宿主机用 rootful podman 加 systemd 运行:UID/GID 1654、只读文件系统、`--cap-drop all`、`no-new-privileges`、`/tmp` 用 tmpfs;配置和身份放在挂载的数据目录里。
- 权限分离:注册(`fleet-enroll`,一次性运行)、采集(`moshiro-agent` / `moshiro-agent`)、更新是不同的进程。容器节点里,采集进程在容器内以 UID 1654 运行(只读文件系统、不带额外能力),更新由宿主机上 root 运行的 `moshiro-update` 完成:它只读取节点放进 `update/` 的普通文件、复制到自己的临时目录后验签,状态写在节点只读的 `update-status/`;采集进程改写不了自己的二进制,更新器也读不到节点身份私钥。纯二进制节点的更新见[路线图](../roadmap.md)。
- 容器运行:Agent 的镜像基于 Alpine(musl,NativeAOT),宿主机用 rootful podman 加 systemd 运行:UID/GID 1654、只读文件系统、`--cap-drop all`、`no-new-privileges`、`/tmp` 用 tmpfs;配置和身份放在挂载的数据目录里。
- 属主检查:[`OwnerGuard.cs`](../../backend/fleet/src/Fleet.Hosting/OwnerGuard.cs);预握手敲门:[`KnockGate.cs`](../../backend/fleet/src/Fleet.Identity/KnockGate.cs)。
- 最小权限运行:非 root 用户;只在需要时授予 `CAP_NET_RAW`(ICMP);默认端口都高于 1024,不需要 `CAP_NET_BIND_SERVICE`,只有在 Agent 本地显式开启 443 时才授予。
- 身份([`NodeIdentity.cs`](../../backend/fleet/src/Fleet.Identity/NodeIdentity.cs)):节点使用本地生成的 ECDSA P-256 密钥对签名认证(Agent 和 Probe 都自己生成;Probe 有两把独立的密钥,一把连面板、一把连 Agent),Backend 只存公钥,数据库泄露也无法冒充节点。
- 身份([`NodeIdentity.cs`](../../backend/fleet/src/Fleet.Identity/NodeIdentity.cs)):节点使用本地生成的 ECDSA P-256 密钥对签名认证(Agent 都自己生成;拨号方 有两把独立的密钥,一把连面板、一把连 Agent),Backend 只存公钥,数据库泄露也无法冒充节点。
- 防放大([`Registries.cs`](../../backend/agent/src/MoShiro.Agent/DataPlane/Registries.cs)):未认证的包静默丢弃;回复长度与请求相等;每个流按声明速率限速;使用重放窗口。
- 容器里的主机指标:容器里的 Agent 通过只读挂载宿主机的 `/proc`、`/sys`、根目录和 `/etc/os-release`(环境变量 `HOST_PROC`、`HOST_SYS`、`HOST_ROOT`)读取宿主机的资源占用和系统名(采样见 [`HostSampler.cs`](../../backend/shared/MoShiro.Measurement/Platform/HostSampler.cs));读不到的项返回空值,不会退回去读容器自己的数据。Probe 的 compose 也一样挂载这几项。
- 容器里的主机指标:容器里的 Agent 通过只读挂载宿主机的 `/proc`、`/sys`、根目录和 `/etc/os-release`(环境变量 `HOST_PROC`、`HOST_SYS`、`HOST_ROOT`)读取宿主机的资源占用和系统名(采样见 [`HostSampler.cs`](../../backend/shared/MoShiro.Measurement/Platform/HostSampler.cs));读不到的项返回空值,不会退回去读容器自己的数据。拨号方 的 compose 也一样挂载这几项。
- 自身状态:节点上报进程内的计数(会话数、包速率、队列、丢弃数);Agent 另外每隔几秒用 `moshiro.host` 上报一份主机资源占用的汇总数字(可以在 `agent.json` 里用 `reporting.host_stats = false` 整个关掉),最新值实时推给展示页,Backend 每分钟存一个样本(默认保留 7 天,`retention.host_days`),见 [`HostStatusService.cs`](../../backend/src/MoShiro.Backend.Application/Agents/HostStatusService.cs)。
## 6. 可靠性与资源
- Probe:每个 Agent 未交付的原始列 ≤ 8 MB;Agent:上传队列 ≤ 64 MB,满时丢弃最旧的块,并通过 `moshiro.event(gap)` 上报缺口。
- 拨号方:每个 Agent 未交付的原始列 ≤ 8 MB;Agent:上传队列 ≤ 64 MB,满时丢弃最旧的块,并通过 `moshiro.event(gap)` 上报缺口。
- 至少一次投递:Backend 持久化之后才回复 ACK,按 `chunk_id` 去重([`RawChunkInbox.cs`](../../backend/src/MoShiro.Backend.Gateway/RawChunkInbox.cs))。
- 节点重启会丢失未上传的数据,并体现为数据缺口。
- 窗口时间以 Agent 时钟为准;时钟偏差超过 5 秒时记录告警事件。
+24 -24
View File
@@ -3,17 +3,17 @@
## 1. 原则
- 只采集原始数据:逐包、逐消息、逐事件,TCP 另加周期性的内核采样;不计算任何统计,解释全部放在 [analysis.md](analysis.md)。
- 一处声明,处处生效:采集方法(Kind)由积木组合声明而成。Probe 和 Agent 共用同一套积木(`backend/shared/MoShiro.Measurement`)。
- 一处声明,处处生效:采集方法(Kind)由积木组合声明而成。Agent 共用同一套积木(`backend/shared/MoShiro.Measurement`)。
- 密度即参数:频率、包大小、采样间隔全部是任务参数,由 API 下发,不需要改代码。
## 2. 任务模型
同一个 Probe 与 Agent 的 IPv4、IPv6 路径并行执行相同的持续线路任务模板,各自记录、上传、分析。IPv6 只在管理端配置了地址且 Agent 上报支持时建立;两端的控制和数据通道固定使用同一地址族。按需测试通过所选线路 id 指定地址族,重负载并发限制仍按 Agent 和 Probe 的本机策略执行。两个地址族分别使用有界发件箱(Probe 每路径 8 MB,Agent 的共同上传队列 64 MB)。
同一个 Agent 节点 的 IPv4、IPv6 路径并行执行相同的持续线路任务模板,各自记录、上传、分析。IPv6 只在管理端配置了地址且 Agent 上报支持时建立;两端的控制和数据通道固定使用同一地址族。按需测试通过所选线路 id 指定地址族,重负载并发限制仍按 Agent 的本机策略执行。两个地址族分别使用有界发件箱(拨号方 每路径 8 MB,Agent 的共同上传队列 64 MB)。
```text
Task = { id, scope, kind, target, params, schedule, enabled }
scope line : Probe ↔ Agent 线路任务 target 为空(agent_id 为空时表示对所有 Agent 生效的模板)
scope line : 拨号方 ↔ Agent 线路任务 target 为空(agent_id 为空时表示对所有 Agent 生效的模板)
target : Agent → 目标 IP 的单边任务 target = ip[:port](受 Agent 本地 allowed_target_cidrs 限制)
kind Kind 目录中的 id,例如 udp_echo / tcp_echo / udp_train …
params 按 Kind 的 ParamSpec 校验(默认值、上下限);未指定的参数取默认值
@@ -24,7 +24,7 @@ schedule continuous 持续运行,按时间切块
- 线路任务模板:`agent_id` 为空的 `line` 任务会自动应用到所有 `active` 的 Agent。新 Agent 注册后自动获得这些任务;也可以针对单个 Agent 覆盖参数或停用。
- 测试方案(Plan):多个阶段的 `once` 任务组合,例如 `hy2_ladder` 是 `udp_train` 在 10、25、50、100 Mbps 下各运行 10 秒。Plan 定义在 `backend/src/MoShiro.Backend.Application/Tests/Plans.cs`。
- 期望任务集:Backend 为每个 Agent 计算“有效任务集”(模板 + 覆盖 + 目标任务),带版本号,作为 Fleet 的期望状态(`fleet.state.sync`,文档类型 `TaskSet`)下发。Agent 的 `TaskManager` 与当前运行的任务对比后,启动、停止或修改参数;其中线路任务再经过控制通道同步给 Probe。新增任务类型时协议不变。
- 期望任务集:Backend 为每个 Agent 计算“有效任务集”(模板 + 覆盖 + 目标任务),带版本号,作为 Fleet 的期望状态(`fleet.state.sync`,文档类型 `TaskSet`)下发。Agent 的 `TaskManager` 与当前运行的任务对比后,启动、停止或修改参数;其中线路任务再经过控制通道同步给 拨号方。新增任务类型时协议不变。
## 3. Kind 目录:积木组合
@@ -39,7 +39,7 @@ schedule continuous 持续运行,按时间切块
| Sampler(采样器,可挂到任意连接上) | `tcp_info`、`socket_counters` | `every_ms` |
| Recorder(记录器) | 根据 Kind 声明的列自动生成的列式记录器 | — |
两种角色互相独立:连接角色(Probe 永远是拨号方,适配 NAT)与数据角色(发送端、接收端、回显端,由 `direction` 决定)无关。每种积木只实现一次,Probe 和 Agent 共用。
两种角色互相独立:连接角色(拨号方 永远是拨号方,适配 NAT)与数据角色(发送端、接收端、回显端,由 `direction` 决定)无关。每种积木只实现一次,Agent 共用。
### 3.2 Kind 声明(`MoShiro.Protocol/Catalog/Kinds`)
@@ -55,7 +55,7 @@ public static readonly KindSpec TcpEcho = new(
Records:
[
Rec.Stream("msg", Col.U32("seq"), Col.U16("size"),
Col.TsProbe("t1"), Col.TsAgent("t2"), Col.TsAgent("t3"), Col.TsProbe("t4")),
Col.Ts拨号方("t1"), Col.TsAgent("t2"), Col.TsAgent("t3"), Col.Ts拨号方("t4")),
Rec.Stream("tcp_info", Col.TsLocal("ts"), Col.U8("side"), Col.U32("srtt_us"),
Col.U32("rttvar_us"), Col.U32("cwnd"), Col.U32("retrans_total"), Col.U64("bytes_acked")),
],
@@ -64,7 +64,7 @@ public static readonly KindSpec TcpEcho = new(
这一条声明会被四个地方使用:
- Probe/Agent:组装积木执行任务;
- Agent:组装积木执行任务;
- Recorder:根据列定义记录数据;
- Backend:校验参数和预算,并在 `/catalog/kinds` 中展示;
- Analysis:按列名读取数据。
@@ -76,7 +76,7 @@ public static readonly KindSpec TcpEcho = new(
| `udp_echo` | udp + fixed + fixed + echo | 10 Hz × 64 B | 基础指标、mc、hy2 |
| `udp_train` | udp + fixed/ladder + fixed + sink | 1250 B,`rev` | hy2 |
| `tcp_echo` | tcp/tls + fixed + uniform + echo + tcp_info | 20 Hz × 32–64 B,`tcp_info` 每 100 ms | mc、reality |
| `tcp_bulk` | tcp/tls + source/sink + tcp_info | `streams` 条连接并行传输(`direction` = fwd Probe 发 / rev Agent 发),Probe 每 `sample_ms` 记录每条连接的累计字节数(`bytes` 流);两端各自只采样第一条连接的内核 tcp_info;发送端受本地 `MaxBulkMbps` 约束;只用于按需测试,默认 4 秒(够量到峰值,省流量) | reality |
| `tcp_bulk` | tcp/tls + source/sink + tcp_info | `streams` 条连接并行传输(`direction` = fwd 拨号方 发 / rev Agent 发),拨号方 每 `sample_ms` 记录每条连接的累计字节数(`bytes` 流);两端各自只采样第一条连接的内核 tcp_info;发送端受本地 `MaxBulkMbps` 约束;只用于按需测试,默认 4 秒(够量到峰值,省流量) | reality |
| `connect` | tcp/tls + fixed + connect | 每分钟 6 次 | reality,以及目标任务 |
| `hold` | tls + hold | 每 30 s 一次心跳 | reality |
| `icmp_echo` | icmp + fixed + fixed + echo | 1 Hz × 64 B | 目标任务 |
@@ -86,7 +86,7 @@ public static readonly KindSpec TcpEcho = new(
### 3.4 回显的四个时间戳
```text
Probe 单调时钟 Agent 单调时钟
拨号方 单调时钟 Agent 单调时钟
t1 发送 ─────────────> t2 收到
t4 收到 <───────────── t3 回复
```
@@ -99,9 +99,9 @@ RTT = `(t4 − t1) − (t3 − t2)`。缺少 `t2` 表示上行丢失;有 `t2`/
RawChunk
├─ header(JSON,自描述)
│ chunk_id(UUIDv7) container_version kind task_id test_id? phase?
│ agent_id probe_id? target? params(完整快照) port direction
│ agent_id dialer_id? target? params(完整快照) port direction
│ start_utc_ms duration_ms
│ clock:agent 单调时钟 ↔ UTC 锚点,probe 时钟偏移估计
│ clock:agent 单调时钟 ↔ UTC 锚点,拨号方时钟偏移估计
│ streams: [ { name: "msg", columns: [ {name:"seq", type:"u32", enc:"delta"}, … ], count } … ]
├─ 各列数据(delta / zigzag / varint,按列定义编码)
├─ Brotli 压缩(BCL 自带)
@@ -111,13 +111,13 @@ RawChunk
- 列定义写在块头中:新增列或新增 Kind 时不需要升级 `container_version`。只有容器格式本身变化时才升级。Backend 可以通用地解码任何块,不认识的列会被保留,但会被分析层忽略。
- 切块规则:持续任务每 10 秒切一块,或者记录数达到上限(默认 50 000)时提前切块。这样采集密度提高后,单块大小仍然有界,实时显示和持久化也共用同一条链路。
- 按需测试按阶段切块,阶段内同样受记录数上限约束。
- 编解码器位于 `MoShiro.Protocol/Raw`,Probe、Agent、Backend、离线工具使用同一份代码。
- 编解码器位于 `MoShiro.Protocol/Raw`,Agent、Backend、离线工具使用同一份代码。
## 5. 列的归属与合并
两侧各自记录完整行的流(`StreamSpec.Union`,例如 `tcp_info`):Probe 和 Agent 各自采样自己的套接字(Linux `TCP_INFO`,通过 `Socket.GetRawSocketOption`,不需要 P/Invoke),每一行带 `side` 标明属于哪一侧;合并时直接拼接,不连接,Agent 一侧的行每行只交出一次。Probe 的重传反映上行方向,Agent 的重传反映下行方向(代理场景里大流量走这个方向)。非 Linux 平台没有这类采样,对应的指标在窗口里就是没有数据。
两侧各自记录完整行的流(`StreamSpec.Union`,例如 `tcp_info`):Agent 各自采样自己的套接字(Linux `TCP_INFO`,通过 `Socket.GetRawSocketOption`,不需要 P/Invoke),每一行带 `side` 标明属于哪一侧;合并时直接拼接,不连接,Agent 一侧的行每行只交出一次。拨号方 的重传反映上行方向,Agent 的重传反映下行方向(代理场景里大流量走这个方向)。非 Linux 平台没有这类采样,对应的指标在窗口里就是没有数据。
`tcp_info` 的可空 `conn` 列标识当前记录器内的一条 TCP 连接,不包含地址。Backend 按 `(side, conn)` 分别求累计计数差,避免把重连前后或同时存活的套接字混为一个序列;时间不递增的样本被忽略,任一计数器回退时重新建立基线。旧数据没有 `conn`,仍按 `side` 分组,无法事后恢复丢失的连接归属。升级时先更新 Backend 和 Agent,再更新 Probe;旧 Agent 合并采样时会丢弃尚不认识的列。
`tcp_info` 的可空 `conn` 列标识当前记录器内的一条 TCP 连接,不包含地址。Backend 按 `(side, conn)` 分别求累计计数差,避免把重连前后或同时存活的套接字混为一个序列;时间不递增的样本被忽略,任一计数器回退时重新建立基线。旧数据没有 `conn`,仍按 `side` 分组,无法事后恢复丢失的连接归属。升级时先更新 Backend 和 Agent,再更新 拨号方;旧 Agent 合并采样时会丢弃尚不认识的列。
TCP 回显的长度头和正文合为一次写入,避免在启用 `TCP_NODELAY` 时为长度头单独发送一个小段。`tcp.retrans` 仍按内核的重传段数与发送段数求比,不由应用消息数估算。
@@ -127,19 +127,19 @@ TCP 回显的长度头和正文合为一次写入,避免在启用 `TCP_NODELAY
- `Driver`:行的主导方——行由它发出的包或事件决定;
- `Key`:连接键(通常是 `seq`);
- 每一列属于哪一侧由它的时钟决定:`TsProbe` 属于 Probe,`TsAgent` 属于 Agent,其余列属于主导方。
- 每一列属于哪一侧由它的时钟决定:`Ts拨号方` 属于 拨号方,`TsAgent` 属于 Agent,其余列属于主导方。
两端各自把本侧的列记在一张按键索引的环形行表里(`RowStore`),Probe 每 10 秒把已完成的行切成列块经控制通道交给对应的 Agent,Agent 按声明合并成完整的 RawChunk 后上传:
两端各自把本侧的列记在一张按键索引的环形行表里(`RowStore`),拨号方 每 10 秒把已完成的行切成列块经控制通道交给对应的 Agent,Agent 按声明合并成完整的 RawChunk 后上传:
- `Driver = Probe`(回显):行取自列块,Agent 的列按键补上,缺失就是缺失(上行丢失);
- `Driver = Agent`(单向数据流):行取自 Agent 的记录(按列块头里的序号范围),Probe 的列按键补上,缺失就是缺失(丢失);
- `Driver = 拨号方`(回显):行取自列块,Agent 的列按键补上,缺失就是缺失(上行丢失);
- `Driver = Agent`(单向数据流):行取自 Agent 的记录(按列块头里的序号范围),拨号方 的列按键补上,缺失就是缺失(丢失);
- 没有 `Key`,或只有主导方一侧有列:原样透传。
新增一种记录流只需要写声明,不需要写合并代码。Probe 的列在规定时间内没有送达时,Agent 带着已有的数据继续,不上传只有 Agent 一侧的块。
新增一种记录流只需要写声明,不需要写合并代码。拨号方 的列在规定时间内没有送达时,Agent 带着已有的数据继续,不上传只有 Agent 一侧的块。
## 6. 代码结构(`backend/shared/MoShiro.Measurement`)
同一种采集方法的两端是一对角色写在同一个文件里:拨号方(Probe 永远是拨号方,适配 NAT)和监听方(Agent)。两端共用包的构造和校验、时钟、行表和合并;Probe 和 Agent 只负责组装自己那一侧。
同一种采集方法的两端是一对角色写在同一个文件里:拨号方(拨号方 永远是拨号方,适配 NAT)和监听方(Agent)。两端共用包的构造和校验、时钟、行表和合并;Agent 只负责组装自己那一侧。
```text
MoShiro.Measurement/ 依赖:MoShiro.Protocol(仅 BCL,零 NuGet)
@@ -171,10 +171,10 @@ MoShiro.Measurement/ 依赖:MoShiro.Protocol(仅 BCL,
| 项目 | 默认值 | 由谁约束 |
| --- | --- | --- |
| 同时运行的重负载测试 | 每个 Agent 1 个;Probe 全局 1 个 | Backend 编排 + 节点本地 |
| 同时运行的重负载测试 | 每个 Agent 1 个;拨号方 全局 1 个 | Backend 编排 + 节点本地 |
| 单次测试时长 / 码率 | ≤ 120 s;≤ 本地 `max_total_mbps` | 节点本地硬上限 |
| 单块记录数 | ≤ 50 000 | Recorder |
| Probe 上未交付的列数据 | 每个 Agent ≤ 8 MB,超出则丢弃最旧的数据并计入缺口 | Probe |
| 拨号方 上未交付的列数据 | 每个 Agent ≤ 8 MB,超出则丢弃最旧的数据并计入缺口 | 拨号方 |
| 持续任务的总带宽 | 每条线路 ≤ 100 kbps(默认模板约 40 kbps) | Backend 校验 |
| 接收端速率校验 | 实际速率 > 声明速率 × 1.2 时中止 | 节点 |
@@ -182,8 +182,8 @@ MoShiro.Measurement/ 依赖:MoShiro.Protocol(仅 BCL,
| 用途 | 默认端口 | 说明 |
| --- | --- | --- |
| Probe 控制通道(TLS,Fleet 点对点会话) | 7443/tcp | |
| QUIC Datagram 数据(`udp_echo`、`udp_train`) | 7443/udp | Agent 固定监听;Probe 每次新连接轮换本地源端口 |
| 拨号方 控制通道(TLS,Fleet 点对点会话) | 7443/tcp | |
| QUIC Datagram 数据(`udp_echo`、`udp_train`) | 7443/udp | Agent 固定监听;拨号方 每次新连接轮换本地源端口 |
| TCP/TLS 数据(`tcp_echo`、`tcp_bulk`、`connect`、`hold`) | 7444/tcp | 按首帧中的 `stream_id` 分发 |
- 默认不使用 443,避免与 VPS 上已有的 REALITY 或 Hysteria2 服务冲突,也不需要特权端口权限。
+30 -30
View File
@@ -3,7 +3,7 @@
MoShiro 的两条控制链路都建立在 Fleet 之上:
- Agent ↔ Backend:Fleet 连接服务端(wss),见 [Fleet 文档](../../backend/fleet/README.md);
- Probe ↔ Agent:Fleet 节点间点对点会话(TLS),见 [Fleet 文档](../../backend/fleet/README.md) 的「点对点会话」一节。
- 拨号方 ↔ Agent:Fleet 节点间点对点会话(TLS),见 [Fleet 文档](../../backend/fleet/README.md) 的「点对点会话」一节。
认证、心跳、重连、能力协商、可靠发件箱、期望状态同步、JSON-RPC 信封都由 Fleet 负责。本文只定义 MoShiro 自己的内容:`moshiro.*` 方法、状态文档、二进制通道,以及 Fleet 之外的数据面(QUIC Datagram 和 TCP 数据连接)。所有类型都放在 `MoShiro.Protocol` 中。
@@ -11,14 +11,14 @@ MoShiro 的两条控制链路都建立在 Fleet 之上:
| MoShiro 需要的能力 | 使用的 Fleet 机制 | MoShiro 提供的内容 |
| --- | --- | --- |
| 下发任务 | 期望状态 `fleet.state.sync` | 文档类型 `TaskSet`,包括 `tasks[]`、`peers`(允许连这个 Agent 的 Probe 公钥,已按线路开关过滤) |
| 下发 Agent 列表给 Probe | 期望状态 `fleet.state.sync`(Probe 直连面板) | 文档类型 `probe_agents`:`version`、`agents[]`(`agent_id`、`host`、`tcp_port`、`quic_ports`、`public_key`),`public_key` 是 Agent 面向 Probe 的 TLS 证书公钥(SPKI,base64):Probe 用它固定证书并派生敲门密钥;没有上报公钥的 Agent 不会下发。已按线路开关过滤;Agent 集合或线路开关变化时重新推送 |
| 下发任务 | 期望状态 `fleet.state.sync` | 文档类型 `TaskSet`,包括 `tasks[]`、`peers`(允许连这个 Agent 的 拨号方 公钥,已按线路开关过滤) |
| 下发 Agent 列表给 拨号方 | 期望状态 `fleet.state.sync`(拨号方 直连面板) | 文档类型 `dial_targets`:`version`、`targets[]`(`agent_id`、`host`、`tcp_port`、`quic_ports`、`public_key`),`public_key` 是 Agent 面向 拨号方 的 TLS 证书公钥(SPKI,base64):拨号方 用它固定证书并派生敲门密钥;没有上报公钥的 Agent 不会下发。已按线路开关过滤;Agent 集合或线路开关变化时重新推送 |
| 定时重启 | 通知 `moshiro.schedule`(B→A、B→P) | `{restart_at}`,形如 `04:30+08:00`,空表示关闭;连上时补发、管理端改了设置就群发。节点本地写了 `restart_at` 或 `MOSHIRO_RESTART_AT` 时以本地为准;节点只是到点自己有序退出,由服务管理器拉起 |
| 更新 | 通知 `moshiro.update.offer` / `moshiro.update.apply`(B→A、B→P),心跳附加字段 `update` | `offer` 带 CI 签名的发布清单原文和签名,节点只写进数据目录;`apply` 请求更新;`update` 是宿主机更新器的状态(`state`、`current`、`available`、`error`、`at`),见 [deployment.md](../ops/deployment.md) |
| 上报原始块 | 发件箱,二进制通道 16 | `RawChunk`(自描述,见 [measurement.md](measurement.md) 第 4 节),`msg_id = chunk_id` |
| Probe 交付列块 | 点对点会话的发件箱,二进制通道 17 | 列块:`u32 stream_id \| u32 first_seq \| u32 count \| 列数据(Brotli)` |
| 节点自身状态 | `fleet.heartbeat` 的附加字段 | Agent:`sessions`、`probes[]`、`pps_in/out`、`dropped_invalid`、`dropped_rate_limited`、`queue_bytes`、`update`;Probe:只有 `update` |
| 版本与能力 | `fleet.hello` | capabilities 包括 `moshiro.kinds:<列表>`、`moshiro.blocks:<列表>`;Agent 附加 `platform`(系统、架构、版本、tag、提交、构建时间)、`tls_pubkey`(面向 Probe 的证书公钥,面板自己算指纹)、`addresses`(本机公网地址)、`probe_ports`、`local_limits` 摘要;Probe 附加 `platform` |
| 拨号方 交付列块 | 点对点会话的发件箱,二进制通道 17 | 列块:`u32 stream_id \| u32 first_seq \| u32 count \| 列数据(Brotli)` |
| 节点自身状态 | `fleet.heartbeat` 的附加字段 | Agent:`sessions`、`dialers[]`、`pps_in/out`、`dropped_invalid`、`dropped_rate_limited`、`queue_bytes`、`update`;拨号方:只有 `update` |
| 版本与能力 | `fleet.hello` | capabilities 包括 `moshiro.kinds:<列表>`、`moshiro.blocks:<列表>`;Agent 附加 `platform`(系统、架构、版本、tag、提交、构建时间)、`tls_pubkey`(面向 拨号方 的证书公钥,面板自己算指纹)、`addresses`(本机公网地址)、`listen`、`local_limits` 摘要;拨号方 附加 `platform` |
## 2. `moshiro.*` 方法
@@ -28,40 +28,40 @@ MoShiro 的两条控制链路都建立在 Fleet 之上:
| 方法 | 方向 | 类型 | 参数与结果 |
| --- | --- | --- | --- |
| `moshiro.probe.link_retry` | B→P | 通知 | `agent_id`:Probe 立即重试到该 Agent 的已配置 IP 路径;若列表尚未同步,Probe 在列表更新后补发一次 |
| `moshiro.test.run` | B→A | 请求 | `test_id`、`probe_id`(在哪个 Probe 上跑)、`plan`、`phases[]` → `accepted`,或错误 `test_busy`、`probe_offline` |
| `moshiro.probe.disconnect` | B→A | 请求 | `probe_id` → `accepted`(Agent 是否真的有这个会话);Agent 关闭该 Probe 的会话 |
| `moshiro.link.retry` | B→P | 通知 | `agent_id`:拨号方 立即重试到该 Agent 的已配置 IP 路径;若列表尚未同步,拨号方 在列表更新后补发一次 |
| `moshiro.test.run` | B→A | 请求 | `test_id`、`dialer_id`(在哪个 拨号方 上跑)、`plan`、`phases[]` → `accepted`,或错误 `test_busy`、`dialer_offline` |
| `moshiro.dialer.disconnect` | B→A | 请求 | `dialer_id` → `accepted`(Agent 是否真的有这个会话);Agent 关闭该 拨号方 的会话 |
| `moshiro.test.cancel` | B→A | 请求 | `test_id` |
| `moshiro.test.status` | A→B | 通知 | `test_id`、`status`(`running`、`phase_done`、`succeeded`、`failed`、`aborted`)、`phase?`、`error_code?` |
| `moshiro.event` | A→B | 通知 | `kind`(`probe_connected`(`detail` 是 `probe_id`,`probe` 里带版本、远端地址和连接时间)、`probe_disconnected`、`endpoint_changed`、`session_rejected`、`clock_step`、`gap`、`cpu_saturated`(节点进程 CPU 饱和,可能影响测量,由 Probe 或 Agent 上报))、`at_utc`、`detail` |
| `moshiro.event` | A→B | 通知 | `kind`(`dialer_connected`(`detail` 是 `dialer_id`,`dialer` 里带版本、远端地址和连接时间)、`dialer_disconnected`、`endpoint_changed`、`session_rejected`、`clock_step`、`gap`、`cpu_saturated`(节点进程 CPU 饱和,可能影响测量,由 拨号方 或 Agent 上报))、`at_utc`、`detail` |
| `moshiro.host` | A、P→B | 通知 | `HostSnapshot`:主机资源占用的汇总数字(CPU、内存、根分区、网卡累计流量与网速、运行时长;没有型号、主机名、IP),每 `host_interval_s` 秒一次;`host_stats` 为 false 时不发,见 [enrollment.md](../security/enrollment.md) |
心跳的附加字段里有 `probes[]`:Agent 当前连着的每个 Probe(`id`、`version`、`remote`、`since_utc_ms`),每次都是完整快照,Backend 靠它纠正错过的连接事件。点对点握手的认证消息里可以带 Probe 的 `version`(只用于展示)。块头里的 `probe_id` 以 Agent 认证过的会话为准,Probe 自报的值会被覆盖。
心跳的附加字段里有 `dialers[]`:Agent 当前连着的每个 拨号方(`id`、`version`、`remote`、`since_utc_ms`),每次都是完整快照,Backend 靠它纠正错过的连接事件。点对点握手的认证消息里可以带 拨号方 的 `version`(只用于展示)。块头里的 `dialer_id` 以 Agent 认证过的会话为准,拨号方 自报的值会被覆盖。
### Probe ↔ Agent(点对点会话)
### 拨号方 ↔ Agent(点对点会话)
| 方法 | 方向 | 类型 | 参数与结果 |
| --- | --- | --- | --- |
| `fleet.state.sync`(文档类型 `line_task_set`) | A→P | 请求 | 这条线路的任务(`id`、`kind`、`params`、`stream_id`、`port`)和 Agent 的数据面端口 |
| `moshiro.clock` | P→A | 请求 | `probe_mono_us` → `agent_mono_us`;Probe 按往返中点估计两端单调时钟的偏移(取最小往返的一次),记入块头 `probe_offset_us` |
| `moshiro.test.phase` | A→P | 请求 | 测试的一个阶段:`test_id`、`phase`、`kind`、`params`、`stream_id`、`duration_s`、`port` → `accepted`;Probe 在回复之前准备好自己一侧的角色(单向数据流:先发几个探活包让 Agent 学到源地址) |
| `moshiro.test.phase_end` | A→P | 请求 | `sent_count`(Agent 发出的包数)→ `fragments`:Probe 收尾,把列块交给 Agent,回复交出的个数;Agent 等全部列块合并上传之后才进入下一阶段 |
| `moshiro.clock` | P→A | 请求 | `dialer_mono_us` → `listener_mono_us`;拨号方 按往返中点估计两端单调时钟的偏移(取最小往返的一次),记入块头 `dialer_offset_us` |
| `moshiro.test.phase` | A→P | 请求 | 测试的一个阶段:`test_id`、`phase`、`kind`、`params`、`stream_id`、`duration_s`、`port` → `accepted`;拨号方 在回复之前准备好自己一侧的角色(单向数据流:先发几个探活包让 Agent 学到源地址) |
| `moshiro.test.phase_end` | A→P | 请求 | `sent_count`(Agent 发出的包数)→ `fragments`:拨号方 收尾,把列块交给 Agent,回复交出的个数;Agent 等全部列块合并上传之后才进入下一阶段 |
| `moshiro.test.abort` | A→P | 通知 | `test_id` |
| `fleet.outbox.ack` | 双向 | 通知 | Probe 的列块(通道 17)是至少一次投递,Agent 每收到一个就确认,重复到达的只处理一次 |
| `fleet.outbox.ack` | 双向 | 通知 | 拨号方 的列块(通道 17)是至少一次投递,Agent 每收到一个就确认,重复到达的只处理一次 |
会话密钥不需要单独的方法:点对点握手(TLS 固定证书 → `fleet.peer.challenge` → Probe 用身份私钥签 `(peer_id, nonce, client_pub, server_pub)` → 两端各自 ECDH + HKDF)直接得到 `session_id` 和 `session_key`,`peer.auth` 的应答只回显 `session_id` 用来互相确认。
会话密钥不需要单独的方法:点对点握手(TLS 固定证书 → `fleet.peer.challenge` → 拨号方 用身份私钥签 `(peer_id, nonce, client_pub, server_pub)` → 两端各自 ECDH + HKDF)直接得到 `session_id` 和 `session_key`,`peer.auth` 的应答只回显 `session_id` 用来互相确认。
### 列块(Probe → Agent,通道 17)
### 列块(拨号方 → Agent,通道 17)
Probe 只记录自己一侧的列,Agent 在数据面记录另一侧,由 Agent 合并、盖章、上传(见 `measurement.md` 第 5 节)。合并由 Kind 的流声明驱动(`StreamSpec`:`Driver`、`Key`,每一列的归属由它的时钟决定),没有按 Kind 手写的模式:
拨号方 只记录自己一侧的列,Agent 在数据面记录另一侧,由 Agent 合并、盖章、上传(见 `measurement.md` 第 5 节)。合并由 Kind 的流声明驱动(`StreamSpec`:`Driver`、`Key`,每一列的归属由它的时钟决定),没有按 Kind 手写的模式:
| 流声明 | 例子 | Probe 的列块 | Agent 补充 |
| 流声明 | 例子 | 拨号方 的列块 | Agent 补充 |
| --- | --- | --- | --- |
| `Driver = Probe`,有 `Key`,Agent 一侧有列 | `udp_echo`、`tcp_echo` 的 `msg` | `seq`、`size`、`t1`、`t4` | 按 `seq` 补上 `t2`、`t3`(没有记录表示上行丢失) |
| `Driver = 拨号方`,有 `Key`,Agent 一侧有列 | `udp_echo`、`tcp_echo` 的 `msg` | `seq`、`size`、`t1`、`t4` | 按 `seq` 补上 `t2`、`t3`(没有记录表示上行丢失) |
| `Driver = Agent`,有 `Key` | `udp_train` 的 `pkt` | `pkt(seq, t_recv)`,头部 `_seq_from` / `_seq_to` | 行由 Agent 发出的包决定(`size`、`t_send`),按 `seq` 取 `t_recv`(缺失表示丢失) |
| 没有 `Key`,或只有主导方一侧有列 | `connect`、`hold`、`step` | 完整的行 | —(按声明的列顺序原样透传) |
单块最多 `MaxRecordsPerChunk`(50 000)条记录:Probe 必须把大的阶段按序号范围切成多个列块(例如 100 Mbit/s、1000 B 的 10 秒阶段是 125 000 个包 → 4 个列块),`phase_end` 的应答报告总块数。块头里 Probe 必须带上 `agent_id`(它知道自己在和哪个 Agent 通信)。
单块最多 `MaxRecordsPerChunk`(50 000)条记录:拨号方 必须把大的阶段按序号范围切成多个列块(例如 100 Mbit/s、1000 B 的 10 秒阶段是 125 000 个包 → 4 个列块),`phase_end` 的应答报告总块数。块头里 拨号方 必须带上 `agent_id`(它知道自己在和哪个 Agent 通信)。
新增方法在 `MoShiro.Protocol/Rpc` 中定义,并在节点组装处加一行 `.On(...)`。旧节点通过 capabilities 协商避开不支持的方法。
@@ -94,20 +94,20 @@ Probe 只记录自己一侧的列,Agent 在数据面记录另一侧,由 Agen
### 3.2 TCP/TLS 数据连接
- 连接都由 Probe 发起;首帧为 `session_id | stream_id | nonce | mac`,认证失败直接关闭连接。
- 连接都由 拨号方 发起;首帧为 `session_id | stream_id | nonce | mac`,认证失败直接关闭连接。
- 应用层消息使用 `VarInt 长度 | 正文` 分帧;回显类消息的正文为 `seq | send_ts_us | padding`。
## 4. 版本演进
### IPv4 / IPv6 路径
- 一个 Probe 与 Agent 可以同时建立 IPv4、IPv6 两个会话,共用身份公钥,各自使用独立的会话密钥、流、时钟估计与发件箱。重连只替换同地址族的会话。
- `probe_agents.agents[]` 保留 `host`(IPv4 地址或 A 记录域名),新增可选 `ipv6_host`(IPv6 地址或 AAAA 记录域名)。未配置时不建立 IPv6 会话;Probe 按地址族解析,不跨地址族回退。
- 一个 Agent 节点 可以同时建立 IPv4、IPv6 两个会话,共用身份公钥,各自使用独立的会话密钥、流、时钟估计与发件箱。重连只替换同地址族的会话。
- `dial_targets.targets[]` 保留 `host`(IPv4 地址或 A 记录域名),新增可选 `ipv6_host`(IPv6 地址或 AAAA 记录域名)。未配置时不建立 IPv6 会话;拨号方 按地址族解析,不跨地址族回退。
- Agent 在 hello 的元数据里上报 `ipv6_supported`,只在本机监听 IPv6 时为 true。Backend 只向支持它的 Agent 安排 IPv6 路径,旧 Agent 缺省为 false。
- Agent 和 Probe 会在 hello 与心跳中上报 IPv6 套接字、监听、全局地址、默认路由及网络命名空间状态。Podman/Docker 宿主网络部署里,采样反映宿主接口;桥接网络容器会分别报告容器网络和能读取到的宿主 IPv6。诊断只有布尔值和粗粒度状态,不包含地址和接口名称。
- `ConnectedProbe`、`TestRunParams` 和 RawChunk 块头新增 `ip_version`,取值 `ipv4` / `ipv6`,缺省 `ipv4`。Agent 合并列块时以认证过的控制会话地址族为准,覆盖 Probe 上报的值。
- IPv4 的线路 id `agent~probe`、序列 `line:probe` 保持不变;IPv6 使用 `agent~probe~ipv6`、`line:probe:ipv6`。指标、重算、SSE 和测试结果按路径隔离。
- 双栈是 JSON 字段扩展,二进制包、列编码和容器布局不变,`protocol_version` 与 `container_version` 不升级。双栈采集需要升级 Agent 和 Probe,旧节点继续提供 IPv4。
- Agent 会在 hello 与心跳中上报 IPv6 套接字、监听、全局地址、默认路由及网络命名空间状态。Podman/Docker 宿主网络部署里,采样反映宿主接口;桥接网络容器会分别报告容器网络和能读取到的宿主 IPv6。诊断只有布尔值和粗粒度状态,不包含地址和接口名称。
- `Connected拨号方`、`TestRunParams` 和 RawChunk 块头新增 `ip_version`,取值 `ipv4` / `ipv6`,缺省 `ipv4`。Agent 合并列块时以认证过的控制会话地址族为准,覆盖 拨号方 上报的值。
- IPv4 的线路 id `listener~dialer`、序列 `line:dialer` 保持不变;IPv6 使用 `agent~dialer~ipv6`、`line:dialer:ipv6`。指标、重算、SSE 和测试结果按路径隔离。
- 双栈是 JSON 字段扩展,二进制包、列编码和容器布局不变,`protocol_version` 与 `container_version` 不升级。双栈采集需要升级 Agent,旧节点继续提供 IPv4。
### 通用规则
@@ -115,4 +115,4 @@ Probe 只记录自己一侧的列,Agent 在数据面记录另一侧,由 Agen
- `moshiro.*` 方法:新增方法或可选字段不需要升级版本,接收方忽略未知字段。
- RawChunk:块头自描述,新增列或新增 Kind 不需要升级 `container_version`;只有容器格式变化时才升级。
- QUIC Datagram 和 TCP 数据连接首帧的布局变化时,必须升级 `protocol_version`,并更新黄金字节测试。
- Agent 同时支持当前版本和上一个版本的 Probe;Backend 能解码保留期内所有的 `container_version`。
- Agent 同时支持当前版本和上一个版本的 拨号方;Backend 能解码保留期内所有的 `container_version`。
+3 -3
View File
@@ -103,7 +103,7 @@ id 的格式为 `指标.统计量`,详见 [analysis.md](analysis.md) 第 4 节
```json
{
"line_id": "agt_…~prb_…", "agent_id": "agt_…", "probe_id": "prb_…", "ip_version": "ipv4",
"line_id": "agt_…~agt_…", "agent_id": "agt_…", "dialer_id": "agt_…", "ip_version": "ipv4",
"from": "2026-09-28T10:00:00.000Z", "to": "2026-09-28T10:15:00.000Z",
"step": "2s", "source": "hot", "live": true, "degraded": false,
"t": [1759053600000, 1759053602000, 1759053604000],
@@ -118,7 +118,7 @@ id 的格式为 `指标.统计量`,详见 [analysis.md](analysis.md) 第 4 节
"coverage": 0.998
},
"edge_partial": { "first": false, "last": true },
"gaps": [{ "from": "…", "to": "…", "reason": "probe_disconnected" }],
"gaps": [{ "from": "…", "to": "…", "reason": "dialer_disconnected" }],
"events": [{ "at": "…", "kind": "endpoint_changed", "detail": "…" }],
"versions": { "loss.down": "a1f3…" }
}
@@ -146,7 +146,7 @@ id 的格式为 `指标.统计量`,详见 [analysis.md](analysis.md) 第 4 节
{
"range": "15m", "from": "…", "to": "…",
"lines": [
{ "id": "agt_…~prb_…", "agent_id": "agt_…", "probe_id": "prb_…", "name": "jp-1", "online": true,
{ "id": "agt_…~agt_…", "agent_id": "agt_…", "dialer_id": "agt_…", "name": "jp-1", "online": true,
"summary": { "loss.down": 0.1, "loss.up": 0.0, "rtt.p95": 48.0, "tcp.retrans": 0.1, "score.mc": 94 },
"coverage": 1.0 }
]
+2 -2
View File
@@ -33,10 +33,10 @@ data/
| 类别 | 表 | 说明 |
| --- | --- | --- |
| 身份与注册 | `agents`、`probes`、`join_tokens`、`node_meta` | 节点公钥、状态、地址(含手填标记)、IPv6 诊断、`task_version` / `applied_task_version`、`row_version`(ETag);`node_meta` 保存系统、构建信息与国家 / 运营商;`agents.name` 仅在非 pending 节点间唯一 |
| 身份与注册 | `agents`、`join_tokens`、`node_meta` | 节点公钥、状态、地址(含手填标记)、IPv6 诊断、`task_version` / `applied_task_version`、`row_version`(ETag);`node_meta` 保存系统、构建信息与国家 / 运营商;`agents.name` 仅在非 pending 节点间唯一 |
| 账户与信任 | `users`、`sessions`、`api_tokens`、`passkeys`、`operator_sets`、`audit_log`、`settings` | 会话与 CSRF、能力化 API 令牌、passkey、操作员集合链、审计日志、键值设置 |
| 任务 | `tasks`、`tests`、`test_results` | `tasks.scope` 为 `line` 或 `target`,`agent_id` 为空表示全局模板,`base_task_id` 指向被覆盖的模板;`tests` 记录按需测试及其阶段、地址族、线路;`test_results` 保存指标与曲线 |
| 原始数据索引 | `raw_segments`、`raw_chunks` | `raw_chunks` 记录 `(segment, offset, length)`、`series`(`line:<probe_id>[:ipv6]` 或 `target:<task_id>`)、Kind、任务、测试、起止时间 |
| 原始数据索引 | `raw_segments`、`raw_chunks` | `raw_chunks` 记录 `(segment, offset, length)`、`series`(`line:<dialer_id>[:ipv6]` 或 `target:<task_id>`)、Kind、任务、测试、起止时间 |
| 指标 | `metric_10s`、`metric_1m`、`metric_5m`、`metric_1h`、`metric_versions` | 每个桶一行,`metrics` 为 `AccumulatorCodec` 编码的 `{metric_id: 累加器状态}`;主键 `(agent_id, series, window_start)`;`metric_versions` 保存指标指纹与重算进度 |
| 分析调度 | `analysis_queue`、`rollup_dirty` | 待计算窗口(`seq` 递增,完成时只删除序号未变的项,处理期间到达的迟到块不丢更新);待降采样的桶(先低层 5 分钟,再高层 1 小时) |
| 观测 | `events`、`host_samples` | 节点事件;每个节点每分钟一行的主机资源汇总 |
+6 -6
View File
@@ -34,7 +34,7 @@
- 契约和领域值使用 `sealed record` 或 `readonly record struct`,并且不可变。
- 热路径上的计数器和缓冲区使用可变 `struct` 或 `sealed class`,所有权要写清楚(谁创建、谁归还池)。
- ID 使用强类型(`readonly record struct ProbeId(string Value)`),不直接传裸 `string`。
- ID 使用强类型(`readonly record struct NodeId(string Value)`),不直接传裸 `string`。
- 枚举序列化成 snake_case 字符串(`JsonStringEnumConverter<T>` + `JsonStringEnumMemberName`);新增枚举值不属于破坏性变更,但所有消费方都必须能处理未知值。
- 可空性:公共成员必须标注;使用 `!` 抑制警告时,同一行要写注释说明理由。
@@ -61,19 +61,19 @@
- 序列化只使用 `System.Text.Json` 源生成上下文(或手写的二进制编解码);禁用无上下文的 `JsonSerializer.Serialize<T>(T)` 重载。
- 不做程序集扫描、`Activator.CreateInstance`、`Type.GetType(string)`、动态代理或 `Reflection.Emit`。
- Probe/Agent:不使用 DI 容器,在 `Program.cs` 中手工组装对象,依赖关系一眼可见;不使用 LINQ;不使用 `HttpClient`(Agent 的 WebSocket 使用 `ClientWebSocket`)。
- 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`,两者输出的字段名相同。
- 统一使用 logfmt 字段名:`ts`、`level`、`msg`、`agent`、`dialer`、`session`、`task`、`result`、`err`。
- Backend 使用 `Microsoft.Extensions.Logging` 源生成的 `[LoggerMessage]`;Agent 使用 `NodeLog`,两者输出的字段名相同。
- 令牌、`session_key` 和 MAC 不能出现在日志里;IP 地址可以记录。
- 每个会话或连接的生命周期事件记录为 Info;逐包事件不记录,只累加计数。
## 9. 节点能力边界(Probe/Agent 与 Fleet 节点侧)
## 9. 节点能力边界(Agent 与 Fleet 节点侧)
- 由 `BannedSymbols.Node.txt` 在编译期强制:禁止启动进程、动态加载程序集或本机库、使用 `HttpClient`、在配置目录以外写文件。唯一的豁免点是 `Fleet.Hosting.ConfigStore`,豁免必须写 `#pragma` 并附上理由注释。注册在 `fleet-enroll` 中完成,更新由节点宿主机上的更新器完成,常驻进程当前不需要这些能力。
- 节点不能新增任何“由远端触发的非采集动作”对应的 RPC 方法。凡是新增 RPC 方法的 PR,都要在描述中说明它不会扩大权限边界。
@@ -106,6 +106,6 @@
## 12. 提交与评审
- 提交信息使用 `<范围>: <摘要>` 格式,范围取 `probe`、`agent`、`backend`、`shared`、`web`、`docs`、`scripts`;是否触发 CI 的标记见 [AGENTS.md](../../AGENTS.md)。
- 提交信息使用 `<范围>: <摘要>` 格式,范围取 `dialer`、`agent`、`backend`、`shared`、`web`、`docs`、`scripts`;是否触发 CI 的标记见 [AGENTS.md](../../AGENTS.md)。
- 修改 `backend/shared/MoShiro.Protocol` 的 PR 必须说明兼容性影响(新增字段、行为变化、需要升级版本)。
- 修改 `backend/openapi/v1.json` 的 PR 必须按照 [api.md](../api/api.md) 第 7 节的「版本与兼容性」判断是否属于破坏性变更。
+31 -35
View File
@@ -2,11 +2,11 @@
## 1. 目标
1. 三个独立项目加一个前端:Probe、Agent、Backend 各自拥有解决方案,可以单独构建、测试和发布;Web 是独立的 Vite 项目(`web/`)。
2. 通用能力只写一次:TCP/QUIC Datagram/TLS/ICMP 采集积木(类似 iperf3 的能力,但只记录原始数据)放在 `MoShiro.Measurement` 中,Probe 和 Agent 共用;采集方法(Kind)由积木组合声明而成。
1. 两个独立程序加一个前端:Agent、Backend 各自拥有解决方案,可以单独构建、测试和发布;Web 是独立的 Vite 项目(`web/`)。
2. 通用能力只写一次:TCP/QUIC Datagram/TLS/ICMP 采集积木(类似 iperf3 的能力,但只记录原始数据)放在 `MoShiro.Measurement` 中,Agent 共用;采集方法(Kind)由积木组合声明而成。
3. 节点机制通用化:注册、身份、连接、RPC、可靠上报、状态同步、宿主,由与业务无关的 Fleet 框架提供(见 [Fleet 文档](../../backend/fleet/README.md))。MoShiro 只写业务,其他产品也可以复用 Fleet。
4. 单一真源:协议、原始数据格式、测试方案、指标、错误码、版本号、编译设置、代码风格都只有一处定义,改动后下游自动更新,或在编译期、CI 中报错。
5. 模块化:Probe/Agent 由可插拔的模块组成;Backend 按层分成多个项目,层之间的依赖在编译期强制。
5. 模块化:Agent 由可插拔的模块组成;Backend 按层分成多个项目,层之间的依赖在编译期强制。
6. 轻量:运行时只依赖 BCL 和仓库内的库,不引用任何 NuGet 包。
## 2. 仓库布局
@@ -35,7 +35,6 @@ moshiro/
│ ├─ MoShiro.Protocol/ # 契约真源:Kind 目录、任务集、moshiro.* 方法、测量包、RawChunk、错误码
│ ├─ MoShiro.Measurement/ # 采集引擎:积木、行表、合并、预算(见 measurement.md)
│ └─ tests/
├─ probe/ src/MoShiro.Probe/ tests/ # 家宽端采集器
├─ agent/ src/MoShiro.Agent/ tests/ # VPS 端采集器
├─ src/ # Backend(面板)
│ ├─ MoShiro.Backend/ # 宿主与组合根(Program.cs)
@@ -49,13 +48,13 @@ moshiro/
└─ (解决方案见 backend/MoShiro.slnx)
```
节点机器上的发布物是两个独立进程,注册和采集分开:`fleet-enroll`(一次性运行)和 `moshiro-agent`(或 `moshiro-probe`,常驻)。更新:Quadlet 装的 Agent、Probe 由节点退出后 systemd 重新拉镜像,Backend 和一行脚本装的节点由宿主机上的 `moshiro-update` 完成,见 [deployment.md](../ops/deployment.md)。
节点机器上的发布物是两个独立进程,注册和采集分开:`fleet-enroll`(一次性运行)和 `moshiro-agent`(常驻)。更新:Quadlet 装的 Agent 由节点退出后 systemd 重新拉镜像,Backend 和一行脚本装的节点由宿主机上的 `moshiro-update` 完成,见 [deployment.md](../ops/deployment.md)。
## 3. 依赖关系
```text
┌────────┐ ┌────────┐ ┌──────────────────────────────┐ ┌─────┐
│ Probe │ │ Agent │ │ Backend (Api/Gateway/App/ │─openapi─>│ Web │
│ 拨号方 │ │ Agent │ │ Backend (Api/Gateway/App/ │─openapi─>│ Web │
└─┬──┬───┘ └─┬──┬───┘ │ Analysis/Storage) │ └─────┘
│ └───┬───┘ │ └───┬───────────────┬──────────┘
│ ▼ │ │ │
@@ -76,8 +75,8 @@ moshiro/
简要来说:
```text
Probe, Agent ──> Measurement ──> MoShiro.Protocol ──> Fleet.Core / Fleet.Rpc
Probe, Agent ──> Fleet.Link / Fleet.Hosting / Fleet.Identity
Agent ──> Measurement ──> MoShiro.Protocol ──> Fleet.Core / Fleet.Rpc
Agent ──> Fleet.Link / Fleet.Hosting / Fleet.Identity
Backend.* ──> MoShiro.Protocol + Fleet.Server.* (不引用 Measurement)
fleet-enroll ──> Fleet.Enrollment.Client ──> Fleet.Identity / Core
Web ──> openapi/v1.json
@@ -89,7 +88,7 @@ Web ──> openapi/v1.json
| `Fleet.Server.*` | ASP.NET Core、`Microsoft.Data.Sqlite`(仅 Sqlite 项目)、节点侧 Fleet 库 | 任何 `MoShiro.*` |
| `MoShiro.Protocol` | Fleet.Core、Fleet.Rpc | 任何 I/O、套接字、日志 |
| `MoShiro.Measurement` | MoShiro.Protocol、Fleet.Core | NuGet;日志(只通过计数接收器上报);读取配置;统计或解释逻辑 |
| Probe / Agent | 上述库,以及 Fleet.Link / Hosting / Identity | NuGet 运行时包;彼此互相引用;Fleet.Enrollment.Client |
| Agent | 上述库,以及 Fleet.Link / Hosting / Identity | NuGet 运行时包;彼此互相引用;Fleet.Enrollment.Client |
| Backend.* | MoShiro.Protocol、Fleet.Server.*,以及第 5.2 节列出的包 | Measurement、EF Core 等依赖反射的库 |
分析器类包(`PrivateAssets="all"`)不受限制。`ArchitectureTests` 在构建时检查上述规则。
@@ -105,7 +104,7 @@ Web ──> openapi/v1.json
```text
MoShiro.Protocol/
├─ ProtocolLimits MoShiroErrors(在 Fleet 错误码基础上扩展) IpVersions PublicAddress Ipv6Diagnostic
├─ Ids/ AgentId(= Fleet NodeId)、ProbeId、TaskId、TestId
├─ Ids/ AgentId(= Fleet NodeId)、NodeId、TaskId、TestId
├─ Catalog/
│ ├─ Kinds/ Kind 声明(积木组合 + ParamSpec + 记录列 + 预算)
│ ├─ KindSpec Kind 与记录流(StreamSpec)的类型定义
@@ -127,31 +126,28 @@ MoShiro.Protocol/
- `BlockCatalog`:Kind 到拨号方 / 监听方积木的映射;
- `BudgetGuard`:参数校验与本地硬上限。
任务调和在 Agent 的 `TaskManager` 与 Probe 中完成。
任务调和在 Agent 的 `TaskManager` 与 拨号方 中完成。
详见 [measurement.md](../design/measurement.md) 第 6 节。
## 5. 模块化
### 5.1 Agent 与 Probe:组装
### 5.1 Agent:组装
节点的组装点是 `AgentApp` 和 `ProbeApp`:在构造函数里显式组装,不使用 DI 容器,也不做反射扫描;没有全局状态,测试可以在同一进程里启动多个。连接、认证、重连、发件箱、状态同步都由 Fleet 提供,MoShiro 只写业务:
节点组装点为 `AgentApp`,显式连接各模块,不使用 DI 容器或反射扫描:
```text
AgentApp Probe(ProbeApp)
FleetLink(到面板) FleetLink(到面板)
状态 TaskSet、方法 moshiro.test.* 状态 probe_agents(Agent 列表)、hello 带系统和构建信息
发件箱 RawChunk(通道 16) 心跳带更新器的状态;moshiro.update.* 通知
心跳带更新器的状态;moshiro.update.* AgentDirectory → 每个 Agent 一个 AgentConnection(点对点会话)
PeerListener / PeerHub(Probe 会话) 会话内:线路任务集 line_task_set 的调和、角色、列块发件箱(通道 17)
QuicDatagramTransport / TcpAcceptor(数据端口)
AgentApp
FleetLink -> Backend;TaskSet、DialTargets、心跳和 RawChunk 发件箱
DialerHost / DialDirectory -> 每个目标及 IP 族一个 DialConnection
DialConnection -> TLS 控制会话、TaskLink、任务角色和列块发件箱
PeerListener / PeerHub <- 其他 Agent 的接入会话
QUIC / TCP <- 数据面监听(listen.enabled 默认 true)
TaskManager / TestRunner / ChunkAssembler
RestartUpdater(只报告有无新版本、请求时退出) RestartUpdater
RestartUpdater / OpenWrtUpdater
```
模块之间通过构造函数传入的接口或 `Channel<T>` 通信,禁止通过静态全局状态共享数据。
新增 RPC 方法:在 `MoShiro.Protocol/Rpc` 中定义 params/result,然后在组装处加一行 `.On(Methods.X, HandleX)`。
拨出和接入可同时运行。Backend 的线路策略默认建立所有可拨号 Agent 之间的双向有向线路,排除自连接。节点通过同一条 FleetLink 上报两侧状态。模块使用构造参数或 `Channel<T>` 通信。
### 5.2 Backend:分层与 API 层
@@ -168,7 +164,7 @@ AgentApp Probe(ProbeApp)
▼ │
┌─────────────────────────────────────────────────────────┐ ┌───────────────────┴──┐
│ Backend.Application │───>│ LiveHub(Application)│
│ Agents · Probe · Tasks(有效任务集的计算与下发) │ │ 内存快照 + 主题订阅 │
│ Agents · 拨号方 · Tasks(有效任务集的计算与下发) │ │ 内存快照 + 主题订阅 │
│ Tests · Ingest(chunk → 存储 → 分析队列) │ └──────────────────────┘
│ Analysis(AnalysisScheduler 等)· Query · Auth · Updates │ 调用 ─> Backend.Analysis
└───────────────────────────▲─────────────────────────────┘ 纯计算:算子/指标/模型
@@ -202,8 +198,8 @@ Backend 允许的包:ASP.NET Core 共享框架、`Microsoft.Data.Sqlite`、`Mi
## 6. 编译设置
- 所有 .NET 项目共用 `backend/Directory.Build.props`(目标框架、可空引用、警告当错误、最新分析器、确定性构建、禁用 API 清单 `BannedSymbols.txt`、版本号)和根目录的 `Directory.Packages.props`(NuGet 版本、SQLite 源码版本),SDK 版本在根目录的 `global.json`。
- `backend/probe/`、`backend/agent/` 另有 `Directory.Build.props`:NativeAOT、体积优先、关闭 EventSource、调试支持和 Server/Concurrent GC 等,具体开关以文件为准;保留 StackTraceSupport,线上崩溃需要可读的调用栈。
- `BannedSymbols.Node.txt` 适用于常驻节点进程(Probe、Agent,以及 Fleet 的节点侧运行库),主要条目:
- `backend/agent/` 另有 `Directory.Build.props`:NativeAOT、体积优先、关闭 EventSource、调试支持和 Server/Concurrent GC 等,具体开关以文件为准;保留 StackTraceSupport,线上崩溃需要可读的调用栈。
- `BannedSymbols.Node.txt` 适用于常驻节点进程(Agent,以及 Fleet 的节点侧运行库),主要条目:
- `System.Diagnostics.Process`;
- `System.Reflection.Assembly.Load*`;
- `System.Runtime.InteropServices.NativeLibrary.Load*`;
@@ -216,7 +212,7 @@ Backend 允许的包:ASP.NET Core 共享框架、`Microsoft.Data.Sqlite`、`Mi
| 程序 | 发布体积 | 空闲常驻内存 | 负载 |
| --- | --- | --- | --- |
| 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%(单核) |
| 拨号方 | ≤ 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 | — | 一次性运行 |
| Backend | 不设硬性限制 | ≤ 150 MB | — |
@@ -235,28 +231,28 @@ Backend 允许的包:ASP.NET Core 共享框架、`Microsoft.Data.Sqlite`、`Mi
| 程序 | 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)视工具链验证结果决定是否支持 |
| 拨号方 | `linux-x64`、`linux-arm64`、`win-x64` | 普通 PC 或服务器 |
| 拨号方(OpenWrt) | `linux-musl-x64`、`linux-musl-arm64` | 正式支持;`linux-musl-arm`(armv7)视工具链验证结果决定是否支持 |
| fleet-enroll | 与所在节点相同 | |
| Backend | `linux-x64`、`linux-arm64`、`win-x64` | |
- MIPS 不支持(NativeAOT 没有 MIPS 目标)。使用 MIPS 路由器(例如 MT7621)时,把 Probe 运行在局域网内的其他设备上,比如 NAS、树莓派或 PC,测量结果同样代表这条家宽。
- MIPS 不支持(NativeAOT 没有 MIPS 目标)。使用 MIPS 路由器(例如 MT7621)时,把 拨号方 运行在局域网内的其他设备上,比如 NAS、树莓派或 PC,测量结果同样代表这条家宽。
- NativeAOT 需要目标平台的原生工具链:musl 目标在 Alpine 容器中构建,arm64 使用交叉编译镜像。CI 矩阵为 Windows、Linux glibc、Linux musl。
- `scripts/publish.sh <probe|agent|backend|fleet-enroll> <rid>` 输出到 `backend/artifacts/publish/<project>/<rid>/`。
- `scripts/publish.sh <agent|backend|fleet-enroll> <rid>` 输出到 `backend/artifacts/publish/<project>/<rid>/`。
- 容器镜像由 CI 构建并对镜像 digest 签名,节点的宿主机更新器验签后更新,见 [deployment.md](../ops/deployment.md);纯二进制节点的更新见 [路线图](../roadmap.md)。
- Linux 上 TLS 依赖系统 OpenSSL(`libssl.so.3`)。
### 8.1 OpenWrt 上的 Probe
### 8.1 OpenWrt 上的 拨号方
| 事项 | 做法 |
| --- | --- |
| C 库 | musl:使用 `linux-musl-*` 目标发布 |
| TLS | .NET 在 Linux 上依赖 OpenSSL,而 OpenWrt 默认使用 mbedTLS/wolfSSL。需要安装 `libopenssl3`(安装脚本自动检查并提示)。启动时自检,缺少时明确报错,不会静默降级 |
| 全球化 | 已启用 `InvariantGlobalization`,不依赖 ICU |
| 服务管理 | procd:提供 `/etc/init.d/moshiro-probe`(`procd_set_param respawn`,前台运行,日志输出到 logd);`Fleet.Hosting.ServiceShell` 在 procd 下不需要 `sd_notify` |
| 服务管理 | procd:提供 `/etc/init.d/moshiro-agent`(`procd_set_param respawn`,前台运行,日志输出到 logd);`Fleet.Hosting.ServiceShell` 在 procd 下不需要 `sd_notify` |
| 存储 | 闪存很小,而且需要避免频繁写入:二进制放在 `/usr/lib/moshiro/`,配置和身份放在 `/etc/moshiro/`(sysupgrade 时通过 `/lib/upgrade/keep.d/` 保留);测量数据本来就不落盘;`ConfigStore` 只在 Agent 列表变化时写入 |
| 内存 | 预算 ≤ 32 MB,适合 ≥ 256 MB 内存的路由器;在本地配置中限制重负载测试的码率(`limits.max_test_mbps`),防止路由器 CPU 成为瓶颈,反过来影响测量结果 |
| 测量偏差 | Probe 运行在网关上时,自己没有 NAT;一旦路由器 CPU 饱和,结果就会失真。因此 Probe 在 `moshiro.event` 中上报自身的 CPU 繁忙标记(仅进程级别),分析层据此标注置信说明 |
| 测量偏差 | 拨号方 运行在网关上时,自己没有 NAT;一旦路由器 CPU 饱和,结果就会失真。因此 拨号方 在 `moshiro.event` 中上报自身的 CPU 繁忙标记(仅进程级别),分析层据此标注置信说明 |
| 打包 | 提供 `tar.gz` 和 procd 脚本;包格式(ipk 或 apk)按目标 OpenWrt 版本提供 |
| 更新 | 纯二进制节点的更新器还没有实现([路线图](../roadmap.md));实现时 `keep_versions = 1`,下载前检查剩余空间,避免写满闪存 |
+74 -79
View File
@@ -1,16 +1,16 @@
# 部署
节点(Agent、Probe)使用 NativeAOT。Linux standalone 归档会附带 MsQuic、NUMA、zstd、zlib 和 musl 所需的 LTTng 共享库;运行主机仍需提供 OpenSSL 3(`libcrypto.so.3`)和对应 libc。Release Agent/Probe OCI 镜像以 Alpine 为运行基底,从对应架构归档复制这些共享库到可执行文件旁边。`moshiro-dev` 镜像则从 `packaging/native/msquic` 链接对应架构的静态 MsQuic、OpenSSL 和 .NET 加密 shim,并使用静态 musl 可执行文件;这两类镜像都不需要安装 MsQuic 系统包。
节点(Agent)使用 NativeAOT。Linux standalone 归档会附带 MsQuic、NUMA、zstd、zlib 和 musl 所需的 LTTng 共享库;运行主机仍需提供 OpenSSL 3(`libcrypto.so.3`)和对应 libc。Release Agent OCI 镜像以 Alpine 为运行基底,从对应架构归档复制这些共享库到可执行文件旁边。`moshiro-dev` 镜像则从 `packaging/native/msquic` 链接对应架构的静态 MsQuic、OpenSSL 和 .NET 加密 shim,并使用静态 musl 可执行文件;这两类镜像都不需要安装 MsQuic 系统包。
## 1. 构建与打包
### Probe ↔ Agent 连接日志
### 拨号方 ↔ Agent 连接日志
管理端节点日志收集节点通过心跳上传的近期日志;原始日志可从 systemd journal、容器 logs 或 OpenWrt `logread` 查看。排障时同时查看 Probe 和 Agent,并按时间、`remote` 端点对应两端记录。
管理端节点日志收集节点通过心跳上传的近期日志;原始日志可从 systemd journal、容器 logs 或 OpenWrt `logread` 查看。排障时同时查看 Agent,并按时间、`remote` 端点对应两端记录。
- Probe 每次重连生成独立 `attempt_id`,附带 `agent`、`ip_version`。阶段依次为 DNS、TCP 连接、敲门、TLS、对等认证、时钟同步、QUIC 握手和测量。失败记录包括阶段、耗时、异常及重试间隔。
- Agent 控制会话带 `probe`、`ip_version`、`control_id`,记录替换、主动断开、吊销、空闲超时、控制连接异常和会话时长。通过敲门验证前的连接保持静默。
- 每条 QUIC 连接带独立 `quic_id`,记录远端、本地 UDP 端口、握手、DATAGRAM 协商状态及最大长度、首个数据报、首个通过认证的测量数据报和关闭状态。首个认证数据报在 Agent 端关联公开 Probe ID;这些关联 ID 均为本地诊断标记,两端的 ID 不相同。
- 拨号方 每次重连生成独立 `attempt_id`,附带 `agent`、`ip_version`。阶段依次为 DNS、TCP 连接、敲门、TLS、对等认证、时钟同步、QUIC 握手和测量。失败记录包括阶段、耗时、异常及重试间隔。
- Agent 控制会话带 `dialer`、`ip_version`、`control_id`,记录替换、主动断开、吊销、空闲超时、控制连接异常和会话时长。通过敲门验证前的连接保持静默。
- 每条 QUIC 连接带独立 `quic_id`,记录远端、本地 UDP 端口、握手、DATAGRAM 协商状态及最大长度、首个数据报、首个通过认证的测量数据报和关闭状态。首个认证数据报在 Agent 端关联公开 拨号方 ID;这些关联 ID 均为本地诊断标记,两端的 ID 不相同。
- QUIC 原生关闭码同时保留十六进制和可识别的原因,例如 Linux `0x0000006E` 为 `connection_timeout`。原生收发统计来自回调缓存,`stats_age_ms` 表示缓存年龄;空值表示尚无快照,不能据此断言没有发包,确认网络丢包仍需抓包。
- 测量数据报拒绝区分格式、未知流、未知会话、MAC 校验、准入策略和限速。拒绝日志每个传输实例最多每 30 秒一条,计数始终累计;发送失败也按连接限频。正常会话每 60 秒输出摘要,避免逐包日志干扰测量。
@@ -18,26 +18,26 @@
### IPv6 状态诊断
Agent 和 Probe 在 hello 初始化及心跳中上报 IPv6 检测结果,包括监听状态、全局地址、默认路由和网络命名空间。容器可提供只读宿主 `/proc` 挂载,附带宿主网络状态。只传布尔状态,不传 IP 或网卡名称。Docker 和 Podman 推荐宿主网络模式;自定义桥接网络时,需要启用 IPv6 并提供 `/proc` 宿主挂载,前端会提示宿主和容器网络状态是否不同。
Agent 在 hello 初始化及心跳中上报 IPv6 检测结果,包括监听状态、全局地址、默认路由和网络命名空间。容器可提供只读宿主 `/proc` 挂载,附带宿主网络状态。只传布尔状态,不传 IP 或网卡名称。Docker 和 Podman 推荐宿主网络模式;自定义桥接网络时,需要启用 IPv6 并提供 `/proc` 宿主挂载,前端会提示宿主和容器网络状态是否不同。
### 启用 IPv6 测量
升级 Backend、Agent、Probe 后,在管理端的 Agent 编辑页保留 IPv4 地址,并填写 IPv6 地址或带 AAAA 记录的域名。未填写 IPv6 地址的 VPS 只采集 IPv4;Probe 没有 IPv6 路由或 IPv6 连接失败时,IPv4 会话继续工作。
升级 Backend、Agent 后,在管理端的 Agent 编辑页保留 IPv4 地址,并填写 IPv6 地址或带 AAAA 记录的域名。未填写 IPv6 地址的 VPS 只采集 IPv4;拨号方 没有 IPv6 路由或 IPv6 连接失败时,IPv4 会话继续工作。
Agent 的新默认监听地址为 `::`(双栈);现有 `agent.json` 如果显式配置了 `0.0.0.0`,需要在宿主机改为 `::` 并重启节点。系统不支持 IPv6 套接字时,新默认值退回 IPv4 监听。防火墙需允许 IPv6 的 `7443/tcp`、`7443/udp`(QUIC)、`7444/tcp`(自定义端口时用实际端口);容器推荐宿主网络。管理端的「IPv6 监听」显示节点实际是否启用。
原来的 SQLite 数据库自动迁移,旧原始块、指标和测试结果继续属于 IPv4。先升级 Backend,再升级节点,最后填写 IPv6 地址;旧 Agent 不会上报 IPv6 支持,Backend 不会给它安排第二条会话。
上线后,在管理端的 Probe / Agent 页面查看「IPv6 诊断」。它显示检测网络范围、全局地址和默认路由;机器有 IPv6 但容器没有时,检查是否使用宿主网络。没有填 Agent IPv6 测量地址时,诊断仍会上报,但面板不会建立该 Agent 的 IPv6 测量线路。
上线后,在管理端的 Agent 页面查看「IPv6 诊断」。它显示检测网络范围、全局地址和默认路由;机器有 IPv6 但容器没有时,检查是否使用宿主网络。没有填 Agent IPv6 测量地址时,诊断仍会上报,但面板不会建立该 Agent 的 IPv6 测量线路。
管理端把节点审批状态与进程运行状态分开显示。运行状态为初始化中、运行中、暂停探测、维护中、停止中、失联或更新中;Agent 停用/Probe 吊销显示为维护中,Probe 没有任何面板下发的 Agent 目标时显示暂停探测。节点通过心跳上报运行状态,正常关闭时 Fleet 连接会发送停止标记;突然断网或心跳超时显示失联。线路只有在 Probe 和 Agent 都处于运行中且 Agent 确认了 Probe 会话时才显示已连接。
管理端把节点审批状态与进程运行状态分开显示。运行状态为初始化中、运行中、暂停探测、维护中、停止中、失联或更新中;Agent 停用/拨号方 吊销显示为维护中,拨号方 没有任何面板下发的 Agent 目标时显示暂停探测。节点通过心跳上报运行状态,正常关闭时 Fleet 连接会发送停止标记;突然断网或心跳超时显示失联。线路只有在 Agent 都处于运行中且 Agent 确认了 拨号方 会话时才显示已连接。
```text
scripts/publish.sh <agent|probe|backend|fleet-enroll> <rid> → backend/artifacts/publish/<project>/<rid>/
scripts/package.sh <agent|probe> <rid> → backend/artifacts/packages/moshiro-<role>-<version>-<rid>.tar.gz(附 .sha256)
scripts/publish.sh <agent|backend|fleet-enroll> <rid> → backend/artifacts/publish/<project>/<rid>/
scripts/package.sh <agent> <rid> → backend/artifacts/packages/moshiro-<role>-<version>-<rid>.tar.gz(附 .sha256)
```
构建信息(程序里显示的 tag、提交、构建时间):`publish.sh` 会用 `git describe --tags --always --dirty`、`git rev-parse` 和当前时间自动设置环境变量 `MOSHIRO_TAG`、`MOSHIRO_COMMIT`、`MOSHIRO_BUILT_AT`,直接用 `dotnet publish` 或 CI 构建时自己设置它们(容器镜像的 `*.NativeBuild.Containerfile` 用同名的 `--build-arg`,CI 脚本会传);不设置就是空,本地构建照常可重复。版本号取自 `backend/Directory.Build.props` 的 `Version`;仓库里它只是占位(`0.0.0-dev`),CI 构建时由 `ci/version.sh` 写进工作区(见下文「版本号」)。前端的信息由 `web/build-info.mjs` 在构建时写入(版本取自 `web/package.json`,同样由 CI 填写)。Agent、Probe、Backend 启动时都会在日志里打印一行构建信息:版本、tag、提交、构建时间(没设置环境变量的本地构建显示 `unknown`)。
构建信息(程序里显示的 tag、提交、构建时间):`publish.sh` 会用 `git describe --tags --always --dirty`、`git rev-parse` 和当前时间自动设置环境变量 `MOSHIRO_TAG`、`MOSHIRO_COMMIT`、`MOSHIRO_BUILT_AT`,直接用 `dotnet publish` 或 CI 构建时自己设置它们(容器镜像的 `*.NativeBuild.Containerfile` 用同名的 `--build-arg`,CI 脚本会传);不设置就是空,本地构建照常可重复。版本号取自 `backend/Directory.Build.props` 的 `Version`;仓库里它只是占位(`0.0.0-dev`),CI 构建时由 `ci/version.sh` 写进工作区(见下文「版本号」)。前端的信息由 `web/build-info.mjs` 在构建时写入(版本取自 `web/package.json`,同样由 CI 填写)。Agent、Backend 启动时都会在日志里打印一行构建信息:版本、tag、提交、构建时间(没设置环境变量的本地构建显示 `unknown`)。
| rid | 目标 | 构建方式 |
| --- | --- | --- |
@@ -48,7 +48,7 @@ scripts/package.sh <agent|probe> <rid> → backend/art
macOS 的 Apple Silicon 产物带临时 ad-hoc 签名,Intel 产物未签名。通过浏览器下载时,macOS Gatekeeper 可能阻止启动;面向普通用户分发前需要 Developer ID 签名和 Apple 公证。MIPS 不在当前目标列表中(见 `project-layout.md`)。
仓库内的 `scripts/publish.sh` 面向本机目标平台;Woodpecker 的 tag 流水线在 Windows runner 上构建 Agent、Probe 和 `fleet-enroll` 的所有发布目标,Backend OCI 镜像仍在 Linux runner 上构建。
仓库内的 `scripts/publish.sh` 面向本机目标平台;Woodpecker 的 tag 流水线在 Windows runner 上构建 Agent 和 `fleet-enroll` 的所有发布目标,Backend OCI 镜像仍在 Linux runner 上构建。
## Backend 容器
@@ -84,13 +84,13 @@ Backend 的 NativeAOT 发布默认启用 `SqliteStaticLink`,支持 Linux glibc
## 节点二进制发布
Woodpecker 在 `vMAJOR.MINOR.PATCH` tag 上,由 Windows runner 使用 AotAnywhere 并行构建 Agent/Probe 的 Linux glibc、Linux musl、macOS 和 Windows 产物。Agent 包还包含一次性注册工具 `fleet-enroll`。Linux 归档带有安装脚本和 systemd/OpenWrt 单元;Windows 产物为 `.zip`,Linux/macOS 产物为 `.tar.gz`。glibc sidecar 选用 Debian 12 兼容构建,musl sidecar 来自 Alpine v3.22。Linux musl 的 Agent 与 Probe 二进制还会打包为 OCI 镜像,基底为 `mcr.microsoft.com/dotnet/runtime-deps:10.0-alpine`,支持 `linux/amd64` 和 `linux/arm64`;镜像直接带有归档中的 MsQuic、NUMA 和 LTTng 共享库,并通过 `LD_LIBRARY_PATH` 加载。Linux 原生库包按 RID 缓存在 CI runner 的 `.nuget/moshiro-quic-runtime`,索引最多缓存 24 小时,已下载的版本包会复用。所有平台产物和 OCI 镜像发布成功后,CI 会创建对应的 Gitea Release,并附上压缩包和 SHA-256 校验文件。macOS 产物目前未签名/公证。安装说明见 [Agent](../../backend/agent/README.md) 与 [Probe](../../backend/probe/README.md) 文档。
Woodpecker 在 `vMAJOR.MINOR.PATCH` tag 上,由 Windows runner 使用 AotAnywhere 并行构建 Agent 的 Linux glibc、Linux musl、macOS 和 Windows 产物。Agent 包还包含一次性注册工具 `fleet-enroll`。Linux 归档带有安装脚本和 systemd/OpenWrt 单元;Windows 产物为 `.zip`,Linux/macOS 产物为 `.tar.gz`。glibc sidecar 选用 Debian 12 兼容构建,musl sidecar 来自 Alpine v3.22。Linux musl 的 Agent 二进制还会打包为 OCI 镜像,基底为 `mcr.microsoft.com/dotnet/runtime-deps:10.0-alpine`,支持 `linux/amd64` 和 `linux/arm64`;镜像直接带有归档中的 MsQuic、NUMA 和 LTTng 共享库,并通过 `LD_LIBRARY_PATH` 加载。Linux 原生库包按 RID 缓存在 CI runner 的 `.nuget/moshiro-quic-runtime`,索引最多缓存 24 小时,已下载的版本包会复用。所有平台产物和 OCI 镜像发布成功后,CI 会创建对应的 Gitea Release,并附上压缩包和 SHA-256 校验文件。macOS 产物目前未签名/公证。安装说明见 [Agent](../../backend/agent/README.md) 文档。
## CI 工作流
### Linux runner 的运行环境
Linux workflow 使用 `ci=domain-linux-native`、`platform=linux/amd64` 标签和 Woodpecker `local` backend;步骤里的 `image: bash` 是执行程序,构建工具来自 runner 环境。测试、前端和 Backend 编译使用 .NET 10、Node/npm、Python、clang 和 Zig;Agent/Probe 开发镜像通过 Buildah 构建独立的 NativeAOT builder,再在镜像里编译。
Linux workflow 使用 `ci=domain-linux-native`、`platform=linux/amd64` 标签和 Woodpecker `local` backend;步骤里的 `image: bash` 是执行程序,构建工具来自 runner 环境。测试、前端和 Backend 编译使用 .NET 10、Node/npm、Python、clang 和 Zig;Agent 开发镜像通过 Buildah 构建独立的 NativeAOT builder,再在镜像里编译。
WSL 的 agent 通过 rootless Podman 运行,systemd 模板见 `ci/woodpecker-agent-podman.service`。工具全部在 `packaging/container/Woodpecker.Agent.Containerfile` 中安装,包括 .NET 10 SDK、Node/npm、Buildah、netavark/aardvark-dns、Git、Python、clang、Woodpecker agent/clone 插件和测试用 MsQuic 库;AotAnywhere SDK、Zig 与四个 Linux RID 的 NativeAOT 包由 `NativeAot.Builder.Containerfile` 预热。容器 PID 上限是 8192,避免多个并行 AotAnywhere/Zig 链接时撞到默认 2048 的限制。容器只把 `/home/justme` 作为可写工作区和缓存,不挂载宿主工具目录。连接信息与 7897 代理配置放在宿主 `/home/justme/woodpecker/agent-podman.env`,不要提交密钥。容器允许 `unshare`、提供 `SYS_ADMIN` 和 `/dev/fuse`,以便 Buildah 挂载镜像层;不要通过 `chroot /host` 启动 agent,因为 Linux 会拒绝 chroot 进程创建新的用户命名空间。
@@ -125,31 +125,31 @@ Pull request 和 `main` 的 push 会跑三个 workflow:
### main 开发镜像
`main` 上只有提交信息包含 `[CI]` 的 push 才构建镜像,并且要等上面的 `verify`(快测试)和 `web-build` 都通过(提交信息写 `[SKIP TESTS]` 可以跳过测试);慢的端到端测试在镜像推完之后才跑,不拦着出镜像。合并 PR 时,请把 `[CI]` 放入 PR 标题或合并提交标题。Backend 构建 Linux/amd64;Agent、Probe 构建 Linux/amd64 和 Linux/arm64,并把两个架构发布为多架构索引到同一个 Zot Registry 下独立的 `moshiro-dev/` 仓库:
`main` 上只有提交信息包含 `[CI]` 的 push 才构建镜像,并且要等上面的 `verify`(快测试)和 `web-build` 都通过(提交信息写 `[SKIP TESTS]` 可以跳过测试);慢的端到端测试在镜像推完之后才跑,不拦着出镜像。合并 PR 时,请把 `[CI]` 放入 PR 标题或合并提交标题。Backend 构建 Linux/amd64;Agent 构建 Linux/amd64 和 Linux/arm64,并把两个架构发布为多架构索引到同一个 Zot Registry 下独立的 `moshiro-dev/` 仓库:
| 镜像 | 滚动标签 | 固定版本标签 |
| --- | --- | --- |
| Backend | `registry.oeo.one/moshiro-dev/moshiro-backend:latest` | `registry.oeo.one/moshiro-dev/moshiro-backend:<版本>`(如 `0.4.1-dev.12-044ce0c`) |
| Agent | `registry.oeo.one/moshiro-dev/moshiro-agent:latest` | `registry.oeo.one/moshiro-dev/moshiro-agent:<版本>` |
| Probe | `registry.oeo.one/moshiro-dev/moshiro-probe:latest` | `registry.oeo.one/moshiro-dev/moshiro-probe:<版本>` |
| 拨号方 | `registry.oeo.one/moshiro-dev/moshiro-agent:latest` | `registry.oeo.one/moshiro-dev/moshiro-agent:<版本>` |
Agent、Probe 也发布架构固定标签 `:<版本>-amd64` 与 `:<版本>-arm64`。它们在 amd64 的 `sdk:10.0-noble-aot` 衍生 builder 中使用 AotAnywhere 编译 `linux-musl-x64` 和 `linux-musl-arm64`,ARM64 无需 QEMU;builder 预热项目固定版本的 AotAnywhere SDK、Zig 和 NativeAOT 包,不挂载宿主可写 NuGet 缓存。运行基底为 `runtime-deps:10.0-alpine`。开发镜像不创建 Gitea Release,也不修改正式版 `moshiro/` 仓库中的标签。普通 main push 不会构建镜像。节点和 Backend 判断自己是不是开发构建,看构建信息里的 tag 含 `-dev.`(旧的 `dev-<sha>`、`main-<sha>` 也认)。
Agent 也发布架构固定标签 `:<版本>-amd64` 与 `:<版本>-arm64`。它们在 amd64 的 `sdk:10.0-noble-aot` 衍生 builder 中使用 AotAnywhere 编译 `linux-musl-x64` 和 `linux-musl-arm64`,ARM64 无需 QEMU;builder 预热项目固定版本的 AotAnywhere SDK、Zig 和 NativeAOT 包,不挂载宿主可写 NuGet 缓存。运行基底为 `runtime-deps:10.0-alpine`。开发镜像不创建 Gitea Release,也不修改正式版 `moshiro/` 仓库中的标签。普通 main push 不会构建镜像。节点和 Backend 判断自己是不是开发构建,看构建信息里的 tag 含 `-dev.`(旧的 `dev-<sha>`、`main-<sha>` 也认)。
### 正式版发布
在 `main` 上推送 `vMAJOR.MINOR.PATCH` tag 会触发完整跨平台构建、正式 OCI 镜像推送和 Gitea Release,版本取自 tag,不需要先提交版本号。正式镜像使用 `registry.oeo.one/moshiro/` 路径;Agent、Probe 提供 amd64 与 arm64 多架构标签,稳定版本更新正式仓库自己的 `latest`。这与 `moshiro-dev/` 的 dev `latest` 相互独立。
在 `main` 上推送 `vMAJOR.MINOR.PATCH` tag 会触发完整跨平台构建、正式 OCI 镜像推送和 Gitea Release,版本取自 tag,不需要先提交版本号。正式镜像使用 `registry.oeo.one/moshiro/` 路径;Agent 提供 amd64 与 arm64 多架构标签,稳定版本更新正式仓库自己的 `latest`。这与 `moshiro-dev/` 的 dev `latest` 相互独立。
Agent 和 Probe 镜像分别发布为 `registry.oeo.one/moshiro/moshiro-agent:<version>` 与 `registry.oeo.one/moshiro/moshiro-probe:<version>`,包含 amd64、arm64 多架构索引;也可指定 `:<version>-amd64` 或 `:<version>-arm64`。稳定版本同时更新 `latest`。镜像以非 root 用户(UID/GID 1654)运行,默认读取 `/etc/moshiro/agent.json` 或 `/etc/moshiro/probe.json`。使用 `packaging/compose/agent`、`probe` 下的标准 Compose 模板,配置和身份放在命名 volume 中;首次注册/导入、宿主指标挂载与更新器见下文。
Agent 镜像分别发布为 `registry.oeo.one/moshiro/moshiro-agent:<version>` 与 `registry.oeo.one/moshiro/moshiro-agent:<version>`,包含 amd64、arm64 多架构索引;也可指定 `:<version>-amd64` 或 `:<version>-arm64`。稳定版本同时更新 `latest`。镜像以非 root 用户(UID/GID 1654)运行,默认读取 `/etc/moshiro/agent.json` 或 `/etc/moshiro/agent.json`。使用 `packaging/compose/agent`、`dialer` 下的标准 Compose 模板,配置和身份放在命名 volume 中;首次注册/导入、宿主指标挂载与更新器见下文。
Agent 和 Probe 的默认发布配置:NativeAOT、完整裁剪、`OptimizationPreference=Size`、符号剥离、相同方法体折叠、InvariantGlobalization;关闭 Server/Concurrent GC、异常栈、EventSource、调试、Hot Reload、HTTP tracing、HTTP/3 和 .NET Metrics。JSON 全部使用源码生成上下文,禁用反射序列化回退;LINQ 使用体积优化实现。HTTP/3 裁剪不影响现有 HTTP/WebSocket/TLS 连接,也不影响程序自身上报的监控指标。
Agent 的默认发布配置:NativeAOT、完整裁剪、`OptimizationPreference=Size`、符号剥离、相同方法体折叠、InvariantGlobalization;关闭 Server/Concurrent GC、异常栈、EventSource、调试、Hot Reload、HTTP tracing、HTTP/3 和 .NET Metrics。JSON 全部使用源码生成上下文,禁用反射序列化回退;LINQ 使用体积优化实现。HTTP/3 裁剪不影响现有 HTTP/WebSocket/TLS 连接,也不影响程序自身上报的监控指标。
`fleet-enroll` 使用同一套体积优化开关。Fleet 共享库已声明 AOT 兼容,由 Agent、Probe、Backend 或注册工具的最终发布一起裁剪;GC 和运行时功能开关配置在各可执行项目中。
`fleet-enroll` 使用同一套体积优化开关。Fleet 共享库已声明 AOT 兼容,由 Agent、Backend 或注册工具的最终发布一起裁剪;GC 和运行时功能开关配置在各可执行项目中。
NativeAOT 可执行文件本身的体积如下,不包含 Linux 包附带的 MsQuic 等共享库,最新数值以各 RID 的 CI 产物为准:
| 程序 | linux-musl-x64 | linux-x64 | win-x64 |
| --- | --- | --- | --- |
| moshiro-probe | 7.7 MB | 7.7 MB | 5.6 MB |
| moshiro-agent | 7.7 MB | 7.7 MB | 5.6 MB |
| moshiro-agent | 8.4 MB | 8.4 MB | 7.3 MB |
| fleet-enroll | 6.4 MB | 6.4 MB | — |
@@ -163,94 +163,89 @@ Linux 产物比 Windows 大,是因为 .NET 的加密、压缩、网络本机
# Agent(服务器):注册并启动
./install.sh agent --server https://panel.example.com --join <加入令牌> --name hk-1
# Probe(家宽网络里的机器或 OpenWrt 路由器):用面板里 probe 角色的加入令牌注册,密钥由它自己生成
./install.sh probe --server https://panel.example.com --join <加入令牌> --name home-1
# 拨号方(家宽网络里的机器或 OpenWrt 路由器):用面板里 agent 角色的加入令牌注册,密钥由它自己生成
./install.sh agent --server https://panel.example.com --join <加入令牌> --name home-1
./install.sh uninstall agent|probe [--purge] # --purge 同时删除身份(节点需要重新注册)
./install.sh uninstall agent [--purge] # --purge 同时删除身份(节点需要重新注册)
```
| 位置 | 内容 | 所有者 |
| --- | --- | --- |
| `/usr/lib/moshiro/` | 二进制、MsQuic/NUMA/zstd/LTTng 共享库 | root;二进制 0755、共享库 0644,采集进程不能改写 |
| `/etc/moshiro/` | `agent.json` 或 `probe.json`、身份(`node.json`、`node.key`、`tls.pfx`)、`agents.json` | `moshiro` 用户,0750 |
| `/etc/moshiro/` | `agent.json` 或 `agent.json`、身份(`node.json`、`node.key`、`tls.pfx`)、`agents.json` | `moshiro` 用户,0750 |
安装脚本会:检查 OpenSSL、创建 `moshiro` 用户(`useradd`、busybox `adduser`,OpenWrt 没有这些时直接写 `/etc/passwd`)、安装二进制、QUIC sidecar 共享库和服务文件;缺少配置时只安装、不启动,并打印下一步。
### systemd(`packaging/systemd/*.service`)
以 `moshiro` 用户运行,沙箱:`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)。
以 `moshiro` 用户运行,沙箱:`NoNewPrivileges`、`ProtectSystem=strict`、`ProtectHome`、`PrivateTmp/Devices`、`ProtectKernel*`、`RestrictAddressFamilies=AF_INET AF_INET6`、`RestrictNamespaces`、`MemoryDenyWriteExecute`(NativeAOT 不 JIT)、`SystemCallArchitectures=native`,`MemoryMax`(Agent 128 MB、拨号方 64 MB)。
- Agent 只保留 `CAP_NET_RAW`(ICMP 目标任务用),`/etc/moshiro` 只读;Probe 没有任何 capability,只对 `agents.json` 所在的目录可写。
- 监听 1024 以下的端口(例如把数据面放到 443)需要在 drop-in 里加 `AmbientCapabilities=CAP_NET_BIND_SERVICE` 和对应的 `CapabilityBoundingSet`。默认端口是 7443/tcp(Probe 控制通道)、7443/udp(QUIC Datagram 数据面)、7444/tcp(TCP 测量)。Agent 在 UDP 端口固定监听;Probe 的 QUIC 连接使用轮换的临时本地源端口。
- Agent 只保留 `CAP_NET_RAW`(ICMP 目标任务用),`/etc/moshiro` 只读;拨号方 没有任何 capability,只对 `agents.json` 所在的目录可写。
- 监听 1024 以下的端口(例如把数据面放到 443)需要在 drop-in 里加 `AmbientCapabilities=CAP_NET_BIND_SERVICE` 和对应的 `CapabilityBoundingSet`。默认端口是 7443/tcp(拨号方 控制通道)、7443/udp(QUIC Datagram 数据面)、7444/tcp(TCP 测量)。Agent 在 UDP 端口固定监听;拨号方 的 QUIC 连接使用轮换的临时本地源端口。
### OpenWrt(`packaging/openwrt/`)
#### 原生一键安装与自动更新
管理端「添加 Probe」提供 OpenWrt · 原生二进制 命令,以 root 执行。支持 ARM64/aarch64 和 x86_64,不需要容器或 .NET;自动安装依赖、验签下载、固定属主并注册、配置 procd。身份在 `/etc/moshiro/`,更新策略在 `/etc/moshiro-native.conf`(`CHANNEL=dev|release`、`POLICY=auto|manual`、`INTERVAL=30`)。动态 musl 正式包仍需 `libopenssl3`,实际设备兼容性需实机验证。
管理端「添加 拨号方」提供 OpenWrt · 原生二进制 命令,以 root 执行。支持 ARM64/aarch64 和 x86_64,不需要容器或 .NET;自动安装依赖、验签下载、固定属主并注册、配置 procd。身份在 `/etc/moshiro/`,更新策略在 `/etc/moshiro-native.conf`(`CHANNEL=dev|release`、`POLICY=auto|manual`、`INTERVAL=30`)。动态 musl 正式包仍需 `libopenssl3`,实际设备兼容性需实机验证。
带 `[CI]` 的 main push 发布 dev,稳定 tag 发布 release,沿用 `gitea_token` 和 `release_signing_key`;Gitea generic package `moshiro-openwrt` 需允许匿名下载。制品发布后,在线节点收到通知就唤醒更新器,另有 30 秒轮询兜底,不等待 10 分钟。更新器验签、防重放、原子切换完整版本,健康检查失败回滚;这些步骤由独立的更新器完成,不在 Probe 进程内执行。
带 `[CI]` 的 main push 发布 dev,稳定 tag 发布 release,沿用 `gitea_token` 和 `release_signing_key`;Gitea generic package `moshiro-openwrt` 需允许匿名下载。制品发布后,在线节点收到通知就唤醒更新器,另有 30 秒轮询兜底,不等待 10 分钟。更新器验签、防重放、原子切换完整版本,健康检查失败回滚;这些步骤由独立的更新器完成,不在 拨号方 进程内执行。
```sh
logread -e moshiro
/etc/init.d/moshiro-probe status
/etc/init.d/moshiro-probe-update status
/etc/init.d/moshiro-agent status
/etc/init.d/moshiro-agent-update status
/usr/libexec/moshiro-openwrt check # 立即检查;auto 策略下有新版就安装
cat /var/run/moshiro-update/status.json
```
默认测量上限 20 Mbps/2000 pps,可修改 `probe.json`。sysupgrade 保留身份和更新配置,升级固件后重新校验安装脚本并执行 `install` 恢复程序,不需新加入码;旧部署需要人工迁移,不覆盖现有身份。首次安装没有旧版可回滚。发布公钥轮换需 root 手动更新本机公钥。隔离测试:`python3 scripts/test-openwrt-updater.py`。
默认测量上限 20 Mbps/2000 pps,可修改 `agent.json`。sysupgrade 保留身份和更新配置,升级固件后重新校验安装脚本并执行 `install` 恢复程序,不需新加入码;旧部署需要人工迁移,不覆盖现有身份。首次安装没有旧版可回滚。发布公钥轮换需 root 手动更新本机公钥。隔离测试:`python3 scripts/test-openwrt-updater.py`。
#### 旧的解压包安装方式
只支持 Probe(Agent 面向服务器)。`install.sh probe` 安装 `/etc/init.d/moshiro-probe`(procd,崩溃自动重启,日志进 logd)并写入 `/lib/upgrade/keep.d/moshiro`,sysupgrade 时保留 `/etc/moshiro/`。
统一安装 Agent。`install.sh agent` 安装 `/etc/init.d/moshiro-agent`(procd,崩溃自动重启,日志进 logd)并写入 `/lib/upgrade/keep.d/moshiro`,sysupgrade 时保留 `/etc/moshiro/`。
OpenWrt 默认不带 OpenSSL:`opkg install libopenssl3`(较新的快照和 24.10 之后用 `apk add libopenssl3`)。安装包会把 MsQuic、NUMA 和相关共享库放到 `/usr/lib/moshiro`,procd 会设置 `LD_LIBRARY_PATH`;OpenSSL 3 仍由系统提供。闪存很小时注意:Probe 二进制约 7.7 MB,配置只在 Agent 列表变化时才写。路由器上建议把 `probe.json` 的 `limits.max_test_mbps` 调低。
OpenWrt 默认不带 OpenSSL:`opkg install libopenssl3`(较新的快照和 24.10 之后用 `apk add libopenssl3`)。安装包会把 MsQuic、NUMA 和相关共享库放到 `/usr/lib/moshiro`,procd 会设置 `LD_LIBRARY_PATH`;OpenSSL 3 仍由系统提供。闪存很小时注意:拨号方 二进制约 7.7 MB,配置只在 Agent 列表变化时才写。路由器上建议把 `agent.json` 的 `limits.max_test_mbps` 调低。
## 3. 验证
`scripts/smoke-linux.sh` 在 Linux 容器里用发布的二进制跑完整链路(后端 → 注册 → Agent → Probe → 数据落库),`MODE=install` 用打包产物和 `install.sh` 安装、以 `moshiro` 用户运行。`scripts/smoke-systemd.sh` 在 systemd 容器里验证服务单元。已经验证过:
`backend/tests/MoShiro.Backend.Tests/AgentMeshTests.cs` 在回环网络中启动两个默认开启监听的 Agent,检查双向连接、通过 Backend 心跳汇总的线路状态,以及两个方向的原始测量块入库。其余慢测覆盖 TCP/QUIC 数据面、阶段测试、重连、IPv6 和公开数据边界。
- 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/moshiro`,没有重启和错误日志。
```sh
bash ci/fetch-quic-test-runtime.sh /tmp/moshiro-quic
LD_LIBRARY_PATH=/tmp/moshiro-quic/runtime dotnet test --solution backend/MoShiro.slnx -c Release
python scripts/test-openwrt-updater.py
bash packaging/update/tests/feed-test.sh
```
没有验证的:arm64 构建、真正的 OpenWrt 设备(有 procd 和真实的内存/闪存限制)、跨公网的真实线路。
实测常驻内存(RSS,回环,默认模板,稳定后):Linux 上 Probe 约 21 MB、Agent 约 23 MB;Windows 上约 22 MB、25 MB。
### 故障注入
`scripts/smoke-wsl.py`:Windows 上的 NativeAOT Probe 连 WSL2 里的 Agent 和 Backend(真实的虚拟网络,两端的单调时钟原点不同),用 `tc netem` 注入单向丢包和延迟、8 秒黑洞、Agent / Probe 重启,检查指标落在预期的一侧:上行丢包只出现在 `loss.up`、下行只出现在 `loss.down`;黑洞期间 UDP 连续丢包、TCP 不丢包只在 RTT 里出现几秒的长尾;重启之后几秒内自动恢复。具体数字以脚本的输出为准,不记在这里。
这类验证发现过的几个问题,都已经修掉,改动时要留意它们为什么存在:Probe 重启后序号从 0 开始,Agent 不能沿用上一个会话的记录(否则真正的上行丢失会被算成下行丢失);TCP 回显的几秒中断必须记成延迟而不是丢包(TCP 的行要等回复,最长 30 秒才交出);`hy2_ladder` 的阶梯要把同一阶段的块先合并再算(真实的 Probe 会把一个阶段切成几块)。
真实 OpenWrt 设备、跨公网线路及各平台 NativeAOT 发布仍需对应环境验证。
### 家宽机器上的 Backend
没有公网入站的家宽机器上跑 Backend 时,用隧道(Cloudflare Tunnel 之类)把一个公网域名转到 Backend 的端口;Backend 只写一个监听地址(同时写 `0.0.0.0` 和 `[::]` 会报地址已被占用);Probe 的 `fleet.server` 就是这个面板地址。小 VPS 上把 `limits.max_total_mbps` 调低。家宽的 IPv6 前缀会在路由器重新拨号(比如停电)后变化,用 IPv6 直连时地址要跟着换;走隧道的域名不受影响。
没有公网入站的家宽机器上跑 Backend 时,用隧道(Cloudflare Tunnel 之类)把一个公网域名转到 Backend 的端口;Backend 只写一个监听地址(同时写 `0.0.0.0` 和 `[::]` 会报地址已被占用);拨号方 的 `fleet.server` 就是这个面板地址。小 VPS 上把 `limits.max_total_mbps` 调低。家宽的 IPv6 前缀会在路由器重新拨号(比如停电)后变化,用 IPv6 直连时地址要跟着换;走隧道的域名不受影响。
国家和运营商:Backend 用 Probe、Agent 的来源地址向 IP 归属服务查一次,展示在国旗和运营商图标上(`MoShiro__GeoLookupUrl`,缺省 `https://ip.007995.xyz`;留空就不查;这个服务只供公益使用,署名和使用条款由部署的人自己遵守)。Probe、Agent 自己不访问外部服务,所以客户的防火墙审计里不会多出出站连接;Backend 发出去的只有公网 IP(内网地址不发)。Agent 的地址优先用它的对外地址(`public_host`),没有就用 IPv6 地址,再没有(NAT 后面、没有上报公网地址)才用它连面板的来源地址;面板前面有反向代理时,要配置 `MoShiro__TrustedProxies`,否则来源地址是代理的内网地址,这类节点查不到国家。没查到的节点每隔十分钟在心跳里重试一次。
国家和运营商:Backend 用 Agent 的来源地址向 IP 归属服务查一次,展示在国旗和运营商图标上(`MoShiro__GeoLookupUrl`,缺省 `https://ip.007995.xyz`;留空就不查;这个服务只供公益使用,署名和使用条款由部署的人自己遵守)。Agent 自己不访问外部服务,所以客户的防火墙审计里不会多出出站连接;Backend 发出去的只有公网 IP(内网地址不发)。Agent 的地址优先用它的对外地址(`public_host`),没有就用 IPv6 地址,再没有(NAT 后面、没有上报公网地址)才用它连面板的来源地址;面板前面有反向代理时,要配置 `MoShiro__TrustedProxies`,否则来源地址是代理的内网地址,这类节点查不到国家。没查到的节点每隔十分钟在心跳里重试一次。
从别处看这个面板:本机跑前端,在页面里把数据源指向面板的地址,用 API 令牌访问,Backend 设 `MoShiro__CorsOrigins` 放行前端的来源(见 [web/README.md](../../web/README.md))。
## 容器部署(Podman Quadlet 优先,Docker 用 compose)
普通用户执行 Podman 即为 rootless,使用命名 volume 不需要 `keep-id` 或手动 `chown`。Agent 的配置、原有身份和缓存保存在 `moshiro-agent-config`,Probe 保存在 `moshiro-probe-config`,更新状态使用独立的 `moshiro-<role>-status` volume,在业务容器内只读。不会重新生成已有身份。更新/重建容器不删除 volume;不要删除这些 volume(`podman volume rm`),会丢身份和配置。
普通用户执行 Podman 即为 rootless,使用命名 volume 不需要 `keep-id` 或手动 `chown`。Agent 的配置、原有身份和缓存保存在 `moshiro-agent-config`,拨号方 保存在 `moshiro-agent-config`,更新状态使用独立的 `moshiro-<role>-status` volume,在业务容器内只读。不会重新生成已有身份。更新/重建容器不删除 volume;不要删除这些 volume(`podman volume rm`),会丢身份和配置。
Quadlet 安装(Agent 和 Probe,Podman 系优先,Linux + Podman 4.6 及以上):管理端「添加 Agent」「添加 Probe」生成加入令牌之后,第一条命令就是它(面板地址、一次性码、信任根指纹和属主都是公开的值,没有私钥):写 env(权限 600)、从面板下载静态的 Quadlet 单元并核对 SHA-256、用一个 drop-in 把镜像仓库指向面板配置的、启动用户服务:
Quadlet 安装(Agent,Podman 系优先,Linux + Podman 4.6 及以上):管理端「添加 Agent」生成加入令牌之后,第一条命令就是它(面板地址、一次性码、信任根指纹和属主都是公开的值,没有私钥):写 env(权限 600)、从面板下载静态的 Quadlet 单元并核对 SHA-256、用一个 drop-in 把镜像仓库指向面板配置的、启动用户服务:
```sh
mkdir -p ~/.config/moshiro ~/.config/containers/systemd/moshiro-probe.container.d && cat > ~/.config/moshiro/probe.env <<'EOF'
mkdir -p ~/.config/moshiro ~/.config/containers/systemd/moshiro-agent.container.d && cat > ~/.config/moshiro/agent.env <<'EOF'
MOSHIRO_SERVER=https://panel.example.com
MOSHIRO_JOIN=jt_…
MOSHIRO_GENESIS=…
MOSHIRO_CONTROLLERS=…
# MOSHIRO_UPDATE_POLICY=manual
EOF
chmod 600 ~/.config/moshiro/probe.env && curl -fsSL 'https://panel.example.com/install/quadlet/probe.container' -o "$HOME/.config/containers/systemd/moshiro-probe.container" && echo "<sha256> $HOME/.config/containers/systemd/moshiro-probe.container" | sha256sum -c - && printf '[Container]\nImage=registry.oeo.one/moshiro/moshiro-probe:latest\n' > ~/.config/containers/systemd/moshiro-probe.container.d/10-image.conf && systemctl --user daemon-reload && systemctl --user start moshiro-probe
chmod 600 ~/.config/moshiro/agent.env && curl -fsSL 'https://panel.example.com/install/quadlet/agent.container' -o "$HOME/.config/containers/systemd/moshiro-agent.container" && echo "<sha256> $HOME/.config/containers/systemd/moshiro-agent.container" | sha256sum -c - && printf '[Container]\nImage=registry.oeo.one/moshiro/moshiro-agent:latest\n' > ~/.config/containers/systemd/moshiro-agent.container.d/10-image.conf && systemctl --user daemon-reload && systemctl --user start moshiro-agent
```
- 单元是静态的(`packaging/quadlet/moshiro-agent.container`、`moshiro-probe.container`,作为嵌入资源打进 Backend,由 `/install/quadlet/{agent,probe}.container` 原样提供,不含任何一次性的值)。镜像仓库不写死在单元里,由命令里的 drop-in 指定,来自面板的 `MoShiro__NodeImageRegistry` 和 `MoShiro__ImageNamespace`(默认 `registry.oeo.one/moshiro`,开发镜像 `moshiro-dev`)。单元显式设置 `LD_LIBRARY_PATH=/usr/local/bin`,从 Alpine 镜像随包提供的 MsQuic sidecar 加载 QUIC 运行库。
- 单元是静态的(`packaging/quadlet/moshiro-agent.container`,作为嵌入资源打进 Backend,由 `/install/quadlet/{agent}.container` 原样提供,不含任何一次性的值)。镜像仓库不写死在单元里,由命令里的 drop-in 指定,来自面板的 `MoShiro__NodeImageRegistry` 和 `MoShiro__ImageNamespace`(默认 `registry.oeo.one/moshiro`,开发镜像 `moshiro-dev`)。单元显式设置 `LD_LIBRARY_PATH=/usr/local/bin`,从 Alpine 镜像随包提供的 MsQuic sidecar 加载 QUIC 运行库。
- 只读根文件系统、丢掉全部能力、`no-new-privileges`、命名卷;用宿主网络(Agent 对外暴露端口,也靠它识别本机公网地址),并只读挂载 `/proc`、`/sys`、`/` 读主机指标。
- 身份和配置在命名卷 `moshiro-<role>-config` 里:镜像入口脚本(`packaging/container/entrypoint.sh`)第一次启动用 `MOSHIRO_SERVER`、`MOSHIRO_JOIN`(和信任根指纹、属主)注册并写配置,之后重启、换新镜像重建容器都不需要再给参数,所以注册成功之后可以把 env 里的 `MOSHIRO_JOIN` 删掉。缺了信任根指纹或属主就不注册(只有还没有信任根的老面板才用 `MOSHIRO_ALLOW_NO_OWNER=1`)。
- 开机自启:单元的 `[Install]` 已经写了,退出登录后继续运行需要管理员执行一次 `loginctl enable-linger <用户>`。
@@ -258,27 +253,27 @@ chmod 600 ~/.config/moshiro/probe.env && curl -fsSL 'https://panel.example.com/i
- 信任:节点信任镜像仓库(Podman 校验其 TLS 证书);单元和命令带有哈希,面板被攻破也改不了镜像地址;节点不验发布签名。
- 没有 Podman 4.6 或不是 Linux:用下面的 compose(Docker)、一行安装脚本或发布的二进制。
compose 安装(Agent 和 Probe,Docker;Podman 系请优先用上面的 Quadlet):管理端「添加 Agent」「添加 Probe」生成加入令牌之后,给出一段命令(面板地址、一次性码、信任根指纹和属主都是公开的值,没有私钥):建一个目录,把参数写进 `.env`(权限 600),下载面板提供的静态 compose 文件、核对 SHA-256,一致才启动:
compose 安装(Agent,Docker;Podman 系请优先用上面的 Quadlet):管理端「添加 Agent」生成加入令牌之后,给出一段命令(面板地址、一次性码、信任根指纹和属主都是公开的值,没有私钥):建一个目录,把参数写进 `.env`(权限 600),下载面板提供的静态 compose 文件、核对 SHA-256,一致才启动:
```sh
mkdir -p moshiro-probe && cd moshiro-probe && cat > .env <<'EOF'
mkdir -p moshiro-agent && cd moshiro-agent && cat > .env <<'EOF'
MOSHIRO_SERVER=https://panel.example.com
MOSHIRO_JOIN=jt_…
MOSHIRO_GENESIS=…
MOSHIRO_CONTROLLERS=…
MOSHIRO_IMAGE=registry.oeo.one/moshiro/moshiro-probe
MOSHIRO_IMAGE=registry.oeo.one/moshiro/moshiro-agent
EOF
chmod 600 .env && curl -fsSL 'https://panel.example.com/install/compose/probe.yaml' -o compose.yaml && echo "<sha256> compose.yaml" | sha256sum -c - && podman compose up -d
chmod 600 .env && curl -fsSL 'https://panel.example.com/install/compose/agent.yaml' -o compose.yaml && echo "<sha256> compose.yaml" | sha256sum -c - && podman compose up -d
```
- compose 文件是静态的(`packaging/compose/agent|probe|backend/compose.yaml`,作为嵌入资源打进 Backend,由 `/install/compose/{agent,probe,backend}.yaml` 原样提供,不含任何一次性的值)。镜像是 `${MOSHIRO_IMAGE}:${MOSHIRO_VERSION:-latest}`,由 CI 渠道决定,不由面板决定;`MOSHIRO_IMAGE` 取自面板的 `MoShiro__NodeImageRegistry` 和 `MoShiro__ImageNamespace`(默认 `registry.oeo.one/moshiro`,开发镜像 `moshiro-dev`)。
- 只读根文件系统、丢掉全部能力、`no-new-privileges`、命名卷;Linux 上用宿主网络(Agent 对外暴露端口,也靠它识别本机公网地址,并只读挂载 `/proc`、`/sys`、`/` 读主机指标)。Agent 的 compose 只适用于 Linux;Probe 在 macOS 和 Windows(Docker Desktop)上没有宿主网络,面板给的命令会在 `.env` 里写 `MOSHIRO_NETWORK_MODE=bridge`。
- compose 文件是静态的(`packaging/compose/agent|backend/compose.yaml`,作为嵌入资源打进 Backend,由 `/install/compose/{agent,backend}.yaml` 原样提供,不含任何一次性的值)。镜像是 `${MOSHIRO_IMAGE}:${MOSHIRO_VERSION:-latest}`,由 CI 渠道决定,不由面板决定;`MOSHIRO_IMAGE` 取自面板的 `MoShiro__NodeImageRegistry` 和 `MoShiro__ImageNamespace`(默认 `registry.oeo.one/moshiro`,开发镜像 `moshiro-dev`)。
- 只读根文件系统、丢掉全部能力、`no-new-privileges`、命名卷;Linux 上用宿主网络(Agent 对外暴露端口,也靠它识别本机公网地址,并只读挂载 `/proc`、`/sys`、`/` 读主机指标)。Agent 的 compose 只适用于 Linux;拨号方 在 macOS 和 Windows(Docker Desktop)上没有宿主网络,面板给的命令会在 `.env` 里写 `MOSHIRO_NETWORK_MODE=bridge`。
- 身份和配置在命名卷 `moshiro-<role>-config` 里:镜像入口脚本(`packaging/container/entrypoint.sh`)第一次启动用 `MOSHIRO_SERVER`、`MOSHIRO_JOIN`(和信任根指纹、属主)注册并写配置,之后重启、换新镜像重建容器都不需要再给参数,所以注册成功之后可以把 `.env` 里的 `MOSHIRO_JOIN` 删掉。缺了信任根指纹或属主就不注册(只有还没有信任根的老面板才用 `MOSHIRO_ALLOW_NO_OWNER=1`)。
- Podman 的开机自启要靠 `podman-restart.service`(`systemctl enable --now podman-restart`;rootless 用 `--user`,并 `loginctl enable-linger`);Docker 用 compose 里的 `restart: unless-stopped`。
- 更新:`podman compose pull && podman compose up -d`(Docker 用 `docker compose`),只重建镜像变了的容器,身份和配置不动,节点几十秒内重连。想固定版本,在 `.env` 里写 `MOSHIRO_VERSION=<版本号>`;想要更严,写成 `MOSHIRO_VERSION=<版本号>@sha256:<digest>`,digest 取自面板信任页或签名清单(那里有发布签名)。回滚就是改回旧版本再 `up -d`。
- 信任:compose 来自面板或 CI 渠道,面板被攻破也改不了镜像地址;但客户端不再验发布签名,拉取靠 TLS 加「注册表可信」这个假设(文档里本来就把 CI、Gitea、注册表放在可信范围内)。compose 不会自动更新(见下节),也没有管理端的「更新」列和确认更新按钮。
一行安装脚本(备用,Docker 或 Podman,Agent 和 Probe 共用):管理端弹窗中折叠着一行安装命令,先核对脚本哈希再执行,脚本向面板取签名清单、用内置发布公钥验签、按 digest 拉镜像:
一行安装脚本(备用,Docker 或 Podman,Agent 共用):管理端弹窗中折叠着一行安装命令,先核对脚本哈希再执行,脚本向面板取签名清单、用内置发布公钥验签、按 digest 拉镜像:
```sh
f=$(mktemp) && curl -fsSL 'https://panel.example.com/install' -o "$f" && echo "<sha256> $f" | sha256sum -c - >/dev/null \
@@ -288,13 +283,13 @@ f=$(mktemp) && curl -fsSL 'https://panel.example.com/install' -o "$f" && echo "<
脚本是静态的(`packaging/install/install.sh` 和 `install.ps1`,嵌入 Backend,不替换任何值),所以它的 SHA-256 在一个版本内固定;角色、面板地址、代码和属主走环境变量。环境变量 `MOSHIRO_NAME`、`MOSHIRO_ENGINE=docker|podman`、`MOSHIRO_DRY_RUN=1`(只打印 run 命令);Podman 会顺便启用 `podman-restart`。
用脚本部署和更新(Agent、Probe;推荐用 Quadlet,Backend 只有 Quadlet 和 compose):`packaging/container/deploy-node.sh` 可以重复运行,每次都会拉取镜像并固定到 digest、把配置迁移到当前版本需要的样子(例如旧的 `listen.bind` 为 `0.0.0.0` 的 Agent 改成 `::`,这样 IPv6 线路才能建立)、重装单元和更新器,然后重启节点;已有的身份不会动,更新策略沿用上次的。
用脚本部署和更新(Agent;推荐用 Quadlet,Backend 只有 Quadlet 和 compose):`packaging/container/deploy-node.sh` 可以重复运行,每次都会拉取镜像并固定到 digest、把配置迁移到当前版本需要的样子(例如旧的 `listen.bind` 为 `0.0.0.0` 的 Agent 改成 `::`,这样 IPv6 线路才能建立)、重装单元和更新器,然后重启节点;已有的身份不会动,更新策略沿用上次的。
```sh
# 首次部署(需要 root;脚本和 packaging/ 里的 systemd/、update/ 放在一起)
sudo sh deploy-node.sh agent --image-repo registry.oeo.one/moshiro/moshiro-agent --server https://<backend> --join <token> --name <name>
sudo sh deploy-node.sh probe --image-repo registry.oeo.one/moshiro/moshiro-probe --server https://<backend> --join <probe 令牌> --name <name>
# 之后更新:不带参数(旧版 Probe 的 probe.json 没有 fleet 段,加上 --server --join --name 重新运行即可,旧文件备份为 probe.json.old)
sudo sh deploy-node.sh agent --image-repo registry.oeo.one/moshiro/moshiro-agent --server https://<backend> --join <Agent 令牌> --name <name>
# 之后更新:不带参数(旧版 拨号方 的 agent.json 没有 fleet 段,加上 --server --join --name 重新运行即可,旧文件备份为 agent.json.old)
sudo sh deploy-node.sh agent
```
@@ -304,14 +299,14 @@ Agent 的 `public_host` 和 `ipv6_host` 由 Agent 上报自动识别(需要宿
Backend 用 `packaging/compose/backend/compose.yaml`(见上文「Backend 容器」),数据库和原始数据在命名卷 `moshiro-backend-data` 里。首次安装 Podman 与用户 subuid/subgid 映射需要管理员权限,之后普通用户运行无需 sudo。
已在 Fedora CoreOS 的 SELinux Enforcing 环境、Podman 6.0.2 rootless 下用同一套加固设置实测:Agent 注册、宿主指标、Probe 登录和 TLS 连接正常,IPv4/IPv6 ICMP 目标任务收到有效回包,容器保持 `cap_drop: ALL`,未添加 NET_RAW。Linux ICMP 优先使用 datagram ping socket,不依赖外部 ping 程序;需要 `net.ipv4.ping_group_range` 包含容器映射后的 GID(测试环境为 `0 2147483647`)。宿主禁止普通用户 ping 时,需管理员配置该范围;例如 rootful Docker 的用户 GID 1654 可设置 `sysctl -w "net.ipv4.ping_group_range=1654 1654"`。rootless 应使用映射后的宿主 GID,不能直接套用容器 GID。单加 `cap_add: NET_RAW` 不保证非 root 用户获得有效 capability。有原始套接字权限的部署仍保留原有 Ping 回退路径。其他操作系统继续使用 System.Net.Ping。ICMP 目标任务当前由 Agent 执行,Probe 没有独立目标任务入口。
已在 Fedora CoreOS 的 SELinux Enforcing 环境、Podman 6.0.2 rootless 下用同一套加固设置实测:Agent 注册、宿主指标、拨号方 登录和 TLS 连接正常,IPv4/IPv6 ICMP 目标任务收到有效回包,容器保持 `cap_drop: ALL`,未添加 NET_RAW。Linux ICMP 优先使用 datagram ping socket,不依赖外部 ping 程序;需要 `net.ipv4.ping_group_range` 包含容器映射后的 GID(测试环境为 `0 2147483647`)。宿主禁止普通用户 ping 时,需管理员配置该范围;例如 rootful Docker 的用户 GID 1654 可设置 `sysctl -w "net.ipv4.ping_group_range=1654 1654"`。rootless 应使用映射后的宿主 GID,不能直接套用容器 GID。单加 `cap_add: NET_RAW` 不保证非 root 用户获得有效 capability。有原始套接字权限的部署仍保留原有 Ping 回退路径。其他操作系统继续使用 System.Net.Ping。ICMP 目标任务当前由 Agent 执行,拨号方 没有独立目标任务入口。
### 定时重启
Agent 和 Probe 每天固定时刻有序退出一次,由服务管理器拉起(所有 systemd 单元和 OpenWrt 的 procd 脚本都是「总是重启」,正常退出也会被拉起)。节点只是自己退出,没有任何执行命令的能力;重启期间测量会中断几十秒。
Agent 每天固定时刻有序退出一次,由服务管理器拉起(所有 systemd 单元和 OpenWrt 的 procd 脚本都是「总是重启」,正常退出也会被拉起)。节点只是自己退出,没有任何执行命令的能力;重启期间测量会中断几十秒。
- 在管理端控制:系统 → 设置 →「定时重启」,开关、时刻和时区(默认开,北京时间 04:30)。保存后马上通知所有在线节点,新连上的节点会补发。
- 节点本地覆盖:节点本地写了就以本地为准(面板的设置对它不生效)。`agent.json` 或 `probe.json` 里加 `"restart_at": "04:30+08:00"`,或者设置环境变量 `MOSHIRO_RESTART_AT`(优先)。写法 `HH:mm`(按 UTC)、`HH:mmZ` 或 `HH:mm±hh:mm`;写错了启动时就会报错。Probe 的容器同样可以用环境变量。
- 节点本地覆盖:节点本地写了就以本地为准(面板的设置对它不生效)。`agent.json` 或 `agent.json` 里加 `"restart_at": "04:30+08:00"`,或者设置环境变量 `MOSHIRO_RESTART_AT`(优先)。写法 `HH:mm`(按 UTC)、`HH:mmZ` 或 `HH:mm±hh:mm`;写错了启动时就会报错。拨号方 的容器同样可以用环境变量。
- 启动后不满 10 分钟不会触发,所以重启之后不会立刻又重启一次。日志里有 `restart scheduled at=… source=panel|local` 和 `stopping result=scheduled_restart`。
- Windows 上没有自带的服务单元,需要服务管理器配置为退出后重启。
@@ -330,11 +325,11 @@ Agent 的 Quadlet 单元已经包含以下只读挂载和环境变量:
磁盘指标是根文件系统容量,不是所有硬盘合计,也不采集磁盘 I/O。链路速率是参与流量统计的接口速率合计,单位 Mbit/s,不是互联网测速结果;任一接口不支持读取速率时显示空值。接口仍按名称排除回环、容器、网桥和部分隧道,复杂 bonding/VLAN 配置可能存在重复计数。主机名、硬件型号、MAC/IP 不作为资源指标上报。
Probe 也上报同样的汇总数字(`probe.json` 的 `reporting.host_stats` 默认开,设为 `false` 就一个字也不上报,同时可以去掉 `/proc`、`/sys`、`/` 这三个挂载),所以 Probe 的 Quadlet 单元和 Agent 一样挂载它们并设置 `HOST_*` 环境变量。Docker Desktop(macOS、Windows)上读到的是 Docker 的虚拟机,不是宿主系统本身;Windows 上直接运行的 Probe 和 Agent 用系统调用读 CPU、内存和物理网卡的流量(不读网卡名和地址);macOS 目前只有磁盘和运行时长。
拨号方 也上报同样的汇总数字(`agent.json` 的 `reporting.host_stats` 默认开,设为 `false` 就一个字也不上报,同时可以去掉 `/proc`、`/sys`、`/` 这三个挂载),所以 拨号方 的 Quadlet 单元和 Agent 一样挂载它们并设置 `HOST_*` 环境变量。Docker Desktop(macOS、Windows)上读到的是 Docker 的虚拟机,不是宿主系统本身;Windows 上直接运行的 Agent 用系统调用读 CPU、内存和物理网卡的流量(不读网卡名和地址);macOS 目前只有磁盘和运行时长。
## Podman(Quadlet)的更新(Agent、Probe、Backend,推荐)
## Podman(Quadlet)的更新(Agent、Backend,推荐)
节点跑成一个 Podman Quadlet 单元(`packaging/quadlet/moshiro-agent.container`、`moshiro-probe.container`,Podman 4.6 及以上),更新是销毁容器、重新拉最新镜像再启动:该路径下节点本身不下载、不替换文件,只是有序退出;Quadlet 生成的 systemd 服务用 `podman run --pull always` 启动,所以被拉起时先从镜像仓库拉最新的镜像。默认自动更新:面板发现新版本后,节点运行满 10 分钟(防止重启循环)就自己退出重启;想改成在管理端点「更新」才更新,在 env 文件里写 `MOSHIRO_UPDATE_POLICY=manual`。
节点跑成一个 Podman Quadlet 单元(`packaging/quadlet/moshiro-agent.container`,Podman 4.6 及以上),更新是销毁容器、重新拉最新镜像再启动:该路径下节点本身不下载、不替换文件,只是有序退出;Quadlet 生成的 systemd 服务用 `podman run --pull always` 启动,所以被拉起时先从镜像仓库拉最新的镜像。默认自动更新:面板发现新版本后,节点运行满 10 分钟(防止重启循环)就自己退出重启;想改成在管理端点「更新」才更新,在 env 文件里写 `MOSHIRO_UPDATE_POLICY=manual`。
```text
CI 推镜像到 registry ─▶ 面板收到 CI 签名的清单 ─▶ 转发给节点(只是「建议」)
@@ -343,7 +338,7 @@ CI 推镜像到 registry ─▶ 面板收到 CI 签名的清单 ─▶ 转发给
然后:systemd 重启单元(Pull=always)─▶ 新镜像、新容器
```
- 安装(rootless,普通用户):管理端「添加 Agent / Probe」的第一条命令(Linux + Podman)就是它:env 写进 `~/.config/moshiro/<role>.env`(权限 600),从面板的 `/install/quadlet/<role>.container` 下载单元并核对 SHA-256,用一个 drop-in 把镜像仓库指向面板配置的(开发通道是 `moshiro-dev`),`systemctl --user start`。再让管理员执行一次 `loginctl enable-linger <用户>`。rootful 的路径和改法写在模板开头的注释里。身份和配置仍在命名卷 `moshiro-<role>-config`,换新镜像不丢。
- 安装(rootless,普通用户):管理端「添加 Agent」的第一条命令(Linux + Podman)就是它:env 写进 `~/.config/moshiro/<role>.env`(权限 600),从面板的 `/install/quadlet/<role>.container` 下载单元并核对 SHA-256,用一个 drop-in 把镜像仓库指向面板配置的(开发通道是 `moshiro-dev`),`systemctl --user start`。再让管理员执行一次 `loginctl enable-linger <用户>`。rootful 的路径和改法写在模板开头的注释里。身份和配置仍在命名卷 `moshiro-<role>-config`,换新镜像不丢。
- compose 不能自动更新:compose 的 `restart: unless-stopped` 是引擎直接重启同一个容器,不会拉新镜像(`pull_policy: always` 只在 `compose up` 时生效),容器自己又没法重建自己,所以 compose(Docker)仍是手动 `compose pull && compose up -d`;要自动更新就用 Quadlet。
- 信任:信任镜像仓库(`registry.oeo.one`,Podman 校验它的 TLS 证书),并按标签拉取(默认 `latest`,想固定版本就把模板里的标签换成版本号)。节点不验证清单:它不安装任何东西,最多被管理员请求一次重启,和定时重启是同一种能力。
- 只有设了 `MOSHIRO_UPDATE=restart` 的节点才报告更新状态(模板里写好了):别的部署方式退出之后不会重新拉取,开了也不会更新。
@@ -367,13 +362,13 @@ sh packaging/update/install-updater.sh agent --user \
--unit moshiro-agent-rootless.service --policy auto
```
使用 Docker 时以管理员运行相同安装命令,去掉 `--user`,加 `--engine docker`,单元选择 `moshiro-agent-docker.service`。rootful Podman 去掉 `--user`,单元选择 `moshiro-agent-container.service`。Probe 替换角色名。初始化对应 `<role>.image` 为 `IMAGE=<当前镜像引用>` 后启动容器单元。辅助镜像可用本机已有的 digest 引用,避免标签变化。Docker rootless 更新尚未适配。
使用 Docker 时以管理员运行相同安装命令,去掉 `--user`,加 `--engine docker`,单元选择 `moshiro-agent-docker.service`。rootful Podman 去掉 `--user`,单元选择 `moshiro-agent-container.service`。拨号方 替换角色名。初始化对应 `<role>.image` 为 `IMAGE=<当前镜像引用>` 后启动容器单元。辅助镜像可用本机已有的 digest 引用,避免标签变化。Docker rootless 更新尚未适配。
命名 volume 的更新服务由 systemd timer 每 30 秒检查一次。`--policy auto` 验签后自动应用,默认 `manual` 等待面板写入应用请求。清单必须通过 ECDSA P-256 签名校验,组件、镜像仓库和递增 seq 都必须匹配本机配置;镜像按签名清单中的 digest 拉取。更新后观察服务 20 秒,启动失败或崩溃循环会恢复旧镜像引用并重启。无效签名、重放清单、拉取失败不会切换当前镜像。同一组件的更新通过 flock 串行处理。
更新器依赖 Linux、systemd、OpenSSL、util-linux/flock 和所选引擎。用户部署把配置、公钥和镜像引用放在 `~/.config/moshiro/`、版本记录放在 `~/.local/state/moshiro-update/`;系统部署使用 `/etc/moshiro-update/` 和 `/var/lib/moshiro-update/`。私有仓库的 `--authfile` 对 Podman 使用其认证文件,对 Docker 使用标准 `config.json`,不改写全局 Docker 配置。旧 `--data-dir` 安装方式保留兼容,但新部署统一使用 `--volume`。
发布私钥只放在 Woodpecker 的 `release_signing_key` secret;发布公钥位于 `packaging/update/release-key.pub.pem`。`RELEASE_API_URL` 指向面板,CI 把签名清单 POST 到 `/api/release/offer`:这个入口不需要令牌,面板用内嵌的发布公钥验签,签名无效就拒绝,所以 CI 不需要在面板里持有任何凭证(清库或重装后照常登记,也没有长期令牌可偷)。面板保存并转发签名清单,Agent/Probe 将它与更新请求写进配置 volume;更新器验签后才调用容器引擎和 systemd。管理端仍能看到可更新、更新中、已回滚和无效清单状态。纯二进制更新器尚未实现。
发布私钥只放在 Woodpecker 的 `release_signing_key` secret;发布公钥位于 `packaging/update/release-key.pub.pem`。`RELEASE_API_URL` 指向面板,CI 把签名清单 POST 到 `/api/release/offer`:这个入口不需要令牌,面板用内嵌的发布公钥验签,签名无效就拒绝,所以 CI 不需要在面板里持有任何凭证(清库或重装后照常登记,也没有长期令牌可偷)。面板保存并转发签名清单,Agent 将它与更新请求写进配置 volume;更新器验签后才调用容器引擎和 systemd。管理端仍能看到可更新、更新中、已回滚和无效清单状态。纯二进制更新器尚未实现。
自有发布密钥(自己构建、自己发布的部署;默认关闭):管理端「系统 → 信任 → 可信资产来源」。官方 CI 发布密钥始终信任,不能关;另外导入自己的发布公钥(P-256 的 PEM,面板拒绝私钥,私钥留在你自己的机器或 CI 密钥库)之后,用你的私钥签的清单也会被面板登记和分发。签名方法:`openssl dgst -sha256 -sign release.key -out manifest.sig.bin manifest.json`,再 `base64 -w0` 得到签名;清单是一行扁平的 JSON(字段见 `ci/release-manifest.sh`),交给面板用 `POST /api/release/offer`,或者像官方那样放进你镜像仓库里的清单镜像。节点宿主机的更新器只认本机的公钥文件,所以用你自己的密钥发布时,还要把你的公钥放进这些节点的 `release-key.pub.pem`,并且清单里的 `image` 必须等于它本机配置的镜像仓库。停用自有密钥时,只有它签过的已登记清单会被丢掉。
@@ -397,8 +392,8 @@ CI 给每个镜像的 digest 签名之后,除了(面板在线时)登记给
```
- 更新器:配置里 `FEED=auto`(默认)时先看仓库里的清单,验签通过才用;取不到或验不过就用面板放进信箱的那份。`MANIFEST_REF` 可以指定清单镜像,`FEED=off` 关掉;通道取配置里的 `CHANNEL`,没有就按仓库路径推断(`moshiro-dev` → dev,`moshiro` → release)。仓库里的清单验签失败而信箱里又没有东西时,状态会报「无效清单」而不是悄悄当作没有更新。
- 面板自己取:安装脚本向面板要某个组件的签名清单时,面板里没有(清过库、CI 登记那一步网络失败)或者已经放了十分钟以上,面板就按 OCI 分发协议匿名读一次仓库里的清单镜像(只读清单和它的配置两个小 JSON,不拉镜像层),用内嵌的 CI 发布公钥验过签才登记;验不过(仓库或路上被改)就当没有。所以清库之后不用手工补登记,也不会被一份伪造的清单骗到。后台还每 5 分钟替 Agent、Probe 和 Backend 自己各取一次:CI 把清单登记给面板那一步是尽力而为的(网络不通只警告,流水线仍是绿的),没有这个后台轮询的话,面板收不到登记就看不到 Backend 的新版,宿主机上旧版的更新器也就一直报「最新」。仓库要匿名可读(`moshiro/`、`moshiro-dev/`)。
- 发版:打 `vMAJOR.MINOR.PATCH` tag 时,节点镜像(Agent、Probe 的多架构镜像)也和 Backend 一样签清单,放到 `moshiro/…-manifest:release`。
- 面板自己取:安装脚本向面板要某个组件的签名清单时,面板里没有(清过库、CI 登记那一步网络失败)或者已经放了十分钟以上,面板就按 OCI 分发协议匿名读一次仓库里的清单镜像(只读清单和它的配置两个小 JSON,不拉镜像层),用内嵌的 CI 发布公钥验过签才登记;验不过(仓库或路上被改)就当没有。所以清库之后不用手工补登记,也不会被一份伪造的清单骗到。后台还每 5 分钟替 Agent 和 Backend 自己各取一次:CI 把清单登记给面板那一步是尽力而为的(网络不通只警告,流水线仍是绿的),没有这个后台轮询的话,面板收不到登记就看不到 Backend 的新版,宿主机上旧版的更新器也就一直报「最新」。仓库要匿名可读(`moshiro/`、`moshiro-dev/`)。
- 发版:打 `vMAJOR.MINOR.PATCH` tag 时,节点镜像(Agent 的多架构镜像)也和 Backend 一样签清单,放到 `moshiro/…-manifest:release`。
- 压缩包的校验和也签名:Release 里除了每个压缩包的 `.sha256`,还有汇总的 `SHA256SUMS` 和 `SHA256SUMS.sig`(用发布私钥签,base64 的 DER ECDSA P-256,和清单同一把密钥)。验证:`base64 -d SHA256SUMS.sig > s.bin && openssl dgst -sha256 -verify packaging/update/release-key.pub.pem -signature s.bin SHA256SUMS`,通过之后再 `sha256sum -c SHA256SUMS`。macOS 的 Developer ID 签名和公证、Windows 的 Authenticode 需要苹果和微软的证书,目前没有做。
- 首次部署:`deploy-node.sh` 默认就走这条路,不需要面板,也不用手动指定镜像(Backend 第一次部署时本来就没有面板)。`--tag` 指定标签时不验签并有警告;`--image` 直接用你给的引用。
- 别人自己部署后端,什么都不用改:仓库对匿名读开放(`moshiro/*` 和各 `*-manifest` 仓库),然后按 `packaging/quadlet/moshiro-backend.container` 的说明装 Backend(`MoShiro__PublicUrl` 写自己的域名),只是配置,不改代码、不需要自己的 CI;passkey 绑定在自己的域名上。自己 fork 并自建镜像的人要换自己的发布公钥和仓库地址(`--image-repo`)并重新构建,这是 fork 的正常代价。
+1 -1
View File
@@ -8,7 +8,7 @@
| 任务与面板 | 单 Agent 独立任务;原生 webhook 投递与基于 webhook 的告警(前置约束见 [security.md](security/security.md) 第 7.2 节:仅 HTTPS、禁止私网与回环、不跟随重定向、限制超时与响应大小、解析后校验地址) |
| 访客页面 | 人机验证(候选:tiago2/cap、Cloudflare Turnstile) |
| 更新 | 更新遥测;容器节点回滚路径的实机验证;按标签灰度与 `POLICY=auto` 长期验证;纯二进制节点更新器(OpenWrt 已有签名更新器与 procd 适配,待 ARM64 路由器验证、发布密钥轮换、原生软件包;通用 `fleet-updater` / `fleet-release` 与 Windows 服务适配未实现) |
| 安全 | 测量数据产生者签名与归属校验;入库配额;Probe 仅拨公网地址;吊销表;extension 框架(敏感能力独立可选安装,核心保持 NativeAOT,且须维持节点采集边界);可选 UDP 单包授权与会话迁移路径验证;UDP 包 / 控制帧 / RawChunk / JSON-RPC 模糊测试与伪造、重放专项测试 |
| 安全 | 测量数据产生者签名与归属校验;入库配额;拨号方 仅拨公网地址;吊销表;extension 框架(敏感能力独立可选安装,核心保持 NativeAOT,且须维持节点采集边界);可选 UDP 单包授权与会话迁移路径验证;UDP 包 / 控制帧 / RawChunk / JSON-RPC 模糊测试与伪造、重放专项测试 |
| 打包 | OpenWrt ipk/apk 包与 sysupgrade 保留列表(aarch64 长稳验证);Windows 服务适配 |
## 纯二进制节点更新器的设计约束
+7 -7
View File
@@ -48,7 +48,7 @@
| 组 | 能力 | 说明 | 信任类 |
| --- | --- | --- | --- |
| 展示 | `public.read` | 看隐藏线路;站点私有时看展示页 | |
| 节点 | `fleet.read` | 看 Agent、Probe、线路、加入代码 | |
| 节点 | `fleet.read` | 看 Agent、线路、加入代码 | |
| | `fleet.write` | 改名、标签、可见性、手填地址、线路开关、断开会话 | |
| | `fleet.enroll` | 生成加入代码 | |
| | `fleet.revoke` | 吊销或删除节点 | 是 |
@@ -75,7 +75,7 @@
| passkey | WebAuthn,只支持 ES256,attestation 一律当 `none`;可以登录,也能给操作员集合签名(节点验证) |
- 两种方式的会话拿同样的能力。passkey 只用在节点自己会验证的事上,见第 5 节。
- 认领向导(没有默认账户):面板里还没有任何用户时,打开 `/admin/` 进向导——用后端日志里的认领码(或 `MoShiro__ClaimCode`)创建第一个管理员(用户名和密码自己填),注册第一个 passkey,再用它给操作员集合签名建立信任根。谁先打开页面谁就能当管理员,所以要认领码;创建完码作废、入口关闭(`POST /api/admin/auth/setup` 之后返回 409)。后两步是引导不是门槛,可以稍后补;不注册 passkey、不建立信任根,就添加不了 Agent 和 Probe,只能进面板做基础管理,这是架构如此。之后新增的 passkey 不会自动成为操作员:要由现有操作员在信任页看清「新增了哪些」再签新版本,节点才认。
- 认领向导(没有默认账户):面板里还没有任何用户时,打开 `/admin/` 进向导——用后端日志里的认领码(或 `MoShiro__ClaimCode`)创建第一个管理员(用户名和密码自己填),注册第一个 passkey,再用它给操作员集合签名建立信任根。谁先打开页面谁就能当管理员,所以要认领码;创建完码作废、入口关闭(`POST /api/admin/auth/setup` 之后返回 409)。后两步是引导不是门槛,可以稍后补;不注册 passkey、不建立信任根,就添加不了 Agent,只能进面板做基础管理,这是架构如此。之后新增的 passkey 不会自动成为操作员:要由现有操作员在信任页看清「新增了哪些」再签新版本,节点才认。
- 建议每个账户至少注册两个 passkey(手机加硬件密钥)。
### 4.1 passkey 登录的实现
@@ -121,7 +121,7 @@ CI 把签名清单 POST 给 `POST /api/release/offer`,面板用发布公钥验
## 7. 归属与审计
- 加入代码记 `created_by`,节点记 `added_by`;管理页的 Agent、Probe 详情显示「由 xxx 于 xxx 添加」。
- 加入代码记 `created_by`,节点记 `added_by`;管理页的 Agent 详情显示「由 xxx 于 xxx 添加」。
- 审计日志(`GET /api/admin/audit`):所有非 GET 的管理操作和登录自动记录(时间、账户、登录方式、动作、对象、详情)。写不进去只记日志,不让已完成的操作报错。保留 365 天,只追加。
## 8. 入网信任链的实现要点
@@ -136,19 +136,19 @@ CI 把签名清单 POST 给 `POST /api/release/offer`,面板用发布公钥验
## 9. 节点属主与 extension 授权
extension(web ssh、文件、抓包,见 [security.md](security.md))现在都不实现,Agent 和 Probe 一样。这类能力连管理员都不信任:管理员能删,不能读,也不能用。所以入网时就给每个节点固定「属主」,之后不用重装节点。
extension(web ssh、文件、抓包,见 [security.md](security.md))现在都不实现,Agent 一样。这类能力连管理员都不信任:管理员能删,不能读,也不能用。所以入网时就给每个节点固定「属主」,之后不用重装节点。
已经实现的部分
- 属主 `controllers` 是一组 passkey 凭证 id,必须是操作员集合里的凭证。由安装命令带来,节点注册时写进 `trust.json`,Backend 之后改不了。
- 每个 Agent 和 Probe 都必须有属主,至少一把。这由节点自己强制:节点启动时检查本地固定的属主,没有(或者属主的 passkey 已经不在操作员集合里)就不运行(共用 `Fleet.Hosting` 里的 `OwnerGuard`;配置 `fleet.require_owner`,`MOSHIRO_ALLOW_NO_OWNER=1` 放行)。面板被攻破也改不了。
- 每个 Agent 都必须有属主,至少一把。这由节点自己强制:节点启动时检查本地固定的属主,没有(或者属主的 passkey 已经不在操作员集合里)就不运行(共用 `Fleet.Hosting` 里的 `OwnerGuard`;配置 `fleet.require_owner`,`MOSHIRO_ALLOW_NO_OWNER=1` 放行)。面板被攻破也改不了。
- 添加节点时选属主:`POST /api/admin/join-tokens` 的 `controllers: [credential_id…]`,不给就是操作员集合里的全部凭证;给了就必须非空且都在集合里。还没有信任根或一把属主都没有时,面板提前拒绝创建加入码(400,字段 `controllers`),这只是尽早失败,真正的强制在节点;`MoShiro__RequireOwner=false` 可以关掉这个提前检查(只给没有信任根的老部署)。界面里是必选的「属主(passkey)」多选列表,只是选,不用触碰。
- 安装端也强制:`install.sh`、`install.ps1`、容器入口缺了 `MOSHIRO_GENESIS` 或 `MOSHIRO_CONTROLLERS` 就不装;`fleet-enroll --genesis` 不带 `--controllers` 也拒绝。
- 逆推出绑定关系:节点握手时把本地固定的属主(passkey 凭证 id)报给面板(hello 的 `owners`),面板顺着凭证 id 反查出它绑定在哪个账户的哪把 passkey 上,显示在「系统 → 版本」的「属主」列(对不上任何已注册 passkey 的显示「未知凭证」)。上报只是自述,真正的授权由节点自己校验 passkey 签名。
预留的设计(还没做)
- 谁来强制:节点本地。web ssh 这类会话要不要加密、要不要验证,由 Agent 和 Probe 自己要求。让 Backend 决定,被攻破的 Backend 一句「这次不用加密」就绕过去了。节点本地强制:没有操作员的现场证明不启动会话;会话密钥只和证明里的那把密钥协商,不和 Backend 转来的公钥协商;加密字段缺失就拒绝,不降级;能开哪些 extension、哪些 passkey 能授权,只看本节点属主。
- 谁来强制:节点本地。web ssh 这类会话要不要加密、要不要验证,由 Agent 自己要求。让 Backend 决定,被攻破的 Backend 一句「这次不用加密」就绕过去了。节点本地强制:没有操作员的现场证明不启动会话;会话密钥只和证明里的那把密钥协商,不和 Backend 转来的公钥协商;加密字段缺失就拒绝,不降级;能开哪些 extension、哪些 passkey 能授权,只看本节点属主。
- 证明的方向是反的:节点先出题(nonce 加它的临时公钥,用节点身份密钥签名),属主的浏览器把 nonce 和自己的临时公钥放进 passkey 断言的挑战里;节点验断言来自本节点属主、要求用户验证、nonce 是自己刚出的且没用过。Backend 仍然管谁有资格发起请求、审计、限流、转发密文。
- 交互式会话(终端、ssh)用现场签名的临时 ECDH 密钥协商,passkey 只签名授权,不参与解密,所以不支持 PRF 的 passkey 也能用,还有前向保密;之后双向是带序号的 AEAD。静态数据(抓包文件、会话录像)需要事后解密,才用属主记录里的接收者公钥加密(来自 WebAuthn PRF 或浏览器里不可导出的密钥)。
- 删除和使用分开:admin 能删节点、卸载 extension,读不到也用不了。改属主要现有属主的 passkey 签名(`ControllerUpdate`),账户被删、角色被改都改不了某个节点的属主。属主是 passkey 凭证,不是账户;一个人丢了全部属主 passkey,这个节点上的 extension 就没人能用了,所以添加时建议绑两把以上。
@@ -182,7 +182,7 @@ macOS 用 `shasum -a 256 -c`,Windows 用 `Get-FileHash` 比较,不通过就
④ 其余
- 角色(Agent 还是 Probe)、镜像仓库地址仍由代码决定,但镜像身份由签名清单决定。
- 角色(Agent 还是 拨号方)、镜像仓库地址仍由代码决定,但镜像身份由签名清单决定。
- macOS 的 Developer ID 签名和公证、Windows 的 Authenticode 需要证书,目前没有做。
## 11. 运行中的二进制不做校验
+33 -63
View File
@@ -1,66 +1,34 @@
# MoShiro 的注册与身份
通用的注册、身份、认证机制由 Fleet 提供,见 [Fleet 文档](../../backend/fleet/README.md) 的「身份」「注册」两节。本文只描述 MoShiro 在 Fleet 之上的约定:Agent 注册后如何自动接入测量、Probe 的身份以及 Agent 列表同步,以及节点本地的硬上限。
通用注册、认证和信任链由 [Fleet](../../backend/fleet/README.md) 提供。MoShiro 统一使用 Agent 节点;监听方、拨号方描述一次测量会话中的方向,同一 Agent 可以同时承担两侧工作。
## 1. 身份一览
## 1. 身份
| 主体 | 凭据 | 生成方 | Backend 保存 |
| --- | --- | --- | --- |
| Agent | Fleet 节点身份(ECDSA P-256)+ 面向 Probe 的 TLS 证书 | `fleet-enroll` | 公钥、SPKI 指纹 |
| Probe | Fleet 节点身份(连面板)+ 另一把独立的密钥(连 Agent,放在 `tls.pfx`) | `fleet-enroll`,和 Agent 一样用加入令牌(`role=probe`);可以有多个 Probe,每个独立吊销 | 两把公钥 |
| 面板用户 | 账号 + 密码(PBKDF2-SHA256) | 面板 | 密码哈希 |
| API 令牌 | 随机高熵令牌,带一组能力(见 [access.md](access.md)) | 面板 | SHA-256 |
| 发布签名 | ECDSA P-256 发布密钥 | CI(Woodpecker 密钥 `release_signing_key`)或离线环境 | 只在宿主机的更新器里配置公钥(Backend 和脚本装的节点用 `moshiro-update`,见 [deployment.md](../ops/deployment.md);Quadlet 节点不验证清单,只是退出后重新拉镜像;纯二进制节点的更新见[路线图](../roadmap.md)),面板不保存私钥;登记清单时面板用公钥验签,宿主机的更新器之后再验一次 |
`fleet-enroll` 在节点本地生成连接 Backend 的 ECDSA P-256 身份密钥,以及 TLS 证书中的独立对等身份密钥。拨号和监听共用该节点的对等身份。Backend 保存公钥与 SPKI 指纹,不接收私钥。节点固定操作员集合的创世指纹和属主,并在注册、连接及状态同步时验证信任链。
## 2. Agent 接入流程
面板用户通过密码或 Passkey 登录;API 令牌带有明确的能力集合,服务端保存令牌哈希。发布清单使用独立发布密钥签名,该私钥不进入面板或仓库。更新器在节点进程之外执行,具体边界见 [deployment.md](../ops/deployment.md) 和 [access.md](access.md)。
## 2. 接入
```text
管理员 ── 创建加入令牌 {标签, max_uses, 过期时间, auto_approve, controllers} ──> 面板(Fleet.Server.Enrollment)
<── 一段 Quadlet 安装命令(默认,Podman 系;Docker 另有 compose 命令):把面板地址、一次性码、创世指纹和属主写进本地 env,下载面板提供的静态
Quadlet 单元并核对 SHA-256,再启动用户服务;命令里只有公开的值(见 access.md 第 10 节)。
备用:一行安装脚本(先核对脚本哈希再执行),参数走环境变量
两条路径做的事一样:容器的入口脚本,或 install.sh / install.ps1(packaging/install/,嵌入 Backend,静态文件,不替换任何值):
├─ 向面板取 CI 签名的发布清单,用脚本里内置的发布公钥验签,按清单里的 digest 拉镜像
├─ 用 Docker 或 Podman 起容器,身份和配置存在命名卷里;节点名字默认取主机名
└─ 容器入口用代码注册:fleet-enroll --server … --join jt_… --genesis … --controllers …
├─ 先验操作员集合链(创世指纹、属主在集合里),验不过就不交出密钥
├─ 注册元数据(MoShiro 定义):{ probe_tcp_port, probe_quic_ports, public_host?, kinds[] }
└─ 写 trust.json(创世指纹、链、属主)和 agent.json(本地硬上限)
下载压缩包的二进制安装(packaging/install.sh)做同样的注册,另外创建系统用户 moshiro、启用 moshiro-agent.service,见 deployment.md。
面板收到 OnEnrolled(MoShiro 挂钩)
├─ public_host 未提供时,来源 IP 是公网地址才用;之后 hello 里 Agent 上报本机公网地址(`addresses`),面板自动填 public_host 和 ipv6_host,手填的优先
├─ 按“线路任务模板”为该 Agent 计算有效任务集(TaskSet)
└─ 面板把新的 Agent 列表作为期望状态推给所有 Probe → Probe 自动连接新 Agent
从安装到开始出数据,全程不需要手工配置
管理端创建加入令牌(统一 agent)
-> 安装命令携带面板地址、一次性码、创世指纹、属主和脚本哈希
-> fleet-enroll 验证信任链并在本机生成身份
-> Agent 连接 Backend,上报监听信息与本地能力
-> Backend 推送 task_set 和 dial_targets
-> Agent 调和拨出连接,同时接受名册内其他 Agent 的接入
```
名称冲突、审批、接管、禁用等行为都采用 Fleet 的默认规则。
同名节点进入待审批状态,由管理员决定是否接管。激活、停用、接管和删除通过统一的 Agent API 完成。
## 3. Probe 身份与 Agent 列表同步
Backend 默认启用 `fleet.auto_link`,所有 Agent 向其他可拨号 Agent 建立线路,自连接被排除。A→B、B→A 是两个独立方向;手动线路开关优先于自动连接。节点本地 `listen.enabled` 默认 true,可显式关闭,不产生另一种节点角色。
Probe 直接连面板(Fleet 长连接,身份是下面生成的密钥),Agent 列表由面板作为期望状态下发;测量数据仍然只走 Probe → Agent。
## 3. 任务、拨号目标与状态
```text
面板:POST /api/admin/join-tokens {role: "probe"} → 一次性加入令牌 jt_…
Probe 机器:fleet-enroll --server … --join jt_… --name home --identity-dir …
├─ 本地生成两把密钥:node.key(连面板认证)和 tls.pfx 里的密钥(连 Agent 对端认证);面板从不接触私钥
├─ 注册请求带两把公钥(pubkey、tls_pubkey)和持有证明;面板据此写 probes 记录,ID 前缀 prb_
└─ 吊销 /revoke 会让面板和所有 Agent 断开它;换密钥 = 吊销后重新注册(没有 rotate)
probe.json:{ fleet: { server, identity_dir }, restart_at?, limits? } ← limits 是本地硬上限,面板无法修改;在路由器上建议调低
Probe 启动
├─ 连接 server:Fleet 长连接(用 probe_id 和私钥认证),fleet.hello 带上系统、架构和构建信息
├─ 面板推送期望状态 probe_agents:{ version, agents: [ {agent_id, host, tcp_port, quic_ports, public_key}... ] }
├─ 调和:新增的 Agent → 建立点对点会话(peer 用途签名);被移除或禁用的 → 断开
└─ 之后列表的任何变化(Agent 注册、禁用、地址或证书变化),面板都重新推送同一种期望状态;
Probe 用 Fleet.Hosting.ConfigStore 把最新列表写回 agents.json(这是配置,不是测量数据;面板暂时连不上时,Probe 靠它继续测量)
```
- 每个 Agent 通过自己的期望状态(`TaskSet.peers`)获得 Probe 的公钥,用来验证点对点会话的签名。
- 哪些 Probe 连哪些 Agent 由面板决定,始终是 Probe 主动拨出:设置 `fleet.auto_link`(默认开)打开时,所有未吊销的 Probe 和所有 Agent 两两连;关闭时只连手动选中的。每一对还可以在管理端 Probe 页的「线路」矩阵里手动强制开或关(`PUT /api/admin/links/{probe_id}/{agent_id}`),手动设置优先于自动连接。结果同时用在 Probe 的 Agent 列表、Agent 的 `peers` 和线路目录(关掉的线路从总览里消失,历史数据还在)。
- 吊销 Probe:`POST /api/admin/probes/{id}/revoke`,旧公钥立即从所有 Agent 的期望状态中移除,旧会话随之断开;要继续用就重新注册。
- `TaskSet.peers` 下发允许接入的节点公钥。
- `DialTargets` 下发目标节点的地址、端口、证书公钥和 IP 路径。节点在 `dial_targets.json` 中缓存已验证的目标文档。
- 节点集合、地址、证书或线路策略变化后,Backend 重算并推送期望状态。节点断开已移除的目标,并连接新增目标。
- 每个 Agent 通过自身的 Backend 心跳上报运行状态、拨出线路的控制层和任务层健康、错误与重试,以及接入会话快照。测量流量走节点直连,监听方合并列块后上传原始数据。
## 4. Agent 本地配置(`/etc/moshiro/agent.json`,面板无法修改)
@@ -87,23 +55,25 @@ Probe 启动
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| 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/PATCH/DELETE | `/api/admin/probes`、`/probes/{id}` | Probe 的列表、改名、删除 |
| POST | `/api/admin/probes/{id}/revoke` | 吊销(换密钥就是吊销之后重新注册,没有轮换) |
| POST | `/api/admin/agents/{id}/probes/{probe_id}/disconnect` | 让 Agent 断开某个 Probe |
| GET | `/install`、`/install.ps1` | 静态安装脚本(Agent 和 Probe 共用,不带参数,哈希在一个版本内固定) |
| GET | `/install/manifest?code=…`、`/install/manifest.sig?code=…` | 凭一次性代码取该角色已签名的发布清单(原样,一个字节也不改) |
| POST | `/api/agent/v1/enroll` | 注册节点 |
| GET | `/api/agent/v1/rpc` | WebSocket 节点链路 |
| GET/POST/DELETE | `/api/admin/join-tokens` | 加入令牌 |
| GET | `/api/admin/agents` | 统一节点列表 |
| GET/PATCH/DELETE | `/api/admin/agents/{id}` | 节点详情、修改、删除 |
| POST | `/api/admin/agents/{id}/approve`、`/disable`、`/enable`、`/takeover` | 审批、启停与接管 |
| POST | `/api/admin/agents/{id}/dialers/{dialer_id}/disconnect` | 关闭指定接入会话 |
| GET | `/api/admin/links` | 有向线路矩阵与连接状态 |
| PUT | `/api/admin/links/{dialer_id}/{listener_id}` | 设置单方向线路策略 |
| POST | `/api/admin/links/{dialer_id}/{listener_id}/retry` | 开启并立即重试 |
| GET | `/install`、`/install.ps1` | 静态安装脚本 |
| GET | `/install/manifest?code=…`、`/install/manifest.sig?code=…` | 原样的签名发布清单及签名 |
## Agent 的本地配置:主机状态上报
`agent.json` 和 `probe.json` 的 `reporting` 段(面板无法修改):
`agent.json` 的 `reporting` 段(面板无法修改):
```json
{ "reporting": { "host_stats": true, "host_interval_s": 2 } }
```
`host_stats` 为 true(默认)时,Agent 和 Probe 每隔 `host_interval_s` 秒用 `moshiro.host` 上报一份主机资源占用的汇总数字(CPU、内存、根分区、网卡累计流量与网速、运行时长;没有型号、主机名、IP);设为 false 就一个字也不上报。
`host_stats` 为 true(默认)时,Agent 每隔 `host_interval_s` 秒用 `moshiro.host` 上报一份主机资源占用的汇总数字(CPU、内存、根分区、网卡累计流量与网速、运行时长;没有型号、主机名、IP);设为 false 就一个字也不上报。
+34 -34
View File
@@ -1,19 +1,19 @@
# 零信任设计
> 实现状态:协议方向声明表和卡口、Probe 自己生成密钥、预握手敲门、操作员集合与创世固定、服务器身份和安全链路、属主固定都已实现;测量数据的产生者签名、入库配额、Probe 拨号地址限制等还没做(第 3 节有逐项状态)。
> 实现状态:协议方向声明表和卡口、拨号方 自己生成密钥、预握手敲门、操作员集合与创世固定、服务器身份和安全链路、属主固定都已实现;测量数据的产生者签名、入库配额、拨号方 拨号地址限制等还没做(第 3 节有逐项状态)。
Agent、Backend、Probe 之间互相不信任。这份文档回答三个问题:现在谁听谁的、被攻破之后最坏会怎样、怎么用密码学把信任从 Backend 身上拿走。账户、能力、passkey 和入网信任链的具体做法见 [access.md](access.md);节点能力边界、本地硬上限和签名更新见 [architecture.md](../design/architecture.md) 第 5 节,这里不重复。
Agent、Backend、拨号方 之间互相不信任。这份文档回答三个问题:现在谁听谁的、被攻破之后最坏会怎样、怎么用密码学把信任从 Backend 身上拿走。账户、能力、passkey 和入网信任链的具体做法见 [access.md](access.md);节点能力边界、本地硬上限和签名更新见 [architecture.md](../design/architecture.md) 第 5 节,这里不重复。
## 1. 范围
要防的
- Backend(或 Backend 前面的 Cloudflare Tunnel、frp、Caddy 等中间环节)被攻破或被劫持之后,给节点下发恶意内容。
- Agent 或 Probe 自己被入侵之后,去影响 Backend 或对端。
- Agent 或 拨号方 自己被入侵之后,去影响 Backend 或对端。
有意不做的
- 进程沙箱(seccomp、pids 和内存上限、出站白名单)。Agent 和 Probe 的定位是 1 核 1 GB 的小机器,不能吃资源。
- 进程沙箱(seccomp、pids 和内存上限、出站白名单)。Agent 的定位是 1 核 1 GB 的小机器,不能吃资源。
- 供应链的多签、冷却期和可复现构建。代码风险靠 PR 评审降一个等级;发布仍然是单把发布密钥签名(见 [deployment.md](../ops/deployment.md))。
- 顺带记一条:Agent 的单元挂了 `/:/host/root:ro`、`/proc`、`/sys`,被攻破的 Agent 能遍历宿主机。它们多半是不必要的(宿主网络模式下容器自己的 `/proc` 就是宿主的,磁盘用量可以对已挂载的 `/etc/os-release` 做 `statvfs`),属于沙箱范畴,先不动。
@@ -23,7 +23,7 @@ Agent、Backend、Probe 之间互相不信任。这份文档回答三个问题
| --- | --- | --- |
| T1 | Backend,或它前面的中间环节 | 链路经过 Cloudflare Tunnel、frp、Caddy,它们都能看到 wss 的明文 |
| T2 | 某个 Agent(在 VPS 上,旁边还有别的服务) | VPS 被入侵 |
| T3 | 某个 Probe(在客户内网里) | 客户机器被入侵 |
| T3 | 某个 拨号方(在客户内网里) | 客户机器被入侵 |
| T4 | 管理员的会话或令牌 | 令牌泄露 |
| T5 | 管理员的浏览器 | XSS,或者节点上报的字符串被渲染 |
@@ -35,28 +35,28 @@ Agent、Backend、Probe 之间互相不信任。这份文档回答三个问题
管理员 ──令牌/会话──▶ ┌──────────┐ ◀── 原始块、事件、主机数字
│ Backend │
└──┬───┬───┘
任务集、对端公钥、 │ │ Agent 名单(host:port,Probe 全部照拨)
任务集、对端公钥、 │ │ Agent 名单(host:port,拨号方 全部照拨)
重启计划、更新清单 ▼ ▼
┌──────┐ ┌──────┐
│Agent │◀─▶│Probe │ Probe 私钥由节点自己生成
│Agent │◀─▶│拨号方 │ 拨号方 私钥由节点自己生成
└──────┘ └──────┘
```
| # | 缺口 | 状态 |
| --- | --- | --- |
| G1 | 节点不验证 Backend 的身份,只靠 TLS 加公共 CA | 已解决:操作员集合里登记服务器密钥,节点要求服务器在认证挑战里签名,链路用应用层加密([access.md](access.md) 第 8 节)。对 Backend 自己被攻破没有帮助 |
| G2 | Probe 的私钥由 Backend 生成 | 已解决:Probe 自己生成密钥,用加入令牌注册,私钥只在节点上(破坏性:旧 `probe.json` 失效) |
| G3 | Probe 会拨 Backend 给的任意 `host:port` | 未解决:被攻破的 Backend 能让客户内网里的 Probe 去探测内网;本地 `limits` 只有速率、pps 和 `allowed_target_cidrs` |
| G4 | Agent 的 TCP 端口对任何人完成 TLS 握手并出示证书 | 已解决:预握手敲门,见第 8 节(破坏性:旧 Probe 被拒绝) |
| G2 | 拨号方 的私钥由 Backend 生成 | 已解决:拨号方 自己生成密钥,用加入令牌注册,私钥只在节点上(破坏性:旧 `agent.json` 失效) |
| G3 | 拨号方 会拨 Backend 给的任意 `host:port` | 未解决:被攻破的 Backend 能让客户内网里的 拨号方 去探测内网;本地 `limits` 只有速率、pps 和 `allowed_target_cidrs` |
| G4 | Agent 的 TCP 端口对任何人完成 TLS 握手并出示证书 | 已解决:预握手敲门,见第 8 节(破坏性:旧 拨号方 被拒绝) |
| G5 | Backend 入库只限制了单块的大小,没有按节点的速率或配额 | 未解决:恶意节点可以灌满磁盘 |
| G6 | 对端公钥、Agent 地址全由 Backend 决定 | 未解决:操作员集合不包含成员名册,被攻破的 Backend 能把恶意 Probe 加进 Agent 的白名单,或把 Probe 指向恶意 Agent |
| G6 | 对端公钥、Agent 地址全由 Backend 决定 | 未解决:操作员集合不包含成员名册,被攻破的 Backend 能把恶意 拨号方 加进 Agent 的白名单,或把 拨号方 指向恶意 Agent |
| G7 | 方法的方向和角色没有统一声明 | 已解决:声明表和卡口,见第 6 节 |
| G8 | 测量数据没有产生者签名(块只有 CRC32C) | 未解决:中间环节可以改写测量数据;被攻破的 Agent 可以伪造 Probe 侧的时间戳 |
| G9 | 入库只核对了 `agent_id`,不核对块里的 `series`(`line:<probe_id>`) | 未解决:被攻破的 Agent 可以为从没连过它的 Probe 灌入假线路数据 |
| G8 | 测量数据没有产生者签名(块只有 CRC32C) | 未解决:中间环节可以改写测量数据;被攻破的 Agent 可以伪造 拨号方 侧的时间戳 |
| G9 | 入库只核对了 `agent_id`,不核对块里的 `series`(`line:<dialer_id>`) | 未解决:被攻破的 Agent 可以为从没连过它的 拨号方 灌入假线路数据 |
| G10 | 没有合理性检查(`EchoOps` 不检查回显时间戳的一致性) | 未解决:恶意 Agent 可以谎报 `t2`、`t3` |
| G11 | Probe 的一把密钥多处使用 | 已解决:面向 Backend 和面向对等面的是独立的密钥,用途前缀做域隔离 |
| G11 | 拨号方 的一把密钥多处使用 | 已解决:面向 Backend 和面向对等面的是独立的密钥,用途前缀做域隔离 |
没有问题的部分:UDP 端口对未认证的包静默丢弃,不回应(`DropInvalid`,有测试);Probe 本身没有 TCP 监听;任务有本地硬上限;更新清单有签名和 `seq` 防回滚。
没有问题的部分:UDP 端口对未认证的包静默丢弃,不回应(`DropInvalid`,有测试);拨号方 本身没有 TCP 监听;任务有本地硬上限;更新清单有签名和 `seq` 防回滚。
## 4. 信任锚点
@@ -65,7 +65,7 @@ Agent、Backend、Probe 之间互相不信任。这份文档回答三个问题
```text
① 发布密钥(CI) ──▶ 「跑什么代码」 清单签名、seq 防重放
② 操作员密钥(管理员 passkey)──▶ 「谁是操作员、哪个是合法的 Backend」 操作员集合,Backend 持有不了
③ 节点自己的密钥 ──▶ 「我是我」 Agent 和 Probe 都自己生成
③ 节点自己的密钥 ──▶ 「我是我」 Agent 都自己生成
被攻破的 Backend 能做的:拒绝服务、看数据、改成员名册和任务(G3、G6)。
不能做的:改操作员集合、冒充节点、塞恶意更新(发布清单要 CI 签名,节点的更新器还会再验)。
@@ -88,11 +88,11 @@ Agent、Backend、Probe 之间互相不信任。这份文档回答三个问题
外部面(人、浏览器、脚本) /api/public(匿名只读,SSE) /api/admin(Cookie + CSRF,或 API 令牌)
/install /openapi /healthz /metrics
节点面(节点主动拨入) /api/agent/v1/enroll(加入令牌,一次性) /api/agent/v1/rpc(wss,节点签名认证)
对等面(Probe 拨 Agent) TLS 控制 7443/tcp、数据 7443/udp、7444/tcp(先过敲门)
对等面(拨号方 拨 Agent) TLS 控制 7443/tcp、数据 7443/udp、7444/tcp(先过敲门)
```
- 每个平面只认自己的凭证:节点面不接受令牌和 Cookie;外部面不接受节点签名;对等面只认敲门通过的成员。
- 连接方向固定:节点面永远是节点拨入 Backend,Backend 从不主动连节点,所谓「下发」只是在节点拨入的连接上发下行消息;对等面永远是 Probe 拨 Agent,Agent 不拨 Probe。
- 连接方向固定:节点面永远是节点拨入 Backend,Backend 从不主动连节点,所谓「下发」只是在节点拨入的连接上发下行消息;对等面永远是 拨号方 拨 Agent,Agent 不拨 拨号方。
- 外部面不能直通节点面:管理端的操作(例如发起测试)只会触发 Backend 里一个强类型的下行方法,管理端不能指定方法名,也不能把请求体原样转给节点(`IAgentGateway` 里全是强类型方法)。
- 三个平面共用同一个监听端口和同一条 ASP.NET 管线,靠路径和凭证类型区分。把节点面和外部面做成可以分别配置监听地址(默认仍同一个),这样管理面可以只对内网或 VPN 开放,是待定的目标。
@@ -102,7 +102,7 @@ Agent、Backend、Probe 之间互相不信任。这份文档回答三个问题
强制方式:
1. 一份声明表,单一真源:框架的 `fleet.*` 在 `Fleet.Rpc/MethodTable.cs` 的 `FleetMethodTable`,`moshiro.*` 和两条二进制通道在 `MoShiro.Protocol` 的 `MoShiroMethodTable`;角色是 `NodeRoles`(服务端按节点 ID 前缀判断)。声明表按(平面,名字)查找,同名方法在两个平面里可以有不同的发起方(`fleet.state.sync` 在节点面是 Backend→节点,在对等面是 Agent→Probe)。
1. 一份声明表,单一真源:框架的 `fleet.*` 在 `Fleet.Rpc/MethodTable.cs` 的 `FleetMethodTable`,`moshiro.*` 和两条二进制通道在 `MoShiro.Protocol` 的 `MoShiroMethodTable`;角色是 `NodeRoles`(服务端按节点 ID 前缀判断)。声明表按(平面,名字)查找,同名方法在两个平面里可以有不同的发起方(`fleet.state.sync` 在节点面是 Backend→节点,在对等面是 Agent→拨号方)。
2. 卡口在 `RpcRouter`:创建时必须给出本侧(`RpcSide`)和声明表。注册处理函数时核对方向、类型、角色,不符就在启动时失败;分发时拒绝没有声明、方向不对、请求和通知用反了、角色不允许的消息,回 `fleet_protocol_violation`;发送前核对本侧有没有资格发起(违反是程序缺陷,抛异常)。
3. 违规处理:`RpcSession` 和服务端的 `NodeConnection` 对违规计数,超过上限(`MaxViolations`,默认 20)就断开连接;二进制通道也在服务端按声明核对。
4. 响应匹配:只接受对应未决请求 ID 的响应,不接受主动「响应」。
@@ -115,7 +115,7 @@ Agent、Backend、Probe 之间互相不信任。这份文档回答三个问题
| --- | --- | --- |
| 操作员集合新版本 | 操作员 passkey 签名,节点自己验 | 什么都改不了;能转发旧的合法版本,版本号递增防回放 |
| 更新清单 | 发布密钥(CI) | 不能伪造;能转发旧的合法清单,`seq` 防回滚;节点的更新器还会用本机公钥再验 |
| 成员名册、对端公钥、Agent 地址 | Backend(G3、G6 未解决) | 能把恶意 Probe 加进 Agent 的白名单,或把 Probe 指向恶意 Agent |
| 成员名册、对端公钥、Agent 地址 | Backend(G3、G6 未解决) | 能把恶意 拨号方 加进 Agent 的白名单,或把 拨号方 指向恶意 Agent |
| 任务集、测试请求 | Backend 计算;节点本地硬上限兜底 | 只能在本地速率和端口上限内发测量流量 |
| 重启计划(`moshiro.schedule`) | Backend;节点本地启动后 10 分钟内不触发 | 最多让节点每 10 分钟重启一次 |
| 未来的 `extension`(exec 等,尚未实现) | 属主 passkey 签名的一次性能力令牌,见第 9 节 | 设计目标是没有属主签名就无法调用 |
@@ -124,12 +124,12 @@ Agent、Backend、Probe 之间互相不信任。这份文档回答三个问题
### 7.1 测量数据的可信(G8、G9、G10)
测量数据是这个项目的产出,不能只靠传输层可信:链路有 Cloudflare 这样的中间环节,Agent 又要转发 Probe 的数据。
测量数据是这个项目的产出,不能只靠传输层可信:链路有 Cloudflare 这样的中间环节,Agent 又要转发 拨号方 的数据。
- 产生者签名:每个列块由 Probe 签名,每个原始块由上传它的节点签名;Agent 转发 Probe 的块时原样保留签名。签名覆盖块的摘要(SHA-256),签名串带用途前缀 `chunk` 和节点 ID。Backend 入库时逐个验签,不通过的丢弃并计数。每 10 秒一块,一次 ECDSA 验签的开销可以忽略。
- 归属校验:入库时核对块里的 Probe 是和这个 Agent 有线路的成员,不是就丢弃并计数。
- 产生者签名:每个列块由 拨号方 签名,每个原始块由上传它的节点签名;Agent 转发 拨号方 的块时原样保留签名。签名覆盖块的摘要(SHA-256),签名串带用途前缀 `chunk` 和节点 ID。Backend 入库时逐个验签,不通过的丢弃并计数。每 10 秒一块,一次 ECDSA 验签的开销可以忽略。
- 归属校验:入库时核对块里的 拨号方 是和这个 Agent 有线路的成员,不是就丢弃并计数。
- 合理性检查:`t3−t2` 必须小于 `t4−t1`,各时间戳单调;违反的样本不计入指标,记为异常并产生事件。
- 边界:这样中间环节改不了数据,被攻破的 Agent 改不了 Probe 一侧的数据;但它仍然可以谎报自己一侧的 `t2`、`t3`,合理性检查只能限制它的幅度,因为这一侧的真值只有它自己知道。
- 边界:这样中间环节改不了数据,被攻破的 Agent 改不了 拨号方 一侧的数据;但它仍然可以谎报自己一侧的 `t2`、`t3`,合理性检查只能限制它的幅度,因为这一侧的真值只有它自己知道。
### 7.2 对 Backend 的资源和输入
@@ -140,34 +140,34 @@ Agent、Backend、Probe 之间互相不信任。这份文档回答三个问题
### 7.3 对对端和身份
- 被攻破的 Probe 只能拿到 Backend 授权的会话,Agent 的速率上限由本地兜底;被攻破的 Agent 发来的数据只会影响它自己的线路。
- Probe 只拨公网地址(G3):固定 SPKI 只能防止建立会话,防不了探测本身;要在 Probe 本地拒绝私网、回环、链路本地地址。
- 被攻破的 拨号方 只能拿到 Backend 授权的会话,Agent 的速率上限由本地兜底;被攻破的 Agent 发来的数据只会影响它自己的线路。
- 拨号方 只拨公网地址(G3):固定 SPKI 只能防止建立会话,防不了探测本身;要在 拨号方 本地拒绝私网、回环、链路本地地址。
- 名册里的主机名解析之后再检查地址:签名保护的是字符串,不是解析结果;默认只放行公网地址,内网需要节点本地显式允许。
- QUIC 会话迁移要做路径验证:协议允许会话在 NAT 映射变化后跟随新的源地址,路径上的攻击者可以借此把会话牵到自己的地址。迁移前先做一次挑战应答,验证前限制发送量(不超过收到字节数的 3 倍),由 QUIC path validation 提供。
- 不协商、不降级:现在固定 ECDSA P-256 和固定的签名串格式,没有算法协商,保持这一点。
- Agent 的 `max_total_mbps` 默认是 1200,对 1 核 1 GB 的小机器没有意义,却是被攻破的 Backend 能让 Agent 向某个 Probe 打出去的上限。默认值应该保守,需要的人再调高;`allowed_target_cidrs` 默认为空是对的。
- Agent 的 `max_total_mbps` 默认是 1200,对 1 核 1 GB 的小机器没有意义,却是被攻破的 Backend 能让 Agent 向某个 拨号方 打出去的上限。默认值应该保守,需要的人再调高;`allowed_target_cidrs` 默认为空是对的。
## 8. 监听隐身:预握手敲门
目标:Agent 的 TCP 端口对没有完成挑战的人不写出任何字节,包括 TLS 的 ServerHello,扫描器拿不到证书和 TLS 指纹(G4)。UDP 端口本来就是静默的。
```text
Probe Agent TCP 7443 / 7444
拨号方 Agent TCP 7443 / 7444
│── TCP 连接 ───────────────────────────────────▶ accept(内核已完成三次握手)
│── 敲门包 32 字节:nonce(16) │ tag(16) ─────────▶ 读 32 字节(限时 3 秒)
│ tag = HMAC(K_pair, 时间窗 ‖ nonce) 用名册里每个 Probe 的 K_pair 逐个核对
│ tag = HMAC(K_pair, 时间窗 ‖ nonce) 用名册里每个 拨号方 的 K_pair 逐个核对
│ ├ 合法:继续 TLS 握手 → 现有的签名认证
│◀──────────── TLS ServerHello ─────────────────── └ 不合法或超时:什么都不发,直接断开
```
- `K_pair = HKDF(ECDH(Probe 身份私钥, Agent 身份公钥), "moshiro-knock-v1")`:只有名册里的成员算得出;时间窗 30 秒,接受前后各一个窗;`nonce` 在窗内去重,防重放;每个来源地址限速(默认每来源每 10 秒 300 次)。
- 实现:`Fleet.Identity.Knock`(构造与核对)和 `KnockGate`(读 32 字节、名册、nonce 去重、按来源限速);控制端口(`PeerListener`)和数据端口(`TcpAcceptor`)共用同一个关卡,Probe 一侧 `PeerConnector` 和 `TcpLink` 在 TCP 连接建立后、TLS 之前发敲门包。名册是期望状态里的 `peers`(Probe 的对等面公钥);`probe_agents` 下发的是 Agent 的 `public_key`(Agent 在 hello 里上报)。陌生连接得到的是被关闭的连接,没有任何字节。
- `K_pair = HKDF(ECDH(拨号方 身份私钥, Agent 身份公钥), "moshiro-knock-v1")`:只有名册里的成员算得出;时间窗 30 秒,接受前后各一个窗;`nonce` 在窗内去重,防重放;每个来源地址限速(默认每来源每 10 秒 300 次)。
- 实现:`Fleet.Identity.Knock`(构造与核对)和 `KnockGate`(读 32 字节、名册、nonce 去重、按来源限速);控制端口(`PeerListener`)和数据端口(`TcpAcceptor`)共用同一个关卡,拨号方 一侧 `PeerConnector` 和 `TcpLink` 在 TCP 连接建立后、TLS 之前发敲门包。名册是期望状态里的 `peers`(拨号方 的对等面公钥);`dial_targets` 下发的是 Agent 的 `public_key`(Agent 在 hello 里上报)。陌生连接得到的是被关闭的连接,没有任何字节。
局限:TCP 的 SYN-ACK 由内核发出,用户态进程拦不住,所以端口在扫描器眼里仍是「开着,但不说话」,不是「没监听」。真正的没监听有两条路:等级二的 UDP 单包授权(Probe 先发签名的 UDP 敲门包,Agent 才临时开 TCP 监听几秒,只放行敲门者的源地址;代价是多一个往返、UDP 丢包重试、多出口源地址可能对不上),或宿主防火墙规则(需要 root,不适合小机器,不做)。先做等级一,等级二可选。
局限:TCP 的 SYN-ACK 由内核发出,用户态进程拦不住,所以端口在扫描器眼里仍是「开着,但不说话」,不是「没监听」。真正的没监听有两条路:等级二的 UDP 单包授权(拨号方 先发签名的 UDP 敲门包,Agent 才临时开 TCP 监听几秒,只放行敲门者的源地址;代价是多一个往返、UDP 丢包重试、多出口源地址可能对不上),或宿主防火墙规则(需要 root,不适合小机器,不做)。先做等级一,等级二可选。
## 9. extension:敏感能力的归宿
当前 Agent 和 Probe 的核心只包含测量与上报。敏感能力(exec、web ssh 和终端、文件读写、抓包等)计划做成 extension,由用户选择安装,默认不启用,目前都没有实现。
当前 Agent 的核心只包含测量与上报。敏感能力(exec、web ssh 和终端、文件读写、抓包等)计划做成 extension,由用户选择安装,默认不启用,目前都没有实现。
- 独立进程:单独的二进制和镜像,和核心之间没有进程内接口,核心不加载外部代码、不用反射和动态加载,核心的 NativeAOT 和构建期限制不受影响。
- 授权只认节点的属主:每次调用的能力令牌必须由这个节点绑定的属主 passkey 签名,账户角色不赋予权力;管理员只能删除节点或卸载 extension。交互式会话端到端加密,Backend 只转发密文。属主和会话密钥的设计见 [access.md](access.md) 第 9 节。
@@ -187,7 +187,7 @@ Agent、Backend、Probe 之间互相不信任。这份文档回答三个问题
| 令牌哈希、SPKI 指纹 | SHA-256 |
| 原始块完整性 | CRC32C,不是密码学校验 |
BLAKE3 的评估(已定:不采用):ECDSA P-256 本身用 SHA-256,令牌哈希和 SPKI 指纹也继续用 SHA-256,所以 BLAKE3 是多一个哈希族,不是换掉一个;BCL 里没有,Probe 和 Agent 不引用 NuGet 包,只能自己实现几百行的便携版本加官方测试向量,这是要长期维护的密码学代码;托管实现没有 SHA-NI 加速,在 x86 上未必更快。若以后在目标硬件上实测发现逐包 MAC 是瓶颈,再单独评估,届时只替换 `Fleet.Identity` 里 `Mac` 和 `Kdf` 两个静态函数,调用方不动。
BLAKE3 的评估(已定:不采用):ECDSA P-256 本身用 SHA-256,令牌哈希和 SPKI 指纹也继续用 SHA-256,所以 BLAKE3 是多一个哈希族,不是换掉一个;BCL 里没有,Agent 不引用 NuGet 包,只能自己实现几百行的便携版本加官方测试向量,这是要长期维护的密码学代码;托管实现没有 SHA-NI 加速,在 x86 上未必更快。若以后在目标硬件上实测发现逐包 MAC 是瓶颈,再单独评估,届时只替换 `Fleet.Identity` 里 `Mac` 和 `Kdf` 两个静态函数,调用方不动。
## 11. 还没定的事