From a9bf8496977aa090d7ad496eccf37226bf0ddccb Mon Sep 17 00:00:00 2001 From: justme Date: Wed, 21 Oct 2026 06:28:19 +0800 Subject: [PATCH] docs: rewrite README and AGENTS.md etc.. --- AGENTS.md | 92 +++++++++++++------- README.md | 139 ++++++++++--------------------- TODO.md | 44 ---------- backend/agent/README.md | 2 +- backend/fleet/README.md | 18 ++-- backend/probe/README.md | 4 +- docs/README.md | 18 ---- docs/api/api.md | 66 +++++++++------ docs/design/analysis.md | 24 +++--- docs/design/architecture.md | 70 ++++++++-------- docs/design/measurement.md | 55 ++++++------ docs/design/protocol.md | 22 ++--- docs/design/query.md | 53 ++++++------ docs/design/storage.md | 162 +++++------------------------------- docs/dev/coding-style.md | 30 +++---- docs/dev/project-layout.md | 68 +++++++-------- docs/ops/deployment.md | 94 ++++++++++----------- docs/roadmap.md | 22 +++++ docs/security/access.md | 90 ++++++++++---------- docs/security/enrollment.md | 8 +- docs/security/security.md | 92 ++++++++++---------- web/README.md | 6 +- 22 files changed, 514 insertions(+), 665 deletions(-) delete mode 100644 TODO.md delete mode 100644 docs/README.md create mode 100644 docs/roadmap.md diff --git a/AGENTS.md b/AGENTS.md index 2ba06f1..62a36bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,44 +1,78 @@ # Agent Instructions -## Project +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. Backend coordinates Agents and Probes, stores measurements, and serves the REST/SSE API. `web/display` is the read-only overview; `web/admin` manages nodes, tests, and settings. +## Mental model -## Branch and workspace safety +| 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:` | +| 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 | +| Plan | A sequence of on-demand test phases (for example `hy2_ladder`). Exists only in the Backend (`Application/Tests/Plans.cs`) | +| Fleet | Product-agnostic node framework under `backend/fleet/` (identity, enrollment, RPC, link, outbox, state sync) | +| Operator set | Passkey-signed trust root. Nodes pin its genesis fingerprint and owner at install time and verify the chain themselves | -- `main` is the only working branch (the old `dev` branch is retired and no pipeline uses it). Commit and push to `main` directly; use a pull request when a review is wanted. -- A push to `main` publishes development images only if its commit message contains `[CI]` (for a merged PR, put it in the PR or merge commit title). Without `[CI]` nothing is built; use `[CI SKIP]` to skip the pipeline entirely. The fast Backend tests (`verify`) and the web build run on every `main` push and must pass first; the slow end-to-end tests (`[Trait("Category", "Slow")]`) run after the images are built and pushed (inside `dev-image` for `[CI]` pushes, workflow `e2e` otherwise) and never block them; `[SKIP TESTS]` skips only the Backend tests. -- Versions are filled in by CI (`ci/version.sh`): a `vX.Y.Z` tag builds exactly `X.Y.Z`; a `[CI]` build on `main` is `vX.Y.(Z+1)-dev.-` (`N` = commits since the latest tag). The `` in `backend/Directory.Build.props` and the `version` in the `web` `package.json` files are placeholders (`0.0.0-dev`); never bump them by hand. -- Before editing, inspect `git status`. Preserve pre-existing user changes: do not reset, clean, stash, or include unrelated files in a commit. -- Do not commit credentials, API keys, registry passwords, or generated runtime data. CI credentials belong in Woodpecker secrets (`zot_secret`, `gitea_token`, `release_signing_key` for signing release manifests). CI registers the signed manifests at `/api/release/offer`, which needs no token because the panel verifies the signature. The release signing private key never goes into the repository or the panel; only the public key in `packaging/update/` does. -- Keep development OCI images in the `moshiro-dev/` repository path and release images in `moshiro/`. Do not let a development build overwrite release tags such as `latest`. +## Invariants (violations fail the build or review) -## Project layout +- Nodes are pure collectors: no process start, dynamic loading, `HttpClient`, or file writes outside `Fleet.Hosting.ConfigStore` (`backend/BannedSymbols.Node.txt`). Do not add RPC methods that trigger non-measurement actions. +- `backend/fleet/**` must not reference `MoShiro.*` (`ArchitectureTests`). `MoShiro.Measurement` contains no service concepts such as REALITY or Hysteria2. +- Every RPC method is declared in a method table (`FleetMethodTable`, `MoShiroMethodTable`) with direction, type and roles. Add the declaration before the handler. +- `Backend.Api` must not reference `Storage`. Error-code to HTTP mapping happens once, in the Api layer. +- Server-sent parameters pass `ParamSpec` validation and then the node-local hard limits (`BudgetGuard`). Never bypass either. +- Public API responses contain no IP, city or ASN. +- Code style is enforced at build time (`.editorconfig`, `BannedSymbols.txt`, warnings as errors). Details in [`docs/dev/coding-style.md`](docs/dev/coding-style.md). -- `backend/src/MoShiro.Backend/`: service entry point and composition. -- `backend/src/MoShiro.Backend.Api/`: REST endpoints, contracts, and OpenAPI. -- `backend/agent/`, `backend/probe/`: node applications and their READMEs. -- `backend/fleet/`, `backend/shared/`: reusable node framework, measurement engine, protocols, and data formats. -- `web/display/`, `web/admin/`: Vite applications; `web/shared/`: API client, generated types, charts, and shared UI. -- `packaging/`, `scripts/`, `.woodpecker/`, `docs/`: container/package builds, CI workflows, and project documentation. +## Where to change what -## Local development and builds +| Task | Location | +| --- | --- | +| 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 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` | +| 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` | -Requirements: .NET SDK 10 and Node.js/npm. The SDK selection is defined by the root `global.json`; NuGet package versions are centralized in the root `Directory.Packages.props`. +## Documentation -- All .NET projects: `dotnet build MoShiro.slnx -c Release` from the repository root. -- Backend build: `cd backend && dotnet build MoShiro.slnx -c Release` -- Backend tests: `cd backend && dotnet test --solution MoShiro.slnx -c Release` -- Frontend dependencies/build: `npm ci --prefix web && npm run build --prefix web` -- Frontend style check: `npm run lint:css --prefix web` -- Local backend: from `backend/`, run `dotnet run --project src/MoShiro.Backend -- --urls http://localhost:5000`. -- Local web apps: from the repository root, run `npm ci --prefix web` then `npm run dev --prefix web`. The current dev config uses display port 5173 and admin port 5175; `/admin/` is proxied through the display server. Check the Vite configs when working on another branch because ports can differ. -- After changing API contracts, regenerate/check `backend/openapi/v1.json` and run `npm run gen:api --prefix web` so frontend API types stay aligned. +Index: [`README.md`](README.md#文档). Prefer the code and `backend/openapi/v1.json` when they disagree with a document, and fix the document. Update docs in the same change as behaviour: `docs/design` (architecture, protocol, measurement, analysis, storage, query), `docs/security`, `docs/api`, `docs/ops`, `docs/dev`, and `docs/roadmap.md` for unimplemented work. Do not record dates or change history in docs. -Follow the user's requested validation scope. Do not start a full tag/release build unless requested. +## Workspace safety + +- Inspect `git status` before editing. Preserve existing uncommitted changes; do not reset, clean, stash, or commit unrelated files. +- `main` is the only working branch. Commit to it directly; use a pull request when review is required. +- Never commit credentials, API keys, registry passwords, or generated runtime data. CI credentials are Woodpecker secrets (`zot_secret`, `gitea_token`, `release_signing_key`). +- The release signing private key must not enter the repository or the panel. Only the public key under `packaging/update/` is versioned. + +## Build and test + +Requirements: .NET SDK 10 (pinned by `global.json`) and Node.js with npm. NuGet versions are centralized in `Directory.Packages.props`. + +| Task | Command | +| --- | --- | +| Build all .NET projects | `dotnet build MoShiro.slnx -c Release` | +| Backend tests | `cd backend && dotnet test --solution MoShiro.slnx -c Release` | +| Run Backend | `cd backend && dotnet run --project src/MoShiro.Backend -- --urls http://localhost:5000` | +| Web dependencies and build | `npm ci --prefix web && npm run build --prefix web` | +| Web dev servers | `npm run dev --prefix web` (ports are set in the Vite configs) | +| Style check | `npm run lint:css --prefix web` | +| Regenerate API types | `npm run gen:api --prefix web` | + +First run of the Backend prints a claim code in its log; the admin UI asks for it. Slow end-to-end tests carry `[Trait("Category", "Slow")]`. After changing API contracts, update `backend/openapi/v1.json` and regenerate the web types. Run only the validation the task requires; do not start a full tag or release build unless requested. ## CI and releases -CI triggers and image tags can change; inspect `.woodpecker/*.yml` and `docs/ops/deployment.md` before changing or describing them. In particular, check the distinction between `[CI]` main builds, `[SKIP TESTS]`, and version-tag releases in the current branch. +Triggers, tags and image paths are defined in `.woodpecker/*.yml` and [`docs/ops/deployment.md`](docs/ops/deployment.md). Verify them there before changing or describing them. The rules below are the stable contract. -A version tag `vMAJOR.MINOR.PATCH` runs the full cross-platform release pipeline; it needs no version-bump commit, because CI takes the version from the tag. Development image builds and releases must remain isolated in their respective Zot repository paths. +- Every push to `main` runs the fast Backend tests and the web build. Both must pass before images are published. +- A commit message containing `[CI]` builds and pushes development images. `[CI SKIP]` skips the pipeline; `[SKIP TESTS]` skips only the Backend tests. Slow end-to-end tests run after image publication and do not block it. +- A tag `vMAJOR.MINOR.PATCH` runs the full cross-platform release pipeline. No version-bump commit is needed. +- Versions are produced by `ci/version.sh`. The `` in `backend/Directory.Build.props` and the `version` in `web` `package.json` files are placeholders (`0.0.0-dev`) and must not be edited. +- Development images use the `moshiro-dev/` repository path and release images use `moshiro/`. A development build must never overwrite a release tag such as `latest`. +- Release manifests are registered at `/api/release/offer`, which requires no token because the panel verifies the signature. diff --git a/README.md b/README.md index 5595be4..8048399 100644 --- a/README.md +++ b/README.md @@ -1,124 +1,71 @@ # MoShiro -MoShiro 是一个自托管的**网络线路质量观测工具**。它在若干 Probe(例如家宽出口)与多台 Agent(例如海外 VPS)之间做主动网络测量,只记录逐包、逐事件的原始数据;Backend 保存这些原始数据,推算延迟、丢包、抖动、吞吐等指标,并回答「如果在这条线路上部署某类服务,体验会怎样」;Web 界面用来查看、比较和管理。 +MoShiro 旨在解决跨网络线路质量难以量化的问题: -## 项目边界 +- 家宽到海外 VPS 的延迟、丢包、抖动随时段波动,单点 ping 无法反映真实体验; +- 不同代理、游戏、流媒体服务对线路的敏感度各异,缺少统一的评价口径。 -**它做什么** +它通过 Probe 与 Agent 之间的主动测量采集原始数据,由 Backend 推算指标,并按服务模型(REALITY、Hysteria2、Minecraft 等)对线路评分与排序。 -- 主动测量:组合 TCP、UDP、TLS、ICMP 等采集积木,只记录时间、序号、大小这类原始事实,**不转发也不记录业务内容**。 -- 线路分析:Backend 从原始数据生成时间序列指标,按服务模型(REALITY、Hysteria2、Minecraft 等)评分并对多条线路排序、对比;模型和指标可以事后重算。 -- 管理:节点注册与审批、Probe 和 Agent 之间连哪些线路、任务与按需测试、节点版本与更新,都在管理端完成。 +## 角色 -**它不做什么** - -- **节点是纯采集器**:Agent 和 Probe 没有终端、命令执行、文件读写和自更新能力,也不加载外部代码。更新由节点外面的服务管理器完成:Podman Quadlet 单元每次启动都先拉最新镜像,管理端点「更新」时节点只是有序退出(没有 Quadlet 的环境用宿主机更新器),节点自己不下载、不替换任何东西。 -- **不是主机监控**:Agent 和 Probe 只上报 CPU、内存、磁盘、网卡流量和运行时长这类只读的汇总数字(Probe 可以在本地配置里关掉),不采集型号、主机名、进程和任何 IP。 -- **不暴露地址**:公开接口里没有 IP、城市、ASN;国家和运营商只由 Backend 查询(节点自己不访问任何外部服务)。 -- 暂不包含告警(见文末待办)。 - -## 信任关系 +| 角色 | 定位 | 部署位置 | +| --- | --- | --- | +| Probe | 测量发起端,向 Agent 拨出测量会话 | 家宽等出口,可位于 NAT 后 | +| Agent | 测量接收端,响应 Probe 并汇总两端数据上报 | 具备公网地址的 VPS 等节点 | +| Backend | 控制面与数据服务:注册、任务编排、存储、分析、REST / SSE | 自托管服务器 | +| Web | `display` 展示端(可匿名只读)与 `admin` 管理端 | 与 Backend 同源部署 | ```text - 管理员 ── 密码或 passkey(Cookie 会话 + CSRF),或 API 令牌(按能力授权)──▶ Backend - 访客 ── 匿名只读,站点设为公开时才可用(没有 IP、城市、ASN)──────────────▶ Backend 公开接口 - - 操作员 ── passkey 给「操作员集合」签名 ──▶ Backend 只转发 ──▶ 节点自己验链 - (创世指纹和属主在安装时固定在节点上;集合里还登记了 Backend 的服务器密钥,节点据此认 Backend、加密链路) - 节点 ── 加入令牌一次性注册,之后用自己的 ECDSA P-256 密钥签名认证 ───────▶ Backend(只存公钥) - Probe ── 先敲门,再用自己的密钥签名建立测量会话 ───────────────────────────▶ Agent(只放行面板名单里的 Probe 公钥) - CI ── 发布私钥对「镜像 digest 清单」签名 ──▶ 镜像仓库 / Backend ──▶ 节点宿主机的更新器用本机公钥验签 + ┌───────────── Backend ─────────────┐ + │ 注册 · 编排 · 存储 · 分析 · API │──▶ Web + └───────▲───────────────────▲───────┘ + │ wss(节点发起) │ + ┌───────┴────────┐ ┌──────┴───────┐ + │ Agent × N │◀──│ Probe × N │ + │ 公网可达 │ │ NAT 后 │ + └────────────────┘ └──────────────┘ + 测量会话:Probe → Agent ``` -- **Backend 不信任节点上报的内容**:数据按线路隔离存放;节点的身份靠签名,数据库泄露也无法冒充节点。 -- **节点不信任 Backend 的下发**:任务超出节点本机的硬上限(目标网段、码率、包速率、监听端口)就被拒绝;节点不执行任何「执行类」指令;改信任(操作员集合)要操作员 passkey 签名,Backend 伪造不了。 -- **更新不依赖面板的诚实**:清单必须由发布私钥签名(面板登记时验一次,节点宿主机的更新器用本机公钥再验一次)、只认本机配置的镜像仓库、按 digest 拉取、拒绝旧的 `seq`。面板被攻破也推不出恶意镜像。可信的发布密钥由部署的人决定:官方 CI 密钥始终信任,也可以另外信任自己的公钥。 -- **没有默认账户**:第一次打开管理端进认领向导,用后端日志里的认领码创建第一个管理员、注册 passkey、建立信任根。每个节点都必须有属主(passkey),由节点自己强制。 -- 运行中的二进制不做校验(节点自报的哈希不可信);「资产是我们的」靠安装时的签名清单和签名的 `SHA256SUMS`。详见 [`docs/security/`](docs/security/security.md)。 +Probe 与 Agent 只做测量与上报,没有终端、命令执行和自更新,更新由外部服务管理器完成。节点与 Backend 之间的身份、任务和更新均基于签名校验,详见[安全文档](docs/security/security.md)。 -## 架构 +## 快速开始 -```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 主动拨出 - 宿主机更新器(容器节点):用本机公钥验签 → 按 digest 拉镜像 → 重启,失败回滚 -``` - -两种连接:**Probe 和 Agent 都用长连接连 Backend**(身份、任务、状态、更新通知),**测量只走 Probe → Agent**(Probe 主动拨出,Agent 不需要连 Probe,家宽也不需要入站端口)。哪些 Probe 连哪些 Agent 由 Backend 决定:默认自动全连,也可以在管理端手动选择。 - -```text - 采集(只记录) 传输(有界、至少一次) 分析(注册制、可重算) 展示 - Probe / Agent 积木 ──▶ 原始列块(10 秒一块)──▶ Backend 段文件 ──▶ 指标与服务模型 ──▶ REST / SSE ──▶ Web -``` - -Backend 用段文件保存原始数据,用 SQLite 保存索引、指标与配置。**完整的架构、数据链路、安全边界和设计决策见 [`docs/design/architecture.md`](docs/design/architecture.md)。** - -## 协议 - -- **控制链路(Fleet)**:节点到 Backend 的 `wss` 长连接(`/api/agent/v1/rpc`),JSON-RPC 2.0 信封加二进制通道;挑战加签名认证,`hello` 协商版本和能力,心跳;**期望状态同步**(Backend 下发完整快照,节点调和并回报已应用的版本和被拒绝的条目);**可靠发件箱**(原始块至少一次投递,按 id 去重)。Fleet 与业务无关,放在 `backend/fleet/`。 -- **业务方法**:`moshiro.*`(按需测试、Probe 断开、事件、主机汇总、更新通知等),状态文档有 `task_set`(Agent 的任务集)和 `probe_agents`(Probe 的 Agent 列表)。 -- **测量数据面**:Probe 到 Agent 的 TLS 控制通道、QUIC Datagram 和 TCP 数据通道(默认 `7443/tcp`、`7443/udp`、`7444/tcp`);测量包不走可靠重传,继续由会话 MAC、序号窗口和速率准入校验。 -- **REST + SSE**:`/api/public`(展示,可匿名)、`/api/admin`(管理);OpenAPI 在 `backend/openapi/v1.json`,前端据此生成类型。 - -细节见 [`docs/design/protocol.md`](docs/design/protocol.md)、[`backend/fleet/README.md`](backend/fleet/README.md)、[`docs/api/api.md`](docs/api/api.md)、[`docs/security/enrollment.md`](docs/security/enrollment.md)。 - -## 如何构建 - -环境:.NET SDK 10(`global.json` 指定版本)、Node.js 与 npm。在仓库根目录: +环境:.NET SDK 10(版本见 `global.json`)、Node.js 与 npm。 ```sh -dotnet build MoShiro.slnx -c Release # Backend、Agent、Probe、Fleet、测试和 CI 工具 -npm ci --prefix web && npm run build --prefix web # 展示端和管理端,产物在 web/display/dist、web/admin/dist +cd backend && dotnet run --project src/MoShiro.Backend -- --urls http://localhost:5000 # Backend +npm ci --prefix web && npm run dev --prefix web # Web(另开终端,仓库根目录) ``` -本地运行:先在 `backend/` 里 `dotnet run --project src/MoShiro.Backend -- --urls http://localhost:5000`,再 `npm run dev --prefix web`,展示端是 ,管理端是 。第一次打开管理端会进认领向导,认领码在后端日志里(或用 `MoShiro__ClaimCode` 指定)。 +展示端 ,管理端 。首次访问管理端进入认领向导,认领码输出在后端日志。 -测试和检查: +构建与测试: ```sh -dotnet test --solution MoShiro.slnx # 后端和节点的测试 -npm run lint:css --prefix web # 前端样式(颜色只能写在主题令牌里) -npm run gen:api --prefix web # 后端改了接口之后重新生成前端类型 +dotnet build MoShiro.slnx -c Release +dotnet test --solution MoShiro.slnx +npm run build --prefix web ``` -Backend、Agent、Probe 以 OCI 镜像发布(Linux amd64、arm64),Agent 和 Probe 另有 Linux glibc/musl、macOS、Windows 的二进制包。NativeAOT 的构建细节、CI 触发规则(`[CI]`、`[SKIP TESTS]`、版本 tag)、镜像路径、容器节点的运行和更新见 [`docs/ops/deployment.md`](docs/ops/deployment.md);前端见 [`web/README.md`](web/README.md),后端见 [`backend/README.md`](backend/README.md)。 - ## 文档 -| 文档 | 内容 | +| 主题 | 文档 | | --- | --- | -| [`docs/README.md`](docs/README.md) | 文档索引,按类别组织 | -| [`docs/design/`](docs/design/architecture.md) | 设计:架构与决策、线协议、存储与查询、采集与分析 | -| [`docs/security/`](docs/security/security.md) | 安全:零信任与威胁模型、访问控制与入网信任链、注册与身份 | -| [`docs/api/`](docs/api/api.md) | 接口:端点说明、API 规范 | -| [`docs/ops/deployment.md`](docs/ops/deployment.md) | 运维:构建、发布、CI、部署与更新 | -| [`docs/dev/`](docs/dev/project-layout.md) | 开发:仓库结构与依赖规则、代码风格 | -| [`backend/fleet/README.md`](backend/fleet/README.md) | Fleet 节点框架(身份、注册、连接) | +| 架构与设计决策 | [architecture](docs/design/architecture.md) | +| 线协议与方法 | [protocol](docs/design/protocol.md) | +| 采集引擎与任务 | [measurement](docs/design/measurement.md) | +| 分析、存储、查询 | [analysis](docs/design/analysis.md) · [storage](docs/design/storage.md) · [query](docs/design/query.md) | +| 安全:威胁模型、访问控制、注册与身份 | [security](docs/security/security.md) · [access](docs/security/access.md) · [enrollment](docs/security/enrollment.md) | +| REST / SSE 接口 | [api](docs/api/api.md) | +| 构建、发布、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、`fleet/` 节点框架、`shared/` 测量引擎和协议、`agent/`、`probe/`、`tests/`)、`web/`(`display` 展示端、`admin` 管理端、`shared`)、`packaging/` 和 `scripts/`(部署与维护)、`ci/` 和 `.woodpecker/`(流水线)。 - -## 待办 - -见 [`TODO.md`](TODO.md)。 +贡献与自动化代理的工作约定见 [`AGENTS.md`](AGENTS.md)。 ## 许可证 -本项目采用 [Apache License 2.0](LICENSE)。 +[Apache License 2.0](LICENSE) diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 43e34ce..0000000 --- a/TODO.md +++ /dev/null @@ -1,44 +0,0 @@ -# TODO - -按主题记录还没做的事,做完就删掉对应的条目。已经实现的东西以 [`docs/`](docs/) 和代码为准。 - -## 测量 - -- 路由跳数、路径漂移和 trace -- QUIC 握手耗时:TCP 建连和 TLS 握手已经有了(`connect` 任务)。.NET 的 QUIC 依赖系统里的 msquic 原生库,和节点「只依赖 OpenSSL、NativeAOT 单文件」冲突,要做只能自己写最小的 QUIC Initial 握手探测;另外 .NET 还没有 QUIC datagram([dotnet/runtime#123418](https://github.com/dotnet/runtime/issues/123418) 只是提案),`System.Net.Quic` 只有可靠的流,重传会盖住丢包,测不了 UDP 丢包率 -- 网页长截图 -- pcap / eBPF 采集 - -## 任务与面板 - -- 单个 Agent 的任务(目前是线路模板加按 Agent 的覆盖,没有只属于某一个 Agent 的独立任务) -- 原生 webhook 投递;告警(基于 webhook)。实现之前先定约束(只允许 HTTPS、禁止私网和回环、不跟随重定向、限制超时和响应大小、解析后再检查地址),见 [`docs/security/security.md`](docs/security/security.md) 第 9.2 节 - -## 访客页面 - -- 人机验证:访客页面要通过验证才能拉数据,前端访问时静默触发。候选 tiago2/cap 和 Cloudflare Turnstile。 - -## 更新 - -- 更新遥测(现在面板只有每个节点的「当前更新状态」) -- **回滚路径的实机测试**:容器节点更新后观察 20 秒、失败回滚,只有单元和脚本测试,没有在真实节点上触发过。 -- **更新的灰度和自动策略**:按标签分批发布;`POLICY=auto` 的长期验证。 -- **纯二进制节点的更新**:OpenWrt Probe 已有独立的签名更新器、procd 适配和 CI dev/release 源(见部署文档),仍需真实 ARM64 路由器验证、发布密钥轮换和原生软件包。其他平台的通用 `fleet-updater`、`fleet-release`、Windows 服务适配仍待实现。设计要点: - - 信任来自签名:只接受离线发布密钥签名的清单,发布源可以是面板、GitHub Releases 或任意 HTTP 服务;面板被攻破也推不出恶意二进制。 - - 本地说了算:更新策略(`auto` / `notify` / `pinned:`)和信任的公钥写在节点本地配置里,面板只能「建议」;支持多把公钥以便轮换。 - - 清单带 `app`、`channel`、`version`、`min_from`、各 RID 制品的 `sha256` 和大小、`key_id`、签名(对规范化 JSON 签名);本地记录已装过的最高版本,拒绝降级和旧清单重放。 - - 流程:下载 → 校验 → 解包到 `versions//` → 原子切换 `current`(Linux 符号链接,Windows 目录联结)→ 重启服务 → 等健康信标确认 → 失败切回旧版本并把该版本标记为坏版本。 - - 权限分离:更新进程只能写安装目录和重启被管理的服务,读不到节点身份私钥。 - - OpenWrt:`keep_versions = 1`,下载前检查闪存剩余空间。 - - 容器节点已经实现的那一套(清单签名、`seq` 防重放、按 digest 拉取、回滚)见 [`docs/ops/deployment.md`](docs/ops/deployment.md)。 - -## 安全 - -- **零信任**:设计和各缺口的状态在 [`docs/security/security.md`](docs/security/security.md)。已完成:协议方向声明表、Probe 自己生成密钥、Agent 预握手敲门、操作员集合与创世固定、服务器身份和安全链路、属主固定。还没做:测量数据的产生者签名和归属校验、入库配额、Probe 只拨公网地址、管理端 CSP 之外的输入校验、extension 框架(敏感能力都放进可选安装的独立 extension,核心保持 NativeAOT)、吊销表;可选的 UDP 单包授权、UDP 会话迁移的路径验证。 -- **extension**(Web SSH、TTY、更新遥测,敏感 operator):设计时必须保住「节点是纯采集器」这条边界,见 [`README.md`](README.md) 的项目边界。 -- 对 UDP 包、控制帧、RawChunk、JSON-RPC 做模糊测试;伪造签名和重放的专项测试。 - -## 打包 - -- OpenWrt:procd 脚本、ipk/apk 包、sysupgrade 保留列表,在 aarch64 路由器上长时间运行验证。 -- Windows 服务适配。 diff --git a/backend/agent/README.md b/backend/agent/README.md index 90c8e0b..f6eef83 100644 --- a/backend/agent/README.md +++ b/backend/agent/README.md @@ -14,7 +14,7 @@ Probe ──TLS 控制 / QUIC Datagram、TCP 测量──> Agent ──长连接 ## 构建 -需要 .NET SDK 10。为应用 根目录的 `global.json` 的 SDK 设置,请从 `backend/` 目录构建: +需要 .NET SDK 10(版本见根目录 `global.json`)。从 `backend/` 目录构建: ```sh cd backend diff --git a/backend/fleet/README.md b/backend/fleet/README.md index e3557ce..f5b68aa 100644 --- a/backend/fleet/README.md +++ b/backend/fleet/README.md @@ -19,11 +19,11 @@ ## 设计原则 -1. **与业务无关**:产品通过扩展点接入:元数据类型、RPC 方法、发件箱消息类型、期望状态文档类型。 -2. **安全默认值**:Fleet 永远不提供 exec、shell、文件读写、远程下发代码这类方法;节点只响应本地注册过的方法(本地策略 `allow_methods` 还能收窄);调用对方不支持的方法在本地直接返回 `fleet_unsupported`。 -3. **权限分离**:注册(`fleet-enroll`,一次性)和常驻业务进程是两个进程;常驻进程不含注册代码,也不使用 `HttpClient`。 -4. **NativeAOT**:节点侧只依赖 BCL,泛型扩展点通过产品传入的 `JsonTypeInfo` 序列化,不使用反射。 -5. **更新不在 Fleet 里**:节点进程没有自更新能力。Quadlet 装的节点和 Backend 由退出后 systemd 重新拉镜像、脚本装的节点由宿主机上的更新器验签更新,见 [`docs/ops/deployment.md`](../../docs/ops/deployment.md);纯二进制节点的更新是待办,见 [`TODO.md`](../../TODO.md)。 +1. 与业务无关:产品通过扩展点接入:元数据类型、RPC 方法、发件箱消息类型、期望状态文档类型。 +2. 安全默认值:Fleet 永远不提供 exec、shell、文件读写、远程下发代码这类方法;节点只响应本地注册过的方法(本地策略 `allow_methods` 还能收窄);调用对方不支持的方法在本地直接返回 `fleet_unsupported`。 +3. 权限分离:注册(`fleet-enroll`,一次性)和常驻业务进程是两个进程;常驻进程不含注册代码,也不使用 `HttpClient`。 +4. NativeAOT:节点侧只依赖 BCL,泛型扩展点通过产品传入的 `JsonTypeInfo` 序列化,不使用反射。 +5. 更新不在 Fleet 里:节点进程没有自更新能力。Quadlet 装的节点和 Backend 由退出后 systemd 重新拉镜像、脚本装的节点由宿主机上的更新器验签更新,见 [`docs/ops/deployment.md`](../../docs/ops/deployment.md);纯二进制节点的更新见 [路线图](../../docs/roadmap.md)。 ## 身份 @@ -56,7 +56,7 @@ - JSON-RPC 2.0;方法名 `领域.动作`,`fleet.*` 保留给框架,产品用自己的前缀(MoShiro 是 `moshiro.*`)。 - 二进制帧 `u8 channel | 正文`:`0`–`15` 框架保留,产品从 `16` 开始。 - 错误:`error.data.code` 为 snake_case,框架的以 `fleet_` 开头,产品可以扩展。 -- **方法必须声明**:每个方法在声明表(`MethodTable`)里写明发起方、请求还是通知、哪些角色可以参与;不存在双向方法,同一个方法名在一个平面里只有一个发起方。`RpcRouter` 在注册、分发和发送三处核对,违规回 `fleet_protocol_violation`,超过上限断开连接。详见 [`docs/security/security.md`](../../docs/security/security.md) 第 8 节。 +- 方法必须声明:每个方法在声明表(`MethodTable`)里写明发起方、请求还是通知、哪些角色可以参与;不存在双向方法,同一个方法名在一个平面里只有一个发起方。`RpcRouter` 在注册、分发和发送三处核对,违规回 `fleet_protocol_violation`,超过上限断开连接。详见 [`docs/security/security.md`](../../docs/security/security.md) 第 6 节。 - 与传输无关:同一套路由跑在 wss 上,也跑在 TLS 帧(`u32 长度 | u8 类型 | 正文`)上。 | 保留方法 | 方向 | 说明 | @@ -79,9 +79,9 @@ connect(wss) → fleet.challenge → fleet.auth → fleet.hello → 状态同步 本地策略:`allow_methods`、`max_frame_bytes`、`max_inflight`。 -**可靠发件箱**:产品 `EnqueueAsync` → 有界队列(按字节和条数;满时 `DropOldest` 或 `Reject`)→ 发送 → 服务端收件箱(`IInbox`)持久化后 `fleet.outbox.ack` → 移除。至少一次,按 `msg_id`(UUIDv7)去重;在途数据默认限 4 MiB;丢弃会累计缺口,通过 `OnGap` 通知产品。只在内存里,不落盘。 +可靠发件箱:产品 `EnqueueAsync` → 有界队列(按字节和条数;满时 `DropOldest` 或 `Reject`)→ 发送 → 服务端收件箱(`IInbox`)持久化后 `fleet.outbox.ack` → 移除。至少一次,按 `msg_id`(UUIDv7)去重;在途数据默认限 4 MiB;丢弃会累计缺口,通过 `OnGap` 通知产品。只在内存里,不落盘。 -**期望状态同步**:服务端 `IStateProvider` 给每个节点算 `{version, doc}`,变化时 `NotifyChanged` 推送;节点 `StateHandler` 调和后返回 `{applied_version, rejected[]}`。下发的是**完整快照**,天然幂等,重连后服务端总会重发当前版本。`rejected[]` 带结构化原因(`unsupported`、`budget_exceeded`、`policy_denied`),面板可以直接展示。 +期望状态同步:服务端 `IStateProvider` 给每个节点算 `{version, doc}`,变化时 `NotifyChanged` 推送;节点 `StateHandler` 调和后返回 `{applied_version, rejected[]}`。下发的是完整快照,天然幂等,重连后服务端总会重发当前版本。`rejected[]` 带结构化原因(`unsupported`、`budget_exceeded`、`policy_denied`),面板可以直接展示。 ## 点对点会话 @@ -100,6 +100,6 @@ connect(wss) → fleet.challenge → fleet.auth → fleet.hello → 状态同步 - `ModuleHost`:按依赖顺序启动模块、相反顺序停止,隔离故障,重启时退避。 - `NodeConfig`:源生成 JSON 读取,环境变量覆盖,启动时校验。 -- `ConfigStore`:常驻进程里**唯一**允许写文件的地方,只能写配置目录,写入原子替换、权限 0600;父目录不存在会创建。 +- `ConfigStore`:常驻进程里唯一允许写文件的地方,只能写配置目录,写入原子替换、权限 0600;父目录不存在会创建。 - `ServiceShell`:SIGTERM、Ctrl+C,systemd `sd_notify`、OpenWrt procd(前台运行)和 Windows 服务。 - 服务端接口:`IFleetStore`(节点、令牌、公钥、状态版本)、`IStateProvider`、`IInbox`、`INodeEventSink`、`IEnrollmentHook`。MoShiro 把 `IFleetStore` 放进自己的 `moshiro.db`,和业务数据由同一个单写入者写入。 diff --git a/backend/probe/README.md b/backend/probe/README.md index a1f56c3..b594278 100644 --- a/backend/probe/README.md +++ b/backend/probe/README.md @@ -19,7 +19,7 @@ Agent 测量连接失败时,Probe 按 1、2、4、8、16 秒递增等待,再 ## 构建 -需要 .NET SDK 10。为应用 根目录的 `global.json` 的 SDK 设置,请从 `backend/` 目录构建: +需要 .NET SDK 10(版本见根目录 `global.json`)。从 `backend/` 目录构建: ```sh cd backend @@ -38,7 +38,7 @@ dotnet publish probe/src/MoShiro.Probe -c Release -r -p:PublishAot=true ### OpenWrt 原生一键部署 -管理端「添加 Probe」提供 **OpenWrt · 原生二进制** 命令,支持 ARM64/x86_64,以 root 执行即可注册并配置 procd。支持 dev/release 自动验签更新、失败回滚,不需要容器;详见 [部署文档](../../docs/ops/deployment.md#原生一键安装与自动更新)。 +管理端「添加 Probe」提供 OpenWrt · 原生二进制 命令,支持 ARM64/x86_64,以 root 执行即可注册并配置 procd。支持 dev/release 自动验签更新、失败回滚,不需要容器;详见 [部署文档](../../docs/ops/deployment.md#原生一键安装与自动更新)。 下面是手动注册方式;现代面板需同时传入面板提供的 `--genesis` 和 `--controllers`,否则默认属主检查会拒绝启动。 diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 3f2647a..0000000 --- a/docs/README.md +++ /dev/null @@ -1,18 +0,0 @@ -# 文档 - -按类别组织。每份文档开头写明它是已实现的说明还是设计稿;对不上代码时以代码为准。 - -| 类别 | 文档 | 内容 | -| --- | --- | --- | -| 设计 | [architecture.md](design/architecture.md) | 架构、数据链路、安全边界、设计决策 | -| | [protocol.md](design/protocol.md) | 线协议、方法的方向与角色 | -| | [storage.md](design/storage.md)、[query.md](design/query.md) | 存储;展示数据查询 | -| | [measurement.md](design/measurement.md)、[analysis.md](design/analysis.md) | 采集引擎与任务模型;分析层与指标 | -| 安全 | [security.md](security/security.md) | 零信任:威胁模型、缺口与状态、协议卡口、监听隐身、extension | -| | [access.md](security/access.md) | 访问控制与入网信任链:能力、API key、passkey、操作员集合、属主、安装的供应链 | -| | [enrollment.md](security/enrollment.md) | 注册与身份:Agent、Probe 的接入,节点本地硬上限 | -| 接口 | [api.md](api/api.md) | 端点说明;API 设计约束 | -| 运维 | [deployment.md](ops/deployment.md) | 构建、发布、CI、部署与更新、发布密钥 | -| 开发 | [project-layout.md](dev/project-layout.md)、[coding-style.md](dev/coding-style.md) | 仓库结构与依赖规则;代码风格 | - -各子项目的说明在它们自己的目录里:[backend](../backend/README.md)、[fleet](../backend/fleet/README.md)、[agent](../backend/agent/README.md)、[probe](../backend/probe/README.md)、[web](../web/README.md)。 diff --git a/docs/api/api.md b/docs/api/api.md index 8eb3e64..3e6107c 100644 --- a/docs/api/api.md +++ b/docs/api/api.md @@ -2,7 +2,7 @@ 本文列出 Backend 的全部对外接口。通用规则见本文第 7 节「设计约束」;OpenAPI 3.1 文档在运行时由 `/openapi/v1.json` 提供,构建时同时生成快照 `backend/openapi/v1.json`。两者冲突时,以 OpenAPI 为准。 -## 1. 分层 +## 1. 分层与资源 ### IPv4 / IPv6 @@ -15,9 +15,10 @@ ```text /api/public/* 只读。站点设为公开时匿名可访问,设为私有时需要登录或只读令牌; public_visible = false 的 Agent 对匿名访客隐藏 -/api/admin/* 管理。需要 Cookie 会话(含 CSRF 校验)或带有对应**能力**的 API 令牌;每个端点声明自己需要的一项能力(OpenAPI 里的 `x-moshiro-capability`),完整目录见 `GET /api/admin/capabilities`,设计见 [access.md](../security/access.md) +/api/admin/* 管理。需要 Cookie 会话(含 CSRF 校验)或带有对应能力的 API 令牌;每个端点声明自己需要的一项能力(OpenAPI 里的 `x-moshiro-capability`),完整目录见 `GET /api/admin/capabilities`,设计见 [access.md](../security/access.md) /api/agent/v1/* 节点专用:注册(使用加入令牌)和 RPC(使用签名认证) -/healthz /metrics /openapi/v1.json /install /install.ps1 +/api/release/offer CI 登记签名发布清单(凭签名,无需令牌) +/healthz /metrics /openapi/v1.json /install/* ``` 资源总览: @@ -28,22 +29,26 @@ ├─ live SSE 实时推送 ├─ lines/{line_id} 线路详情(线路 = Agent 与 Probe 的一对,id 形如 agt_…~prb_…) ├─ lines/{line_id}/series 历史曲线(指标与评分) -├─ nodes about 节点的展示信息(系统、国家、运营商,开启后带版本和 tag);后端的版本信息 +├─ nodes about ipv6 节点的展示信息(系统、国家、运营商,开启后带版本和 tag);后端的版本信息;IPv6 配置与采集诊断(不含地址) ├─ compare 多条线路对比 ├─ tests/{test_id} 测试结果 └─ catalog/{kinds|plans|metrics|models} 目录(由代码注册表生成) /api/admin -├─ auth/{login|logout|me} +├─ auth/{setup|login|logout|me|passkey|passkeys} 首次认领、登录、passkey +├─ trust 操作员集合(信任根) +├─ 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} 线路开关:哪些(Probe,Agent)连成线路(自动连接 + 手动选择) +├─ links links/{probe_id}/{agent_id}[/retry] 线路开关:哪些(Probe,Agent)连成线路(自动连接 + 手动选择),立即重试 ├─ tasks tasks/{id} 线路模板、针对单个 Agent 的覆盖、目标任务 -├─ lines/{line_id}/tests 在某条线路(某个 Probe)上发起按需测试;tests/{id}/cancel +├─ lines/{line_id}/tests 在某条线路(某个 Probe)上发起按需测试、列出近期测试;tests/{id}/cancel ├─ raw/chunks raw/chunks/{id} 原始数据检索与下载 ├─ build-info 后端和每个节点的版本、tag、提交、构建时间、更新状态 -├─ releases nodes/{id}/update CI 签名的发布清单(登记、列表);请求节点更新 +├─ releases release-key nodes/{id}/update CI 签名的发布清单、可信发布密钥;请求节点更新 +├─ backend/update backend/update/live Backend 自身的更新确认与通知 +├─ nodes/{id}/logs 节点近期运行日志 ├─ events analysis/recompute api-tokens settings users ``` @@ -85,7 +90,7 @@ 参数、响应示例和限额见 [query.md](../design/query.md)。 -线路详情 `GET /api/public/lines/{line_id}` 带 `data_since`:这条线路最早的数据是什么时候(没有数据是 null),前端据此只提供「有数据」的时间范围。**展示端的响应里没有 Probe 的版本和来源地址**(那些只在管理端)。 +线路详情 `GET /api/public/lines/{line_id}` 带 `data_since`:这条线路最早的数据是什么时候(没有数据是 null),前端据此只提供「有数据」的时间范围。展示端的响应里没有 Probe 的版本和来源地址(那些只在管理端)。 ### 2.3.1 Agent 的主机资源占用 @@ -94,16 +99,16 @@ | `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` | -`host` 只有汇总的数字:CPU 占用、内存与根分区的已用和总量、网卡累计收发字节与瞬时网速、系统运行时长。**没有 CPU 型号、主机名、网卡名,也没有任何 IP**——Agent 不采集这些,公开接口当然也不会返回;有测试断言响应里不出现这类字段。实时更新走 SSE 的 `hosts` 主题,历史每个 Agent 每分钟存一个样本(`retention.host_days`,默认 7 天)。 +`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 和没被吊销的 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/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`,不对外。 +- 国家和运营商由 Backend 用节点的来源地址向 IP 归属服务查一次(`MoShiro__GeoLookupUrl`,缺省 `https://ip.007995.xyz`,留空就不查):Probe 用 Agent 看到的来源 IP,Agent 用它的公开地址。Probe 和 Agent 不访问任何外部服务。只查公网地址(回环、内网、CGNAT、链路本地一律不发出去);同一个地址一周内只查一次,失败 10 分钟后再试;查到的只留国家代码和运营商,地址只存在 Backend 内部的 `node_meta.geo_host`,不对外。 ### 2.4 测试结果与目录 @@ -113,7 +118,7 @@ ### 2.5 跨域访问 -默认前端和 Backend 同源(Cookie 会话)。前端在别的来源时(比如本机跑前端、连线上的 Backend),Backend 设 `MoShiro__CorsOrigins`(逗号分隔的来源,或 `*`;默认空 = 不允许)。只对 `/api/*` 生效,响应带 `Access-Control-Allow-Origin`,预检请求(`OPTIONS`)返回 204,允许的头是 `Authorization, Content-Type, X-CSRF-Token`。**不带凭据**,Cookie 不会跨域,所以跨域的调用只能用 `Authorization: Bearer `:展示端用只读令牌(站点私有时需要),管理端用 admin 范围的令牌(`/api/admin/auth/me` 对令牌同样可用)。SSE 在跨域时前端用 fetch 读流,因为 `EventSource` 带不了请求头。 +默认前端和 Backend 同源(Cookie 会话)。前端在别的来源时(比如本机跑前端、连线上的 Backend),Backend 设 `MoShiro__CorsOrigins`(逗号分隔的来源,或 `*`;默认空 = 不允许)。只对 `/api/*` 生效,响应带 `Access-Control-Allow-Origin`,预检请求(`OPTIONS`)返回 204,允许的头是 `Authorization, Content-Type, X-CSRF-Token`。不带凭据,Cookie 不会跨域,所以跨域的调用只能用 `Authorization: Bearer `:展示端用只读令牌(站点私有时需要),管理端用 admin 范围的令牌(`/api/admin/auth/me` 对令牌同样可用)。SSE 在跨域时前端用 fetch 读流,因为 `EventSource` 带不了请求头。 ## 3. Admin API @@ -134,7 +139,7 @@ | POST | `/api/admin/agents/{id}/probes/{probe_id}/disconnect` | 让这个 Agent 断开这个 Probe 的会话(Probe 会重连,除非已吊销);没有会话时返回 409 `probe_offline` | | GET/POST | `/api/admin/tasks` | 列表和创建:line 模板(不带 `agent_id`)、单个 Agent 的覆盖(带 `agent_id` + `base_task_id`)、target 任务 | | GET/PATCH/DELETE | `/api/admin/tasks/{id}` | 参数按 Kind 的 `ParamSpec` 校验,并检查带宽预算 | -| POST | `/api/admin/lines/{line_id}/tests` | `{plan, params?}` → **202** + `Location: /api/public/tests/{id}`;Probe 离线时返回 409 `probe_offline`,节点繁忙时返回 409 `test_busy` | +| POST | `/api/admin/lines/{line_id}/tests` | `{plan, params?}` → 202 + `Location: /api/public/tests/{id}`;Probe 离线时返回 409 `probe_offline`,节点繁忙时返回 409 `test_busy` | | POST | `/api/admin/tests/{id}/cancel` | | | GET | `/api/admin/raw/chunks?agent_id=&kind=&from=&to=&test_id=` | 原始块元数据 | | GET | `/api/admin/raw/chunks/{chunk_id}` | 下载原始块(`application/vnd.moshiro.rawchunk`);过期返回 410 `raw_expired` | @@ -148,11 +153,18 @@ | GET | `/api/admin/build-info` | 后端和每个 Agent、Probe 的版本、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 时照常工作 | +| 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/POST/PATCH | `/api/admin/users` | 首次启动时通过命令行或环境变量创建初始管理员 | +| 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 重试连接 | +| GET | `/api/admin/nodes/{id}/logs` | 节点近期运行日志(经心跳上传) | +| GET/POST | `/api/admin/backend/update`、GET `/backend/update/live` | Backend 自身的更新状态、管理员确认更新,以及 SSE 通知 | 任务的请求体示例: @@ -165,7 +177,8 @@ | 方法 | 路径 | 说明 | | --- | --- | --- | -| POST | `/api/agent/v1/enroll` | 由 `MapFleetEnrollment` 提供(使用加入令牌认证),见 [../backend/fleet/README.md](../../backend/fleet/README.md) 的「注册」一节 | +| POST | `/api/agent/v1/enroll` | 由 `MapFleetEnrollment` 提供(使用加入令牌认证),见 [Fleet 文档](../../backend/fleet/README.md)的「注册」一节 | +| POST | `/api/agent/v1/enroll/trust` | 返回操作员集合链和加入令牌的授权,节点据此验证后再注册 | | GET | `/api/agent/v1/rpc` | 由 `MapFleetLink` 提供,升级为 WebSocket;MoShiro 的方法见 [protocol.md](../design/protocol.md) | ## 5. 运维端点 @@ -175,7 +188,8 @@ | `GET /healthz` | 无 | SQLite 与原始数据目录都可写 | | `GET /metrics` | admin 令牌 | Prometheus 格式:在线数、块入库速率、分析队列长度、重算进度、磁盘占用、SSE 连接数 | | `GET /openapi/v1.json` | 无 | | -| `GET /install`(也接受 `/install.sh`)、`GET /install.ps1` | 无 | 静态安装脚本:Agent 和 Probe 共用同一份,原样返回、不替换任何值,所以哈希在一个版本内固定;角色、面板地址、代码和属主走环境变量 | +| `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/manifest?code=…`、`GET /install/manifest.sig?code=…` | 无(凭一次性代码) | 该角色已签名的发布清单(原样)和签名,安装脚本据此验签、按 digest 拉镜像;代码不能用就是 404 | ## 6. 错误码(节选,完整列表见共享的 `ErrorCode`) @@ -194,7 +208,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,并根据本项目做了取舍。内部协议(Probe↔Agent、Agent↔Backend)的兼容规则见 [protocol.md](../design/protocol.md) 第 4 节。 ### 7.1 契约生成链路 @@ -208,14 +222,14 @@ backend/openapi/v1.json(OpenAPI 3.1,提交到仓库) web:openapi-typescript 生成类型,调用方的类型错误会直接暴露 ``` -- 端点**必须**声明精确的返回类型,例如 `Results, NotFound>`,不能返回 `IResult` 或匿名对象,否则 OpenAPI 文档会不准确。 +- 端点必须声明精确的返回类型,例如 `Results, NotFound>`,不能返回 `IResult` 或匿名对象,否则 OpenAPI 文档会不准确。 - 每个端点都必须设置 `WithName("agents_list")`(即 operationId,snake_case、`资源_动作`)、`WithSummary`、`WithTags`。 -- 请求和响应 DTO 放在 `MoShiro.Backend.Api/Contracts/`,并注册到 Backend 的 JSON 源生成上下文。**不要直接暴露 `MoShiro.Protocol` 中的线协议类型**,因为 REST 与线协议的演进节奏不同。 -- **结构强类型,指标弱类型**:资源结构(Agent、任务、测试等)必须是强类型 DTO;而**指标值和评分**统一使用字典 `metrics: { "": number }`、`scores: { "": number }`,键的含义、单位、版本由 `/api/public/catalog/*` 给出。这样新增指标或模型不需要改 OpenAPI,也不需要改前端类型。 +- 请求和响应 DTO 放在 `MoShiro.Backend.Api/Contracts/`,并注册到 Backend 的 JSON 源生成上下文。不要直接暴露 `MoShiro.Protocol` 中的线协议类型,因为 REST 与线协议的演进节奏不同。 +- 结构强类型,指标弱类型:资源结构(Agent、任务、测试等)必须是强类型 DTO;而指标值和评分统一使用字典 `metrics: { "": number }`、`scores: { "": number }`,键的含义、单位、版本由 `/api/public/catalog/*` 给出。这样新增指标或模型不需要改 OpenAPI,也不需要改前端类型。 ### 7.2 版本与兼容性 -- 当前版本为 v1,路径前缀为 `/api/public`、`/api/admin`、`/api/agent/v1`。出现破坏性变更时,新增 `/api/v2/public`、`/api/v2/admin` 等路径。v1 内只允许**非破坏性变更**: +- 当前版本为 v1,路径前缀为 `/api/public`、`/api/admin`、`/api/agent/v1`。出现破坏性变更时,新增 `/api/v2/public`、`/api/v2/admin` 等路径。v1 内只允许非破坏性变更: | 非破坏性变更(v1 内允许) | 破坏性变更(需要新建 v2) | | --- | --- | @@ -231,7 +245,7 @@ web:openapi-typescript 生成类型,调用方的类型错误会直接暴露 ### 7.3 URL 与方法 -- **按调用方分层**:`/api/public`(只读展示)、`/api/admin`(管理)、`/api/agent`(节点)。同一个资源在不同层中可以有不同的 DTO,例如 public 层不暴露 `pubkey`、`local_limits`。 +- 按调用方分层:`/api/public`(只读展示)、`/api/admin`(管理)、`/api/agent`(节点)。同一个资源在不同层中可以有不同的 DTO,例如 public 层不暴露 `pubkey`、`local_limits`。 - 路径全部小写,资源名用复数名词,多个单词用 `-` 连接:`/api/admin/join-tokens`。 - 嵌套最多一层;子资源一旦有全局唯一 ID,就提供顶层路径(`/probes/{probe_id}`)。 - 查询参数和 JSON 字段使用 snake_case。 @@ -251,7 +265,7 @@ web:openapi-typescript 生成类型,调用方的类型错误会直接暴露 | 类别 | 规则 | 示例 | | --- | --- | --- | -| 时间点 | RFC 3339 UTC,以 `Z` 结尾,精确到毫秒。**唯一例外**:列式时序响应中的时间轴数组 `t` 使用 Unix 毫秒(见 [query.md](../design/query.md)) | `2026-09-27T10:00:00.000Z` | +| 时间点 | RFC 3339 UTC,以 `Z` 结尾,精确到毫秒。唯一例外:列式时序响应中的时间轴数组 `t` 使用 Unix 毫秒(见 [query.md](../design/query.md)) | `2026-09-27T10:00:00.000Z` | | 时长 | 整数 + 单位后缀 | `interval_ms: 1000` | | 延迟 | 带小数的毫秒值(指标字典中的单位见目录) | `rtt_p95_ms: 58.93` | | 百分比 | 0–100 的数字 + `_pct` | `loss_pct: 0.5` | @@ -277,7 +291,7 @@ web:openapi-typescript 生成类型,调用方的类型错误会直接暴露 } ``` -- `code` 取自共享的 `ErrorCode` 枚举,是客户端做判断的**唯一**依据;`title`/`detail` 只供人阅读,内容可能变化。 +- `code` 取自共享的 `ErrorCode` 枚举,是客户端做判断的唯一依据;`title`/`detail` 只供人阅读,内容可能变化。 - `errors` 只在 400 `validation_failed` 中出现,键名是 JSON 字段路径。 - 5xx 响应不能泄露异常信息或调用栈,只提供 `trace_id`,用于在日志中定位问题。 diff --git a/docs/design/analysis.md b/docs/design/analysis.md index 9785a89..3fdc3eb 100644 --- a/docs/design/analysis.md +++ b/docs/design/analysis.md @@ -16,11 +16,11 @@ 指标窗口(1m) → 5m → 1h API:metrics{} + scores{} ``` -三层都采用**注册制**,由代码中的注册表驱动;API 的 `/catalog/metrics`、`/catalog/models` 也直接由注册表生成。 +三层都采用注册制,由代码中的注册表驱动;API 的 `/catalog/metrics`、`/catalog/models` 也直接由注册表生成。 ## 2. 累加器(固定的少数几种) -所有指标的中间状态只能是下面几种累加器之一。**合并规则只写一次**,存储、降采样、按时间范围聚合都通用处理: +所有指标的中间状态只能是下面几种累加器之一。合并规则只写一次,存储、降采样、按时间范围聚合都通用处理: | 累加器 | 状态 | 合并 | 可导出的值 | | --- | --- | --- | --- | @@ -79,14 +79,14 @@ public static class LinkMetrics ``` - `from` 写成 `kind.stream`,`*` 表示所有具有该 stream 的 Kind;某个窗口里没有对应的输入时,该指标在这个窗口就不存在。 -- **TCP 长尾**:均值和 p95 看不到偶发的重传尖峰(一次丢包后的 RTT 聚在 RTO 附近),所以 `tcp.rtt` 还导出 `p999`、`max` 和长尾比 `tail`(p99 / p50,从同一个直方图导出,窗口合并后仍然精确);`tcp.slow` 是慢回显的占比——阈值 `max(1.8 × 基线, 基线 + max(15 ms, 0.35 × 基线))`,基线是**同一个 10 秒块**里 RTT 的中位数(所以每个观测只依赖自己所在的块,窗口合并仍然精确;块里少于 20 个样本不产生观测)。把它和 `tcp.retrans`(内核计数,Agent 一侧反映下行)、`tcp.stall` 对照,可以区分"重传造成的尖峰"和"队头阻塞"。 +- TCP 长尾:均值和 p95 看不到偶发的重传尖峰(一次丢包后的 RTT 聚在 RTO 附近),所以 `tcp.rtt` 还导出 `p999`、`max` 和长尾比 `tail`(p99 / p50,从同一个直方图导出,窗口合并后仍然精确);`tcp.slow` 是慢回显的占比——阈值 `max(1.8 × 基线, 基线 + max(15 ms, 0.35 × 基线))`,基线是同一个 10 秒块里 RTT 的中位数(所以每个观测只依赖自己所在的块,窗口合并仍然精确;块里少于 20 个样本不产生观测)。把它和 `tcp.retrans`(内核计数,Agent 一侧反映下行)、`tcp.stall` 对照,可以区分"重传造成的尖峰"和"队头阻塞"。 - 导出值的名字为 `指标id.统计量`,例如 `rtt.p95`、`loss.up`、`tcp.stall.per_min`。 -**新增一个统计 = 新增一行 `MetricDef`**,如果需要新的算子,再加一个算子类。存储、降采样、API 输出、目录说明都不需要改。新指标上线后,会自动对保留期内(30 天)的原始数据回填(见第 7 节)。 +新增一个统计只需要新增一行 `MetricDef`,需要新算子时再加一个算子类。存储、降采样、API 输出、目录说明都不需要改。新指标上线后,会自动对保留期内(30 天)的原始数据回填(见第 7 节)。 ## 5. 模型(服务体验评分) -模型是一组指标的评分函数,**在查询时计算**,不存储。改动评分公式后,立即对全部历史生效。 +模型是一组指标的评分函数,在查询时计算,不存储。改动评分公式后,立即对全部历史生效。 ```csharp public sealed class McModel : IServiceModel @@ -117,7 +117,7 @@ chunk 入库 ─┬─> 流式路径:按 1s 桶计算累加器 → 热层环 两条路径调用同一份 `MetricDef` 和算子代码,所以实时值与历史值使用的是同一套口径。迟到的块(例如 Agent 重连后补传)会同时更新热层(如果仍在 1 小时内)、`metric_10s`,并重新进入持久路径。 -**指标分组(preset)** 与指标一样采用注册制:`Presets.Udp = [LinkMetrics.LossUp, …]`,由 `/api/public/catalog/presets` 下发。前端按分组展示,新增指标只需要把它加进分组。查询规则见 [query.md](query.md)。 +指标分组(preset)同样是注册制:`MetricRegistry.Presets` 中的 `PresetDef(id, label, metricIds)`,由 `/api/public/catalog/presets` 下发。前端按分组展示,新增指标只需要把它加进分组。查询规则见 [query.md](query.md)。 ## 7. 版本与重算 @@ -136,12 +136,12 @@ chunk 入库 ─┬─> 流式路径:按 1s 桶计算累加器 → 热层环 ```text backend/src/MoShiro.Backend.Analysis/ 依赖:MoShiro.Protocol、Fleet.Core(纯计算,没有 I/O) -├─ Accumulators/ Counter Ratio Sum Min Max Histogram RunLength Series + AccumulatorCodec -├─ Ops/ Rtt Loss OwdVar LossRuns Reorder Gap KernelDelta Rate PhaseMs Outcome ChangePoint -├─ Metrics/ LinkMetrics TcpMetrics UdpTrainMetrics ConnectMetrics …(只包含声明) -├─ Models/ BaseModel McModel Hy2Model RealityModel -├─ Engine/ ChunkView(列视图) WindowEvaluator TestEvaluator MetricRegistry ModelRegistry +├─ Accumulators/ Accumulator(Counter、Ratio、Sum、Min、Max、RunLength、Series)、HistogramAcc、AccumulatorCodec +├─ Ops/ Ops(通用算子)、EchoOps、SessionOps +├─ Metrics/ LinkMetrics、TcpMetrics、SessionMetrics(只包含声明)、MetricRegistry(指标与分组的注册表) +├─ Models/ ServiceModel(基类)、Models(base、mc、hy2、reality) +├─ Engine/ ChunkView(列视图)、MetricDef、WindowEvaluator、TestEvaluator、ChangePoint └─ Clock/ ClockDriftFit(估计两端的时钟偏移和漂移) ``` -分析库没有 I/O,可以直接用于离线 CLI `tools/moshiro-analyze`,对下载的原始块做实验。 +分析库没有 I/O,可以脱离 Backend 单独引用。 diff --git a/docs/design/architecture.md b/docs/design/architecture.md index cfe84dd..0a98646 100644 --- a/docs/design/architecture.md +++ b/docs/design/architecture.md @@ -2,11 +2,13 @@ ## 1. 目标与定位 -一个**只关注网络层**的线路质量面板:单体面板(Backend)+ 轻量节点(Probe、Agent)+ 长连接 RPC + 任务下发。核心能力: +只关注网络层的线路质量面板:单体面板(Backend)、轻量节点(Probe、Agent)、长连接 RPC 与任务下发。 -- 在**若干 Probe**(家宽等需要观测的出口,可以有多个)与**多台海外 VPS Agent**(10 几台)之间,采集**最原始的逐包、逐事件数据**;哪些 Probe 连哪些 Agent 由面板决定(自动全连,也可以手动选择); -- 由 Backend 的分析层推算:**如果在某条线路上部署 REALITY 类、Hysteria2 类或 Minecraft 服务,体验会怎样**,并对多条线路排序和对比; -- **主机层信息只上报只读的汇总数字**(2026-10 起,由用户确认放开):Agent 和 Probe 读 `/proc` 得到 CPU 占用、内存、根分区、网卡累计流量与瞬时网速、运行时长,不采集 CPU 型号、主机名、网卡名和任何 IP,不采集进程;Probe 的上报可以在 `probe.json` 里关掉(`reporting.host_stats: false`)。展示页用卡片画出每个 Agent 和 Probe 的主机状态,线路单独放在卡片下面的面板里。节点只在连接面板时自报粗粒度的系统、架构和构建信息(版本、tag、提交、构建时间),用于展示和更新;国家和运营商由 Backend 用节点的来源地址查询,节点自己不访问任何外部服务。展示页看不到任何 IP。Agent 和 Probe 仍然是**纯采集器**,不具备 SSH、远程执行、终端、文件读写、自更新等能力:更新由节点宿主机上的更新器完成,节点进程只往数据目录放面板转来的发布清单和更新请求。 +- 采集:多个 Probe(家宽等需要观测的出口)与多台海外 VPS Agent 之间记录逐包、逐事件的原始数据。哪些 Probe 连哪些 Agent 由面板决定,默认全连,也可以手动选择。 +- 分析:Backend 的分析层推算在某条线路上部署 REALITY 类、Hysteria2 类或 Minecraft 服务的体验,并对多条线路排序和对比。 +- 主机状态:Agent 和 Probe 读取 `/proc`,上报 CPU 占用、内存、根分区、网卡累计流量与瞬时网速、运行时长这类汇总数字。不采集 CPU 型号、主机名、网卡名、IP 和进程。Probe 的上报可在 `probe.json` 中用 `reporting.host_stats: false` 关闭。展示页以卡片呈现每个节点的主机状态,线路在卡片下方的面板中。 +- 构建信息:节点连接面板时自报系统、架构和构建信息(版本、tag、提交、构建时间),用于展示和更新。国家和运营商由 Backend 依据节点来源地址查询,节点不访问任何外部服务,展示页不含任何 IP。 +- 节点边界:Agent 和 Probe 是纯采集器,没有 SSH、远程执行、终端、文件读写和自更新。更新由节点宿主机上的更新器完成,节点进程只把面板转来的发布清单和更新请求写入数据目录。 ## 2. 拓扑 @@ -34,8 +36,8 @@ 更新(Agent、Probe、Backend):面板登记 CI 签名的清单 → 管理端确认 → 节点(Backend 先备份数据库)有序退出 → systemd Pull=always 拉最新镜像重启 ``` -- **线路(line)** = 一对(Probe,Agent)。可以有多个 Probe(身份由 Probe 自己生成并用加入令牌注册,可以吊销、删除),每个 Probe 连接多个 Agent,每个 Agent 同时服务多个 Probe;一条线路的数据按 `line:` 单独存放,互不混合。对外的线路 id 是 `~`。 -- **目标任务(target)** = 某个 Agent → 指定的 IP:端口(ICMP、TCP/TLS 建连),只能访问 Agent 本地允许的网段。 +- 线路(line):一对(Probe,Agent)。可以有多个 Probe(身份由 Probe 自己生成并用加入令牌注册,可以吊销、删除),每个 Probe 连接多个 Agent,每个 Agent 同时服务多个 Probe;一条线路的数据按 `line:` 单独存放,互不混合。对外的线路 id 是 `~`。 +- 目标任务(target):某个 Agent → 指定的 IP:端口(ICMP、TCP/TLS 建连),只能访问 Agent 本地允许的网段。 ## 3. 数据链路 @@ -58,9 +60,9 @@ | 主题 | 要点 | 文档 | | --- | --- | --- | -| 节点框架 | 注册、身份、连接、RPC、可靠上报、状态同步、宿主都由与业务无关的 **Fleet** 提供;MoShiro 只写业务,其他产品也能复用 | [../backend/fleet/README.md](../../backend/fleet/README.md) | +| 节点框架 | 注册、身份、连接、RPC、可靠上报、状态同步、宿主都由与业务无关的 Fleet 提供;MoShiro 只写业务,其他产品也能复用 | [Fleet 文档](../../backend/fleet/README.md) | | 注册与身份 | 使用 `fleet-enroll` 和加入令牌自动注册,之后用签名认证;Probe 用自己的身份直连面板,Agent 列表由面板下发 | [enrollment.md](../security/enrollment.md) | -| 更新 | 只接受发布密钥签名的清单,更新策略由节点本地决定,失败自动回滚。CI 对镜像 digest 签名;Backend 和一行脚本装的节点由宿主机上的 `moshiro-update` 验签、按 digest 拉取、失败回滚;Podman Quadlet 装的 Agent、Probe 更简单:节点有序退出,systemd 用 `--pull always` 重新拉最新镜像,节点自己不下载、不验证清单;纯二进制节点的更新是待办([TODO.md](../../TODO.md)) | [deployment.md](../ops/deployment.md) | +| 更新 | 只接受发布密钥签名的清单,更新策略由节点本地决定,失败自动回滚。CI 对镜像 digest 签名;Backend 和一行脚本装的节点由宿主机上的 `moshiro-update` 验签、按 digest 拉取、失败回滚;Podman Quadlet 装的 Agent、Probe 更简单:节点有序退出,systemd 用 `--pull always` 重新拉最新镜像,节点自己不下载、不验证清单;纯二进制节点的更新见[路线图](../roadmap.md) | [deployment.md](../ops/deployment.md) | | 任务模型 | `Task = {kind, target, params, schedule}`;持续采集、按需测试、目标任务统一建模;下发“期望任务集”,由节点自行调和 | [measurement.md](measurement.md) | | 采集扩展 | Kind 由积木组合声明;采集密度靠参数调整;原始块自描述 | [measurement.md](measurement.md) | | 统计扩展 | 指标注册制:少数几种累加器 + 统计算子;一行 `MetricDef` 就是一个新指标 | [analysis.md](analysis.md) | @@ -91,15 +93,15 @@ Backend:不自动,只有管理员点「更新并重启」;退出之前先 ### 4.1 回显的四个时间戳 -回显类 Kind 记录 `t1..t4`(Probe 发出、Agent 收到、Agent 回复、Probe 收到)。借此可以扣除 Agent 的处理耗时得到 RTT,可以分别判断上行和下行的丢包,并在去除时钟偏移和漂移后,得到**每个方向各自的抖动**,不需要两端时钟同步。 +回显类 Kind 记录 `t1..t4`,用于扣除 Agent 处理耗时、分别判断上下行丢包和计算各方向的抖动,不要求两端时钟同步。推导见 [measurement.md](measurement.md) 第 3.4 节。 ### 4.2 NAT / CGNAT 适配 -所有连接和 UDP 流都由 Probe 发起;Agent 把回复发往请求的**实际源地址**。会话以 `session_id` 和 MAC 认证,不绑定五元组,NAT 映射变化后自动跟随新地址。持续的 UDP 回显本身就起到保活作用。 +所有连接和 UDP 流都由 Probe 发起;Agent 把回复发往请求的实际源地址。会话以 `session_id` 和 MAC 认证,不绑定五元组,NAT 映射变化后自动跟随新地址。持续的 UDP 回显本身就起到保活作用。 ## 5. 安全边界:纯采集 -> 这一节是现状。「Backend 被攻破、节点被入侵」之后的威胁模型、现有的几处缺口(例如 Probe 会拨 Backend 给的任意地址、测量数据没有产生者签名)和每项的状态,见 [security.md](../security/security.md);预握手敲门、Probe 自己生成密钥、操作员集合与服务器身份已经实现。 +> 本节描述现状。Backend 或节点被攻破后的威胁模型、现有缺口(例如 Probe 会拨 Backend 给的任意地址、测量数据没有产生者签名)及状态见 [security.md](../security/security.md)。 ```text Agent / Probe 能做的 执行编译在二进制中的 Kind;监听和访问本地配置允许的端口与网段;受本地硬上限约束 @@ -108,15 +110,15 @@ Agent / Probe 能做的 执行编译在二进制中的 Kind;监听和访问 也推不出恶意更新(更新清单必须由发布密钥签名,节点宿主机上的更新器用本机公钥验签,并且只认本机配置的镜像仓库) ``` -- **本地硬上限**写在节点本机的配置文件中,Backend 下发的任务如果超出,节点直接拒绝:允许访问的目标网段(默认为空,即不开放目标任务)、总码率和包速率上限、允许监听的端口。 -- **构建期强制**:常驻节点进程使用 `BannedSymbols.Node.txt`,禁用 `System.Diagnostics.Process`、`Assembly.Load*`、`NativeLibrary.Load`、`HttpClient`,以及 `ConfigStore` 以外的文件写入 API,详见 [project-layout.md](../dev/project-layout.md) 第 7 节。 -- **权限分离**:注册(`fleet-enroll`,一次性运行)、采集(`moshiro-agent` / `moshiro-probe`)、更新是不同的进程。容器节点里,采集进程在容器内以 UID 1654 运行(只读文件系统、不带额外能力),更新由宿主机上 root 运行的 `moshiro-update` 完成:它只读取节点放进 `update/` 的普通文件、复制到自己的临时目录后验签,状态写在节点只读的 `update-status/`;采集进程改写不了自己的二进制,更新器也读不到节点身份私钥。纯二进制节点的更新是待办(见 [TODO.md](../../TODO.md))。 -- **容器运行**:Agent、Probe 的镜像基于 Alpine(musl,NativeAOT),宿主机用 rootful podman 加 systemd 运行:UID/GID 1654、只读文件系统、`--cap-drop all`、`no-new-privileges`、`/tmp` 用 tmpfs;配置和身份放在挂载的数据目录里。 -- **最小权限运行**:非 root 用户;只在需要时授予 `CAP_NET_RAW`(ICMP);默认端口都高于 1024,不需要 `CAP_NET_BIND_SERVICE`,只有在 Agent 本地显式开启 443 时才授予。 -- **身份**:节点使用本地生成的 ECDSA P-256 密钥对签名认证(Agent 和 Probe 都自己生成;Probe 有两把独立的密钥,一把连面板、一把连 Agent),Backend 只存公钥,数据库泄露也无法冒充节点。 -- **防放大**:未认证的包静默丢弃;回复长度与请求相等;每个流按声明速率限速;使用重放窗口。 -- **容器里的主机指标**:容器里的 Agent 通过只读挂载宿主机的 `/proc`、`/sys`、根目录和 `/etc/os-release`(环境变量 `HOST_PROC`、`HOST_SYS`、`HOST_ROOT`)读取宿主机的资源占用和系统名;读不到的项返回空值,不会退回去读容器自己的数据。Probe 的 compose 也一样挂载这几项。 -- **自身状态**:节点上报进程内的计数(会话数、包速率、队列、丢弃数);Agent 另外每隔几秒用 `moshiro.host` 上报一份主机资源占用的汇总数字(可以在 `agent.json` 里用 `reporting.host_stats = false` 整个关掉),最新值实时推给展示页,Backend 每分钟存一个样本(默认保留 7 天,`retention.host_days`)。 +- 本地硬上限写在节点本机的配置文件中,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;配置和身份放在挂载的数据目录里。 +- 最小权限运行:非 root 用户;只在需要时授予 `CAP_NET_RAW`(ICMP);默认端口都高于 1024,不需要 `CAP_NET_BIND_SERVICE`,只有在 Agent 本地显式开启 443 时才授予。 +- 身份:节点使用本地生成的 ECDSA P-256 密钥对签名认证(Agent 和 Probe 都自己生成;Probe 有两把独立的密钥,一把连面板、一把连 Agent),Backend 只存公钥,数据库泄露也无法冒充节点。 +- 防放大:未认证的包静默丢弃;回复长度与请求相等;每个流按声明速率限速;使用重放窗口。 +- 容器里的主机指标:容器里的 Agent 通过只读挂载宿主机的 `/proc`、`/sys`、根目录和 `/etc/os-release`(环境变量 `HOST_PROC`、`HOST_SYS`、`HOST_ROOT`)读取宿主机的资源占用和系统名;读不到的项返回空值,不会退回去读容器自己的数据。Probe 的 compose 也一样挂载这几项。 +- 自身状态:节点上报进程内的计数(会话数、包速率、队列、丢弃数);Agent 另外每隔几秒用 `moshiro.host` 上报一份主机资源占用的汇总数字(可以在 `agent.json` 里用 `reporting.host_stats = false` 整个关掉),最新值实时推给展示页,Backend 每分钟存一个样本(默认保留 7 天,`retention.host_days`)。 ## 6. 可扩展性目标 @@ -142,10 +144,10 @@ Kind 目录编译在二进制中,节点不接受远程下发代码(安全边 | # | 决策 | 理由 | | --- | --- | --- | -| D1 | 只关注网络层;节点是纯采集器,没有远程执行能力 | 由用户确认;防止权限泄露 | -| D2 | 拓扑为多个 Probe 连接多个 Agent(10+);线路 = (Probe, Agent),数据按 Probe 隔离 | 由用户确认 | +| D1 | 只关注网络层;节点是纯采集器,没有远程执行能力 | 防止权限泄露 | +| D2 | 拓扑为多个 Probe 连接多个 Agent(10+);线路 = (Probe, Agent),数据按 Probe 隔离 | 拓扑需求 | | D3 | Agent 用加入令牌自动注册,之后用密钥对签名认证 | 批量部署省事;Backend 不保存可用于冒充节点的秘密 | -| D4 | Probe 直连 Backend:身份、Agent 列表(期望状态 `probe_agents`)、状态和更新都走这条链路,测量仍然只走 Probe → Agent(2026-10 由「Probe 不认识 Backend、经 Agent 同步列表」改成直连,因为面板本来就要对外暴露,直连让更新和管理少一跳)。哪些 Probe 连哪些 Agent 由面板决定,`fleet.auto_link` 打开时两两连,也可以手动选择,Probe 始终主动拨出 | 新增 Agent 后自动覆盖,不需要改 Probe 配置;Probe 的版本、更新和线路都能在面板上管理 | +| D4 | Probe 直连 Backend:身份、Agent 列表(期望状态 `probe_agents`)、状态和更新都走这条链路,测量仍然只走 Probe → Agent。哪些 Probe 连哪些 Agent 由面板决定,`fleet.auto_link` 打开时两两连,也可以手动选择,Probe 始终主动拨出 | 新增 Agent 后自动覆盖,不需要改 Probe 配置;Probe 的版本、更新和线路都能在面板上管理 | | D5 | 只采集逐包、逐事件的原始数据,解释全部放在 Backend | 模型和指标可以事后重算;采集端最轻 | | D6 | 统一的任务模型 + 期望任务集调和 | 新增任务类型时协议不变 | | D7 | Kind 由积木组合声明;原始块自描述 | 调整密度或新增采集方法只改一处 | @@ -155,20 +157,18 @@ Kind 目录编译在二进制中,节点不接受远程下发代码(安全边 | D11 | API 分为 public / admin / agent 三层;实时数据用 SSE | 前端只读场景足够;反向代理和 CDN 更友好 | | D12 | 单仓库,三个独立项目加 Web(展示端、管理端);契约以 C# 代码为真源 | 改一处,编译期即可发现不一致 | | D13 | Probe/Agent 运行时只依赖 BCL;.NET 10 NativeAOT | 轻量 | -| D14 | 节点机制抽成与业务无关的 Fleet 框架,放在同一仓库的 `backend/fleet/` 目录,暂不独立(由用户确认;不设拆分时间表,需要时再评估;不使用 PublicApiAnalyzers) | 可复用;不引用任何 `MoShiro.*` 的约束由 `ArchitectureTests` 在构建时检查 | +| D14 | 节点机制抽成与业务无关的 Fleet 框架,放在同一仓库的 `backend/fleet/` 目录,暂不独立(不设拆分时间表,需要时再评估;不使用 PublicApiAnalyzers) | 可复用;不引用任何 `MoShiro.*` 的约束由 `ArchitectureTests` 在构建时检查 | | D15 | 注册独立为 `fleet-enroll` 工具;常驻进程不包含注册代码 | 常驻进程去掉 HttpClient,边界更纯 | -| D16 | 更新与业务进程分离:发布密钥签名、本地策略、健康确认与回滚。已实现:宿主机 `moshiro-update`(Backend、脚本装的节点,清单里是镜像 digest),Quadlet 节点则靠退出后由 systemd 重新拉镜像;纯二进制节点的更新见 [TODO.md](../../TODO.md) | 面板被攻破时也无法推送恶意二进制 | -| D17 | Probe 支持 OpenWrt:正式支持 `linux-musl-x64`、`linux-musl-arm64`;armv7 视工具链验证结果决定;MIPS 不支持,改为在局域网设备上运行 Probe | 由用户确认;NativeAOT 不支持 MIPS | -| D18 | 默认测量端口不使用 443:控制 7443/tcp,QUIC Datagram 数据 7443/udp,TCP 数据 7444/tcp;443 只在 Agent 本地显式开启时才允许使用 | 由用户确认;避免与 VPS 上的真实服务冲突;结果会记录实际端口 | -| D19 | 展示查询最细 1s:最近 1 小时来自内存热层,更早的数据来自 10s/1m/5m/1h 各层,现场合并;时间段内的汇总由累加器精确合并得出 | 由用户确认;5 分钟、15 分钟这类视图足够精细且快 | -| D20 | 时序响应采用列式格式(`t[]` + `values{}`,时间轴使用 Unix 毫秒) | 由用户确认;体积小,可以直接交给图表库 | +| D16 | 更新与业务进程分离:发布密钥签名、本地策略、健康确认与回滚。已实现:宿主机 `moshiro-update`(Backend、脚本装的节点,清单里是镜像 digest),Quadlet 节点则靠退出后由 systemd 重新拉镜像;纯二进制节点的更新见[路线图](../roadmap.md) | 面板被攻破时也无法推送恶意二进制 | +| D17 | Probe 支持 OpenWrt:正式支持 `linux-musl-x64`、`linux-musl-arm64`;armv7 视工具链验证结果决定;MIPS 不支持,改为在局域网设备上运行 Probe | NativeAOT 不支持 MIPS | +| D18 | 默认测量端口不使用 443:控制 7443/tcp,QUIC Datagram 数据 7443/udp,TCP 数据 7444/tcp;443 只在 Agent 本地显式开启时才允许使用 | 避免与 VPS 上的真实服务冲突;结果会记录实际端口 | +| D19 | 展示查询最细 1s:最近 1 小时来自内存热层,更早的数据来自 10s/1m/5m/1h 各层,现场合并;时间段内的汇总由累加器精确合并得出 | 5 分钟、15 分钟这类视图足够精细且快 | +| D20 | 时序响应采用列式格式(`t[]` + `values{}`,时间轴使用 Unix 毫秒) | 体积小,可以直接交给图表库 | | D21 | 国家和运营商只由 Backend 向 IP 归属服务查询(缺省 `https://ip.007995.xyz`,可关闭),只查公网地址;节点不访问任何外部服务;公开接口不返回 IP、城市、ASN | 客户的防火墙常常要审计节点的出站连接;访客拿不到敏感信息(有测试保证) | | D22 | 构建信息(版本、tag、提交、构建时间)由节点自报,访客是否可见由设置 `site.show_build` 控制(默认关);提交号只在管理端 | 展示页可以选择不暴露版本;管理员能看出哪个节点没升级 | -| D23 | 零信任方向:信任锚点从 Backend 移到操作员 passkey(操作员集合,节点安装时固定创世指纹和属主);Probe 自己生成密钥;Agent 的 TCP 端口预握手敲门;敏感能力以后放进独立 extension,用属主 passkey 签名的能力令牌授权。不做进程沙箱(节点是 1 核 1 GB 的小机器),代码风险靠 PR 评审 | 由用户确认;做法和各缺口的状态见 [security.md](../security/security.md)、[access.md](../security/access.md) | +| D23 | 零信任方向:信任锚点从 Backend 移到操作员 passkey(操作员集合,节点安装时固定创世指纹和属主);Probe 自己生成密钥;Agent 的 TCP 端口预握手敲门;敏感能力以后放进独立 extension,用属主 passkey 签名的能力令牌授权。不做进程沙箱(节点是 1 核 1 GB 的小机器),代码风险靠 PR 评审 | 做法和各缺口的状态见 [security.md](../security/security.md)、[access.md](../security/access.md) | -## 9. 仍待确认 +## 9. 未决事项 -待确认事项: - -1. **告警**:首版不包含,是否需要预留 Webhook? -2. **与真实服务共用 443**(例如 SNI 分流):首版不做,默认使用非 443 端口;以后如有需要再评估。 +- 告警:首版不包含,是否预留 Webhook 见[路线图](../roadmap.md)。 +- 与真实服务共用 443(例如 SNI 分流):默认使用非 443 端口,需要时再评估。 diff --git a/docs/design/measurement.md b/docs/design/measurement.md index c991edf..c0f6fb8 100644 --- a/docs/design/measurement.md +++ b/docs/design/measurement.md @@ -2,9 +2,9 @@ ## 1. 原则 -- **只采集原始数据**:逐包、逐消息、逐事件,TCP 另加周期性的内核采样;不计算任何统计,解释全部放在 [analysis.md](analysis.md)。 -- **一处声明,处处生效**:采集方法(Kind)由积木组合**声明**而成。Probe 和 Agent 共用同一套积木(`backend/shared/MoShiro.Measurement`)。 -- **密度即参数**:频率、包大小、采样间隔全部是任务参数,由 API 下发,不需要改代码。 +- 只采集原始数据:逐包、逐消息、逐事件,TCP 另加周期性的内核采样;不计算任何统计,解释全部放在 [analysis.md](analysis.md)。 +- 一处声明,处处生效:采集方法(Kind)由积木组合声明而成。Probe 和 Agent 共用同一套积木(`backend/shared/MoShiro.Measurement`)。 +- 密度即参数:频率、包大小、采样间隔全部是任务参数,由 API 下发,不需要改代码。 ## 2. 任务模型 @@ -22,9 +22,9 @@ schedule continuous 持续运行,按时间切块 once 按需测试,由 API 触发,也就是一次 Test ``` -- **线路任务模板**:`agent_id` 为空的 `line` 任务会自动应用到所有 `active` 的 Agent。新 Agent 注册后自动获得这些任务;也可以针对单个 Agent 覆盖参数或停用。 -- **测试方案(Plan)**:多个阶段的 `once` 任务组合,例如 `hy2_ladder` 是 `udp_train` 在 10、25、50、100 Mbps 下各运行 10 秒。Plan 定义在 `Protocol/Catalog/Plans`。 -- **期望任务集**:Backend 为每个 Agent 计算“有效任务集”(模板 + 覆盖 + 目标任务),带版本号,作为 Fleet 的期望状态(`fleet.state.sync`,文档类型 `TaskSet`)下发。Agent 的 `TaskReconciler` 与当前运行的任务对比后,启动、停止或修改参数;其中线路任务再经过控制通道同步给 Probe。**新增任务类型时,协议不需要任何改动。** +- 线路任务模板:`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。新增任务类型时协议不变。 ## 3. Kind 目录:积木组合 @@ -37,9 +37,9 @@ schedule continuous 持续运行,按时间切块 | Payload(负载形态) | `fixed`、`uniform`、`choice` | `size`、`min`/`max`、`sizes[]` | | Behavior(对端行为) | `echo`、`sink`、`source`、`connect`、`hold` | `direction`(`fwd`/`rev`/`bidir`)、`streams` | | Sampler(采样器,可挂到任意连接上) | `tcp_info`、`socket_counters` | `every_ms` | -| Recorder(记录器) | 根据 Kind 声明的列**自动生成**的列式记录器 | — | +| Recorder(记录器) | 根据 Kind 声明的列自动生成的列式记录器 | — | -**两种角色互相独立**:连接角色(Probe 永远是拨号方,适配 NAT)与数据角色(发送端、接收端、回显端,由 `direction` 决定)无关。每种积木只实现一次,Probe 和 Agent 共用。 +两种角色互相独立:连接角色(Probe 永远是拨号方,适配 NAT)与数据角色(发送端、接收端、回显端,由 `direction` 决定)无关。每种积木只实现一次,Probe 和 Agent 共用。 ### 3.2 Kind 声明(`MoShiro.Protocol/Catalog/Kinds`) @@ -64,10 +64,10 @@ public static readonly KindSpec TcpEcho = new( 这一条声明会被四个地方使用: -- **Probe/Agent**:组装积木执行任务; -- **Recorder**:根据列定义记录数据; -- **Backend**:校验参数和预算,并在 `/catalog/kinds` 中展示; -- **Analysis**:按列名读取数据。 +- Probe/Agent:组装积木执行任务; +- Recorder:根据列定义记录数据; +- Backend:校验参数和预算,并在 `/catalog/kinds` 中展示; +- Analysis:按列名读取数据。 ### 3.3 内置 Kind @@ -91,7 +91,7 @@ Probe 单调时钟 Agent 单调时钟 t4 收到 <───────────── t3 回复 ``` -RTT = `(t4 − t1) − (t3 − t2)`。缺少 `t2` 表示上行丢失;有 `t2`/`t3` 但缺少 `t4` 表示下行丢失。`t2 − t1` 和 `t4 − t3` 的**变化量**在去除偏移和漂移后,就是每个方向各自的时延抖动。 +RTT = `(t4 − t1) − (t3 − t2)`。缺少 `t2` 表示上行丢失;有 `t2`/`t3` 但缺少 `t4` 表示下行丢失。`t2 − t1` 和 `t4 − t3` 的变化量在去除偏移和漂移后,就是每个方向各自的时延抖动。 ## 4. 原始块(自描述) @@ -108,20 +108,20 @@ RawChunk └─ crc32c ``` -- **列定义写在块头中**:新增列或新增 Kind 时,**不需要升级 `container_version`**。只有容器格式本身变化时才升级。Backend 可以通用地解码任何块,不认识的列会被保留,但会被分析层忽略。 -- **切块规则**:持续任务每 **10 秒**切一块,或者记录数达到上限(默认 50 000)时提前切块。这样采集密度提高后,单块大小仍然有界,实时显示和持久化也共用同一条链路。 +- 列定义写在块头中:新增列或新增 Kind 时不需要升级 `container_version`。只有容器格式本身变化时才升级。Backend 可以通用地解码任何块,不认识的列会被保留,但会被分析层忽略。 +- 切块规则:持续任务每 10 秒切一块,或者记录数达到上限(默认 50 000)时提前切块。这样采集密度提高后,单块大小仍然有界,实时显示和持久化也共用同一条链路。 - 按需测试按阶段切块,阶段内同样受记录数上限约束。 - 编解码器位于 `MoShiro.Protocol/Raw`,Probe、Agent、Backend、离线工具使用同一份代码。 ## 5. 列的归属与合并 -> **两侧各自记录完整行的流**(`StreamSpec.Union`,例如 `tcp_info`):Probe 和 Agent 各自采样自己的套接字(Linux `TCP_INFO`,通过 `Socket.GetRawSocketOption`,不需要 P/Invoke),每一行带 `side` 标明属于哪一侧;合并时直接拼接,不连接,Agent 一侧的行每行只交出一次。Probe 的重传反映上行方向,Agent 的重传反映下行方向(代理场景里大流量走这个方向)。非 Linux 平台没有这类采样,对应的指标在窗口里就是没有数据。 +两侧各自记录完整行的流(`StreamSpec.Union`,例如 `tcp_info`):Probe 和 Agent 各自采样自己的套接字(Linux `TCP_INFO`,通过 `Socket.GetRawSocketOption`,不需要 P/Invoke),每一行带 `side` 标明属于哪一侧;合并时直接拼接,不连接,Agent 一侧的行每行只交出一次。Probe 的重传反映上行方向,Agent 的重传反映下行方向(代理场景里大流量走这个方向)。非 Linux 平台没有这类采样,对应的指标在窗口里就是没有数据。 `tcp_info` 的可空 `conn` 列标识当前记录器内的一条 TCP 连接,不包含地址。Backend 按 `(side, conn)` 分别求累计计数差,避免把重连前后或同时存活的套接字混为一个序列;时间不递增的样本被忽略,任一计数器回退时重新建立基线。旧数据没有 `conn`,仍按 `side` 分组,无法事后恢复丢失的连接归属。升级时先更新 Backend 和 Agent,再更新 Probe;旧 Agent 合并采样时会丢弃尚不认识的列。 TCP 回显的长度头和正文合为一次写入,避免在启用 `TCP_NODELAY` 时为长度头单独发送一个小段。`tcp.retrans` 仍按内核的重传段数与发送段数求比,不由应用消息数估算。 -采集层只产生**原始列**(`seq`、`size`、`t1…t4`、`t_send`/`t_recv`、`outcome`……),不做任何统计,也不认识上层的方案或协议;丢包率、RTT、抖动由 Backend 的分析层从这些列推算。 +采集层只产生原始列(`seq`、`size`、`t1…t4`、`t_send`/`t_recv`、`outcome`……),不做任何统计,也不认识上层的方案或协议;丢包率、RTT、抖动由 Backend 的分析层从这些列推算。 每个记录流(`StreamSpec`)声明: @@ -129,17 +129,17 @@ TCP 回显的长度头和正文合为一次写入,避免在启用 `TCP_NODELAY - `Key`:连接键(通常是 `seq`); - 每一列属于哪一侧由它的时钟决定:`TsProbe` 属于 Probe,`TsAgent` 属于 Agent,其余列属于主导方。 -两端各自把**本侧的列**记在一张按键索引的环形行表里(`RowStore`),Probe 每 10 秒把已完成的行切成列块经控制通道交给**对应的 Agent**,Agent 按声明合并成完整的 RawChunk 后上传: +两端各自把本侧的列记在一张按键索引的环形行表里(`RowStore`),Probe 每 10 秒把已完成的行切成列块经控制通道交给对应的 Agent,Agent 按声明合并成完整的 RawChunk 后上传: - `Driver = Probe`(回显):行取自列块,Agent 的列按键补上,缺失就是缺失(上行丢失); - `Driver = Agent`(单向数据流):行取自 Agent 的记录(按列块头里的序号范围),Probe 的列按键补上,缺失就是缺失(丢失); - 没有 `Key`,或只有主导方一侧有列:原样透传。 -新增一种记录流只需要写声明,不需要写合并代码。Probe 的列在规定时间内没有送达时,目前 Agent 带着已有的数据继续(不上传只有 Agent 一侧的 `partial` 块,见已知缺口)。 +新增一种记录流只需要写声明,不需要写合并代码。Probe 的列在规定时间内没有送达时,Agent 带着已有的数据继续,不上传只有 Agent 一侧的块。 ## 6. 代码结构(`backend/shared/MoShiro.Measurement`) -同一种采集方法的两端是**一对角色**写在同一个文件里:拨号方(Probe 永远是拨号方,适配 NAT)和监听方(Agent)。两端共用包的构造和校验、时钟、行表和合并;Probe 和 Agent 只负责组装自己那一侧。 +同一种采集方法的两端是一对角色写在同一个文件里:拨号方(Probe 永远是拨号方,适配 NAT)和监听方(Agent)。两端共用包的构造和校验、时钟、行表和合并;Probe 和 Agent 只负责组装自己那一侧。 ```text MoShiro.Measurement/ 依赖:MoShiro.Protocol(仅 BCL,零 NuGet) @@ -149,18 +149,23 @@ MoShiro.Measurement/ 依赖:MoShiro.Protocol(仅 BCL, │ ├─ MonoClock.cs 单调时钟(微秒)与 UTC 锚点 │ └─ RunningBlock.cs 一个运行中的积木(取消令牌 + 后台任务) ├─ Net/ -│ ├─ QuicDatagramTransport.cs、MeasurementDatagramRouter.cs MsQuic 数据面与逐流认证分发 +│ ├─ MsQuic*.cs、QuicDatagramTransport.cs、MeasurementDatagramRouter.cs MsQuic 数据面与逐流认证分发 +│ ├─ LinuxIcmpEcho.cs、TcpInfo.cs ICMP 回显;Linux `TCP_INFO` 采样 │ └─ Tcp.cs TcpLink(拨号:TLS 按指纹固定 + 认证首帧)、TcpAcceptor(监听:TLS 自动识别 + 认证首帧) ├─ Blocks/ 一个文件 = 一种采集方法的两个角色 +│ ├─ BlockBase.cs 积木的公共基类 │ ├─ Echo.cs DatagramEchoDialer/Listener、TcpEchoDialer/Listener │ ├─ Session.cs ConnectDialer、HoldDialer、IcmpEchoSolo(没有对端)、IdleListener │ ├─ Train.cs TrainDialer(接收,记 t_recv)、TrainListener(按码率发包,记发送行) +│ ├─ Bulk.cs `tcp_bulk` 的两个角色 │ └─ BlockCatalog.cs Kind → (拨号方, 监听方);capabilities、本地预算的支持列表都从这里推出来 ├─ Merging/ FragmentMerger(声明驱动的按键合并)、ChunkFinalizer(盖章:块头、时钟锚点、起止时间) -└─ Execution/ BudgetGuard(参数校验 + 本地硬上限,超出即拒绝) +├─ Execution/ BudgetGuard(参数校验 + 本地硬上限,超出即拒绝) +├─ Platform/ 主机汇总采样、IPv6 诊断、本机地址、平台识别 +└─ Update/ RestartUpdater(收到更新请求后有序退出) ``` -**边界**(由架构测试强制):采集层的任何代码里不能出现 REALITY、Hysteria2、Minecraft 这些业务概念,也没有测试方案——方案(例如 `hy2_ladder`、`reality_session`)只是若干采集原语阶段的编排,只存在于 Backend。监听方的准入策略(重放窗口、令牌桶、源地址学习)是安全边界,通过 `IAdmission` 由 Agent 实现,积木本身不含策略。 +边界(由架构测试强制):采集层的任何代码里不能出现 REALITY、Hysteria2、Minecraft 这些业务概念,也没有测试方案——方案(例如 `hy2_ladder`、`reality_session`)只是若干采集原语阶段的编排,只存在于 Backend。监听方的准入策略(重放窗口、令牌桶、源地址学习)是安全边界,通过 `IAdmission` 由 Agent 实现,积木本身不含策略。 ## 7. 资源与安全上限 @@ -181,10 +186,10 @@ MoShiro.Measurement/ 依赖:MoShiro.Protocol(仅 BCL, | QUIC Datagram 数据(`udp_echo`、`udp_train`) | 7443/udp | Agent 固定监听;Probe 每次新连接轮换本地源端口 | | TCP/TLS 数据(`tcp_echo`、`tcp_bulk`、`connect`、`hold`) | 7444/tcp | 按首帧中的 `stream_id` 分发 | -- **默认不使用 443**,避免与 VPS 上已有的 REALITY 或 Hysteria2 服务冲突,也不需要特权端口权限。 +- 默认不使用 443,避免与 VPS 上已有的 REALITY 或 Hysteria2 服务冲突,也不需要特权端口权限。 - 任务参数中的 `port` 只能取 Agent 本地配置 `listen` 列表中的端口。要用 443 测量,需要节点所有者在本机把 443 加入 `listen`(并授予 `CAP_NET_BIND_SERVICE`),然后在任务中指定 `port: 443`。 - 每个原始块的头部都记录实际使用的端口。当测量端口与被推算服务的常用端口(443)不同时,分析结果附带 `port_differs_from_service` 置信说明,因为运营商的 QoS 可能按端口区别对待。 ## 9. 边界 -采集端只模拟流量特征(端口、包大小、速率、握手、方向),**不实现** REALITY 或 Hysteria2 协议本身,分析结果会附带置信说明。用户态看不到 TCP 的逐段收发,所以 TCP 能拿到的最原始数据是“逐消息时间戳 + 内核采样”;pcap/eBPF 不在首版范围内。 +采集端只模拟流量特征(端口、包大小、速率、握手、方向),不实现 REALITY 或 Hysteria2 协议本身,分析结果附带置信说明。用户态看不到 TCP 的逐段收发,TCP 的原始数据是逐消息时间戳加内核采样;pcap / eBPF 见[路线图](../roadmap.md)。 diff --git a/docs/design/protocol.md b/docs/design/protocol.md index 2776633..8352df2 100644 --- a/docs/design/protocol.md +++ b/docs/design/protocol.md @@ -2,10 +2,10 @@ MoShiro 的两条控制链路都建立在 Fleet 之上: -- **Agent ↔ Backend**:Fleet 连接服务端(wss),见 [../backend/fleet/README.md](../../backend/fleet/README.md); -- **Probe ↔ Agent**:Fleet 节点间点对点会话(TLS),见 [../backend/fleet/README.md](../../backend/fleet/README.md) 的「点对点会话」一节。 +- Agent ↔ Backend:Fleet 连接服务端(wss),见 [Fleet 文档](../../backend/fleet/README.md); +- Probe ↔ Agent:Fleet 节点间点对点会话(TLS),见 [Fleet 文档](../../backend/fleet/README.md) 的「点对点会话」一节。 -认证、心跳、重连、能力协商、可靠发件箱、期望状态同步、JSON-RPC 信封都由 Fleet 负责。本文只定义 **MoShiro 自己的内容**:`moshiro.*` 方法、状态文档、二进制通道,以及 Fleet 之外的数据面(QUIC Datagram 和 TCP 数据连接)。所有类型都放在 `MoShiro.Protocol` 中。 +认证、心跳、重连、能力协商、可靠发件箱、期望状态同步、JSON-RPC 信封都由 Fleet 负责。本文只定义 MoShiro 自己的内容:`moshiro.*` 方法、状态文档、二进制通道,以及 Fleet 之外的数据面(QUIC Datagram 和 TCP 数据连接)。所有类型都放在 `MoShiro.Protocol` 中。 ## 1. Fleet 能力的映射 @@ -15,8 +15,8 @@ MoShiro 的两条控制链路都建立在 Fleet 之上: | 下发 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 集合或线路开关变化时重新推送 | | 定时重启 | 通知 `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)` | +| 上报原始块 | 发件箱,二进制通道 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` | @@ -53,7 +53,7 @@ MoShiro 的两条控制链路都建立在 Fleet 之上: ### 列块(Probe → Agent,通道 17) -Probe 只记录自己一侧的列,Agent 在数据面记录另一侧,由 Agent 合并、盖章、上传(见 `measurement.md` 第 5 节)。合并**由 Kind 的流声明驱动**(`StreamSpec`:`Driver`、`Key`,每一列的归属由它的时钟决定),没有按 Kind 手写的模式: +Probe 只记录自己一侧的列,Agent 在数据面记录另一侧,由 Agent 合并、盖章、上传(见 `measurement.md` 第 5 节)。合并由 Kind 的流声明驱动(`StreamSpec`:`Driver`、`Key`,每一列的归属由它的时钟决定),没有按 Kind 手写的模式: | 流声明 | 例子 | Probe 的列块 | Agent 补充 | | --- | --- | --- | --- | @@ -63,7 +63,7 @@ Probe 只记录自己一侧的列,Agent 在数据面记录另一侧,由 Agen 单块最多 `MaxRecordsPerChunk`(50 000)条记录:Probe 必须把大的阶段按序号范围切成多个列块(例如 100 Mbit/s、1000 B 的 10 秒阶段是 125 000 个包 → 4 个列块),`phase_end` 的应答报告总块数。块头里 Probe 必须带上 `agent_id`(它知道自己在和哪个 Agent 通信)。 -新增方法只需要在 `MoShiro.Protocol/Rpc` 中定义,并在节点组装处加一行 `.On(...)`。旧节点会通过 capabilities 协商自动避开不支持的方法。 +新增方法在 `MoShiro.Protocol/Rpc` 中定义,并在节点组装处加一行 `.On(...)`。旧节点通过 capabilities 协商避开不支持的方法。 ## 3. 数据面(不属于 Fleet) @@ -82,7 +82,7 @@ Probe 只记录自己一侧的列,Agent 在数据面记录另一侧,由 Agen 28+N 16 mac HMAC-SHA256(session_key, 前面全部字节),截断至 16 字节 ``` -**接收方处理规则**(任一步失败即静默丢弃并计数,**不回复**): +接收方处理规则(任一步失败即静默丢弃并计数,不回复): 1. 长度、magic、version、type 合法; 2. `session_id` 和 `stream_id` 都处于活跃状态; @@ -90,7 +90,7 @@ Probe 只记录自己一侧的列,Agent 在数据面记录另一侧,由 Agen 4. `seq` 不在重放窗口中; 5. 速率不超过声明速率的 1.2 倍,也不超过本地硬上限。 -通过后先**记录原始列**。收到 `EchoRequest` 时,立即回复一个长度**完全相同**的包,发往请求的**实际源地址**(这样可以跟随 NAT 映射变化),并记录回复时刻。 +通过后先记录原始列。收到 `EchoRequest` 时,立即回复一个长度相同的包,发往请求的实际源地址(这样可以跟随 NAT 映射变化),并记录回复时刻。 ### 3.2 TCP/TLS 数据连接 @@ -107,11 +107,11 @@ Probe 只记录自己一侧的列,Agent 在数据面记录另一侧,由 Agen - Agent 和 Probe 会在 hello 与心跳中上报 IPv6 套接字、监听、全局地址、默认路由及网络命名空间状态。Podman/Docker 宿主网络部署里,采样反映宿主接口;桥接网络容器会分别报告容器网络和能读取到的宿主 IPv6。诊断只有布尔值和粗粒度状态,不包含地址和接口名称。 - `ConnectedProbe`、`TestRunParams` 和 RawChunk 块头新增 `ip_version`,取值 `ipv4` / `ipv6`,缺省 `ipv4`。Agent 合并列块时以认证过的控制会话地址族为准,覆盖 Probe 上报的值。 - IPv4 的线路 id `agent~probe`、序列 `line:probe` 保持不变;IPv6 使用 `agent~probe~ipv6`、`line:probe:ipv6`。指标、重算、SSE 和测试结果按路径隔离。 -- 本次是 JSON 字段扩展,二进制包、列编码和容器布局没有变化,协议与 `container_version` 保持原版本。双栈采集需要升级 Agent 和 Probe;旧节点继续提供 IPv4。 +- 双栈是 JSON 字段扩展,二进制包、列编码和容器布局不变,`protocol_version` 与 `container_version` 不升级。双栈采集需要升级 Agent 和 Probe,旧节点继续提供 IPv4。 ### 通用规则 -- Fleet 层的兼容规则见 [../backend/fleet/README.md](../../backend/fleet/README.md)。 +- Fleet 层的兼容规则见 [Fleet 文档](../../backend/fleet/README.md)。 - `moshiro.*` 方法:新增方法或可选字段不需要升级版本,接收方忽略未知字段。 - RawChunk:块头自描述,新增列或新增 Kind 不需要升级 `container_version`;只有容器格式变化时才升级。 - QUIC Datagram 和 TCP 数据连接首帧的布局变化时,必须升级 `protocol_version`,并更新黄金字节测试。 diff --git a/docs/design/query.md b/docs/design/query.md index cb730b3..679576f 100644 --- a/docs/design/query.md +++ b/docs/design/query.md @@ -4,11 +4,11 @@ ## 1. 设计目标 -1. **任意时间段**:支持 5 分钟、15 分钟这类相对时间段,也支持任意绝对区间。 -2. **短时间段足够精细**:近 5 分钟精确到 1 秒,近 1 小时精确到 5 秒。 -3. **汇总精确**:“这段时间内的丢包率、p95”由累加器精确合并得出,**不是各点的平均**。 -4. **快**:常用的短时间段直接从内存读取;较长的时间段读取预聚合层,现场合并。 -5. **新增指标时前端不用改**:指标按 id 选取,按分组(preset)展示,含义和单位来自目录。 +1. 任意时间段:支持 5 分钟、15 分钟这类相对时间段,也支持任意绝对区间。 +2. 短时间段足够精细:近 5 分钟精确到 1 秒,近 1 小时精确到 5 秒。 +3. 汇总精确:“这段时间内的丢包率、p95”由累加器精确合并得出,不是各点的平均。 +4. 快:常用的短时间段直接从内存读取;较长的时间段读取预聚合层,现场合并。 +5. 新增指标时前端不用改:指标按 id 选取,按分组(preset)展示,含义和单位来自目录。 ## 2. 时间段与步长 @@ -56,7 +56,7 @@ 原始段文件(30 天)── 超出热层时间范围的 1s~5s 精度查询 → 从原始块现算(区间 ≤ 15 分钟) ``` -**选择数据源的规则**:在“覆盖该时间段”并且“粒度能整除步长”的各层中,选择**最粗**的一层,然后把累加器按步长现场合并。 +选择数据源的规则:在“覆盖该时间段”并且“粒度能整除步长”的各层中,选择最粗的一层,然后把累加器按步长现场合并。 示例: @@ -72,7 +72,7 @@ | 同上,但区间 > 15 分钟 | 降级为 10s 层或 1m 层,并标记 `degraded: true` | | 所需的层已经过期 | 降级为下一个更粗的可用层,并标记 `degraded: true` | -**热层内存估算**:15 条线路 × 3600 秒 × 约 300 B(稀疏直方图 + 计数)≈ 16 MB。热层也是 LiveHub 实时推送的数据来源。 +热层内存估算:15 条线路 × 3600 秒 × 约 300 B(稀疏直方图 + 计数)≈ 16 MB。热层也是 LiveHub 实时推送的数据来源。 ## 4. 指标选择 @@ -86,14 +86,15 @@ id 的格式为 `指标.统计量`,详见 [analysis.md](analysis.md) 第 4 节 `preset=udp,tcp` -分组在 `Backend.Analysis` 的注册表中声明,并通过 `/api/public/catalog/presets` 下发给前端。新增指标后,只要把它加进某个分组,前端就会自动展示。 +分组在 `Backend.Analysis` 的 `MetricRegistry.Presets` 中声明,并通过 `/api/public/catalog/presets` 下发给前端。新增指标后,只要把它加进某个分组,前端就会自动展示。 | 分组 | 指标 | | --- | --- | -| `udp` | `loss.up`、`loss.down`、`loss.total`、`rtt.p50`、`rtt.p95`、`rtt.p99`、`jitter.up.p95`、`jitter.down.p95`、`loss.run_max`、`reorder`、`dup` | +| `quic` | `loss.up`、`loss.down`、`loss.total`、`rtt.p50`、`rtt.p95`、`rtt.p99`、`jitter.up.p95`、`jitter.down.p95`、`loss.run.up_max`、`loss.run.down_max`、`reorder`、`dup` | +| `udp` | 与 `quic` 相同,保留为旧链接的别名;连续丢包使用 `loss.run_max` | | `tcp` | `tcp.rtt.p50`、`tcp.rtt.p95`、`tcp.rtt.p99`、`tcp.rtt.p999`、`tcp.rtt.max`、`tcp.rtt.tail`、`tcp.slow`、`tcp.stall.per_min`、`tcp.stall_max`、`tcp.retrans`、`tcp.srtt.p95` | | `connect` | `connect.success`、`connect.tcp_ms.p95`、`connect.tls_ms.p95`、`connect.fail.timeout`、`connect.fail.rst`、`connect.fail.tls` | -| `session` | `hold.resets.per_h`、`hold.lifetime_max`、`probe.endpoint_changes` | +| `session` | `hold.resets.per_h`、`hold.lifetime_max` | | `scores` | `score.base`、`score.mc`、`score.hy2`、`score.reality` | 目录中每个 id 都附带 `unit`、`label`、`description`、`better`(`lower` 或 `higher`,用于前端配色),以及 `thresholds`(优、良、差三档阈值,由模型给出)。 @@ -102,7 +103,7 @@ id 的格式为 `指标.统计量`,详见 [analysis.md](analysis.md) 第 4 节 ```json { - "agent_id": "agt_…", + "line_id": "agt_…~prb_…", "agent_id": "agt_…", "probe_id": "prb_…", "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], @@ -123,9 +124,9 @@ id 的格式为 `指标.统计量`,详见 [analysis.md](analysis.md) 第 4 节 } ``` -- 时间轴 `t` 使用 Unix 毫秒,只出现一次;`values` 中每个数组的长度都与 `t` 相同,可以直接交给 uPlot 或 ECharts。**这是 [api.md](../api/api.md) 第 7.4 节“时间用 RFC 3339”规则的明确例外**,理由是减小体积、方便绘图;其余时间字段仍然使用 RFC 3339。 +- 时间轴 `t` 使用 Unix 毫秒,只出现一次;`values` 中每个数组的长度都与 `t` 相同,可以直接交给 uPlot 或 ECharts。这是 [api.md](../api/api.md) 第 7.4 节“时间用 RFC 3339”规则的明确例外,理由是减小体积、方便绘图;其余时间字段仍然使用 RFC 3339。 - 某个桶没有数据时填 `null`(不填 0),前端据此绘制断线。 -- `summary` 是**整个时间段**的精确合并结果。`coverage` = 实际数据量 / 期望数据量,用来提示前端“数据是否完整”。 +- `summary` 是整个时间段的精确合并结果。`coverage` = 实际数据量 / 期望数据量,用来提示前端“数据是否完整”。 - `gaps` 和 `events` 用于在曲线上做标注(断线、端点变化、时钟跳变等)。 ## 6. 端点 @@ -134,7 +135,7 @@ id 的格式为 `指标.统计量`,详见 [analysis.md](analysis.md) 第 4 节 | --- | --- | --- | | `GET /api/public/overview` | 首页排行:每条线路在该时间段内的汇总,并支持排序 | `range`(默认 `5m`)、`preset` 或 `metrics`、`sort`、`order` | | `GET /api/public/lines/{line_id}/series` | 单条线路的时序 | `range` 或 `from/to`、`step`、`preset` 或 `metrics` | -| `GET /api/public/compare` | 多条线路对比(时间轴对齐) | `agents`、`metric`(单个)、`range` 或 `from/to`、`step` | +| `GET /api/public/compare` | 多条线路对比(时间轴对齐) | `lines`(线路 id,逗号分隔)、`metric`(单个)、`range` 或 `from/to`、`step` | | `GET /api/public/lines/{line_id}/distribution` | 该时间段内的分布(直方图、CDF) | `metric`(直方图类,如 `rtt`、`jitter.down`、`tcp.rtt`)、`range` 或 `from/to` | | `GET /api/public/lines/{line_id}/heatmap` | 时间 × 延迟桶的热力图(类似 smokeping) | `metric`、`range` 或 `from/to`、`step` | | `GET /api/public/catalog/presets` | 分组定义 | — | @@ -145,14 +146,14 @@ id 的格式为 `指标.统计量`,详见 [analysis.md](analysis.md) 第 4 节 { "range": "15m", "from": "…", "to": "…", "lines": [ - { "agent_id": "agt_…", "name": "jp-1", "online": true, + { "id": "agt_…~prb_…", "agent_id": "agt_…", "probe_id": "prb_…", "name": "jp-1", "online": true, "summary": { "loss.down": 0.1, "loss.up": 0.0, "rtt.p95": 48.0, "tcp.retrans": 0.1, "score.mc": 94 }, "coverage": 1.0 } ] } ``` -`compare` 的响应示例:`{ "t":[…], "values":{ "agt_a":[…], "agt_b":[…] }, "summary":{ "agt_a":0.1, "agt_b":0.4 } }` +`compare` 的响应示例:`{ "t":[…], "values":{ "":[…], "":[…] }, "summary":{ "":0.1, "":0.4 } }` `distribution` 的响应示例:`{ "metric":"rtt", "unit":"ms", "buckets":[{"le":40.0,"count":812}, …], "p50":44.2, "p95":61.0, "p99":140.2, "count":8990 }` @@ -166,7 +167,7 @@ id 的格式为 `指标.统计量`,详见 [analysis.md](analysis.md) 第 4 节 ② SSE /api/public/live?topics=series:{id}:2s:udp,tcp → 每当原始块到达(约每 10 s), 推送一批新的 2s 桶和最新的汇总 event: series - data: {"agent_id":"…","step":"2s","t":[…5 个],"values":{…},"summary":{…}} + data: {"line_id":"…","step":"2s","t":[…5 个],"values":{…},"summary":{…}} ③ 前端把新点追加到末尾,并丢弃超出窗口的旧点;用推送来的汇总替换原来的汇总 前端切换为“自定义 09:00–10:00”→ 只执行 ①(live=false) @@ -174,7 +175,7 @@ id 的格式为 `指标.统计量`,详见 [analysis.md](analysis.md) 第 4 节 - SSE 的订阅主题格式为 `series:{line_id}:{step}:{preset}`,只允许 ≤ 1m 的步长;更粗的步长由前端定时重新拉取即可。 - `overview` 也可以跟随实时:订阅 `overview:{range}:{preset}`,每 10 s 推送一次最新汇总。 -- 实时延迟:取决于原始块的切块节奏(10 s)加上处理时间,**典型值为 10~12 s**。1s 步长的点会按批次到达,而不是每秒一个。 +- 实时延迟:取决于原始块的切块节奏(10 s)加上处理时间,典型值为 10~12 s。1s 步长的点会按批次到达,而不是每秒一个。 ## 8. 缓存与限额 @@ -195,15 +196,17 @@ id 的格式为 `指标.统计量`,详见 [analysis.md](analysis.md) 第 4 节 ## 9. 实现位置 ```text -Backend.Api/Public/SeriesEndpoints.cs 参数解析、校验、缓存头(不含业务) +Backend.Api/Public/QueryEndpoints.cs 参数解析、校验、缓存头(不含业务) Backend.Application/Query/ ├─ RangeResolver range 或 from/to 与 step → 对齐后的桶序列 + ├─ Steps / Bucketing 常用步长与分桶 ├─ SourcePlanner 选择数据源(热层 / 10s / 1m / 5m / 1h / 原始块现算),判断是否降级 - ├─ SeriesQuery 读取累加器 → 按步长合并 → 导出统计量 → 列式结果 - ├─ SummaryQuery 整个时间段的精确合并(overview 和 series 共用) - └─ DistributionQuery 合并直方图 → 分桶、CDF、热力图 -Backend.Application/Live/LiveHub 热层环形缓冲(1s,1 小时)+ SSE 主题 -Backend.Analysis 累加器的合并与导出(与持久化路径共用同一份代码) + ├─ RowLoader 读取累加器行 + ├─ MetricSelection 按 id 或分组选取指标 + ├─ QueryService series、overview、compare、distribution、heatmap + └─ QueryResults 列式结果类型 +Backend.Application/Live/ HotLayer(1s 环形缓冲,1 小时)、HotLayerBackfill、LiveHub(SSE 主题) +Backend.Analysis 累加器的合并与导出(与持久化路径共用同一份代码) ``` -新增指标时,这套查询代码不需要任何改动:`SeriesQuery` 只通过注册表按 id 读取累加器并导出值。 +新增指标时查询代码不需要改动:`QueryService` 通过注册表按 id 读取累加器并导出值。 diff --git a/docs/design/storage.md b/docs/design/storage.md index 959b946..a6b26cb 100644 --- a/docs/design/storage.md +++ b/docs/design/storage.md @@ -1,162 +1,53 @@ # Backend 存储 -实现位于 `MoShiro.Backend.Storage.Sqlite`,负责实现 `Backend.Application` 中定义的端口接口,**只做读写,不含业务逻辑**。降采样和分析由 Application 调用 Analysis 完成。 +实现位于 `MoShiro.Backend.Storage.Sqlite`,负责实现 `Backend.Application` 中定义的端口接口,只做读写,不含业务逻辑。降采样和分析由 Application 调用 Analysis 完成。 ## 1. 目录布局 ```text data/ ├─ moshiro.db # SQLite:配置、身份、原始块索引、指标、测试结果 +├─ backups/ # Backend 更新前的数据库备份 └─ raw/ └─ 2026-09-27/ # 按 UTC 日期分目录,保留期满整目录删除 - ├─ agt_01J…/ # 每条线路或每个 Agent 一个目录 + ├─ agt_01J…/ # 每个 Agent 一个目录 │ ├─ udp_echo.seg # 段文件:同一天、同一 Kind 的块依次追加 │ ├─ tcp_echo.seg │ └─ tsk_…_icmp_echo.seg # 目标任务 └─ tests/tst_01J…/phase0.seg … ``` -- **段文件**:把收到的 RawChunk 原样追加写入(块本身已经带 crc,并且自描述),SQLite 只记录 `(segment, offset, length)`。每 10 秒一块也不会产生海量小文件。 -- **写入顺序**:追加写入 → `fsync`(每批合并执行一次)→ 插入索引 → 由收件箱确认(Fleet 回复 `fleet.outbox.ack`)。启动时截断没有索引记录的段文件尾部。 -- **去重**:`chunk_id` 已存在时,直接回复 ACK。 +- 段文件:把收到的 RawChunk 原样追加写入(块本身已经带 crc,并且自描述),SQLite 只记录 `(segment, offset, length)`。每 10 秒一块也不会产生海量小文件。 +- 写入顺序:追加写入 → `fsync`(每批合并执行一次)→ 插入索引 → 由收件箱确认(Fleet 回复 `fleet.outbox.ack`)。启动时截断没有索引记录的段文件尾部。 +- 去重:`chunk_id` 已存在时,直接回复 ACK。 ## 2. SQLite 配置 - `journal_mode=WAL`、`synchronous=NORMAL`、`foreign_keys=ON`、`busy_timeout=5000`。 -- **单写入者**:所有写操作经过一个有界 `Channel`,由一个后台循环执行;读操作使用独立的只读连接池。 +- 单写入者:所有写操作经过一个有界 `Channel`,由一个后台循环执行;读操作使用独立的只读连接池。 - 时间统一存储为 Unix 毫秒 `INTEGER`(UTC);表结构版本记录在 `PRAGMA user_version`,迁移脚本为内置 SQL。 ## 3. 表结构 -`agents`、`join_tokens` 以及公钥和状态版本字段,同时作为 Fleet 的 `IFleetStore` 存储(由 `MoShiro.Backend.Storage.Sqlite` 实现),和 MoShiro 的其他数据放在同一个 `moshiro.db` 中,由同一个单写入者写入。CI 签名的发布清单存在 `settings` 表里(`release.<组件>.manifest`、`release.<组件>.signature`),面板只原样保存和转发。 +完整定义以 `Schema.cs` 为准(当前版本 16,迁移为按版本号追加的内置 SQL)。`agents`、`join_tokens` 及公钥、状态版本字段同时作为 Fleet 的 `IFleetStore` 实现,与其他数据放在同一个 `moshiro.db`,由同一个单写入者写入。CI 签名的发布清单存在 `settings` 表中(`release.<组件>.manifest`、`release.<组件>.signature`),面板原样保存和转发。 -```sql --- 身份与注册 ------------------------------------------------------------- -CREATE TABLE agents ( - id TEXT PRIMARY KEY, -- agt_ - name TEXT NOT NULL UNIQUE, - status TEXT NOT NULL, -- pending | active | disabled - pubkey BLOB NOT NULL, -- ECDSA P-256 身份公钥 - tls_spki BLOB NOT NULL, -- 面向 Probe 的证书指纹(由 tls_pubkey 算出) - tls_pubkey BLOB, -- 面向 Probe 的证书公钥,Agent 在 hello 里上报;Probe 用它固定证书、派生敲门密钥 - public_host TEXT NOT NULL, -- 自动识别(Agent 上报的公网地址),手填后标记 public_host_manual,不再被覆盖 - probe_tcp_port INTEGER NOT NULL, probe_udp_ports TEXT NOT NULL, - labels TEXT NOT NULL DEFAULT '[]', - public_visible INTEGER NOT NULL DEFAULT 1, -- 是否在 public API 中展示 - kinds TEXT, agent_version TEXT, local_limits TEXT, -- 来自 fleet.hello - task_version INTEGER NOT NULL DEFAULT 0, applied_task_version INTEGER NOT NULL DEFAULT 0, - created_at INTEGER NOT NULL, last_seen_at INTEGER, row_version INTEGER NOT NULL DEFAULT 0 -); +| 类别 | 表 | 说明 | +| --- | --- | --- | +| 身份与注册 | `agents`、`probes`、`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、任务、测试、起止时间 | +| 指标 | `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` | 节点事件;每个节点每分钟一行的主机资源汇总 | -CREATE TABLE join_tokens ( - id TEXT PRIMARY KEY, token_sha256 BLOB NOT NULL UNIQUE, note TEXT, - labels TEXT NOT NULL DEFAULT '[]', auto_approve INTEGER NOT NULL, - max_uses INTEGER NOT NULL, uses INTEGER NOT NULL DEFAULT 0, - expires_at INTEGER NOT NULL, revoked_at INTEGER, created_at INTEGER NOT NULL -); - -CREATE TABLE host_samples ( -- 主机资源占用,每个 Agent 每分钟一行(schema v3) - agent_id TEXT NOT NULL, at INTEGER NOT NULL, - cpu REAL, mem_used INTEGER, mem_total INTEGER, disk_used INTEGER, disk_total INTEGER, - net_rx_bps INTEGER, net_tx_bps INTEGER, uptime INTEGER, - PRIMARY KEY (agent_id, at) -) WITHOUT ROWID; - -CREATE TABLE probes ( -- 每个 Probe 身份一行(吊销只设 revoked_at) - id TEXT PRIMARY KEY, name TEXT NOT NULL, pubkey BLOB NOT NULL, - created_at INTEGER NOT NULL, revoked_at INTEGER -); - -CREATE TABLE users ( - id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, - password_hash BLOB NOT NULL, password_salt BLOB NOT NULL, iterations INTEGER NOT NULL, - created_at INTEGER NOT NULL -); -CREATE TABLE sessions ( - id_sha256 BLOB PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - csrf_token BLOB NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL -); -CREATE TABLE api_tokens ( - id TEXT PRIMARY KEY, name TEXT NOT NULL, scope TEXT NOT NULL, -- read | admin - token_sha256 BLOB NOT NULL UNIQUE, created_at INTEGER NOT NULL, last_used_at INTEGER, revoked_at INTEGER -); -CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL); -- 如 site.public = true - --- 任务 ------------------------------------------------------------------ -CREATE TABLE tasks ( - id TEXT PRIMARY KEY, -- tsk_ - scope TEXT NOT NULL, -- line | target - agent_id TEXT REFERENCES agents(id) ON DELETE CASCADE, -- 为 NULL 时,是适用于所有 Agent 的 line 模板 - base_task_id TEXT REFERENCES tasks(id) ON DELETE CASCADE, -- 针对某个 Agent 覆盖模板时,指向模板 - kind TEXT NOT NULL, - target TEXT, -- 仅 target 任务 - params TEXT NOT NULL DEFAULT '{}', - schedule TEXT NOT NULL, -- continuous | every:<分钟> - enabled INTEGER NOT NULL DEFAULT 1, - updated_at INTEGER NOT NULL, row_version INTEGER NOT NULL DEFAULT 0 -); - -CREATE TABLE tests ( -- 按需测试(once) - id TEXT PRIMARY KEY, agent_id TEXT NOT NULL, plan TEXT NOT NULL, params TEXT NOT NULL, - trigger TEXT NOT NULL, -- api | schedule: - status TEXT NOT NULL, -- queued | running | succeeded | failed | aborted - error_code TEXT, requested_at INTEGER NOT NULL, started_at INTEGER, finished_at INTEGER -); -CREATE INDEX ix_tests_agent ON tests (agent_id, requested_at); - --- 原始数据索引 ------------------------------------------------------------ -CREATE TABLE raw_segments ( - id INTEGER PRIMARY KEY, day TEXT NOT NULL, path TEXT NOT NULL UNIQUE, bytes INTEGER NOT NULL -); -CREATE TABLE raw_chunks ( - chunk_id TEXT PRIMARY KEY, segment_id INTEGER NOT NULL, offset INTEGER NOT NULL, length INTEGER NOT NULL, - agent_id TEXT NOT NULL, series TEXT NOT NULL, -- line | target: - kind TEXT NOT NULL, task_id TEXT, test_id TEXT, phase INTEGER, - start_ts INTEGER NOT NULL, duration_ms INTEGER NOT NULL, partial INTEGER NOT NULL -) WITHOUT ROWID; -CREATE INDEX ix_raw_series ON raw_chunks (agent_id, series, start_ts); -CREATE INDEX ix_raw_test ON raw_chunks (test_id) WHERE test_id IS NOT NULL; - --- 指标(通用存储:每个桶一行,内容为 指标id → 累加器状态 的映射)---------------- --- 分层:metric_10s(48 小时)、metric_1m(30 天)、metric_5m(365 天)、metric_1h(永久) --- 另有 1s 精度的内存热层(最近 1 小时,不落盘),见 query.md 第 3 节 -CREATE TABLE metric_1m ( -- 60 s - agent_id TEXT NOT NULL, series TEXT NOT NULL, window_start INTEGER NOT NULL, - partial INTEGER NOT NULL, - metrics BLOB NOT NULL, -- AccumulatorCodec 编码的 {metric_id: state} - PRIMARY KEY (agent_id, series, window_start) -) WITHOUT ROWID; --- metric_10s 结构相同(由流式路径直接写入,不经过降采样) --- metric_5m / metric_1h 结构相同(window_start 换成 bucket_start,另加 windows 计数) - -CREATE TABLE test_results ( - test_id TEXT PRIMARY KEY, metrics BLOB NOT NULL, series BLOB -- series 为逐秒或逐档的曲线 -) WITHOUT ROWID; - -CREATE TABLE metric_versions (metric_id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL, - recompute_from INTEGER, recompute_cursor INTEGER); - -CREATE TABLE analysis_queue ( - agent_id TEXT NOT NULL, series TEXT NOT NULL, window_start INTEGER NOT NULL, - not_before INTEGER NOT NULL, reason TEXT NOT NULL, -- new | late_chunk | recompute - PRIMARY KEY (agent_id, series, window_start) -) WITHOUT ROWID; -CREATE TABLE rollup_dirty (level INTEGER NOT NULL, agent_id TEXT NOT NULL, series TEXT NOT NULL, - bucket_start INTEGER NOT NULL, PRIMARY KEY (level, agent_id, series, bucket_start)) WITHOUT ROWID; - --- 事件 ------------------------------------------------------------------ -CREATE TABLE events (id INTEGER PRIMARY KEY, agent_id TEXT, kind TEXT NOT NULL, - at INTEGER NOT NULL, detail TEXT); -CREATE INDEX ix_events_time ON events (at); -``` - -**要点:新增 Kind 或指标都不需要改表结构。** 指标是 `metric_*.metrics` 里的一个键;Kind 只是 `raw_chunks.kind` 的一个取值。 +新增 Kind 或指标不需要改表结构:指标是 `metric_*.metrics` 中的一个键,Kind 只是 `raw_chunks.kind` 的一个取值。1 秒精度的热层在内存中,不落盘,见 [query.md](query.md) 第 3 节。 ## 4. 保留 | 数据 | 默认保留 | | --- | --- | -| 持续任务的原始段文件 | **30 天** | +| 持续任务的原始段文件 | 30 天 | | 按需测试的原始段文件 | 90 天 | | 热层(1s,内存) | 最近 1 小时 | | `metric_10s` | 48 小时 | @@ -165,9 +56,9 @@ CREATE INDEX ix_events_time ON events (at); | `metric_1h`、`tests`、`test_results` | 永久 | | `events` | 365 天 | -保留期在管理端的设置里改,对应的键:`retention.raw_days`、`retention.test_raw_days`、`retention.metric_10s_hours`、`retention.metric_1m_days`、`retention.metric_5m_days`、`retention.events_days`。 +保留期在管理端设置中修改,键为 `retention.raw_days`、`retention.test_raw_days`、`retention.metric_10s_hours`、`retention.metric_1m_days`、`retention.metric_5m_days`、`retention.events_days`、`retention.host_days`(主机样本,默认 7 天)。 -降采样由 Application 中的 `Downsampler` 负责:读取下一级的累加器状态,用 `AccumulatorCodec.Merge` 合并后写回。所有保留期都可以配置。 +降采样由 Application 的 `AnalysisScheduler` 驱动:读取下一级的累加器状态,合并后写回上一层,待处理的桶记录在 `rollup_dirty`。 ## 5. 容量估算 @@ -175,19 +66,12 @@ CREATE INDEX ix_events_time ON events (at); | 数据 | 估算 | | --- | --- | -| 原始数据 | 每条线路约 7 MB/天(udp_echo)+ 14 MB/天(tcp_echo 及内核采样)≈ 21 MB/天;15 条线路约 0.3 GB/天,30 天约 **9.5 GB** | +| 原始数据 | 每条线路约 7 MB/天(udp_echo)+ 14 MB/天(tcp_echo 及内核采样)≈ 21 MB/天;15 条线路约 0.3 GB/天,30 天约 9.5 GB | | 按需测试 | 一次 `hy2_ladder` 约 1.5 MB;每条线路每天 4 次,90 天约 8 GB | | SQLite | 指标窗口和降采样约 1~2 GB/年 | -写入量约每秒 3 块,单写入者绰绰有余。M5 阶段会用模拟数据验证这些估算。 +写入量约每秒 3 块,单写入者可以承载。以上为估算值。 ## 6. 查询 时间段、自动步长、数据源选择、现场合并和降级规则见 [query.md](query.md)。 - -## 7. 与实现的差异(以 `Schema.cs` 为准) - -- `agents`:`name` 不再 `UNIQUE`,改为“已建立(非 pending)节点之间唯一”的部分唯一索引,pending 节点可以重名;新增 `task_hash`(有效任务集的哈希,用来判断版本是否要递增)、`rejected`(节点上报的拒绝原因)、`meta`(注册元数据)。 -- `tests`:新增 `phase`、`phase_count`;`test_results` 的 `metrics` / `series` 保存 JSON。 -- `analysis_queue`:新增 `seq`,每次入队递增;完成时只删除序号没变的项,处理期间到达的迟到块不会丢更新。 -- `rollup_dirty`:先处理低层(5 分钟)再处理高层(1 小时)。 diff --git a/docs/dev/coding-style.md b/docs/dev/coding-style.md index f80baf2..1f05e13 100644 --- a/docs/dev/coding-style.md +++ b/docs/dev/coding-style.md @@ -1,6 +1,6 @@ # C# 代码风格 -基线是 [dotnet/runtime 编码风格](https://github.com/dotnet/runtime/blob/main/docs/coding-guidelines/coding-style.md),再加上本项目的补充规则。所有能自动检查的规则都写在 `.editorconfig` 或 `BannedSymbols.txt` 里,**违反即编译失败**(`EnforceCodeStyleInBuild` + `TreatWarningsAsErrors`);无法自动检查的规则由代码评审把关。 +基线是 [dotnet/runtime 编码风格](https://github.com/dotnet/runtime/blob/main/docs/coding-guidelines/coding-style.md),再加上本项目的补充规则。所有能自动检查的规则都写在 `.editorconfig` 或 `BannedSymbols.txt` 里,违反即编译失败(`EnforceCodeStyleInBuild` + `TreatWarningsAsErrors`);无法自动检查的规则由代码评审把关。 ## 1. 格式与命名(`.editorconfig` 强制) @@ -26,21 +26,21 @@ ## 2. 命名语义 -- **名称里带单位**:`timeoutMs`、`rttUs`、`payloadBytes`。API 内部统一用 `TimeSpan`,只在边界(线协议、配置、JSON)使用带单位后缀的整数。 +- 名称里带单位:`timeoutMs`、`rttUs`、`payloadBytes`。API 内部统一用 `TimeSpan`,只在边界(线协议、配置、JSON)使用带单位后缀的整数。 - 不使用 `Manager`、`Helper`、`Util`、`Service`(ASP.NET 约定位置除外)之类的模糊名称,名称要说明职责:`UploadQueue`、`SessionTable`、`ResultWriter`。 - 布尔值使用 `is`/`has`/`can` 前缀;返回“成功与否加输出值”的方法使用 `TryXxx(out ...)`。 ## 3. 类型与数据 - 契约和领域值使用 `sealed record` 或 `readonly record struct`,并且不可变。 -- 热路径上的计数器和缓冲区使用可变 `struct` 或 `sealed class`,**所有权要写清楚**(谁创建、谁归还池)。 +- 热路径上的计数器和缓冲区使用可变 `struct` 或 `sealed class`,所有权要写清楚(谁创建、谁归还池)。 - ID 使用强类型(`readonly record struct ProbeId(string Value)`),不直接传裸 `string`。 - 枚举序列化成 snake_case 字符串(`JsonStringEnumConverter` + `JsonStringEnumMemberName`);新增枚举值不属于破坏性变更,但所有消费方都必须能处理未知值。 - 可空性:公共成员必须标注;使用 `!` 抑制警告时,同一行要写注释说明理由。 ## 4. 错误处理 -- **预期内的失败不抛异常**:超时、拒绝连接、认证失败、校验失败都返回结构化结果,其中包含 `ErrorCode`。 +- 预期内的失败不抛异常:超时、拒绝连接、认证失败、校验失败都返回结构化结果,其中包含 `ErrorCode`。 - 异常只用于程序缺陷或无法继续运行的状态;只在边界(会话循环、请求管线、进程入口)捕获,并记录日志。 - 禁止空 `catch`;捕获 `OperationCanceledException` 时必须确认它确实来自自己的取消令牌。 @@ -61,8 +61,8 @@ - 序列化只使用 `System.Text.Json` 源生成上下文(或手写的二进制编解码);禁用无上下文的 `JsonSerializer.Serialize(T)` 重载。 - 不做程序集扫描、`Activator.CreateInstance`、`Type.GetType(string)`、动态代理或 `Reflection.Emit`。 -- **Probe/Agent**:不使用 DI 容器,在 `Program.cs` 中手工组装对象,依赖关系一眼可见;不使用 LINQ;不使用 `HttpClient`(Agent 的 WebSocket 使用 `ClientWebSocket`)。 -- **Backend**:使用 `CreateSlimBuilder` + Minimal API;只能通过 `Microsoft.Data.Sqlite` 执行手写的参数化 SQL,禁止字符串拼接 SQL。 +- Probe/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 中说明理由。 @@ -77,7 +77,7 @@ - 由 `BannedSymbols.Node.txt` 在编译期强制:禁止启动进程、动态加载程序集或本机库、使用 `HttpClient`、在配置目录以外写文件。唯一的豁免点是 `Fleet.Hosting.ConfigStore`,豁免必须写 `#pragma` 并附上理由注释。注册在 `fleet-enroll` 中完成,更新由节点宿主机上的更新器完成,常驻进程不需要这些能力。 - 节点不能新增任何“由远端触发的非采集动作”对应的 RPC 方法。凡是新增 RPC 方法的 PR,都要在描述中说明它不会扩大权限边界。 -- Backend 下发的任何参数都要先经过 `ParamSpec` 校验,再经过本地硬上限(`BudgetGuard`)检查,**两道校验都不能省略**。 +- Backend 下发的任何参数都要先经过 `ParamSpec` 校验,再经过本地硬上限(`BudgetGuard`)检查,两道校验都不能省略。 - 日志中不能出现私钥、签名、`session_key`、令牌或 MAC。 ## 9.1 Fleet 的通用性 @@ -85,15 +85,15 @@ - `backend/fleet/**` 不能引用任何 `MoShiro.*`,也不能出现产品相关的概念、命名或默认值(例如端口 7443、`moshiro` 字样),由 `ArchitectureTests` 和代码评审把关。 - 新增的公开 API 如果只有 MoShiro 使用,应先放在 MoShiro 中,等出现第二个使用者后再下沉到 Fleet。 - Fleet 永远不提供 exec、shell、文件读写或远程下发代码的方法,这条规则不接受例外。 -- 公开 API 的变更记录在 `PublicAPI.Unshipped.txt` 中;PR 描述需要说明是否属于破坏性变更。 +- Fleet 公开 API 的变更,PR 描述需说明是否属于破坏性变更(不使用 PublicApiAnalyzers)。 ## 10. 声明式扩展的写法 -- **Kind**:只放在 `Protocol/Catalog/Kinds`,一个 Kind 一个静态字段;参数一律使用 `P.Int`、`P.Enum` 等 `ParamSpec` 写出默认值和上下限,禁止写没有上下限的参数。 -- **积木**:实现对应的积木接口,在 `BlockRegistry` 中注册一行;积木内部不能读取全局配置,所有输入都来自参数。 -- **指标**:只放在 `Analysis/Metrics`,一行一个 `MetricDef`;指标 id 使用 `领域.名称`(如 `tcp.stall`),导出值为 `id.统计量`(如 `rtt.p95`)。已发布的 id 不能改名,只能新增。 -- **算子**:纯函数,输入列视图,输出累加器更新;每个算子都要有边界用例测试(空输入、单个样本、乱序、时钟回绕)。 -- **模型**:只读取指标,不读取原始列;评分函数需要有单调性测试(指标变差时,分数不能升高)。 +- Kind:只放在 `Protocol/Catalog/Kinds`,一个 Kind 一个静态字段;参数一律使用 `P.Int`、`P.Enum` 等 `ParamSpec` 写出默认值和上下限,禁止写没有上下限的参数。 +- 积木:实现拨号方 / 监听方接口,在 `BlockCatalog` 中登记 Kind 到积木的映射;积木内部不能读取全局配置,所有输入都来自参数。 +- 指标:只放在 `Analysis/Metrics`,一行一个 `MetricDef`,并加入 `MetricRegistry.All`;指标 id 使用 `领域.名称`(如 `tcp.stall`),导出值为 `id.统计量`(如 `rtt.p95`)。已发布的 id 不能改名,只能新增。 +- 算子:纯函数,输入列视图,输出累加器更新;每个算子都要有边界用例测试(空输入、单个样本、乱序、时钟回绕)。 +- 模型:只读取指标,不读取原始列;评分函数需要有单调性测试(指标变差时,分数不能升高)。 ## 11. 测试 @@ -102,10 +102,10 @@ - 协议编解码必须有“黄金字节”测试:固定的输入对应固定的字节序列。布局发生变化时测试会失败,提醒升级协议版本。 - 采集端必须覆盖:逐包记录的完整性(发送数 = 记录数)、丢失标记、重复、乱序、非法长度、重放、会话过期、版本不兼容。 - 分析模型必须有“黄金原始块 → 期望输出”的测试;模型升版时,同时更新期望输出并说明原因。 -- e2e 测试在 Windows 和 Linux 上对 **AOT 发布产物**运行,而不是对 JIT 构建运行。 +- 端到端测试应针对 AOT 发布产物运行,而不是 JIT 构建。 ## 12. 提交与评审 -- 提交信息使用 `<范围>: <摘要>` 格式,范围取值为 `probe`、`agent`、`backend`、`shared`、`web`、`docs`、`scripts`。 +- 提交信息使用 `<范围>: <摘要>` 格式,范围取 `probe`、`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 95307f7..25ce331 100644 --- a/docs/dev/project-layout.md +++ b/docs/dev/project-layout.md @@ -2,18 +2,18 @@ ## 1. 目标 -1. **三个独立项目加一个前端**:Probe、Agent、Backend 各自拥有解决方案,可以单独构建、测试和发布;Web 是独立的 Vite 项目(`web/`)。 -2. **通用能力只写一次**:TCP/QUIC Datagram/TLS/ICMP 采集积木(类似 iperf3 的能力,但只记录原始数据)放在 `MoShiro.Measurement` 中,Probe 和 Agent 共用;采集方法(Kind)由积木组合声明而成。 -3. **节点机制通用化**:注册、身份、连接、RPC、可靠上报、状态同步、宿主,由与业务无关的 **Fleet** 框架提供(见 [../backend/fleet/README.md](../../backend/fleet/README.md))。MoShiro 只写业务,其他产品也可以复用 Fleet。 -4. **改一处,整条链路受益**:协议、原始数据格式、测试方案、指标、错误码、版本号、编译设置、代码风格都只有**一个真源**。改动之后,下游要么自动更新,要么在编译期或 CI 中直接报错。 -5. **模块化**:Probe/Agent 由可插拔的模块组成;Backend 按层分成多个项目,层之间的依赖在编译期强制。 -6. **Probe/Agent 轻量**:运行时只依赖 BCL 和仓库内的库,不引用任何 NuGet 包。 +1. 三个独立项目加一个前端:Probe、Agent、Backend 各自拥有解决方案,可以单独构建、测试和发布;Web 是独立的 Vite 项目(`web/`)。 +2. 通用能力只写一次:TCP/QUIC Datagram/TLS/ICMP 采集积木(类似 iperf3 的能力,但只记录原始数据)放在 `MoShiro.Measurement` 中,Probe 和 Agent 共用;采集方法(Kind)由积木组合声明而成。 +3. 节点机制通用化:注册、身份、连接、RPC、可靠上报、状态同步、宿主,由与业务无关的 Fleet 框架提供(见 [Fleet 文档](../../backend/fleet/README.md))。MoShiro 只写业务,其他产品也可以复用 Fleet。 +4. 单一真源:协议、原始数据格式、测试方案、指标、错误码、版本号、编译设置、代码风格都只有一处定义,改动后下游自动更新,或在编译期、CI 中报错。 +5. 模块化:Probe/Agent 由可插拔的模块组成;Backend 按层分成多个项目,层之间的依赖在编译期强制。 +6. 轻量:运行时只依赖 BCL 和仓库内的库,不引用任何 NuGet 包。 ## 2. 仓库布局 ```text moshiro/ -├─ README.md TODO.md AGENTS.md .gitignore +├─ README.md AGENTS.md .gitignore ├─ docs/ ├─ scripts/ # publish / package / smoke / stability 等脚本 ├─ packaging/ # install.sh、systemd 单元、OpenWrt 文件 @@ -32,8 +32,8 @@ moshiro/ │ ├─ tools/ fleet-enroll │ └─ tests/ ├─ shared/ # MoShiro 自己的共享库 - │ ├─ MoShiro.Protocol/ # 契约真源:Kind/Plan 目录、任务集、moshiro.* 方法、UDP 包、RawChunk、错误码 - │ ├─ MoShiro.Measurement/ # 采集引擎:积木、Recorder、任务调和(见 measurement.md) + │ ├─ MoShiro.Protocol/ # 契约真源:Kind 目录、任务集、moshiro.* 方法、测量包、RawChunk、错误码 + │ ├─ MoShiro.Measurement/ # 采集引擎:积木、行表、合并、预算(见 measurement.md) │ └─ tests/ ├─ probe/ src/MoShiro.Probe/ tests/ # 家宽端采集器 ├─ agent/ src/MoShiro.Agent/ tests/ # VPS 端采集器 @@ -46,7 +46,7 @@ moshiro/ │ └─ MoShiro.Backend.Storage.Sqlite/ # 端口接口的实现,同时实现 IFleetStore ├─ tests/ ├─ openapi/v1.json # 构建生成并提交 - └─ MoShiro.Backend.slnx + └─ (解决方案见 backend/MoShiro.slnx) ``` 节点机器上的发布物是两个独立进程,注册和采集分开:`fleet-enroll`(一次性运行)和 `moshiro-agent`(或 `moshiro-probe`,常驻)。更新:Quadlet 装的 Agent、Probe 由节点退出后 systemd 重新拉镜像,Backend 和一行脚本装的节点由宿主机上的 `moshiro-update` 完成,见 [deployment.md](../ops/deployment.md)。 @@ -104,28 +104,30 @@ Web ──> openapi/v1.json ```text MoShiro.Protocol/ -├─ ProtocolLimits MoShiroErrors(在 Fleet 错误码基础上扩展) -├─ Ids/ AgentId(= Fleet NodeId)、ProbeId、TaskId、TestId、ChunkId、StreamId +├─ ProtocolLimits MoShiroErrors(在 Fleet 错误码基础上扩展) IpVersions PublicAddress Ipv6Diagnostic +├─ Ids/ AgentId(= Fleet NodeId)、ProbeId、TaskId、TestId ├─ Catalog/ -│ ├─ Kinds/ KindSpec 声明(积木组合 + ParamSpec + 记录列 + 预算) +│ ├─ Kinds/ Kind 声明(积木组合 + ParamSpec + 记录列 + 预算) +│ ├─ KindSpec Kind 与记录流(StreamSpec)的类型定义 │ └─ ParamSpec 参数定义(类型、默认值、上下限),节点与 Backend 共用同一套校验 ├─ Tasks/ TaskSet(Fleet 期望状态的文档类型)、调和结果 -├─ Rpc/ moshiro.* 方法名、params/result、二进制通道号(从 16 开始) -├─ Wire/ QUIC Datagram 测量包 +├─ Rpc/ moshiro.* 方法表与 params/result、二进制通道号(从 16 开始) +├─ Wire/ QUIC Datagram 测量包、TCP 数据连接首帧 └─ Raw/ 自描述 RawChunk:头部、列类型、列编解码、Brotli、crc32c ``` -累加器、直方图等统计类型**不在 Protocol 中**,只属于 `Backend.Analysis`。 +累加器、直方图等统计类型只属于 `Backend.Analysis`,不在 Protocol 中。测试方案(Plan)只存在于 Backend(`Application/Tests/Plans.cs`)。 ### 4.3 MoShiro.Measurement(采集引擎) 包含: - 积木:Transport、Pacer、Payload、Behavior、Sampler; -- 根据列定义自动生成的 Recorder; -- `KindAssembler`; -- `TaskReconciler`(实现 Fleet 的 `IStateReconciler`); -- `BudgetGuard`。 +- 按 Kind 声明驱动的行表(`RowStore`)与列块合并(`FragmentMerger`、`ChunkFinalizer`); +- `BlockCatalog`:Kind 到拨号方 / 监听方积木的映射; +- `BudgetGuard`:参数校验与本地硬上限。 + +任务调和在 Agent 的 `TaskManager` 与 Probe 中完成。 详见 [measurement.md](../design/measurement.md) 第 6 节。 @@ -133,7 +135,7 @@ MoShiro.Protocol/ ### 5.1 Agent 与 Probe:组装 -节点的组装点是 `AgentApp` 和 `ProbeApp`:在构造函数里**显式组装**,不使用 DI 容器,也不做反射扫描;没有全局状态,测试可以在同一进程里启动多个。连接、认证、重连、发件箱、状态同步都由 Fleet 提供,MoShiro 只写业务: +节点的组装点是 `AgentApp` 和 `ProbeApp`:在构造函数里显式组装,不使用 DI 容器,也不做反射扫描;没有全局状态,测试可以在同一进程里启动多个。连接、认证、重连、发件箱、状态同步都由 Fleet 提供,MoShiro 只写业务: ```text AgentApp Probe(ProbeApp) @@ -149,7 +151,7 @@ AgentApp Probe(ProbeApp) 模块之间通过构造函数传入的接口或 `Channel` 通信,禁止通过静态全局状态共享数据。 -**新增 RPC 方法**:在 `MoShiro.Protocol/Rpc` 中定义 params/result,然后在组装处加一行 `.On(Methods.X, HandleX)`。 +新增 RPC 方法:在 `MoShiro.Protocol/Rpc` 中定义 params/result,然后在组装处加一行 `.On(Methods.X, HandleX)`。 ### 5.2 Backend:分层与 API 层 @@ -167,8 +169,8 @@ AgentApp Probe(ProbeApp) ┌─────────────────────────────────────────────────────────┐ ┌───────────────────┴──┐ │ Backend.Application │───>│ LiveHub(Application)│ │ Agents · Probe · Tasks(有效任务集的计算与下发) │ │ 内存快照 + 主题订阅 │ - │ Tests · Ingest(chunk → 存储 → 分析队列)· SeriesQuery │ └──────────────────────┘ - │ AnalysisScheduler · Downsampler · Auth · Releases 视图 │ 调用 ─> Backend.Analysis + │ Tests · Ingest(chunk → 存储 → 分析队列) │ └──────────────────────┘ + │ Analysis(AnalysisScheduler 等)· Query · Auth · Updates │ 调用 ─> Backend.Analysis └───────────────────────────▲─────────────────────────────┘ 纯计算:算子/指标/模型 │ 实现端口 ┌───────────────────────────┴─────────────────────────────┐ @@ -181,9 +183,9 @@ AgentApp Probe(ProbeApp) 规则: -- **Api 不能引用 Storage**(编译期保证);Api 和 Gateway 只调用 Application。 -- Application 返回 `Result`。**错误码到 HTTP 状态的映射只在 Api 层做一次**(`ErrorCodeHttpMap`);到 JSON-RPC 错误的映射由 Fleet.Rpc 统一完成。 -- Api 层按调用方分目录:`Public/`、`Admin/`,公共部分放在 `Common/`(分页、ETag、认证过滤器、ProblemFactory、`EndpointConventions`)。每类资源一个端点文件,新增资源时在 `ApiModule.Map` 中加一行。 +- Api 不能引用 Storage(编译期保证);Api 和 Gateway 只调用 Application。 +- Application 返回 `Result`。错误码到 HTTP 状态的映射只在 Api 层做一次(`ErrorCodeHttpMap`);到 JSON-RPC 错误的映射由 Fleet.Rpc 统一完成。 +- Api 层按调用方分目录:`Public/`、`Admin/`,公共部分放在 `Common/`(分页、ETag、认证过滤器、Problems、`EndpointConventions`)。每类资源一个端点文件,新增资源时在 `ApiModule.Map` 中加一行。 - 注册端点、签名认证、连接注册表、状态推送、收件箱 ACK 都来自 Fleet(`MapFleetEnrollment`、`MapFleetLink`)。MoShiro 只提供: - `IFleetStore` 的实现; - `TaskSet` 的状态提供者; @@ -193,7 +195,7 @@ AgentApp Probe(ProbeApp) - 请求校验:简单字段约束使用 .NET 10 内置的 Minimal API 校验;任务参数统一用 `ParamSpec` 校验(与节点共用);跨字段规则放在 Application 层。 - Backend 使用 ASP.NET Core 自带的 DI 容器,只在组合根显式注册。 -Backend.Analysis 只依赖 MoShiro.Protocol 和 Fleet.Core,没有 I/O,可以单独做成离线 CLI 使用。 +Backend.Analysis 只依赖 MoShiro.Protocol 和 Fleet.Core,没有 I/O。 Backend 允许的包:ASP.NET Core 共享框架、`Microsoft.Data.Sqlite`、`Microsoft.AspNetCore.OpenApi`、`Microsoft.Extensions.ApiDescription.Server`(仅构建期使用)。 @@ -210,7 +212,7 @@ Backend 允许的包:ASP.NET Core 共享框架、`Microsoft.Data.Sqlite`、`Mi ## 7. 轻量预算 -M0 阶段先测出基线,之后作为 CI 的回归门槛(超出 10% 即失败): +体积和内存目标如下,表中「实测」为某次测量值: | 程序 | 发布体积 | 空闲常驻内存 | 负载 | | --- | --- | --- | --- | @@ -234,14 +236,14 @@ M0 阶段先测出基线,之后作为 CI 的回归门槛(超出 10% 即失 | --- | --- | --- | | Agent | `linux-x64`、`linux-arm64` | VPS | | Probe | `linux-x64`、`linux-arm64`、`win-x64` | 普通 PC 或服务器 | -| Probe(OpenWrt) | `linux-musl-x64`、`linux-musl-arm64` | 正式支持;`linux-musl-arm`(armv7)视 M0 的工具链验证结果决定是否支持 | +| Probe(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)时,把 Probe 运行在局域网内的其他设备上,比如 NAS、树莓派或 PC,测量结果同样代表这条家宽。 - NativeAOT 需要目标平台的原生工具链:musl 目标在 Alpine 容器中构建,arm64 使用交叉编译镜像。CI 矩阵为 Windows、Linux glibc、Linux musl。 - `scripts/publish.sh ` 输出到 `backend/artifacts/publish///`。 -- 容器镜像由 CI 构建并对镜像 digest 签名,节点的宿主机更新器验签后更新,见 [deployment.md](../ops/deployment.md);纯二进制节点的更新见 [TODO.md](../../TODO.md)。 +- 容器镜像由 CI 构建并对镜像 digest 签名,节点的宿主机更新器验签后更新,见 [deployment.md](../ops/deployment.md);纯二进制节点的更新见 [路线图](../roadmap.md)。 - Linux 上 TLS 依赖系统 OpenSSL(`libssl.so.3`)。 ### 8.1 OpenWrt 上的 Probe @@ -256,7 +258,7 @@ M0 阶段先测出基线,之后作为 CI 的回归门槛(超出 10% 即失 | 内存 | 预算 ≤ 32 MB,适合 ≥ 256 MB 内存的路由器;在本地配置中限制重负载测试的码率(`limits.max_test_mbps`),防止路由器 CPU 成为瓶颈,反过来影响测量结果 | | 测量偏差 | Probe 运行在网关上时,自己没有 NAT;一旦路由器 CPU 饱和,结果就会失真。因此 Probe 在 `moshiro.event` 中上报自身的 CPU 繁忙标记(仅进程级别),分析层据此标注置信说明 | | 打包 | 提供 `tar.gz` 和 procd 脚本;包格式(ipk 或 apk)按目标 OpenWrt 版本提供 | -| 更新 | 纯二进制节点的更新器还没有实现([TODO.md](../../TODO.md));实现时 `keep_versions = 1`,下载前检查剩余空间,避免写满闪存 | +| 更新 | 纯二进制节点的更新器还没有实现([路线图](../roadmap.md));实现时 `keep_versions = 1`,下载前检查剩余空间,避免写满闪存 | ## 9. Web diff --git a/docs/ops/deployment.md b/docs/ops/deployment.md index 69d6fe4..2e62e33 100644 --- a/docs/ops/deployment.md +++ b/docs/ops/deployment.md @@ -54,9 +54,9 @@ macOS 的 Apple Silicon 产物带临时 ad-hoc 签名,Intel 产物未签名。 Backend 使用 OCI 标准镜像,通过 `registry.oeo.one/moshiro/moshiro-backend:` 分发,支持 `linux/amd64` 和 `linux/arm64`。容器使用 glibc NativeAOT 单文件程序(动态链接,运行时要加载 libssl;musl 的静态产物做不到),基底为 `mcr.microsoft.com/dotnet/runtime-deps:10.0`(Debian),以非 root 用户运行,在 `8080/tcp` 提供 HTTP 服务;数据库和原始数据统一写入 `/data`。程序启用 InvariantGlobalization,无需 .NET 运行时或 ICU;时间查询使用 UTC。SQLite 从固定版本的官方源码编译并静态链接,不再随镜像携带 `libe_sqlite3.so`。将 `/data` 挂载为持久卷,并在反向代理处配置 HTTPS。 -**前端内嵌在 Backend 里**:CI 构建 Backend 前先构建 `web/display` 和 `web/admin`,`Backend.Api` 把两个 `dist` 嵌进程序,展示端在 `/`,管理端在 `/admin/`,所以面板的域名(如 `https://mo.oeo.one/admin/`)打开就是管理端,passkey 才能绑定在这个域名上(浏览器只允许页面的来源与 RP 域名一致,本机 `localhost` 上的页面注册不了绑定 `mo.oeo.one` 的 passkey)。本地没有构建前端时不嵌入,Backend 只提供 API。文件是启动时读进内存的白名单,请求时不访问文件系统;带 `..`、反斜杠、`%`、控制字符或空段的路径一律 404,缺失的文件(带扩展名)404,只有无扩展名的路径回退到入口页;`/api`、`/openapi`、`/install`、`/healthz` 下没有匹配到的路径仍是后端的 JSON 404。页面带 CSP(脚本只许同源)、`nosniff`、`X-Frame-Options: DENY`,入口页 `no-cache`,哈希文件名的资源 `immutable`,不嵌入 source map。部署时设置 `MoShiro__PublicUrl=https://<面板域名>`(`deploy-node.sh backend --public-url …`),WebAuthn 的域名和允许的来源由它派生。 +前端内嵌在 Backend 里:CI 构建 Backend 前先构建 `web/display` 和 `web/admin`,`Backend.Api` 把两个 `dist` 嵌进程序,展示端在 `/`,管理端在 `/admin/`,所以面板的域名(如 `https://mo.oeo.one/admin/`)打开就是管理端,passkey 才能绑定在这个域名上(浏览器只允许页面的来源与 RP 域名一致,本机 `localhost` 上的页面注册不了绑定 `mo.oeo.one` 的 passkey)。本地没有构建前端时不嵌入,Backend 只提供 API。文件是启动时读进内存的白名单,请求时不访问文件系统;带 `..`、反斜杠、`%`、控制字符或空段的路径一律 404,缺失的文件(带扩展名)404,只有无扩展名的路径回退到入口页;`/api`、`/openapi`、`/install`、`/healthz` 下没有匹配到的路径仍是后端的 JSON 404。页面带 CSP(脚本只许同源)、`nosniff`、`X-Frame-Options: DENY`,入口页 `no-cache`,哈希文件名的资源 `immutable`,不嵌入 source map。部署时设置 `MoShiro__PublicUrl=https://<面板域名>`(`deploy-node.sh backend --public-url …`),WebAuthn 的域名和允许的来源由它派生。 -**用 compose 部署**(Docker 和 Podman 通用,需要 `docker compose` 或 `podman compose`):compose 文件是静态的,在仓库的 `packaging/compose/backend/compose.yaml`,每个发布版本的 Gitea Release 里也有一份 `moshiro-compose-backend.yaml`(第一次部署时还没有面板,就从这里下载)。镜像用 `:${MOSHIRO_VERSION:-latest}`: +用 compose 部署(Docker 和 Podman 通用,需要 `docker compose` 或 `podman compose`):compose 文件是静态的,在仓库的 `packaging/compose/backend/compose.yaml`,每个发布版本的 Gitea Release 里也有一份 `moshiro-compose-backend.yaml`(第一次部署时还没有面板,就从这里下载)。镜像用 `:${MOSHIRO_VERSION:-latest}`: ```sh mkdir moshiro-backend && cd moshiro-backend @@ -68,9 +68,9 @@ EOF podman compose up -d # Docker 用 docker compose ``` -**更新**:先备份数据卷,再 `podman compose pull && podman compose up -d`。新版启动时自动迁移数据库,但**旧版不认比自己新的数据库**:想回滚要先恢复备份,只改 `MOSHIRO_VERSION` 不够。Backend 能指挥所有节点,更新它尽量用 Quadlet(`packaging/quadlet/moshiro-backend.container`,见下文「Backend 的更新」:管理端确认,更新前自动备份数据库),或者在 `.env` 里把 `MOSHIRO_VERSION` 固定到经过核对的版本或 digest。 +更新:先备份数据卷,再 `podman compose pull && podman compose up -d`。新版启动时自动迁移数据库,但旧版不认比自己新的数据库:想回滚要先恢复备份,只改 `MOSHIRO_VERSION` 不够。Backend 能指挥所有节点,更新它尽量用 Quadlet(`packaging/quadlet/moshiro-backend.container`,见下文「Backend 的更新」:管理端确认,更新前自动备份数据库),或者在 `.env` 里把 `MOSHIRO_VERSION` 固定到经过核对的版本或 digest。 -**首次认领(没有默认管理员)**:面板里还没有任何用户时,打开 `/admin/` 会进入认领向导: +首次认领(没有默认管理员):面板里还没有任何用户时,打开 `/admin/` 会进入认领向导: 1. 填认领码(首次启动时随机生成并写进后端日志,`journalctl -u moshiro-backend` 或 `docker logs`;也可以用 `MoShiro__ClaimCode` / `MOSHIRO_CLAIM_CODE` 指定)、管理员的用户名和密码; 2. 注册你的第一把 passkey; 3. 用它给操作员集合签名,建立信任根。 @@ -80,11 +80,11 @@ Compose 将容器端口只绑定到宿主机 `127.0.0.1:8080`,适合由同机 Backend 的 NativeAOT 发布默认启用 `SqliteStaticLink`,支持 Linux glibc/musl、macOS、Windows 的 x64/arm64 RID;跨平台构建使用 `-p:UseAotAnywhere=true`。SQLite 源码版本、下载年份和 SHA-256 统一配置在根目录 `Directory.Packages.props`,构建会校验下载文件并按 RID 缓存原生对象。普通 JIT 开发构建仍使用 NuGet 原生库。发布 Backend 时设置 `-p:OpenApiGenerateDocuments=false`,API 文档在普通开发构建中生成。 -健康检查端点为 `GET /healthz`。容器定义见 [`../packaging/container/Backend.Containerfile`](../../packaging/container/Backend.Containerfile)。 +健康检查端点为 `GET /healthz`。容器定义见 [`packaging/container/Backend.Containerfile`](../../packaging/container/Backend.Containerfile)。 ## 节点二进制发布 -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 产物目前未签名/公证。安装说明见 [`../backend/agent/README.md`](../../backend/agent/README.md) 与 [`../backend/probe/README.md`](../../backend/probe/README.md)。 +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) 文档。 ## CI 工作流 @@ -106,11 +106,11 @@ Pull request 和 `main` 的 push 会跑三个 workflow: - `verify`:后端的快测试(除了标了 `Category=Slow` 的全部,约 20 秒); - `web-build`:前端构建和 CSS 检查,和 `verify` 并行(约 15 秒); -- 标了 `Category=Slow` 的端到端测试(真实时间的 Hy2 阶梯、IPv6 和真探针的几条,一百来秒):带 `[CI]` 的推送,在 `dev-image` 里**等三个镜像都构建、推送完**再跑(不和 NativeAOT 编译抢内存——同时跑过一次,Backend 的 AOT 编译被内存不足杀掉了);PR 和没带 `[CI]` 的推送由 `e2e` workflow 在 `verify` 之后跑。失败照样红,但不影响已经推出去的镜像。 +- 标了 `Category=Slow` 的端到端测试(真实时间的 Hy2 阶梯、IPv6 和真探针的几条,一百来秒):带 `[CI]` 的推送,在 `dev-image` 里等三个镜像都构建、推送完再跑(不和 NativeAOT 编译抢内存——同时跑过一次,Backend 的 AOT 编译被内存不足杀掉了);PR 和没带 `[CI]` 的推送由 `e2e` workflow 在 `verify` 之后跑。失败照样红,但不影响已经推出去的镜像。 后端测试用增量编译(`ci/verify-backend.sh`):源码按内容同步到 CI 主机上一个固定目录,保留 `obj/bin`,只重编译变了的项目;同一时间只有一条流水线用这份缓存。失败的测试会单独重跑(最多两次),重跑通过算通过。给新的慢测试打标记:`[Trait("Category", "Slow")]`。 -提交信息后缀 `[SKIP TESTS]` 跳过 `verify` 和 `e2e` 里的后端测试,前端构建仍会运行。Gitea PR 事件的 `CI_COMMIT_MESSAGE` 是 PR 标题,因此在 PR 中跳过测试时,把后缀写入 PR 标题;普通分支 push 则写在 commit message 中。项目只用 `main` 一个工作分支,旧的 `dev` 分支不再被任何流水线使用。 +提交信息后缀 `[SKIP TESTS]` 跳过 `verify` 和 `e2e` 里的后端测试,前端构建仍会运行。Gitea PR 事件的 `CI_COMMIT_MESSAGE` 是 PR 标题,因此在 PR 中跳过测试时,把后缀写入 PR 标题;普通分支 push 则写在 commit message 中。项目只使用 `main` 一个工作分支。 ### 版本号 @@ -145,7 +145,7 @@ Agent 和 Probe 的默认发布配置:NativeAOT、完整裁剪、`Optimization `fleet-enroll` 使用同一套体积优化开关。Fleet 共享库已声明 AOT 兼容,由 Agent、Probe、Backend 或注册工具的最终发布一起裁剪;GC 和运行时功能开关配置在各可执行项目中。 -以下是 NativeAOT 可执行文件本身的历史体积,不包含 Linux 包现在附带的 MsQuic sidecar(后续以各 RID 的 CI 产物为准): +NativeAOT 可执行文件本身的体积如下,不包含 Linux 包附带的 MsQuic 等共享库,最新数值以各 RID 的 CI 产物为准: | 程序 | linux-musl-x64 | linux-x64 | win-x64 | | --- | --- | --- | --- | @@ -153,7 +153,7 @@ Agent 和 Probe 的默认发布配置:NativeAOT、完整裁剪、`Optimization | moshiro-agent | 8.4 MB | 8.4 MB | 7.3 MB | | fleet-enroll | 6.4 MB | 6.4 MB | — | -Linux 上比 Windows 大,是因为 .NET 的加密、压缩、网络本机库被静态链接进来。Linux tar.gz 还包含 MsQuic 和支持库,所以以前 Probe 压缩包约 3.6 MB 的数字已不适用。 +Linux 产物比 Windows 大,是因为 .NET 的加密、压缩、网络本机库被静态链接进来;Linux tar.gz 另含 MsQuic 和支持库。 ## 2. 安装 @@ -180,14 +180,14 @@ Linux 上比 Windows 大,是因为 .NET 的加密、压缩、网络本机库 以 `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)。 -- **Agent** 只保留 `CAP_NET_RAW`(ICMP 目标任务用),`/etc/moshiro` 只读;Probe 没有任何 capability,只对 `agents.json` 所在的目录可写。 +- 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 连接使用轮换的临时本地源端口。 ### 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`,实际设备兼容性需实机验证。 +管理端「添加 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`,实际设备兼容性需实机验证。 带 `[CI]` 的 main push 发布 dev,稳定 tag 发布 release,沿用 `gitea_token` 和 `release_signing_key`;Gitea generic package `moshiro-openwrt` 需允许匿名下载。制品发布后,在线节点收到通知就唤醒更新器,另有 30 秒轮询兜底,不等待 10 分钟。更新器验签、防重放、原子切换完整版本,健康检查失败回滚;Probe 本身不下载、不执行命令。 @@ -229,15 +229,15 @@ OpenWrt 默认不带 OpenSSL:`opkg install libopenssl3`(较新的快照和 2 没有公网入站的家宽机器上跑 Backend 时,用隧道(Cloudflare Tunnel 之类)把一个公网域名转到 Backend 的端口;Backend 只写一个监听地址(同时写 `0.0.0.0` 和 `[::]` 会报地址已被占用);Probe 的 `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 用 Probe、Agent 的来源地址向 IP 归属服务查一次,展示在国旗和运营商图标上(`MoShiro__GeoLookupUrl`,缺省 `https://ip.007995.xyz`;留空就不查;这个服务只供公益使用,署名和使用条款由部署的人自己遵守)。Probe、Agent 自己不访问外部服务,所以客户的防火墙审计里不会多出出站连接;Backend 发出去的只有公网 IP(内网地址不发)。Agent 的地址优先用它的对外地址(`public_host`),没有就用 IPv6 地址,再没有(NAT 后面、没有上报公网地址)才用它连面板的来源地址;面板前面有反向代理时,要配置 `MoShiro__TrustedProxies`,否则来源地址是代理的内网地址,这类节点查不到国家。没查到的节点每隔十分钟在心跳里重试一次。 -**从别处看这个面板**:本机跑前端,在页面里把数据源指向面板的地址,用 API 令牌访问,Backend 设 `MoShiro__CorsOrigins` 放行前端的来源(见 [web/README.md](../../web/README.md))。 +从别处看这个面板:本机跑前端,在页面里把数据源指向面板的地址,用 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`),会丢身份和配置。 -**Quadlet 安装(Agent 和 Probe,Podman 系优先,Linux + Podman 4.6 及以上)**:管理端「添加 Agent」「添加 Probe」生成加入令牌之后,第一条命令就是它(面板地址、一次性码、信任根指纹和属主都是公开的值,没有私钥):写 env(权限 600)、从面板下载静态的 Quadlet 单元并核对 SHA-256、用一个 drop-in 把镜像仓库指向面板配置的、启动用户服务: +Quadlet 安装(Agent 和 Probe,Podman 系优先,Linux + Podman 4.6 及以上):管理端「添加 Agent」「添加 Probe」生成加入令牌之后,第一条命令就是它(面板地址、一次性码、信任根指纹和属主都是公开的值,没有私钥):写 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' @@ -254,11 +254,11 @@ chmod 600 ~/.config/moshiro/probe.env && curl -fsSL 'https://panel.example.com/i - 只读根文件系统、丢掉全部能力、`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 <用户>`。 -- **更新**:见下一节,默认自动。想固定版本,把 drop-in 里的 `:latest` 换成版本号。回滚就是改回旧版本再 `systemctl --user restart moshiro-`。 -- **信任**:和之前一样信任镜像仓库(Podman 校验它的 TLS 证书),面板被攻破也改不了镜像地址,因为单元和命令都有哈希;节点不验发布签名。 +- 更新:见下一节,默认自动。想固定版本,把 drop-in 里的 `:latest` 换成版本号。回滚就是改回旧版本再 `systemctl --user restart moshiro-`。 +- 信任:节点信任镜像仓库(Podman 校验其 TLS 证书);单元和命令带有哈希,面板被攻破也改不了镜像地址;节点不验发布签名。 - 没有 Podman 4.6 或不是 Linux:用下面的 compose(Docker)、一行安装脚本或发布的二进制。 -**compose 安装(Agent 和 Probe,Docker;Podman 系请优先用上面的 Quadlet)**:管理端「添加 Agent」「添加 Probe」生成加入令牌之后,给出一段命令(面板地址、一次性码、信任根指纹和属主都是公开的值,没有私钥):建一个目录,把参数写进 `.env`(权限 600),下载面板提供的静态 compose 文件、核对 SHA-256,一致才启动: +compose 安装(Agent 和 Probe,Docker;Podman 系请优先用上面的 Quadlet):管理端「添加 Agent」「添加 Probe」生成加入令牌之后,给出一段命令(面板地址、一次性码、信任根指纹和属主都是公开的值,没有私钥):建一个目录,把参数写进 `.env`(权限 600),下载面板提供的静态 compose 文件、核对 SHA-256,一致才启动: ```sh mkdir -p moshiro-probe && cd moshiro-probe && cat > .env <<'EOF' @@ -271,14 +271,14 @@ 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 ``` -- 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|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`。 - 身份和配置在命名卷 `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 不会自动更新(见下节),也没有管理端的「更新」列和确认更新按钮。 +- 更新:`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 和 Probe 共用):管理端弹窗中折叠着一行安装命令,先核对脚本哈希再执行,脚本向面板取签名清单、用内置发布公钥验签、按 digest 拉镜像: ```sh f=$(mktemp) && curl -fsSL 'https://panel.example.com/install' -o "$f" && echo " $f" | sha256sum -c - >/dev/null \ @@ -288,7 +288,7 @@ 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、Probe;推荐用 Quadlet,Backend 只有 Quadlet 和 compose):`packaging/container/deploy-node.sh` 可以重复运行,每次都会拉取镜像并固定到 digest、把配置迁移到当前版本需要的样子(例如旧的 `listen.bind` 为 `0.0.0.0` 的 Agent 改成 `::`,这样 IPv6 线路才能建立)、重装单元和更新器,然后重启节点;已有的身份不会动,更新策略沿用上次的。 ```sh # 首次部署(需要 root;脚本和 packaging/ 里的 systemd/、update/ 放在一起) @@ -310,8 +310,8 @@ Backend 用 `packaging/compose/backend/compose.yaml`(见上文「Backend 容 Agent 和 Probe 每天固定时刻有序退出一次,由服务管理器拉起(所有 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 的容器同样可以用环境变量。 +- 在管理端控制:系统 → 设置 →「定时重启」,开关、时刻和时区(默认开,北京时间 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 的容器同样可以用环境变量。 - 启动后不满 10 分钟不会触发,所以重启之后不会立刻又重启一次。日志里有 `restart scheduled at=… source=panel|local` 和 `stopping result=scheduled_restart`。 - Windows 上没有自带的服务单元,需要服务管理器配置为退出后重启。 @@ -334,7 +334,7 @@ Probe 也上报同样的汇总数字(`probe.json` 的 `reporting.host_stats` ## Podman(Quadlet)的更新(Agent、Probe、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`、`moshiro-probe.container`,Podman 4.6 及以上),更新是销毁容器、重新拉最新镜像再启动:节点自己不下载、不替换任何东西,只是有序退出;Quadlet 生成的 systemd 服务用 `podman run --pull always` 启动,所以被拉起时先从镜像仓库拉最新的镜像。默认自动更新:面板发现新版本后,节点运行满 10 分钟(防止重启循环)就自己退出重启;想改成在管理端点「更新」才更新,在 env 文件里写 `MOSHIRO_UPDATE_POLICY=manual`。 ```text CI 推镜像到 registry ─▶ 面板收到 CI 签名的清单 ─▶ 转发给节点(只是「建议」) @@ -343,12 +343,12 @@ 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`,换新镜像不丢。 -- **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` 的节点才报告更新状态**(模板里写好了):别的部署方式退出之后不会重新拉取,开了也不会更新。 -- **别的自动更新方式**:另外启用 `podman-auto-update.timer` 并在模板的 `[Container]` 里加 `AutoUpdate=registry`,Podman 按计划比对镜像 digest 并重启,不依赖面板。 -- **回滚**:新镜像起不来时 systemd 一直重试;把模板里的标签改回旧版本再 `systemctl --user restart` 即可。 +- 安装(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`,换新镜像不丢。 +- 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` 的节点才报告更新状态(模板里写好了):别的部署方式退出之后不会重新拉取,开了也不会更新。 +- 别的自动更新方式:另外启用 `podman-auto-update.timer` 并在模板的 `[Container]` 里加 `AutoUpdate=registry`,Podman 按计划比对镜像 digest 并重启,不依赖面板。 +- 回滚:新镜像起不来时 systemd 一直重试;把模板里的标签改回旧版本再 `systemctl --user restart` 即可。 - 测试:`packaging/quadlet` 里的模板用 `/usr/libexec/podman/quadlet -dryrun -user` 检查过(Podman 5.4);节点一侧的逻辑见 `RestartUpdaterTests`。 ## 宿主机更新器(一行脚本装的节点) @@ -375,9 +375,9 @@ sh packaging/update/install-updater.sh agent --user \ 发布私钥只放在 Woodpecker 的 `release_signing_key` secret;发布公钥位于 `packaging/update/release-key.pub.pem`。`RELEASE_API_URL` 指向面板,CI 把签名清单 POST 到 `/api/release/offer`:这个入口不需要令牌,面板用内嵌的发布公钥验签,签名无效就拒绝,所以 CI 不需要在面板里持有任何凭证(清库或重装后照常登记,也没有长期令牌可偷)。面板保存并转发签名清单,Agent/Probe 将它与更新请求写进配置 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` 必须等于它本机配置的镜像仓库。停用自有密钥时,只有它签过的已登记清单会被丢掉。 +自有发布密钥(自己构建、自己发布的部署;默认关闭):管理端「系统 → 信任 → 可信资产来源」。官方 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` 必须等于它本机配置的镜像仓库。停用自有密钥时,只有它签过的已登记清单会被丢掉。 -**换发布密钥**(泄露或定期轮换,不用重装节点): +换发布密钥(泄露或定期轮换,不用重装节点): 1. 生成新密钥对;CI 给发布流水线加 `RELEASE_NEXT_KEY`(新公钥 PEM),仍用旧私钥签名。清单里会带 `next_key`(SPKI DER 的 base64)。 2. 宿主机更新器验过这份清单后,把新公钥存到自己的状态目录(`<组件>.key.pem`),之后只认新密钥,旧密钥签的清单一律拒绝。清单里的 `next_key` 不是合法公钥就整份拒绝。 @@ -388,7 +388,7 @@ sh packaging/update/install-updater.sh agent --user \ ## 签名清单放在镜像仓库里(更新和首次部署都不依赖面板) -CI 给每个镜像的 digest 签名之后,除了(面板在线时)登记给面板,**一定会把签名清单推到镜像仓库**:`<镜像仓库>-manifest:<通道>`,例如 `registry.oeo.one/moshiro-dev/moshiro-backend-manifest:dev`、`registry.oeo.one/moshiro/moshiro-backend-manifest:release`。它是一个没有任何文件层的小镜像,清单和签名放在标签 `org.moshiro.manifest`(base64)和 `org.moshiro.signature` 里。`dev` 通道每次 `[CI]` 构建都会更新,`release` 通道只在打 tag 时更新,所以不发 Release 的构建照样能被更新到。 +CI 给每个镜像的 digest 签名之后,除了(面板在线时)登记给面板,一定会把签名清单推到镜像仓库:`<镜像仓库>-manifest:<通道>`,例如 `registry.oeo.one/moshiro-dev/moshiro-backend-manifest:dev`、`registry.oeo.one/moshiro/moshiro-backend-manifest:release`。它是一个没有任何文件层的小镜像,清单和签名放在标签 `org.moshiro.manifest`(base64)和 `org.moshiro.signature` 里。`dev` 通道每次 `[CI]` 构建都会更新,`release` 通道只在打 tag 时更新,所以不发 Release 的构建照样能被更新到。 ```text 信任锚点:仓库里的发布公钥 packaging/update/release-key.pub.pem(随安装脚本和 deploy-node.sh 分发) @@ -396,20 +396,20 @@ CI 给每个镜像的 digest 签名之后,除了(面板在线时)登记给 仓库、网络、面板都不需要可信:被改只会让验签失败;面板不在线、清库、没有 CI 都不影响 ``` -- **更新器**:配置里 `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`。 -- **压缩包的校验和也签名**: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 的正常代价。 +- 更新器:配置里 `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`。 +- 压缩包的校验和也签名: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 的正常代价。 - 测试:`bash packaging/update/tests/feed-test.sh`(假的 podman + 真的 openssl,覆盖篡改、别的密钥、旧 seq、换钥、仓库不可用回退、首次部署的验签)。 ## Backend 的更新 Backend 和节点是同一种更新:`packaging/quadlet/moshiro-backend.container`(Quadlet,`Pull=always`、`Restart=always`、`Environment=MOSHIRO_UPDATE=restart`)。CI 推镜像并把 CI 签名的清单登记到面板,管理端显示「Backend 有新版本」,管理员点「更新并重启」(`POST /api/admin/backend/update`,带当时登记的清单 `seq`,清单变了就要重新确认);Backend 先把数据库备份到数据卷的 `backups/pre-update-<时间>-.db`(`VACUUM INTO`,保留最近 3 份,备份失败就不更新),再有序退出,systemd 重启单元并拉最新镜像。 -- **不自动重启**:Backend 指挥所有节点,重启会让它们全部断一两分钟,所以只提示,不像节点默认那样自动退出。 -- **没有 `MOSHIRO_UPDATE=restart` 就不能从管理端更新**(compose、一行脚本装的 Backend 没有重新拉取的机制),管理端会提示;这种部署继续用 `compose pull && compose up -d`。 -- **通道**:`MoShiro__UpdateChannel` 为 `auto` 时,镜像命名空间 `moshiro-dev` 选 `dev`,`moshiro` 选 `release`;没写命名空间就按当前运行的构建 tag 判断。 -- **回滚**:新版启动时会迁移数据库,旧版不认比自己新的库。要回旧版:停服务,把 `backups/pre-update-*.db` 复制成数据卷里的 `moshiro.db`(删掉旧的 `-wal`、`-shm`),把单元里的标签固定成旧版本号再启动。新版起不来时 systemd 会一直重试同一个镜像,没有自动回滚,也没有 `/healthz` 的探针(镜像里没有 curl)。 -- **首次升级**:还没有这个机制的旧版 Backend 不认 `MOSHIRO_UPDATE`,第一次要手动 `systemctl --user restart moshiro-backend`(Pull=always 拉到新镜像),之后就能从管理端更新。 +- 不自动重启:Backend 指挥所有节点,重启会让它们全部断一两分钟,所以只提示,不像节点默认那样自动退出。 +- 没有 `MOSHIRO_UPDATE=restart` 就不能从管理端更新(compose、一行脚本装的 Backend 没有重新拉取的机制),管理端会提示;这种部署继续用 `compose pull && compose up -d`。 +- 通道:`MoShiro__UpdateChannel` 为 `auto` 时,镜像命名空间 `moshiro-dev` 选 `dev`,`moshiro` 选 `release`;没写命名空间就按当前运行的构建 tag 判断。 +- 回滚:新版启动时会迁移数据库,旧版不认比自己新的库。要回旧版:停服务,把 `backups/pre-update-*.db` 复制成数据卷里的 `moshiro.db`(删掉旧的 `-wal`、`-shm`),把单元里的标签固定成旧版本号再启动。新版起不来时 systemd 会一直重试同一个镜像,没有自动回滚,也没有 `/healthz` 的探针(镜像里没有 curl)。 +- 首次升级:还没有这个机制的旧版 Backend 不认 `MOSHIRO_UPDATE`,第一次要手动 `systemctl --user restart moshiro-backend`(Pull=always 拉到新镜像),之后就能从管理端更新。 diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..9fecec1 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,22 @@ +# 路线图 + +按主题记录尚未实现的事项,完成后删除对应条目。已实现的内容以文档与代码为准。 + +| 方向 | 事项 | +| --- | --- | +| 测量 | 路由跳数、路径漂移与 trace;QUIC 握手耗时(受限于 msquic 原生依赖与 .NET 缺少 QUIC datagram,需自实现最小 Initial 握手探测);网页长截图;pcap / eBPF 采集 | +| 任务与面板 | 单 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 模糊测试与伪造、重放专项测试 | +| 打包 | OpenWrt ipk/apk 包与 sysupgrade 保留列表(aarch64 长稳验证);Windows 服务适配 | + +## 纯二进制节点更新器的设计约束 + +- 信任来自离线发布密钥签名的清单,发布源可为面板、GitHub Releases 或任意 HTTP 服务。 +- 更新策略(`auto` / `notify` / `pinned:`)与受信公钥保存在节点本地配置,面板仅提供建议;支持多公钥以便轮换。 +- 清单字段:`app`、`channel`、`version`、`min_from`、各 RID 制品的 `sha256` 与大小、`key_id`、规范化 JSON 签名;节点记录已安装的最高版本,拒绝降级与旧清单重放。 +- 流程:下载 → 校验 → 解包至 `versions//` → 原子切换 `current`(Linux 符号链接 / Windows 目录联结)→ 重启服务 → 等待健康信标 → 失败回切并标记坏版本。 +- 更新进程仅可写安装目录与重启受管服务,不可读取节点身份私钥。 +- OpenWrt:`keep_versions = 1`,下载前检查闪存剩余空间。 +- 容器节点已实现的方案(清单签名、`seq` 防重放、按 digest 拉取、回滚)见 [deployment.md](ops/deployment.md)。 diff --git a/docs/security/access.md b/docs/security/access.md index 86392c6..a411837 100644 --- a/docs/security/access.md +++ b/docs/security/access.md @@ -1,8 +1,8 @@ # 访问控制与入网信任链 -这份文档讲**谁能做什么**(账户、能力、API key)和**谁能让节点入网**(passkey 到节点的信任根)。它是 [security.md](security.md) 的展开:security.md 讲缺口和威胁,这里讲现在怎么做。以已实现的为主,没做的在最后一节列出。 +这份文档讲谁能做什么(账户、能力、API key)和谁能让节点入网(passkey 到节点的信任根)。它是 [security.md](security.md) 的展开:security.md 讲缺口和威胁,这里讲现在怎么做。以已实现的为主,没做的在最后一节列出。 -一句话:登录和授权分开。登录证明「你是谁」,授权只看能力;改变信任(操作员集合)只认 passkey 的签名,并且这个签名由**节点自己验证**,Backend 只搬运,伪造不了。 +概要:登录和授权分开。登录证明「你是谁」,授权只看能力;改变信任(操作员集合)只认 passkey 的签名,并且这个签名由节点自己验证,Backend 只搬运,伪造不了。 ## 1. 总览 @@ -30,7 +30,7 @@ 没有采用「成员证明」(每个节点一条 Grant、Ticket、Binding 的入网链):被攻破的 Backend 往名册里塞节点,拿不到比 Backend 已经有的更多的东西(监测数据本来就是明文给 Backend 的;敏感的 extension 靠节点属主,不靠名册),成本却是每个节点一次触碰和一套证明格式。操作员集合和创世固定已经在,以后要叠加不用重装节点。 -**发布密钥可以换**:见 [deployment.md](../ops/deployment.md) 的「换发布密钥」;回滚由清单的 `seq` 防住(更新器只收比已安装更大的)。面板信任哪些发布密钥(官方的,加上可选的自有公钥)在管理端「系统 → 信任 → 可信资产来源」。 +发布密钥可以换:见 [deployment.md](../ops/deployment.md) 的「换发布密钥」;回滚由清单的 `seq` 防住(更新器只收比已安装更大的)。面板信任哪些发布密钥(官方的,加上可选的自有公钥)在管理端「系统 → 信任 → 可信资产来源」。 ## 2. 名词 @@ -61,11 +61,11 @@ | 运维 | `events.read` · `audit.read` · `ops.metrics` | 事件;审计日志;`/metrics` | | | 集成(预留) | `data.export` · `webhooks.read` · `webhooks.manage` | 批量拉数据、外部回调订阅,见第 6.4 节 | | -- **一个能力只在一个地方声明**:`Capability` 常量(`MoShiro.Backend.Application`),端点用 `.Requires(Capability.X)`;`Principal.Has(capability)` 是唯一的判断入口,会话和 API key 都走它。写蕴含读(`fleet.write` 带 `fleet.read`)。 -- **对账测试**:所有 `/api/admin` 端点必须声明能力,没声明测试就失败;OpenAPI 的每个操作带 `x-moshiro-capability`。 -- **信任类**是界面上特别标出的(吊销、账户管理),授权和其他能力一样靠 API key 或会话。真正由 passkey 签名保护的是操作员集合,那是节点自己验证的。 -- **不能越权**:创建 API key 时勾选的能力必须是创建者当前拥有能力的子集。 -- **Backend 更新确认**:更新清单由 CI 的发布密钥签名(面板登记时验签),`seq` 防重放;确认只是让 Backend 有序退出并备份数据库,拉哪个镜像由 Quadlet 单元里写死的仓库和标签决定,面板决定不了。 +- 一个能力只在一个地方声明:`Capability` 常量(`MoShiro.Backend.Application`),端点用 `.Requires(Capability.X)`;`Principal.Has(capability)` 是唯一的判断入口,会话和 API key 都走它。写蕴含读(`fleet.write` 带 `fleet.read`)。 +- 对账测试:所有 `/api/admin` 端点必须声明能力,没声明测试就失败;OpenAPI 的每个操作带 `x-moshiro-capability`。 +- 信任类是界面上特别标出的(吊销、账户管理),授权和其他能力一样靠 API key 或会话。真正由 passkey 签名保护的是操作员集合,那是节点自己验证的。 +- 不能越权:创建 API key 时勾选的能力必须是创建者当前拥有能力的子集。 +- Backend 更新确认:更新清单由 CI 的发布密钥签名(面板登记时验签),`seq` 防重放;确认只是让 Backend 有序退出并备份数据库,拉哪个镜像由 Quadlet 单元里写死的仓库和标签决定,面板决定不了。 ## 4. 登录与会话 @@ -74,8 +74,8 @@ | 密码 | PBKDF2;认领向导创建第一个管理员时设置 | | passkey | WebAuthn,只支持 ES256,attestation 一律当 `none`;可以登录,也能给操作员集合签名(节点验证) | -- 两种方式的会话拿**同样的能力**。passkey 不是 Backend 用来拦操作的一道门,只用在节点自己会验证的事上,见第 5 节。 -- **认领向导(没有默认账户)**:面板里还没有任何用户时,打开 `/admin/` 进向导——用后端日志里的认领码(或 `MoShiro__ClaimCode`)创建第一个管理员(用户名和密码自己填),注册第一个 passkey,再用它给操作员集合签名建立信任根。谁先打开页面谁就能当管理员,所以要认领码;创建完码作废、入口关闭(`POST /api/admin/auth/setup` 之后返回 409)。后两步是引导不是门槛,可以稍后补;不注册 passkey、不建立信任根,就添加不了 Agent 和 Probe,只能进面板做基础管理,这是架构如此。之后新增的 passkey 不会自动成为操作员:要由现有操作员在信任页看清「新增了哪些」再签新版本,节点才认。 +- 两种方式的会话拿同样的能力。passkey 不是 Backend 用来拦操作的一道门,只用在节点自己会验证的事上,见第 5 节。 +- 认领向导(没有默认账户):面板里还没有任何用户时,打开 `/admin/` 进向导——用后端日志里的认领码(或 `MoShiro__ClaimCode`)创建第一个管理员(用户名和密码自己填),注册第一个 passkey,再用它给操作员集合签名建立信任根。谁先打开页面谁就能当管理员,所以要认领码;创建完码作废、入口关闭(`POST /api/admin/auth/setup` 之后返回 409)。后两步是引导不是门槛,可以稍后补;不注册 passkey、不建立信任根,就添加不了 Agent 和 Probe,只能进面板做基础管理,这是架构如此。之后新增的 passkey 不会自动成为操作员:要由现有操作员在信任页看清「新增了哪些」再签新版本,节点才认。 - 建议每个账户至少注册两个 passkey(手机加硬件密钥)。 ### 4.1 passkey 登录的实现 @@ -83,23 +83,23 @@ - 流程:登录页「使用 Passkey 登录」→ `POST /api/admin/auth/passkey/options` 取挑战 → 浏览器 `navigator.credentials.get`(可发现凭证,不用输用户名)→ `POST /api/admin/auth/passkey/login` 提交断言 → 建立会话(Cookie,或跨域前端用 `return_session`)。注册:已登录用户在「系统 → 用户 → 我的 Passkey」添加,`POST /api/admin/auth/passkeys/options` 加 `POST /api/admin/auth/passkeys`;每个用户最多 10 个。 - 校验:挑战在内存里,一次有效、5 分钟过期,并且区分注册和登录、绑定用户;`rpIdHash`、UP 和 UV 标志、`clientDataJSON` 的 type、challenge、origin、ES256 签名;签名计数器只增不减(两边都是 0 的同步型 passkey 不检查)。登录失败一律 401,不区分原因,和密码登录共用限流。 - 验证断言的纯函数是 `Fleet.Identity.WebAuthnAssertion`(只用 BCL,节点验证操作员集合也用它);注册里的 CBOR 和 attestation 解析在 Backend(`WebAuthnRegistration`),节点不带 CBOR。 -- 依赖方(RP):`MoShiro__WebAuthnRpId`(缺省取 `PublicUrl` 的主机,没有就取请求的主机)和 `MoShiro__WebAuthnOrigins`(缺省取 `PublicUrl` 的来源)。**passkey 绑定在这个域名上,改了会让已注册的 passkey 失效。**只有和后端同站的页面能用 passkey 登录,管理端在「添加其他后端」(跨域)时不显示这个入口。 +- 依赖方(RP):`MoShiro__WebAuthnRpId`(缺省取 `PublicUrl` 的主机,没有就取请求的主机)和 `MoShiro__WebAuthnOrigins`(缺省取 `PublicUrl` 的来源)。passkey 绑定在这个域名上,改了会让已注册的 passkey 失效。只有和后端同站的页面能用 passkey 登录,管理端在「添加其他后端」(跨域)时不显示这个入口。 - 会话记录登录方式(`password` 或 `passkey`),只用于审计和显示;API key 不是用户,不能注册 passkey。 - 测试:`SoftwarePasskey` 在测试里模拟认证器走完整的注册和登录,覆盖错误的挑战、来源、RP、签名、缺少 UV、挑战重放、计数器回退、只能管理自己的 passkey。 ## 5. passkey 用在哪里(没有提权窗口) -原则:**passkey 不是 Backend 用来拦操作的一道门**(Backend 被攻破时,它自己做的拦截也会被一起绕过),而是节点或密钥持有者需要它才能验证、解密的东西。 +原则:passkey 不是 Backend 用来拦操作的一道门(Backend 被攻破时,它自己做的拦截也会被一起绕过),而是节点或密钥持有者需要它才能验证、解密的东西。 | 事 | 谁验证 | 要 passkey | | --- | --- | --- | -| 建立、更新操作员集合(信任根) | **节点**验链 | 是(现在唯一做了的) | -| extension 会话(web ssh 等) | **节点**验 passkey 签名的临时密钥 | 是(还没做) | +| 建立、更新操作员集合(信任根) | 节点验链 | 是(现在唯一做了的) | +| extension 会话(web ssh 等) | 节点验 passkey 签名的临时密钥 | 是(还没做) | | 读加密存储的数据 | 私钥持有者 | 是(还没做) | | 吊销或删除节点、账号、API key、加入码、设置 | 只有 Backend | 否,只看能力 | - 这些「只有 Backend」的事靠能力限权:API key 只带需要的能力。密码泄露的后果是这些能力被滥用,改不了信任根,也用不了以后的 extension。 -- 唯一的风险是往账号里塞一把 passkey 再等人签名:信任页的提案会列出**新增和移除的凭证**,签名之前要看一眼是不是自己加的;签了才算授权。 +- 唯一的风险是往账号里塞一把 passkey 再等人签名:信任页的提案会列出新增和移除的凭证,签名之前要看一眼是不是自己加的;签了才算授权。 ## 6. API key @@ -117,7 +117,7 @@ CI 把签名清单 POST 给 `POST /api/release/offer`,面板用发布公钥验 ### 6.3 预留:外部 webhook 回调与数据拉取 -后面要让外部系统能订阅事件、触发回调、拉数据。接口和权限先留(`data.export`、`webhooks.*` 标预留),实现放后面;全部发生在 Backend 里,**节点零开销**。订阅只能订阅创建者自己读得到的事件;投递是带 HMAC 签名的 `POST`,至少一次、指数退避;目标 URL 要做 SSRF 限制(只放行公网地址,不跟随重定向到内网,有超时和大小上限)。这些都属于 Backend 的集成配置,不改变节点信任,但每次增删订阅都进审计。 +后面要让外部系统能订阅事件、触发回调、拉数据。接口和权限先留(`data.export`、`webhooks.*` 标预留),实现放后面;全部发生在 Backend 里,节点零开销。订阅只能订阅创建者自己读得到的事件;投递是带 HMAC 签名的 `POST`,至少一次、指数退避;目标 URL 要做 SSRF 限制(只放行公网地址,不跟随重定向到内网,有超时和大小上限)。这些都属于 Backend 的集成配置,不改变节点信任,但每次增删订阅都进审计。 ## 7. 归属与审计 @@ -127,40 +127,40 @@ CI 把签名清单 POST 给 `POST /api/release/offer`,面板用发布公钥验 ## 8. 入网信任链的实现要点 - 语句是 `key=value` 文本,第一行是带版本的标题,签名覆盖整段文本;WebAuthn 的挑战是语句的 SHA-256。库在 `Fleet.Identity` 的 `OperatorTrust`(操作员集合的编码、解析、链验证)和 `SecureChannel`(服务器证明和链路加密),都是只用 BCL 的纯函数,节点和 Backend 共用。 -- **创世**:第一次建立信任根时,把账号下所有 passkey 和这个面板的服务器密钥登记成一个操作员集合,passkey 对它签名;创世语句的 SHA-256 就是命令里的 `MOSHIRO_GENESIS`。之后加删 passkey 或换服务器密钥要签新版本(上一版里的凭证签),页面会提示;签名更新不会让节点重装。 -- **操作员集合版本**随期望状态(`trust_chain`)下发,节点从固定的创世验起,版本比现有的新才采用并保存。 -- **节点注册**:`fleet-enroll --genesis … --controllers …`:先 `POST /api/agent/v1/enroll/trust` 取链,验过(创世指纹、签名、属主在集合里)才把密钥交出去;之后写 `trust.json`。没有命令里的指纹(`--genesis`)就不固定,节点按旧方式工作。 -- **服务器身份和安全链路**:`ChallengeParams` 带服务器的临时公钥和签名(`server_eph`、`server_sig`),`AuthParams` 带节点的临时公钥(`node_eph`),节点签名的用途是 `auth2`,覆盖双方的临时公钥;通过后两边用 HKDF 派生每个方向一把 AES-256-GCM 密钥,每帧 `计数器(8) ‖ 密文 ‖ 标签(16)`,计数器严格递增,明文帧、改动、重放、乱序一律断开。集合里没有服务器密钥的节点(旧节点)保持明文,不会被降级成放行。 -- **服务器密钥**存在 Backend 的设置里(`server.identity_key`):它防的是面板被冒充,不是面板被攻破;面板被攻破时它也泄露,那时要由 passkey 签一个换掉它的新集合版本。集合里没有这个面板的服务器密钥的版本,Backend 不收(否则固定了它的节点就认不出这个面板了)。 -- **弱点**:创世指纹放在安装命令里,命令本身要从可信渠道拿到(管理端复制)。缓解见第 10 节(静态签名脚本、按 digest 拉镜像),并在信任页展示指纹供核对。 +- 创世:第一次建立信任根时,把账号下所有 passkey 和这个面板的服务器密钥登记成一个操作员集合,passkey 对它签名;创世语句的 SHA-256 就是命令里的 `MOSHIRO_GENESIS`。之后加删 passkey 或换服务器密钥要签新版本(上一版里的凭证签),页面会提示;签名更新不会让节点重装。 +- 操作员集合版本随期望状态(`trust_chain`)下发,节点从固定的创世验起,版本比现有的新才采用并保存。 +- 节点注册:`fleet-enroll --genesis … --controllers …`:先 `POST /api/agent/v1/enroll/trust` 取链,验过(创世指纹、签名、属主在集合里)才把密钥交出去;之后写 `trust.json`。没有命令里的指纹(`--genesis`)就不固定,节点按旧方式工作。 +- 服务器身份和安全链路:`ChallengeParams` 带服务器的临时公钥和签名(`server_eph`、`server_sig`),`AuthParams` 带节点的临时公钥(`node_eph`),节点签名的用途是 `auth2`,覆盖双方的临时公钥;通过后两边用 HKDF 派生每个方向一把 AES-256-GCM 密钥,每帧 `计数器(8) ‖ 密文 ‖ 标签(16)`,计数器严格递增,明文帧、改动、重放、乱序一律断开。集合里没有服务器密钥的节点(旧节点)保持明文,不会被降级成放行。 +- 服务器密钥存在 Backend 的设置里(`server.identity_key`):它防的是面板被冒充,不是面板被攻破;面板被攻破时它也泄露,那时要由 passkey 签一个换掉它的新集合版本。集合里没有这个面板的服务器密钥的版本,Backend 不收(否则固定了它的节点就认不出这个面板了)。 +- 弱点:创世指纹放在安装命令里,命令本身要从可信渠道拿到(管理端复制)。缓解见第 10 节(静态签名脚本、按 digest 拉镜像),并在信任页展示指纹供核对。 ## 9. 节点属主与 extension 授权 -extension(web ssh、文件、抓包,见 [security.md](security.md))**现在都不实现**,Agent 和 Probe 一样。这类能力连管理员都不信任:管理员能删,不能读,也不能用。所以入网时就给每个节点固定「属主」,之后不用重装节点。 +extension(web ssh、文件、抓包,见 [security.md](security.md))现在都不实现,Agent 和 Probe 一样。这类能力连管理员都不信任:管理员能删,不能读,也不能用。所以入网时就给每个节点固定「属主」,之后不用重装节点。 -**已经实现的部分** +已经实现的部分 - 属主 `controllers` 是一组 passkey 凭证 id,必须是操作员集合里的凭证。由安装命令带来,节点注册时写进 `trust.json`,Backend 之后改不了。 -- **每个 Agent 和 Probe 都必须有属主,至少一把——而且这是节点自己的要求,不是 Backend 的要求**:节点启动时检查本地固定的属主,没有(或者属主的 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 签名。 +- 每个 Agent 和 Probe 都必须有属主,至少一把——而且这是节点自己的要求,不是 Backend 的要求:节点启动时检查本地固定的属主,没有(或者属主的 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 签名。 -**预留的设计(还没做)** +预留的设计(还没做) -- **谁来强制:节点本地,不是 Backend。** web ssh 这类会话要不要加密、要不要验证,由 Agent 和 Probe 自己要求。让 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 就没人能用了,所以添加时建议绑两把以上。 -- **数据边界**:监测数据(延迟、丢包、吞吐、事件、主机资源汇总)是明文,Backend 要做分析和展示;extension 会话和原始 payload 端到端加密,Backend 只存转发密文。读取权不是能力,是私钥。 +- 谁来强制:节点本地,不是 Backend。 web ssh 这类会话要不要加密、要不要验证,由 Agent 和 Probe 自己要求。让 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 就没人能用了,所以添加时建议绑两把以上。 +- 数据边界:监测数据(延迟、丢包、吞吐、事件、主机资源汇总)是明文,Backend 要做分析和展示;extension 会话和原始 payload 端到端加密,Backend 只存转发密文。读取权不是能力,是私钥。 ## 10. 安装的供应链 -一行安装要执行 Backend 吐出的脚本、拉仓库里的镜像。Backend 或仓库被攻破,就能在新节点上执行任意命令。收束办法,**优先级最高的是第一跳的哈希核对**: +一行安装要执行 Backend 吐出的脚本、拉仓库里的镜像。Backend 或仓库被攻破,就能在新节点上执行任意命令。收束办法,优先级最高的是第一跳的哈希核对: -**① 脚本是静态产物,哈希固定** +① 脚本是静态产物,哈希固定 -- 不在服务端做字符串替换。CI 构建时算出 `install.sh`、`install.ps1` 的 SHA-256,写进发布清单(发布密钥签名),同时公布在 Gitea 发布页——**这条渠道独立于 Backend**,这是哈希核对有意义的前提:哈希若和脚本从同一处来,攻击者两个一起换就行。 +- 不在服务端做字符串替换。CI 构建时算出 `install.sh`、`install.ps1` 的 SHA-256,写进发布清单(发布密钥签名),同时公布在 Gitea 发布页——这条渠道独立于 Backend,这是哈希核对有意义的前提:哈希若和脚本从同一处来,攻击者两个一起换就行。 - 参数(代码、面板地址)通过命令行环境变量传入,代码因此不出现在 URL 和反向代理日志里。 - 管理端给的命令把「下载、核对、执行」合成一行,哈希核对不通过就不执行: @@ -170,28 +170,28 @@ f=$(mktemp) && curl -fsSL https://panel/install -o "$f" && echo " $f" | macOS 用 `shasum -a 256 -c`,Windows 用 `Get-FileHash` 比较,不通过就 `throw`。命令里的 `` 由 Backend 填入,信任页同时标出它与 CI 签名的发布清单是否一致;严格的人对照 Gitea 发布页一次即可(脚本是静态的,哈希在一个版本内不变)。 -**② 镜像按 digest 拉取** +② 镜像按 digest 拉取 -- 脚本向面板取已签名的清单(和更新用的是同一份,发布密钥签名、`seq` 防回滚),用**脚本里内置的发布公钥**验签,再按清单里的 digest 拉镜像(`repo@sha256:…`)。Backend 改不了 digest,最多让节点装到一个旧的合法版本。 +- 脚本向面板取已签名的清单(和更新用的是同一份,发布密钥签名、`seq` 防回滚),用脚本里内置的发布公钥验签,再按清单里的 digest 拉镜像(`repo@sha256:…`)。Backend 改不了 digest,最多让节点装到一个旧的合法版本。 - 下载压缩包安装的人,用 Release 里签名的 `SHA256SUMS` 核对(见 [deployment.md](../ops/deployment.md))。 -**③ 容器安装:Podman 系用 Quadlet(默认),Docker 用 compose** +③ 容器安装:Podman 系用 Quadlet(默认),Docker 用 compose -- 管理端的容器安装是:建目录,把参数写进本地 `.env`(面板地址、一次性码、创世指纹、属主,都是公开的值),下载面板提供的静态 compose 文件、核对 SHA-256,`compose up -d`。compose 文件是静态的(和安装脚本一样嵌在 Backend 里,也作为 Release 资产发布),镜像是 `:latest` 或 `.env` 里固定的版本,**镜像地址由 CI 渠道决定,不由面板决定**,面板被攻破也改不了;Backend 第一次部署时没有面板,就从 Release 资产下载。 +- 管理端的容器安装是:建目录,把参数写进本地 `.env`(面板地址、一次性码、创世指纹、属主,都是公开的值),下载面板提供的静态 compose 文件、核对 SHA-256,`compose up -d`。compose 文件是静态的(和安装脚本一样嵌在 Backend 里,也作为 Release 资产发布),镜像是 `:latest` 或 `.env` 里固定的版本,镜像地址由 CI 渠道决定,不由面板决定,面板被攻破也改不了;Backend 第一次部署时没有面板,就从 Release 资产下载。 - 代价:客户端不再验发布签名,更新(`compose pull && compose up -d`)靠 TLS 加「注册表可信」这个假设;想要更严,在 `.env` 里把 `MOSHIRO_VERSION` 固定到 digest,或者用宿主机更新器(清单有发布签名,不健康自动回滚)。详见 [deployment.md](../ops/deployment.md)。 -**④ 其余** +④ 其余 - 角色(Agent 还是 Probe)、镜像仓库地址仍由代码决定,但镜像身份由签名清单决定。 - macOS 的 Developer ID 签名和公证、Windows 的 Authenticode 需要证书,目前没有做。 ## 11. 运行中的二进制不做校验 -曾经做过「节点二进制绑定」:节点握手时自报正在运行的二进制的 SHA-256,Backend 在第一次握手时绑定,之后对不上就标红或拒绝。**已经移除。** +曾经做过「节点二进制绑定」:节点握手时自报正在运行的二进制的 SHA-256,Backend 在第一次握手时绑定,之后对不上就标红或拒绝。已经移除。 原因:哈希和清单都是节点自己报的。一个被换进来、故意撒谎的二进制可以照抄原来的哈希和清单,Backend 分辨不出——上报者就是被怀疑的对象。这一层只能抓到「文件被换了、但程序本身没撒谎」的情形,却让人误以为有保障,还要求运维宿主机更新器、状态卷和每个节点的清单。得不偿失。 -「这个资产是我们的」靠**安装时的来源证明**,那里的哈希来自另一个信道,伪造不了: +「这个资产是我们的」靠安装时的来源证明,那里的哈希来自另一个信道,伪造不了: - 安装脚本(SHA-256 记在签名的清单里,并公布在发布页),按清单里的 digest 拉镜像(`repo@sha256:…`); - 下载压缩包的人用签名的 `SHA256SUMS` 核对(验签用 `packaging/update/release-key.pub.pem`); @@ -201,7 +201,7 @@ macOS 用 `shasum -a 256 -c`,Windows 用 `Get-FileHash` 比较,不通过就 ## 12. 还没有的与局限 -- **还没有**:吊销表;任务和配置内容的操作员签名;节点向别人证明自己运行的就是官方二进制(见第 11 节);extension 本身、属主的 `ControllerUpdate`、接收者的实际加密(属主只是被固定在节点本地,没有行为);API key 的 IP 白名单(本部署经 Cloudflare、frp 和 Caddy,后端看到的来源地址是代理,需要先有「可信代理」配置才做得对);OIDC 登录。 +- 还没有:吊销表;任务和配置内容的操作员签名;节点向别人证明自己运行的就是官方二进制(见第 11 节);extension 本身、属主的 `ControllerUpdate`、接收者的实际加密(属主只是被固定在节点本地,没有行为);API key 的 IP 白名单(本部署经 Cloudflare、frp 和 Caddy,后端看到的来源地址是代理,需要先有「可信代理」配置才做得对);OIDC 登录。 - WebAuthn 只验 ES256 和 attestation = `none`,不验设备证明。 - 某个节点的属主 passkey 全部丢失,这个节点上的 extension 就没人能授权,需要重装节点并换属主。 - 删除一把操作员 passkey 只是让它不能再签新版本;服务器密钥换新要靠签一个新集合版本,忘了签会让固定了集合的节点认不出面板(fail closed)。 diff --git a/docs/security/enrollment.md b/docs/security/enrollment.md index a24ca94..ae48f11 100644 --- a/docs/security/enrollment.md +++ b/docs/security/enrollment.md @@ -1,6 +1,6 @@ # MoShiro 的注册与身份 -通用的注册、身份、认证机制由 Fleet 提供,见 [../backend/fleet/README.md](../../backend/fleet/README.md) 的「身份」「注册」两节。本文只描述 **MoShiro 在 Fleet 之上的约定**:Agent 注册后如何自动接入测量、Probe 的身份以及 Agent 列表同步,以及节点本地的硬上限。 +通用的注册、身份、认证机制由 Fleet 提供,见 [Fleet 文档](../../backend/fleet/README.md) 的「身份」「注册」两节。本文只描述 MoShiro 在 Fleet 之上的约定:Agent 注册后如何自动接入测量、Probe 的身份以及 Agent 列表同步,以及节点本地的硬上限。 ## 1. 身份一览 @@ -10,7 +10,7 @@ | 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 节点不验证清单,只是退出后重新拉镜像;纯二进制节点的更新是待办),**面板不保存私钥**;登记清单时面板用公钥验签,宿主机的更新器之后再验一次 | +| 发布签名 | ECDSA P-256 发布密钥 | CI(Woodpecker 密钥 `release_signing_key`)或离线环境 | 只在宿主机的更新器里配置公钥(Backend 和脚本装的节点用 `moshiro-update`,见 [deployment.md](../ops/deployment.md);Quadlet 节点不验证清单,只是退出后重新拉镜像;纯二进制节点的更新见[路线图](../roadmap.md)),面板不保存私钥;登记清单时面板用公钥验签,宿主机的更新器之后再验一次 | ## 2. Agent 接入流程 @@ -59,7 +59,7 @@ 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 连哪些 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 的期望状态中移除,旧会话随之断开;要继续用就重新注册。 ## 4. Agent 本地配置(`/etc/moshiro/agent.json`,面板无法修改) @@ -78,7 +78,7 @@ Probe 启动 } ``` -- `limits` 是**本地硬上限**:期望任务集超出这些范围时,调和器拒绝执行,并在 `rejected[]` 中给出原因。 +- `limits` 是本地硬上限:期望任务集超出这些范围时,调和器拒绝执行,并在 `rejected[]` 中给出原因。 - `allowed_target_cidrs` 默认为空,即不允许执行任何目标任务;需要时由节点所有者在本机显式开启。 - `listen` 默认不包含 443。要用 443 测量,需要在本机把它加入 `quic_ports` 或 `tcp_ports`(端口不能被真实服务占用),见 [measurement.md](../design/measurement.md) 第 8 节。 - `fleet_policy.allow_methods` 在 Fleet 默认值的基础上进一步收窄。 diff --git a/docs/security/security.md b/docs/security/security.md index 28396c7..80525eb 100644 --- a/docs/security/security.md +++ b/docs/security/security.md @@ -1,20 +1,20 @@ # 零信任设计 -> **实现状态**:协议方向声明表和卡口、Probe 自己生成密钥、预握手敲门、操作员集合与创世固定、服务器身份和安全链路、属主固定都已实现;测量数据的产生者签名、入库配额、Probe 拨号地址限制等还没做(第 3 节有逐项状态)。 +> 实现状态:协议方向声明表和卡口、Probe 自己生成密钥、预握手敲门、操作员集合与创世固定、服务器身份和安全链路、属主固定都已实现;测量数据的产生者签名、入库配额、Probe 拨号地址限制等还没做(第 3 节有逐项状态)。 Agent、Backend、Probe 之间互相不信任。这份文档回答三个问题:现在谁听谁的、被攻破之后最坏会怎样、怎么用密码学把信任从 Backend 身上拿走。账户、能力、passkey 和入网信任链的具体做法见 [access.md](access.md);纯采集的边界、本地硬上限和签名更新见 [architecture.md](../design/architecture.md) 第 5 节,这里不重复。 ## 1. 范围 -**要防的** +要防的 - Backend(或 Backend 前面的 Cloudflare Tunnel、frp、Caddy 等中间环节)被攻破或被劫持之后,给节点下发恶意内容。 - Agent 或 Probe 自己被入侵之后,去影响 Backend 或对端。 -**有意不做的** +有意不做的 -- **进程沙箱**(seccomp、pids 和内存上限、出站白名单)。Agent 和 Probe 的定位是 1 核 1 GB 的小机器,不能吃资源。 -- **供应链的多签、冷却期和可复现构建**。代码风险靠 PR 评审降一个等级;发布仍然是单把发布密钥签名(见 [deployment.md](../ops/deployment.md))。 +- 进程沙箱(seccomp、pids 和内存上限、出站白名单)。Agent 和 Probe 的定位是 1 核 1 GB 的小机器,不能吃资源。 +- 供应链的多签、冷却期和可复现构建。代码风险靠 PR 评审降一个等级;发布仍然是单把发布密钥签名(见 [deployment.md](../ops/deployment.md))。 - 顺带记一条:Agent 的单元挂了 `/:/host/root:ro`、`/proc`、`/sys`,被攻破的 Agent 能遍历宿主机。它们多半是不必要的(宿主网络模式下容器自己的 `/proc` 就是宿主的,磁盘用量可以对已挂载的 `/etc/os-release` 做 `statvfs`),属于沙箱范畴,先不动。 ## 2. 威胁模型 @@ -44,23 +44,23 @@ Agent、Backend、Probe 之间互相不信任。这份文档回答三个问题 | # | 缺口 | 状态 | | --- | --- | --- | -| G1 | 节点不验证 Backend 的身份,只靠 TLS 加公共 CA | **已解决**:操作员集合里登记服务器密钥,节点要求服务器在认证挑战里签名,链路用应用层加密([access.md](access.md) 第 8 节)。对 Backend 自己被攻破没有帮助 | -| G2 | Probe 的私钥由 Backend 生成 | **已解决**:Probe 自己生成密钥,用加入令牌注册,私钥只在节点上(破坏性:旧 `probe.json` 失效) | +| 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 被拒绝) | +| G4 | Agent 的 TCP 端口对任何人完成 TLS 握手并出示证书 | 已解决:预握手敲门,见第 8 节(破坏性:旧 Probe 被拒绝) | | G5 | Backend 入库只限制了单块的大小,没有按节点的速率或配额 | 未解决:恶意节点可以灌满磁盘 | | G6 | 对端公钥、Agent 地址全由 Backend 决定 | 未解决:操作员集合不包含成员名册,被攻破的 Backend 能把恶意 Probe 加进 Agent 的白名单,或把 Probe 指向恶意 Agent | -| G7 | 方法的方向和角色没有统一声明 | **已解决**:声明表和卡口,见第 6 节 | +| G7 | 方法的方向和角色没有统一声明 | 已解决:声明表和卡口,见第 6 节 | | G8 | 测量数据没有产生者签名(块只有 CRC32C) | 未解决:中间环节可以改写测量数据;被攻破的 Agent 可以伪造 Probe 侧的时间戳 | | G9 | 入库只核对了 `agent_id`,不核对块里的 `series`(`line:`) | 未解决:被攻破的 Agent 可以为从没连过它的 Probe 灌入假线路数据 | | G10 | 没有合理性检查(`EchoOps` 不检查回显时间戳的一致性) | 未解决:恶意 Agent 可以谎报 `t2`、`t3` | -| G11 | Probe 的一把密钥多处使用 | **已解决**:面向 Backend 和面向对等面的是独立的密钥,用途前缀做域隔离 | +| G11 | Probe 的一把密钥多处使用 | 已解决:面向 Backend 和面向对等面的是独立的密钥,用途前缀做域隔离 | 没有问题的部分:UDP 端口对未认证的包静默丢弃,不回应(`DropInvalid`,有测试);Probe 本身没有 TCP 监听;任务有本地硬上限;更新清单有签名和 `seq` 防回滚。 ## 4. 核心思路:信任锚点从 Backend 移走 -要防 Backend 被攻破,**改变信任的内容必须由 Backend 之外的密钥授权**。Backend 只搬运、计算和存储,不创造信任: +要防 Backend 被攻破,改变信任的内容必须由 Backend 之外的密钥授权。Backend 只搬运、计算和存储,不创造信任: ```text ① 发布密钥(CI) ──▶ 「跑什么代码」 清单签名、seq 防重放 @@ -78,7 +78,7 @@ Agent、Backend、Probe 之间互相不信任。这份文档回答三个问题 - 节点安装时固定创世指纹和属主;节点注册时先验操作员集合链(创世指纹、passkey 签名、属主在集合里),验不过就不交出密钥。之后只认从这个创世出发的新版本,节点持久化见过的最大版本,旧版本不可回放。 - 操作员集合里还登记了 Backend 的服务器密钥(G1)。 - 加节点不需要 passkey:创建加入码是普通操作,命令里只有公开的值;自动化用带 `fleet.enroll` 的 API key。信任来自节点注册时固定的创世指纹和属主。 -- **没有采用**逐节点的「成员证明」链(Grant、Ticket、Binding):被攻破的 Backend 往名册里塞节点,拿不到比 Backend 已经有的更多的东西。所以名册(G6)仍然由 Backend 决定。 +- 没有采用逐节点的「成员证明」链(Grant、Ticket、Binding):被攻破的 Backend 往名册里塞节点,拿不到比 Backend 已经有的更多的东西。所以名册(G6)仍然由 Backend 决定。 ## 6. 协议的方向与平面 @@ -91,34 +91,34 @@ Agent、Backend、Probe 之间互相不信任。这份文档回答三个问题 对等面(Probe 拨 Agent) TLS 控制 7443/tcp、数据 7443/udp、7444/tcp(先过敲门) ``` -- **每个平面只认自己的凭证**:节点面不接受令牌和 Cookie;外部面不接受节点签名;对等面只认敲门通过的成员。 -- **连接方向固定**:节点面永远是节点拨入 Backend,Backend 从不主动连节点,所谓「下发」只是在节点拨入的连接上发下行消息;对等面永远是 Probe 拨 Agent,Agent 不拨 Probe。 -- **外部面不能直通节点面**:管理端的操作(例如发起测试)只会触发 Backend 里一个**强类型**的下行方法,管理端不能指定方法名,也不能把请求体原样转给节点(`IAgentGateway` 里全是强类型方法)。 +- 每个平面只认自己的凭证:节点面不接受令牌和 Cookie;外部面不接受节点签名;对等面只认敲门通过的成员。 +- 连接方向固定:节点面永远是节点拨入 Backend,Backend 从不主动连节点,所谓「下发」只是在节点拨入的连接上发下行消息;对等面永远是 Probe 拨 Agent,Agent 不拨 Probe。 +- 外部面不能直通节点面:管理端的操作(例如发起测试)只会触发 Backend 里一个强类型的下行方法,管理端不能指定方法名,也不能把请求体原样转给节点(`IAgentGateway` 里全是强类型方法)。 - 三个平面共用同一个监听端口和同一条 ASP.NET 管线,靠路径和凭证类型区分。把节点面和外部面做成可以分别配置监听地址(默认仍同一个),这样管理面可以只对内网或 VPN 开放,是待定的目标。 ### 6.2 每个方法声明方向、类型和角色 -**同一个方法名只允许一个发起方**,不存在「双向方法」。一个方法声明:发起方(`B→A`、`B→P`、`A→B`、`P→B`、`P→A`、`A→P`)、类型(请求有响应、通知无响应)、角色(`A`、`P`、`A+P`)、授权(无,或为 extension 预留的 `operator`)。方向总表见 [protocol.md](../design/protocol.md),以代码里的声明为准。 +同一个方法名只允许一个发起方,不存在「双向方法」。一个方法声明:发起方(`B→A`、`B→P`、`A→B`、`P→B`、`P→A`、`A→P`)、类型(请求有响应、通知无响应)、角色(`A`、`P`、`A+P`)、授权(无,或为 extension 预留的 `operator`)。方向总表见 [protocol.md](../design/protocol.md),以代码里的声明为准。 强制方式: -1. **一份声明表,单一真源**:框架的 `fleet.*` 在 `Fleet.Rpc/MethodTable.cs` 的 `FleetMethodTable`,`moshiro.*` 和两条二进制通道在 `MoShiro.Protocol` 的 `MoShiroMethodTable`;角色是 `NodeRoles`(服务端按节点 ID 前缀判断)。声明表按(平面,名字)查找,同名方法在两个平面里可以有不同的发起方(`fleet.state.sync` 在节点面是 Backend→节点,在对等面是 Agent→Probe)。 -2. **卡口在 `RpcRouter`**:创建时必须给出本侧(`RpcSide`)和声明表。**注册**处理函数时核对方向、类型、角色,不符就在启动时失败;**分发**时拒绝没有声明、方向不对、请求和通知用反了、角色不允许的消息,回 `fleet_protocol_violation`;**发送**前核对本侧有没有资格发起(违反是程序缺陷,抛异常)。 -3. **违规处理**:`RpcSession` 和服务端的 `NodeConnection` 对违规计数,超过上限(`MaxViolations`,默认 20)就断开连接;二进制通道也在服务端按声明核对。 -4. **响应匹配**:只接受对应未决请求 ID 的响应,不接受主动「响应」。 -5. **对账测试**:`Fleet.Tests/MethodDirectionTests` 覆盖卡口本身;`MoShiro.Backend.Tests/ProtocolDeclarationTests` 核对「每个方法常量都声明了」「文档里写了每个方法」「各角色该处理的方法都注册了处理函数」「每条声明的通道都有收件箱」。 -6. **下行消息只是建议,不是命令**:节点按本地硬上限执行或拒绝;任何带副作用的敏感能力都不走核心的下行消息,见第 9 节。 +1. 一份声明表,单一真源:框架的 `fleet.*` 在 `Fleet.Rpc/MethodTable.cs` 的 `FleetMethodTable`,`moshiro.*` 和两条二进制通道在 `MoShiro.Protocol` 的 `MoShiroMethodTable`;角色是 `NodeRoles`(服务端按节点 ID 前缀判断)。声明表按(平面,名字)查找,同名方法在两个平面里可以有不同的发起方(`fleet.state.sync` 在节点面是 Backend→节点,在对等面是 Agent→Probe)。 +2. 卡口在 `RpcRouter`:创建时必须给出本侧(`RpcSide`)和声明表。注册处理函数时核对方向、类型、角色,不符就在启动时失败;分发时拒绝没有声明、方向不对、请求和通知用反了、角色不允许的消息,回 `fleet_protocol_violation`;发送前核对本侧有没有资格发起(违反是程序缺陷,抛异常)。 +3. 违规处理:`RpcSession` 和服务端的 `NodeConnection` 对违规计数,超过上限(`MaxViolations`,默认 20)就断开连接;二进制通道也在服务端按声明核对。 +4. 响应匹配:只接受对应未决请求 ID 的响应,不接受主动「响应」。 +5. 对账测试:`Fleet.Tests/MethodDirectionTests` 覆盖卡口本身;`MoShiro.Backend.Tests/ProtocolDeclarationTests` 核对「每个方法常量都声明了」「文档里写了每个方法」「各角色该处理的方法都注册了处理函数」「每条声明的通道都有收件箱」。 +6. 下行消息只是建议,不是命令:节点按本地硬上限执行或拒绝;任何带副作用的敏感能力都不走核心的下行消息,见第 9 节。 ### 6.3 各类下行消息怎么处置 | 下行内容 | 授权 | 被攻破的 Backend 最多能做什么 | | --- | --- | --- | -| 操作员集合新版本 | **操作员 passkey 签名**,节点自己验 | 什么都改不了;能转发旧的合法版本,版本号递增防回放 | +| 操作员集合新版本 | 操作员 passkey 签名,节点自己验 | 什么都改不了;能转发旧的合法版本,版本号递增防回放 | | 更新清单 | 发布密钥(CI) | 不能伪造;能转发旧的合法清单,`seq` 防回滚;节点的更新器还会用本机公钥再验 | | 成员名册、对端公钥、Agent 地址 | Backend(G3、G6 未解决) | 能把恶意 Probe 加进 Agent 的白名单,或把 Probe 指向恶意 Agent | | 任务集、测试请求 | Backend 计算;节点本地硬上限兜底 | 只能在本地速率和端口上限内发测量流量 | | 重启计划(`moshiro.schedule`) | Backend;节点本地启动后 10 分钟内不触发 | 最多让节点每 10 分钟重启一次 | -| 未来的 `extension`(exec 等) | **属主 passkey 签名的一次性能力令牌**,见第 9 节 | 什么都做不了 | +| 未来的 `extension`(exec 等) | 属主 passkey 签名的一次性能力令牌,见第 9 节 | 什么都做不了 | ## 7. 节点自己被攻破时(设计,未实现) @@ -126,30 +126,30 @@ Agent、Backend、Probe 之间互相不信任。这份文档回答三个问题 测量数据是这个项目的产出,不能只靠传输层可信:链路有 Cloudflare 这样的中间环节,Agent 又要转发 Probe 的数据。 -- **产生者签名**:每个列块由 Probe 签名,每个原始块由上传它的节点签名;Agent 转发 Probe 的块时原样保留签名。签名覆盖块的摘要(SHA-256),签名串带用途前缀 `chunk` 和节点 ID。Backend 入库时逐个验签,不通过的丢弃并计数。每 10 秒一块,一次 ECDSA 验签的开销可以忽略。 -- **归属校验**:入库时核对块里的 Probe 是和这个 Agent 有线路的成员,不是就丢弃并计数。 -- **合理性检查**:`t3−t2` 必须小于 `t4−t1`,各时间戳单调;违反的样本不计入指标,记为异常并产生事件。 +- 产生者签名:每个列块由 Probe 签名,每个原始块由上传它的节点签名;Agent 转发 Probe 的块时原样保留签名。签名覆盖块的摘要(SHA-256),签名串带用途前缀 `chunk` 和节点 ID。Backend 入库时逐个验签,不通过的丢弃并计数。每 10 秒一块,一次 ECDSA 验签的开销可以忽略。 +- 归属校验:入库时核对块里的 Probe 是和这个 Agent 有线路的成员,不是就丢弃并计数。 +- 合理性检查:`t3−t2` 必须小于 `t4−t1`,各时间戳单调;违反的样本不计入指标,记为异常并产生事件。 - 边界:这样中间环节改不了数据,被攻破的 Agent 改不了 Probe 一侧的数据;但它仍然可以谎报自己一侧的 `t2`、`t3`,合理性检查只能限制它的幅度,因为这一侧的真值只有它自己知道。 ### 7.2 对 Backend 的资源和输入 -- **按节点的配额**:每个节点的字节、块数和速率上限,只是计数,不占资源;全局再限制同时解码的块数(单块解码上限是 64 MiB)。 -- **节点上报的字符串**:`hello` 里的 `platform`、事件的 `detail` 等一律当不可信数据,入库前统一校验长度、字符集和换行,不依赖前端转义;管理端不渲染成 HTML。 -- **匿名公开接口**:登录已经有限流,但热力图、分布这类重查询只有范围和指标数的上限,要加每源限速和结果缓存。 -- **webhook**:实现之前先定约束:只允许 HTTPS、禁止私网和回环地址、不跟随重定向、限制超时和响应大小、解析之后再检查地址。 +- 按节点的配额:每个节点的字节、块数和速率上限,只是计数,不占资源;全局再限制同时解码的块数(单块解码上限是 64 MiB)。 +- 节点上报的字符串:`hello` 里的 `platform`、事件的 `detail` 等一律当不可信数据,入库前统一校验长度、字符集和换行,不依赖前端转义;管理端不渲染成 HTML。 +- 匿名公开接口:登录已经有限流,但热力图、分布这类重查询只有范围和指标数的上限,要加每源限速和结果缓存。 +- webhook:实现之前先定约束:只允许 HTTPS、禁止私网和回环地址、不跟随重定向、限制超时和响应大小、解析之后再检查地址。 ### 7.3 对对端和身份 - 被攻破的 Probe 只能拿到 Backend 授权的会话,Agent 的速率上限由本地兜底;被攻破的 Agent 发来的数据只会影响它自己的线路。 -- **Probe 只拨公网地址**(G3):固定 SPKI 只能防止建立会话,防不了探测本身;要在 Probe 本地拒绝私网、回环、链路本地地址。 -- **名册里的主机名解析之后再检查地址**:签名保护的是字符串,不是解析结果;默认只放行公网地址,内网需要节点本地显式允许。 -- **QUIC 会话迁移要做路径验证**:协议允许会话在 NAT 映射变化后跟随新的源地址,路径上的攻击者可以借此把会话牵到自己的地址。迁移前先做一次挑战应答,验证前限制发送量(不超过收到字节数的 3 倍),由 QUIC path validation 提供。 -- **不协商、不降级**:现在固定 ECDSA P-256 和固定的签名串格式,没有算法协商,保持这一点。 +- Probe 只拨公网地址(G3):固定 SPKI 只能防止建立会话,防不了探测本身;要在 Probe 本地拒绝私网、回环、链路本地地址。 +- 名册里的主机名解析之后再检查地址:签名保护的是字符串,不是解析结果;默认只放行公网地址,内网需要节点本地显式允许。 +- QUIC 会话迁移要做路径验证:协议允许会话在 NAT 映射变化后跟随新的源地址,路径上的攻击者可以借此把会话牵到自己的地址。迁移前先做一次挑战应答,验证前限制发送量(不超过收到字节数的 3 倍),由 QUIC path validation 提供。 +- 不协商、不降级:现在固定 ECDSA P-256 和固定的签名串格式,没有算法协商,保持这一点。 - Agent 的 `max_total_mbps` 默认是 1200,对 1 核 1 GB 的小机器没有意义,却是被攻破的 Backend 能让 Agent 向某个 Probe 打出去的上限。默认值应该保守,需要的人再调高;`allowed_target_cidrs` 默认为空是对的。 ## 8. 监听隐身:预握手敲门 -目标:Agent 的 TCP 端口对没有完成挑战的人**不写出任何字节**,包括 TLS 的 ServerHello,扫描器拿不到证书和 TLS 指纹(G4)。UDP 端口本来就是静默的。 +目标:Agent 的 TCP 端口对没有完成挑战的人不写出任何字节,包括 TLS 的 ServerHello,扫描器拿不到证书和 TLS 指纹(G4)。UDP 端口本来就是静默的。 ```text Probe Agent TCP 7443 / 7444 @@ -161,20 +161,20 @@ Agent、Backend、Probe 之间互相不信任。这份文档回答三个问题 ``` - `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 里上报)。陌生连接得到的是被关闭的连接,没有任何字节。 +- 实现:`Fleet.Identity.Knock`(构造与核对)和 `KnockGate`(读 32 字节、名册、nonce 去重、按来源限速);控制端口(`PeerListener`)和数据端口(`TcpAcceptor`)共用同一个关卡,Probe 一侧 `PeerConnector` 和 `TcpLink` 在 TCP 连接建立后、TLS 之前发敲门包。名册是期望状态里的 `peers`(Probe 的对等面公钥);`probe_agents` 下发的是 Agent 的 `public_key`(Agent 在 hello 里上报)。陌生连接得到的是被关闭的连接,没有任何字节。 -**局限**:TCP 的 SYN-ACK 由内核发出,用户态进程拦不住,所以端口在扫描器眼里仍是「开着,但不说话」,**不是「没监听」**。真正的没监听有两条路:等级二的 UDP 单包授权(Probe 先发签名的 UDP 敲门包,Agent 才临时开 TCP 监听几秒,只放行敲门者的源地址;代价是多一个往返、UDP 丢包重试、多出口源地址可能对不上),或宿主防火墙规则(需要 root,不适合小机器,不做)。先做等级一,等级二可选。 +局限:TCP 的 SYN-ACK 由内核发出,用户态进程拦不住,所以端口在扫描器眼里仍是「开着,但不说话」,不是「没监听」。真正的没监听有两条路:等级二的 UDP 单包授权(Probe 先发签名的 UDP 敲门包,Agent 才临时开 TCP 监听几秒,只放行敲门者的源地址;代价是多一个往返、UDP 丢包重试、多出口源地址可能对不上),或宿主防火墙规则(需要 root,不适合小机器,不做)。先做等级一,等级二可选。 ## 9. extension:敏感能力的归宿 -Agent 和 Probe 的核心永远是纯采集器。**所有敏感能力**(exec、web ssh 和终端、文件读写、抓包等)都不进核心,做成 extension,**用户可选安装,默认没有**,现在都没有实现。 +Agent 和 Probe 的核心永远是纯采集器。所有敏感能力(exec、web ssh 和终端、文件读写、抓包等)都不进核心,做成 extension,用户可选安装,默认没有,现在都没有实现。 -- **独立进程,不是插件**:单独的二进制和镜像,和核心之间没有进程内接口,核心不加载任何外部代码、不用反射和动态加载,核心的 NativeAOT 和「不能 exec」都不受影响。 -- **授权只认节点的属主**:每次调用的能力令牌必须由这个节点绑定的属主 passkey 签名,账户角色不赋予权力;管理员只能删除节点或卸载 extension。交互式会话端到端加密,Backend 只转发密文。属主和会话密钥的设计见 [access.md](access.md) 第 9 节。 -- **独立身份和发布**:extension 有自己的密钥、自己向 Backend 注册,在名册里是单独的成员(记录它依附的节点 ID);独立的镜像和清单(`component` 不同,例如 `ext-ssh`),装不装、升不升级都不影响核心。 -- **独立命名空间**:extension 的方法只能是 `ext.<名字>.*`,同样进方向声明表;核心的声明表里没有 `ext.*`,核心收到一律拒绝。 -- **本地显式启用**:extension 的本地配置里有 `enabled`、允许的操作员凭证、允许的命令集或目标范围,默认关闭。 -- **不做的**:核心里的任何 exec、文件写、加载 extension;用 extension 绕过核心的本地硬上限。 +- 独立进程,不是插件:单独的二进制和镜像,和核心之间没有进程内接口,核心不加载任何外部代码、不用反射和动态加载,核心的 NativeAOT 和「不能 exec」都不受影响。 +- 授权只认节点的属主:每次调用的能力令牌必须由这个节点绑定的属主 passkey 签名,账户角色不赋予权力;管理员只能删除节点或卸载 extension。交互式会话端到端加密,Backend 只转发密文。属主和会话密钥的设计见 [access.md](access.md) 第 9 节。 +- 独立身份和发布:extension 有自己的密钥、自己向 Backend 注册,在名册里是单独的成员(记录它依附的节点 ID);独立的镜像和清单(`component` 不同,例如 `ext-ssh`),装不装、升不升级都不影响核心。 +- 独立命名空间:extension 的方法只能是 `ext.<名字>.*`,同样进方向声明表;核心的声明表里没有 `ext.*`,核心收到一律拒绝。 +- 本地显式启用:extension 的本地配置里有 `enabled`、允许的操作员凭证、允许的命令集或目标范围,默认关闭。 +- 不做的:核心里的任何 exec、文件写、加载 extension;用 extension 绕过核心的本地硬上限。 ## 10. 密码学原语 @@ -187,7 +187,7 @@ Agent 和 Probe 的核心永远是纯采集器。**所有敏感能力**(exec | 令牌哈希、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 里没有,Probe 和 Agent 不引用 NuGet 包,只能自己实现几百行的便携版本加官方测试向量,这是要长期维护的密码学代码;托管实现没有 SHA-NI 加速,在 x86 上未必更快。若以后在目标硬件上实测发现逐包 MAC 是瓶颈,再单独评估,届时只替换 `Fleet.Identity` 里 `Mac` 和 `Kdf` 两个静态函数,调用方不动。 ## 11. 还没定的事 diff --git a/web/README.md b/web/README.md index ec8e0c3..8222dcd 100644 --- a/web/README.md +++ b/web/README.md @@ -23,9 +23,9 @@ npm run lint:css # 颜色只能写在 shared/theme/tokens.css, 约定: - 总览、线路详情、对比和管理端按需测试均可切换 IPv4 / IPv6;查询和测试结果独立。管理端 Agent 编辑页分别配置 IPv4、IPv6 地址,IPv6 留空只采 IPv4。展示端只显示地址族,不显示 IP。 -- 展示端能拿到的主机数据只有汇总的数字(CPU、内存、磁盘占用,网卡累计流量与瞬时网速,运行时长),**没有 CPU 型号、主机名、网卡名,也没有任何 IP**——后端的公开接口不返回它们,Agent 也根本不采集。展示端不显示 Probe 的版本和来源地址(这些只在管理端)。 -- 线路详情页:先选**哪一天**(前一天 / 后一天 / 日期选择,URL 的 `day=YYYY-MM-DD`),下面是这一天的丢包缩略图,上面有可拖动的选区(拖空白处新建、拖两边调长短、拖中间平移;URL 的 `at=起_止`,单位是从选中那天 0 点算起的分钟,可以是负数或超过 1440)。缩略图的**刻度**缺省是 48 小时(连续两天,选区可以跨零点,比如晚高峰 20:00 到凌晨 02:00),也可以切成 24 小时(只看一天;URL 的 `span=24`,选区跨零点时自动用 48 小时)。刻度下方是**选区长度**:30 分钟、1 小时、3 小时、6 小时、12 小时、整天,点一下把选区设成这么长——今天且选区到现在为止的,继续看「最近一段」(实时刷新),否则保持选区的起点往后取。刻度上每小时一根短线,48 小时刻度每 3 小时标一个钟点(24 小时刻度每 2 小时),零点标日期;鼠标移到缩略图上会标出那个位置的时间;点一下空白处或者刻度下面的钟点:离那里近的端点(左端或右端)贴过去,另一个端点不动。鼠标停在轴上或刻度上时,会动的那个端点变色,它到目标位置之间染色:扩大是橙色,收缩是灰色。往日默认是整天;「整天」是本地时区的自然日。时间的文字不写「次日」,跨零点两头都写日期(2026年10月5日 21:06 – 2026年10月6日 02:31)。 -- 图表上方有 **EWMA 平滑**开关(关、轻 α=0.3、中 α=0.1、重 α=0.03;URL 的 `ewma=low|mid|high`):开了之后每条线画成指数加权移动平均,原始数据以淡线垫在后面,缺口处断开;只在浏览器里算,不影响汇总的指标数字。 +- 展示端能拿到的主机数据只有汇总的数字(CPU、内存、磁盘占用,网卡累计流量与瞬时网速,运行时长),没有 CPU 型号、主机名、网卡名,也没有任何 IP——后端的公开接口不返回它们,Agent 也根本不采集。展示端不显示 Probe 的版本和来源地址(这些只在管理端)。 +- 线路详情页:先选哪一天(前一天 / 后一天 / 日期选择,URL 的 `day=YYYY-MM-DD`),下面是这一天的丢包缩略图,上面有可拖动的选区(拖空白处新建、拖两边调长短、拖中间平移;URL 的 `at=起_止`,单位是从选中那天 0 点算起的分钟,可以是负数或超过 1440)。缩略图的刻度缺省是 48 小时(连续两天,选区可以跨零点,比如晚高峰 20:00 到凌晨 02:00),也可以切成 24 小时(只看一天;URL 的 `span=24`,选区跨零点时自动用 48 小时)。刻度下方是选区长度:30 分钟、1 小时、3 小时、6 小时、12 小时、整天,点一下把选区设成这么长——今天且选区到现在为止的,继续看「最近一段」(实时刷新),否则保持选区的起点往后取。刻度上每小时一根短线,48 小时刻度每 3 小时标一个钟点(24 小时刻度每 2 小时),零点标日期;鼠标移到缩略图上会标出那个位置的时间;点一下空白处或者刻度下面的钟点:离那里近的端点(左端或右端)贴过去,另一个端点不动。鼠标停在轴上或刻度上时,会动的那个端点变色,它到目标位置之间染色:扩大是橙色,收缩是灰色。往日默认是整天;「整天」是本地时区的自然日。时间的文字不写「次日」,跨零点两头都写日期(2026年10月5日 21:06 – 2026年10月6日 02:31)。 +- 图表上方有 EWMA 平滑开关(关、轻 α=0.3、中 α=0.1、重 α=0.03;URL 的 `ewma=low|mid|high`):开了之后每条线画成指数加权移动平均,原始数据以淡线垫在后面,缺口处断开;只在浏览器里算,不影响汇总的指标数字。 - 图表在数据刷新(轮询、实时)时原地更新,不重建;拖选区时只在本地更新,松手才取数。 - 总览是卡片,Agent 主机状态通过 SSE(`/api/public/live?topics=hosts`)实时更新,其余每 10 秒刷新。 - 生产部署时,把 `display/dist` 放在站点根、`admin/dist` 放在 `/admin/` 下(同源,Cookie 和 CSRF 才能工作)。