From c9beeee8e30619560fb9698fae3e8274921571bd Mon Sep 17 00:00:00 2001 From: justme Date: Wed, 21 Oct 2026 11:55:55 +0800 Subject: [PATCH] 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. --- AGENTS.md | 9 ++- README.md | 37 ++++----- docs/api/api.md | 54 ++++++------- docs/design/analysis.md | 2 +- docs/design/architecture.md | 115 ++++++++++++++++----------- docs/design/measurement.md | 48 +++++------ docs/design/protocol.md | 60 +++++++------- docs/design/query.md | 6 +- docs/design/storage.md | 4 +- docs/dev/coding-style.md | 12 +-- docs/dev/project-layout.md | 66 ++++++++-------- docs/ops/deployment.md | 153 +++++++++++++++++------------------- docs/roadmap.md | 2 +- docs/security/access.md | 14 ++-- docs/security/enrollment.md | 96 ++++++++-------------- docs/security/security.md | 68 ++++++++-------- 16 files changed, 363 insertions(+), 383 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index df4ea89..4ce1cb2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `~`, IPv6 adds `~ipv6`; series key `line:` | +| 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 `~`, IPv6 adds `~ipv6`; series key `line:` | | 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` | diff --git a/README.md b/README.md index 45d5e22..ca72d26 100644 --- a/README.md +++ b/README.md @@ -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)。 diff --git a/docs/api/api.md b/docs/api/api.md index 3e6107c..0e068b3 100644 --- a/docs/api/api.md +++ b/docs/api/api.md @@ -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 安全 diff --git a/docs/design/analysis.md b/docs/design/analysis.md index 7efd729..401c8bd 100644 --- a/docs/design/analysis.md +++ b/docs/design/analysis.md @@ -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. 两种处理路径 diff --git a/docs/design/architecture.md b/docs/design/architecture.md index 71a5c81..fdb319c 100644 --- a/docs/design/architecture.md +++ b/docs/design/architecture.md @@ -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:` 单独存放,互不混合。对外的线路 id 是 `~`。 +- 线路(line):一对(拨号方,Agent)。可以有多个 拨号方(身份由 拨号方 自己生成并用加入令牌注册,可以吊销、删除),每个 拨号方 连接多个 Agent,每个 Agent 同时服务多个 拨号方;一条线路的数据按 `line:` 单独存放,互不混合。对外的线路 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 秒时记录告警事件。 diff --git a/docs/design/measurement.md b/docs/design/measurement.md index c0f6fb8..5aac33c 100644 --- a/docs/design/measurement.md +++ b/docs/design/measurement.md @@ -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 服务冲突,也不需要特权端口权限。 diff --git a/docs/design/protocol.md b/docs/design/protocol.md index 8352df2..712f264 100644 --- a/docs/design/protocol.md +++ b/docs/design/protocol.md @@ -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`。 diff --git a/docs/design/query.md b/docs/design/query.md index 679576f..c52d6b6 100644 --- a/docs/design/query.md +++ b/docs/design/query.md @@ -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 } ] diff --git a/docs/design/storage.md b/docs/design/storage.md index a6b26cb..97e433d 100644 --- a/docs/design/storage.md +++ b/docs/design/storage.md @@ -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:[:ipv6]` 或 `target:`)、Kind、任务、测试、起止时间 | +| 原始数据索引 | `raw_segments`、`raw_chunks` | `raw_chunks` 记录 `(segment, offset, length)`、`series`(`line:[:ipv6]` 或 `target:`)、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` | 节点事件;每个节点每分钟一行的主机资源汇总 | diff --git a/docs/dev/coding-style.md b/docs/dev/coding-style.md index 61ff6a1..cdfcf1e 100644 --- a/docs/dev/coding-style.md +++ b/docs/dev/coding-style.md @@ -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` + `JsonStringEnumMemberName`);新增枚举值不属于破坏性变更,但所有消费方都必须能处理未知值。 - 可空性:公共成员必须标注;使用 `!` 抑制警告时,同一行要写注释说明理由。 @@ -61,19 +61,19 @@ - 序列化只使用 `System.Text.Json` 源生成上下文(或手写的二进制编解码);禁用无上下文的 `JsonSerializer.Serialize(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`、`stackalloc`(≤ 256 B)、`ArrayPool.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 节的「版本与兼容性」判断是否属于破坏性变更。 diff --git a/docs/dev/project-layout.md b/docs/dev/project-layout.md index 6ff59da..ffc0e50 100644 --- a/docs/dev/project-layout.md +++ b/docs/dev/project-layout.md @@ -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` 通信,禁止通过静态全局状态共享数据。 - -新增 RPC 方法:在 `MoShiro.Protocol/Rpc` 中定义 params/result,然后在组装处加一行 `.On(Methods.X, HandleX)`。 +拨出和接入可同时运行。Backend 的线路策略默认建立所有可拨号 Agent 之间的双向有向线路,排除自连接。节点通过同一条 FleetLink 上报两侧状态。模块使用构造参数或 `Channel` 通信。 ### 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 ` 输出到 `backend/artifacts/publish///`。 +- `scripts/publish.sh ` 输出到 `backend/artifacts/publish///`。 - 容器镜像由 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`,下载前检查剩余空间,避免写满闪存 | diff --git a/docs/ops/deployment.md b/docs/ops/deployment.md index f552e8e..cae941f 100644 --- a/docs/ops/deployment.md +++ b/docs/ops/deployment.md @@ -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 → backend/artifacts/publish/// -scripts/package.sh → backend/artifacts/packages/moshiro---.tar.gz(附 .sha256) +scripts/publish.sh → backend/artifacts/publish/// +scripts/package.sh → backend/artifacts/packages/moshiro---.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 → 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-`、`main-` 也认)。 +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-`、`main-` 也认)。 ### 正式版发布 -在 `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:` 与 `registry.oeo.one/moshiro/moshiro-probe:`,包含 amd64、arm64 多架构索引;也可指定 `:-amd64` 或 `:-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:` 与 `registry.oeo.one/moshiro/moshiro-agent:`,包含 amd64、arm64 多架构索引;也可指定 `:-amd64` 或 `:-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--status` volume,在业务容器内只读。不会重新生成已有身份。更新/重建容器不删除 volume;不要删除这些 volume(`podman volume rm`),会丢身份和配置。 +普通用户执行 Podman 即为 rootless,使用命名 volume 不需要 `keep-id` 或手动 `chown`。Agent 的配置、原有身份和缓存保存在 `moshiro-agent-config`,拨号方 保存在 `moshiro-agent-config`,更新状态使用独立的 `moshiro--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 " $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 " $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--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 " 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 " 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--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 取自面板信任页或签名清单(那里有发布签名)。回滚就是改回旧版本再 `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 " $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:// --join --name -sudo sh deploy-node.sh probe --image-repo registry.oeo.one/moshiro/moshiro-probe --server https:// --join --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:// --join --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/.env`(权限 600),从面板的 `/install/quadlet/.container` 下载单元并核对 SHA-256,用一个 drop-in 把镜像仓库指向面板配置的(开发通道是 `moshiro-dev`),`systemctl --user start`。再让管理员执行一次 `loginctl enable-linger <用户>`。rootful 的路径和改法写在模板开头的注释里。身份和配置仍在命名卷 `moshiro--config`,换新镜像不丢。 +- 安装(rootless,普通用户):管理端「添加 Agent」的第一条命令(Linux + Podman)就是它:env 写进 `~/.config/moshiro/.env`(权限 600),从面板的 `/install/quadlet/.container` 下载单元并核对 SHA-256,用一个 drop-in 把镜像仓库指向面板配置的(开发通道是 `moshiro-dev`),`systemctl --user start`。再让管理员执行一次 `loginctl enable-linger <用户>`。rootful 的路径和改法写在模板开头的注释里。身份和配置仍在命名卷 `moshiro--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 替换角色名。初始化对应 `.image` 为 `IMAGE=<当前镜像引用>` 后启动容器单元。辅助镜像可用本机已有的 digest 引用,避免标签变化。Docker rootless 更新尚未适配。 +使用 Docker 时以管理员运行相同安装命令,去掉 `--user`,加 `--engine docker`,单元选择 `moshiro-agent-docker.service`。rootful Podman 去掉 `--user`,单元选择 `moshiro-agent-container.service`。拨号方 替换角色名。初始化对应 `.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 的正常代价。 diff --git a/docs/roadmap.md b/docs/roadmap.md index 9fecec1..fde01c6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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 服务适配 | ## 纯二进制节点更新器的设计约束 diff --git a/docs/security/access.md b/docs/security/access.md index df444c1..04bda18 100644 --- a/docs/security/access.md +++ b/docs/security/access.md @@ -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. 运行中的二进制不做校验 diff --git a/docs/security/enrollment.md b/docs/security/enrollment.md index ae48f11..8ef726f 100644 --- a/docs/security/enrollment.md +++ b/docs/security/enrollment.md @@ -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 就一个字也不上报。 diff --git a/docs/security/security.md b/docs/security/security.md index fdc88bd..b5d6d4c 100644 --- a/docs/security/security.md +++ b/docs/security/security.md @@ -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:`) | 未解决:被攻破的 Agent 可以为从没连过它的 Probe 灌入假线路数据 | +| G8 | 测量数据没有产生者签名(块只有 CRC32C) | 未解决:中间环节可以改写测量数据;被攻破的 Agent 可以伪造 拨号方 侧的时间戳 | +| G9 | 入库只核对了 `agent_id`,不核对块里的 `series`(`line:`) | 未解决:被攻破的 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. 还没定的事