31 Commits
Author SHA1 Message Date
justme ded7c4ef50 build: release v0.1.1 2026-10-05 09:38:05 -07:00
justme 2b80bb66d1 ci: use local Gitea endpoint for release uploads 2026-10-05 08:44:19 -07:00
justme 078eacf1cb ci: persist Buildah cache for Linux releases 2026-10-05 08:29:08 -07:00
justme 3db6d5b159 ci: authenticate Zot with API key for justme [CI SKIP] 2026-10-05 22:24:16 +08:00
justme a7a8212c08 ci: push images to public Zot over HTTPS [CI SKIP] 2026-10-05 16:28:15 +08:00
justme 68406843de ci: retry transient Zot registry errors [CI SKIP] 2026-10-04 13:26:09 -07:00
justme fae8c45c90 ci: use bounded runspaces for artifact uploads [CI SKIP] 2026-10-04 13:11:01 -07:00
justme 3bf12ec73d ci: upload release artifacts concurrently [CI SKIP] 2026-10-04 13:05:17 -07:00
justme a9ee4ec34c build: disable PDB debug symbols in releases [CI SKIP] 2026-10-04 12:54:39 -07:00
justme 6b12049df0 ci: drop 32-bit release targets [CI SKIP] 2026-10-04 12:44:32 -07:00
justme d4b4869501 fix(ci): package cross-platform tarballs on Windows [CI SKIP] 2026-10-04 12:38:13 -07:00
justme 92b72d4443 ci: release without blocking on test workflow [CI SKIP] 2026-10-04 12:31:27 -07:00
justme 67b428991f ci: parallelize NativeAOT target builds 2026-10-04 12:29:35 -07:00
justme 55756f7695 ci: cross-publish full NativeAOT matrix from Windows 2026-10-04 12:10:06 -07:00
justme 17a69c1c7a ci: parallelize release components 2026-10-04 11:44:41 -07:00
justme 50d8c07939 ci: gate parallel releases on verified commits 2026-10-05 02:39:21 +08:00
justme 2e549b230d build: use ASP.NET runtime for backend images 2026-10-05 02:24:15 +08:00
justme ace42e5da7 fix(ci): use POST for Gitea release assets 2026-10-05 02:16:14 +08:00
justme ad32afd038 ci: run release builds in parallel 2026-10-05 02:11:29 +08:00
justme 286c9ff7cd ci: publish release assets to Gitea 2026-10-05 02:06:36 +08:00
justme 8aaa574485 fix(ci): make release retryable 2026-10-05 01:21:37 +08:00
justme ce30828ed3 ci: use runner-local Gitea package endpoints 2026-10-05 01:10:17 +08:00
justme a46f45cce3 ci: order multiarch index after builds 2026-10-05 01:05:24 +08:00
justme ce3ba51c02 test: normalize project reference paths across platforms 2026-10-05 00:49:20 +08:00
justme faf1203f0a ci: disable git lfs in clone steps 2026-10-05 00:43:46 +08:00
justme ee422ca366 ci: run verification for push, pull requests and tags 2026-10-05 00:41:29 +08:00
justme c33003c279 ci: restore public Gitea package endpoint 2026-10-04 23:11:37 +08:00
justme 31af31b96f ci: use internal Gitea endpoint 2026-10-04 23:01:59 +08:00
justme 9b6c4e0fd8 ci: skip host OpenAPI generation for arm64 2026-10-04 22:38:26 +08:00
justme ceef543854 ci: verify development branch 2026-10-04 22:20:58 +08:00
justme 299f86dbee ci: configure Woodpecker release pipelines 2026-10-04 22:12:21 +08:00
595 changed files with 7534 additions and 39579 deletions

No files matched your search

-18
View File
@@ -1,18 +0,0 @@
/.gitattributes text eol=lf
ci/build-main-amd64.sh text eol=lf
ci/index-backend-image.sh text eol=lf
ci/publish-release-offer.sh text eol=lf
ci/release-linux.sh text eol=lf
packaging/update/*.sh text eol=lf
packaging/container/*.sh text eol=lf
ci/release-node-images.sh text eol=lf
packaging/install/*.sh text eol=lf
packaging/install/*.ps1 text eol=lf
*.sh text eol=lf
*.init text eol=lf
*.service text eol=lf
*.container text eol=lf
*.Containerfile text eol=lf
*.ps1 text eol=lf
*.yml text eol=lf
*.md text eol=lf
-128
View File
@@ -1,128 +0,0 @@
labels:
ci: domain-linux-native
platform: linux/amd64
clone:
- name: git
image: plugin-git
settings:
lfs: false
# 要拿到 tag 才算得出开发版本号(ci/version.sh:自最近 tag 的提交数)
tags: true
when:
- event: push
branch: main
evaluate: 'CI_COMMIT_MESSAGE contains "[CI]"'
# 先等后端测试和前端构建都通过再出镜像(提交信息写 [SKIP TESTS] 只跳过后端测试)
depends_on:
- verify
- web-build
concurrency: 1
steps:
- name: build-linux-amd64-backend
image: bash
environment:
NUGET_PACKAGES: /home/justme/woodpecker/cache/nuget/packages
ZOT_REGISTRY: registry.oeo.one
ZOT_USERNAME: justme
ZOT_SECRET:
from_secret: zot_secret
RELEASE_API_URL: https://mo.oeo.one
RELEASE_SIGNING_KEY:
from_secret: release_signing_key
commands:
- bash ci/build-dev-image.sh backend
depends_on: []
- name: build-linux-amd64-agent
image: bash
environment:
ZOT_REGISTRY: registry.oeo.one
ZOT_USERNAME: justme
ZOT_SECRET:
from_secret: zot_secret
RELEASE_API_URL: https://mo.oeo.one
RELEASE_SIGNING_KEY:
from_secret: release_signing_key
commands:
- bash ci/build-dev-image.sh agent
depends_on: []
- name: build-linux-amd64-probe
image: bash
environment:
ZOT_REGISTRY: registry.oeo.one
ZOT_USERNAME: justme
ZOT_SECRET:
from_secret: zot_secret
RELEASE_API_URL: https://mo.oeo.one
RELEASE_SIGNING_KEY:
from_secret: release_signing_key
commands:
- bash ci/build-dev-image.sh probe
depends_on: []
# Build both node architectures in parallel; each multi-architecture index waits for both of its images.
- name: build-linux-arm64-agent
image: bash
environment:
ZOT_REGISTRY: registry.oeo.one
ZOT_USERNAME: justme
ZOT_SECRET:
from_secret: zot_secret
RELEASE_API_URL: https://mo.oeo.one
RELEASE_SIGNING_KEY:
from_secret: release_signing_key
commands:
- bash ci/build-dev-image.sh agent arm64
depends_on: []
- name: build-linux-arm64-probe
image: bash
environment:
ZOT_REGISTRY: registry.oeo.one
ZOT_USERNAME: justme
ZOT_SECRET:
from_secret: zot_secret
RELEASE_API_URL: https://mo.oeo.one
RELEASE_SIGNING_KEY:
from_secret: release_signing_key
commands:
- bash ci/build-dev-image.sh probe arm64
depends_on: []
- name: publish-linux-agent-multiarch
image: bash
environment:
ZOT_REGISTRY: registry.oeo.one
ZOT_USERNAME: justme
ZOT_SECRET:
from_secret: zot_secret
RELEASE_API_URL: https://mo.oeo.one
RELEASE_SIGNING_KEY:
from_secret: release_signing_key
commands:
- bash ci/publish-dev-node-index.sh agent
depends_on:
- build-linux-amd64-agent
- build-linux-arm64-agent
- name: publish-linux-probe-multiarch
image: bash
environment:
ZOT_REGISTRY: registry.oeo.one
ZOT_USERNAME: justme
ZOT_SECRET:
from_secret: zot_secret
RELEASE_API_URL: https://mo.oeo.one
RELEASE_SIGNING_KEY:
from_secret: release_signing_key
commands:
- bash ci/publish-dev-node-index.sh probe
depends_on:
- build-linux-amd64-probe
- build-linux-arm64-probe
-32
View File
@@ -1,32 +0,0 @@
labels:
ci: domain-linux-native
platform: linux/amd64
clone:
- name: git
image: plugin-git
settings:
lfs: false
# 带 [CI] 的推送会出镜像,慢测试放在 dev-image 里、等镜像构建完再跑(不和 AOT 编译抢内存);这个 workflow 管其余的:PR 和没带 [CI] 的推送
when:
- event: pull_request
- event: push
branch: main
evaluate: 'not (CI_COMMIT_MESSAGE contains "[CI]")'
# 等 verify 编译完、快的测试过了再跑(共用它的增量编译缓存,不重新编译)
depends_on:
- verify
steps:
- name: backend-e2e-tests
image: bash
# [SKIP TESTS] 和 verify 一样,跳过后端测试
when:
- evaluate: 'not (CI_COMMIT_MESSAGE contains "[SKIP TESTS]")'
environment:
NUGET_PACKAGES: /home/justme/woodpecker/cache/nuget/packages
commands:
# 标了 Category=Slow 的端到端测试,见 ci/verify-backend.sh
- bash ci/verify-backend.sh slow
-5
View File
@@ -27,9 +27,6 @@ steps:
ZOT_USERNAME: justme ZOT_USERNAME: justme
ZOT_SECRET: ZOT_SECRET:
from_secret: zot_secret from_secret: zot_secret
RELEASE_API_URL: https://mo.oeo.one
RELEASE_SIGNING_KEY:
from_secret: release_signing_key
commands: commands:
- bash ci/release-node-images.sh - bash ci/release-node-images.sh
depends_on: [] depends_on: []
@@ -40,8 +37,6 @@ steps:
GITEA_BASE: http://127.0.0.1:3100 GITEA_BASE: http://127.0.0.1:3100
GITEA_TOKEN: GITEA_TOKEN:
from_secret: gitea_token from_secret: gitea_token
RELEASE_SIGNING_KEY:
from_secret: release_signing_key
commands: commands:
- bash ci/publish-gitea-release.sh - bash ci/publish-gitea-release.sh
depends_on: depends_on:
+4 -22
View File
@@ -15,44 +15,29 @@ when:
concurrency: 1 concurrency: 1
steps: steps:
# 前端先打包好(几十秒),两个架构的 Backend 构建再并行:它们把 web/*/dist 内嵌进程序,只读这份产物,不再各自 npm ci
# (并行的两个 npm ci 会同时删 node_modules,互相搞坏)。步骤加了超时:卡住就几分钟内失败,不是耗着。
- name: web-build
image: bash
environment:
NPM_CONFIG_CACHE: /home/justme/.npm
commands:
# 版本取自 tag:先写进工作区(仓库里的版本号只是占位),前端才会带上对的版本
- bash ci/version.sh apply
- npm ci --prefix web --prefer-offline
- timeout 600 npm run build --prefix web
depends_on: []
- name: linux-amd64-backend - name: linux-amd64-backend
image: bash image: bash
environment: environment:
NUGET_PACKAGES: /home/justme/woodpecker/cache/nuget/packages NUGET_PACKAGES: /home/justme/.nuget/packages
ZOT_REGISTRY: registry.oeo.one ZOT_REGISTRY: registry.oeo.one
ZOT_USERNAME: justme ZOT_USERNAME: justme
ZOT_SECRET: ZOT_SECRET:
from_secret: zot_secret from_secret: zot_secret
commands: commands:
- bash ci/release-linux.sh amd64 backend - bash ci/release-linux.sh amd64 backend
depends_on: depends_on: []
- web-build
- name: linux-arm64-backend - name: linux-arm64-backend
image: bash image: bash
environment: environment:
NUGET_PACKAGES: /home/justme/woodpecker/cache/nuget/packages NUGET_PACKAGES: /home/justme/.nuget/packages
ZOT_REGISTRY: registry.oeo.one ZOT_REGISTRY: registry.oeo.one
ZOT_USERNAME: justme ZOT_USERNAME: justme
ZOT_SECRET: ZOT_SECRET:
from_secret: zot_secret from_secret: zot_secret
commands: commands:
- bash ci/release-linux.sh arm64 backend - bash ci/release-linux.sh arm64 backend
depends_on: depends_on: []
- web-build
- name: backend-multiarch-index - name: backend-multiarch-index
image: bash image: bash
@@ -62,9 +47,6 @@ steps:
ZOT_USERNAME: justme ZOT_USERNAME: justme
ZOT_SECRET: ZOT_SECRET:
from_secret: zot_secret from_secret: zot_secret
RELEASE_API_URL: https://mo.oeo.one
RELEASE_SIGNING_KEY:
from_secret: release_signing_key
commands: commands:
- bash ci/index-backend-image.sh - bash ci/index-backend-image.sh
depends_on: depends_on:
+13 -10
View File
@@ -9,19 +9,22 @@ clone:
lfs: false lfs: false
when: when:
- event: pull_request - event: [push, pull_request]
- event: push
branch: main
# On Gitea PR events, CI_COMMIT_MESSAGE is the PR title.
steps: steps:
- name: backend-tests - name: backend-tests
image: bash image: bash
# [SKIP TESTS] omits Backend tests only; web-build remains a dependent workflow.
when:
- evaluate: 'not (CI_COMMIT_MESSAGE contains "[SKIP TESTS]")'
environment: environment:
NUGET_PACKAGES: /home/justme/woodpecker/cache/nuget/packages NUGET_PACKAGES: /home/justme/.nuget/packages
commands: commands:
# 增量编译 + 快的那批测试(约 20 秒);慢的端到端测试在 e2e workflow 里和出镜像并行跑。见 ci/verify-backend.sh - cd backend && dotnet test --solution Monitor.slnx -c Release
- bash ci/verify-backend.sh fast
- name: web-build
image: bash
environment:
NPM_CONFIG_CACHE: /home/justme/.npm
commands:
- npm ci --prefix web --prefer-offline
- npm run build --prefix web
- npm run lint:css --prefix web
depends_on: []
-24
View File
@@ -1,24 +0,0 @@
labels:
ci: domain-linux-native
platform: linux/amd64
clone:
- name: git
image: plugin-git
settings:
lfs: false
when:
- event: pull_request
- event: push
branch: main
# 和 verify 并行:前端构建不依赖后端测试,两个同时跑
steps:
- name: web-build
image: bash
environment:
NPM_CONFIG_CACHE: /home/justme/.npm
commands:
# node_modules 按锁文件哈希缓存,展示端和管理端并行构建,再检查 CSS,见 ci/web-build.sh
- bash ci/web-build.sh
-1
View File
@@ -22,7 +22,6 @@ steps:
GITEA_TOKEN: GITEA_TOKEN:
from_secret: gitea_token from_secret: gitea_token
NUGET_PACKAGES: 'C:\Users\JustMe-NAS\.nuget\packages' NUGET_PACKAGES: 'C:\Users\JustMe-NAS\.nuget\packages'
MOSHIRO_QUIC_CACHE: 'C:\Users\JustMe-NAS\.nuget\moshiro-quic-runtime'
RID: linux-x64 RID: linux-x64
COMPONENT: agent COMPONENT: agent
commands: &build-commands commands: &build-commands
-44
View File
@@ -1,44 +0,0 @@
# Agent Instructions
## Project
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.
## Branch and workspace safety
- `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>-<sha7>` (`N` = commits since the latest tag). The `<Version>` 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`.
## Project layout
- `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.
## Local development and builds
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`.
- 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.
Follow the user's requested validation scope. Do not start a full tag/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.
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.
-52
View File
@@ -1,52 +0,0 @@
<Solution>
<Folder Name="/backend/fleet/src/">
<Project Path="backend/fleet/src/Fleet.Core/Fleet.Core.csproj" />
<Project Path="backend/fleet/src/Fleet.Enrollment.Client/Fleet.Enrollment.Client.csproj" />
<Project Path="backend/fleet/src/Fleet.Hosting/Fleet.Hosting.csproj" />
<Project Path="backend/fleet/src/Fleet.Identity/Fleet.Identity.csproj" />
<Project Path="backend/fleet/src/Fleet.Link/Fleet.Link.csproj" />
<Project Path="backend/fleet/src/Fleet.Rpc/Fleet.Rpc.csproj" />
<Project Path="backend/fleet/src/Fleet.Server.Abstractions/Fleet.Server.Abstractions.csproj" />
<Project Path="backend/fleet/src/Fleet.Server.Enrollment/Fleet.Server.Enrollment.csproj" />
<Project Path="backend/fleet/src/Fleet.Server.Link/Fleet.Server.Link.csproj" />
</Folder>
<Folder Name="/backend/fleet/tests/">
<Project Path="backend/fleet/tests/Fleet.Tests/Fleet.Tests.csproj" />
</Folder>
<Folder Name="/backend/fleet/tools/">
<Project Path="backend/fleet/tools/fleet-enroll/fleet-enroll.csproj" />
</Folder>
<Folder Name="/backend/shared/">
<Project Path="backend/shared/MoShiro.Measurement/MoShiro.Measurement.csproj" />
<Project Path="backend/shared/MoShiro.Protocol/MoShiro.Protocol.csproj" />
</Folder>
<Folder Name="/backend/shared/tests/">
<Project Path="backend/shared/tests/MoShiro.Measurement.Tests/MoShiro.Measurement.Tests.csproj" />
<Project Path="backend/shared/tests/MoShiro.Protocol.Tests/MoShiro.Protocol.Tests.csproj" />
</Folder>
<Folder Name="/backend/agent/src/">
<Project Path="backend/agent/src/MoShiro.Agent/MoShiro.Agent.csproj" />
</Folder>
<Folder Name="/backend/probe/src/">
<Project Path="backend/probe/src/MoShiro.Probe/MoShiro.Probe.csproj" />
</Folder>
<Folder Name="/backend/probe/tests/">
<Project Path="backend/probe/tests/MoShiro.Probe.Tests/MoShiro.Probe.Tests.csproj" />
</Folder>
<Folder Name="/backend/src/">
<Project Path="backend/src/MoShiro.Backend.Analysis/MoShiro.Backend.Analysis.csproj" />
<Project Path="backend/src/MoShiro.Backend.Api/MoShiro.Backend.Api.csproj" />
<Project Path="backend/src/MoShiro.Backend.Application/MoShiro.Backend.Application.csproj" />
<Project Path="backend/src/MoShiro.Backend.Gateway/MoShiro.Backend.Gateway.csproj" />
<Project Path="backend/src/MoShiro.Backend.Storage.Sqlite/MoShiro.Backend.Storage.Sqlite.csproj" />
<Project Path="backend/src/MoShiro.Backend/MoShiro.Backend.csproj" />
</Folder>
<Folder Name="/backend/tests/">
<Project Path="backend/tests/MoShiro.Backend.Analysis.Tests/MoShiro.Backend.Analysis.Tests.csproj" />
<Project Path="backend/tests/MoShiro.Backend.Tests/MoShiro.Backend.Tests.csproj" />
<Project Path="backend/tests/MoShiro.TestSupport/MoShiro.TestSupport.csproj" />
</Folder>
<Folder Name="/ci/">
<Project Path="ci/MoShiro.Tar/MoShiro.Tar.csproj" />
</Folder>
</Solution>
+64 -93
View File
@@ -1,123 +1,94 @@
# MoShiro # Monitor
MoShiro 是一个自托管的**网络线路质量观测工具**。它在若干 Probe(例如家宽出口)与多台 Agent(例如海外 VPS)之间做主动网络测量,只记录逐包、逐事件的原始数据;Backend 保存这些原始数据,推算延迟、丢包、抖动、吞吐等指标,并回答「如果在这条线路上部署某类服务,体验会怎样」;Web 界面用来查看、比较和管理。 Monitor 是一个自托管的网络线路质量观测工具。它在 Probe 与多个 Agent 之间执行主动网络测量,记录逐包和逐事件结果;Backend 保存原始数据并生成可查询的线路指标,Web 界面用于查看、比较和管理节点。
## 项目边界 它面向需要比较不同网络出口、评估线路稳定性,或观察特定网络服务体验的场景。Agent 与 Probe 专注于测量和上报;它们不提供远程终端、命令执行或文件管理能力。
**它做什么** ## 能力概览
- 主动测量:组合 TCP、UDP、TLS、ICMP 等采集积木,只记录时间、序号、大小这类原始事实,**不转发也不记录业务内容**。 - **主动测量**:组合 TCP、UDP 等测量模块,采集延迟、丢包、抖动、吞吐和连接事件。
- 线路分析:Backend 从原始数据生成时间序列指标,按服务模型(REALITY、Hysteria2、Minecraft 等)评分并对多条线路排序、对比;模型和指标可以事后重算。 - **线路分析**:从原始测量数据生成时间序列指标,并比较线路在不同服务模型下的体验。
- 管理:节点注册与审批、Probe 和 Agent 之间连哪些线路、任务与按需测试、节点版本与更新,都在管理端完成。 - **节点管理**:通过 Fleet 节点框架处理身份、注册、连接、任务同步和数据上报。
- **实时面板**:展示端查看线路状态和历史趋势;管理端负责节点、任务和系统配置。
**它不做什么** - **自托管**:Backend 使用段文件和 SQLite 保存数据;提供 Linux、systemd 和 OpenWrt 相关部署文件。
- **节点是纯采集器**:Agent 和 Probe 没有终端、命令执行、文件读写和自更新能力,也不加载外部代码。更新由节点外面的服务管理器完成:Podman Quadlet 单元每次启动都先拉最新镜像,管理端点「更新」时节点只是有序退出(没有 Quadlet 的环境用宿主机更新器),节点自己不下载、不替换任何东西。
- **不是主机监控**:Agent 和 Probe 只上报 CPU、内存、磁盘、网卡流量和运行时长这类只读的汇总数字(Probe 可以在本地配置里关掉),不采集型号、主机名、进程和任何 IP。
- **不暴露地址**:公开接口里没有 IP、城市、ASN;国家和运营商只由 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 不信任节点上报的内容**:数据按线路隔离存放;节点的身份靠签名,数据库泄露也无法冒充节点。
- **节点不信任 Backend 的下发**:任务超出节点本机的硬上限(目标网段、码率、包速率、监听端口)就被拒绝;节点不执行任何「执行类」指令;改信任(操作员集合)要操作员 passkey 签名,Backend 伪造不了。
- **更新不依赖面板的诚实**:清单必须由发布私钥签名(面板登记时验一次,节点宿主机的更新器用本机公钥再验一次)、只认本机配置的镜像仓库、按 digest 拉取、拒绝旧的 `seq`。面板被攻破也推不出恶意镜像。可信的发布密钥由部署的人决定:官方 CI 密钥始终信任,也可以另外信任自己的公钥。
- **没有默认账户**:第一次打开管理端进认领向导,用后端日志里的认领码创建第一个管理员、注册 passkey、建立信任根。每个节点都必须有属主(passkey),由节点自己强制。
- 运行中的二进制不做校验(节点自报的哈希不可信);「资产是我们的」靠安装时的签名清单和签名的 `SHA256SUMS`。详见 [`docs/security/`](docs/security/security.md)。
## 架构 ## 架构
```text ```text
操作员 passkey ──签名「操作员集合」──┐ CI 发布私钥 ──签名「镜像 digest 清单」──▶ 镜像仓库(清单 + 镜像) Probe <──测量会话──> Agent ──长连接与数据上报──> Backend <──REST / SSE──> Web 展示端与管理端
▼ ▲ ├── 段文件:原始数据
Web(展示端 · 管理端) │ 定时取清单 └── SQLite:索引、指标与配置
▲ 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 ### 环境要求
采集(只记录) 传输(有界、至少一次) 分析(注册制、可重算) 展示 - .NET SDK 10;项目使用 `backend/global.json` 指定 SDK 版本和滚动策略。
Probe / Agent 积木 ──▶ 原始列块(10 秒一块)──▶ Backend 段文件 ──▶ 指标与服务模型 ──▶ REST / SSE ──▶ Web - Node.js 与 npm,用于构建 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。在仓库根目录:
```sh ```sh
dotnet build MoShiro.slnx -c Release # Backend、Agent、Probe、Fleet、测试和 CI 工具 cd backend
npm ci --prefix web && npm run build --prefix web # 展示端和管理端,产物在 web/display/dist、web/admin/dist dotnet build Monitor.slnx -c Release
``` ```
本地运行:先在 `backend/` 里 `dotnet run --project src/MoShiro.Backend -- --urls http://localhost:5000`,再 `npm run dev --prefix web`,展示端是 <http://localhost:5173/>,管理端是 <http://localhost:5173/admin/>。第一次打开管理端会进认领向导,认领码在后端日志里(或用 `MoShiro__ClaimCode` 指定)。 安装前端依赖并构建展示端和管理端(在仓库根目录运行):
测试和检查:
```sh ```sh
dotnet test --solution MoShiro.slnx # 后端和节点的测试 npm ci --prefix web
npm run lint:css --prefix web # 前端样式(颜色只能写在主题令牌里) npm run build --prefix web
npm run gen:api --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)。 前端产物位于 `web/display/dist/` 和 `web/admin/dist/`。NativeAOT 目标平台和 Windows 上使用 AotAnywhere 交叉构建的说明见 [`docs/deployment.md`](docs/deployment.md)。
## 文档 Backend、Agent 和 Probe 均以 OCI 镜像发布,支持 Linux/amd64 与 Linux/arm64;Agent 和 Probe 另外提供 Linux glibc/musl、macOS 与 Windows 二进制包。容器部署配置见 [`compose.yaml`](compose.yaml),发布说明见 [`docs/deployment.md`](docs/deployment.md)。
| 文档 | 内容 | ### 本地运行
1. **启动 Backend。** 在仓库根目录运行:
```sh
cd backend
dotnet run --project src/Monitor.Backend -- --urls http://localhost:5000
```
2. **启动前端。** 保持 Backend 运行,在另一个终端的仓库根目录运行:
```sh
npm ci --prefix web
npm run dev --prefix web
```
打开 <http://localhost:5173/> 使用展示端,或访问 <http://localhost:5174/admin/> 进入管理端。依赖安装和前端配置见 [`web/README.md`](web/README.md);Backend 的首次启动与配置见 [`backend/README.md`](backend/README.md)。
## 项目布局
| 路径 | 说明 |
| --- | --- | | --- | --- |
| [`docs/README.md`](docs/README.md) | 文档索引,按类别组织 | | [`backend/`](backend/README.md) | Backend 服务、Fleet 框架、共享测量代码与 .NET 测试 |
| [`docs/design/`](docs/design/architecture.md) | 设计:架构与决策、线协议、存储与查询、采集与分析 | | [`backend/agent/`](backend/agent/README.md) | Agent 节点:注册到 Backend,接受 Probe 测量并上报数据 |
| [`docs/security/`](docs/security/security.md) | 安全:零信任与威胁模型、访问控制与入网信任链、注册与身份 | | [`backend/probe/`](backend/probe/README.md) | Probe 节点:发起线路测量并将采集数据交给 Agent |
| [`docs/api/`](docs/api/api.md) | 接口:端点说明、API 规范 | | `backend/fleet/` | 可复用的节点身份、注册和连接框架 |
| [`docs/ops/deployment.md`](docs/ops/deployment.md) | 运维:构建、发布、CI、部署与更新 | | `backend/shared/` | 测量引擎、协议和原始数据格式 |
| [`docs/dev/`](docs/dev/project-layout.md) | 开发:仓库结构与依赖规则、代码风格 | | `web/display/`、`web/admin/` | 展示端和管理端 |
| [`backend/fleet/README.md`](backend/fleet/README.md) | Fleet 节点框架(身份、注册、连接) | | `web/shared/` | 前端 API、图表、组件与主题 |
| `packaging/`、`scripts/` | 部署、构建与维护脚本 |
| `docs/` | [架构](docs/architecture.md)、[测量模型](docs/measurement.md)、[API](docs/api.md)、[部署](docs/deployment.md)等文档 |
代码目录:`backend/`(Backend、`fleet/` 节点框架、`shared/` 测量引擎和协议、`agent/`、`probe/`、`tests/`)、`web/`(`display` 展示端、`admin` 管理端、`shared`)、`packaging/` 和 `scripts/`(部署与维护)、`ci/` 和 `.woodpecker/`(流水线)。 ## 测试
## 待办 ```sh
cd backend
dotnet test --solution Monitor.slnx
```
见 [`TODO.md`](TODO.md)。 ```sh
# 前端样式检查(仓库根目录)
npm run lint:css --prefix web
```
## 许可证 ## 许可证
-44
View File
@@ -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` 的长期验证。
- **纯二进制节点的更新**(`fleet-updater`、`fleet-release`、Windows 服务和 OpenWrt procd 适配器)。设计要点,实现时照这个来:
- 信任来自签名:只接受离线发布密钥签名的清单,发布源可以是面板、GitHub Releases 或任意 HTTP 服务;面板被攻破也推不出恶意二进制。
- 本地说了算:更新策略(`auto` / `notify` / `pinned:<version>`)和信任的公钥写在节点本地配置里,面板只能「建议」;支持多把公钥以便轮换。
- 清单带 `app`、`channel`、`version`、`min_from`、各 RID 制品的 `sha256` 和大小、`key_id`、签名(对规范化 JSON 签名);本地记录已装过的最高版本,拒绝降级和旧清单重放。
- 流程:下载 → 校验 → 解包到 `versions/<v>/` → 原子切换 `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 服务适配。
+1 -7
View File
@@ -13,17 +13,11 @@
<UseArtifactsOutput>true</UseArtifactsOutput> <UseArtifactsOutput>true</UseArtifactsOutput>
<ArtifactsPath>$(MSBuildThisFileDirectory)artifacts</ArtifactsPath> <ArtifactsPath>$(MSBuildThisFileDirectory)artifacts</ArtifactsPath>
<Deterministic>true</Deterministic> <Deterministic>true</Deterministic>
<Version>0.0.0-dev</Version> <Version>0.1.1</Version>
<NuGetAudit>false</NuGetAudit> <NuGetAudit>false</NuGetAudit>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" PrivateAssets="all" /> <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" PrivateAssets="all" />
<AdditionalFiles Include="$(MSBuildThisFileDirectory)BannedSymbols.txt" /> <AdditionalFiles Include="$(MSBuildThisFileDirectory)BannedSymbols.txt" />
</ItemGroup> </ItemGroup>
<ItemGroup Condition="'$(UseAotAnywhere)' == 'true' and $([MSBuild]::IsOSPlatform('Linux'))">
<PackageReference Include="Vezel.Zig.Toolsets.linux-x64"
VersionOverride="0.16.0.2"
PrivateAssets="all"
GeneratePathProperty="true" />
</ItemGroup>
</Project> </Project>
@@ -1,15 +1,10 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<!-- Native SQLite source is pinned separately from the JIT/test binary package. -->
<MoShiroSqliteSourceVersion>3530400</MoShiroSqliteSourceVersion>
<MoShiroSqliteSourceYear>2026</MoShiroSqliteSourceYear>
<MoShiroSqliteSourceSha256>1e71ddf93849c6a6ecf58b827c0692073d2dd7ee40196158068f7b29f422e87d</MoShiroSqliteSourceSha256>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="5.6.0" /> <PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="5.6.0" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.12" /> <PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.12" />
<PackageVersion Include="SQLitePCLRaw.lib.e_sqlite3" Version="3.53.3" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" /> <PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.ApiDescription.Server" Version="10.0.12" /> <PackageVersion Include="Microsoft.Extensions.ApiDescription.Server" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.0.0" /> <PackageVersion Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.0.0" />
+16 -16
View File
@@ -17,33 +17,33 @@
<Project Path="fleet/tools/fleet-enroll/fleet-enroll.csproj" /> <Project Path="fleet/tools/fleet-enroll/fleet-enroll.csproj" />
</Folder> </Folder>
<Folder Name="/shared/"> <Folder Name="/shared/">
<Project Path="shared/MoShiro.Measurement/MoShiro.Measurement.csproj" /> <Project Path="shared/Monitor.Measurement/Monitor.Measurement.csproj" />
<Project Path="shared/MoShiro.Protocol/MoShiro.Protocol.csproj" /> <Project Path="shared/Monitor.Protocol/Monitor.Protocol.csproj" />
</Folder> </Folder>
<Folder Name="/shared/tests/"> <Folder Name="/shared/tests/">
<Project Path="shared/tests/MoShiro.Measurement.Tests/MoShiro.Measurement.Tests.csproj" /> <Project Path="shared/tests/Monitor.Measurement.Tests/Monitor.Measurement.Tests.csproj" />
<Project Path="shared/tests/MoShiro.Protocol.Tests/MoShiro.Protocol.Tests.csproj" /> <Project Path="shared/tests/Monitor.Protocol.Tests/Monitor.Protocol.Tests.csproj" />
</Folder> </Folder>
<Folder Name="/agent/src/"> <Folder Name="/agent/src/">
<Project Path="agent/src/MoShiro.Agent/MoShiro.Agent.csproj" /> <Project Path="agent/src/Monitor.Agent/Monitor.Agent.csproj" />
</Folder> </Folder>
<Folder Name="/probe/src/"> <Folder Name="/probe/src/">
<Project Path="probe/src/MoShiro.Probe/MoShiro.Probe.csproj" /> <Project Path="probe/src/Monitor.Probe/Monitor.Probe.csproj" />
</Folder> </Folder>
<Folder Name="/probe/tests/"> <Folder Name="/probe/tests/">
<Project Path="probe/tests/MoShiro.Probe.Tests/MoShiro.Probe.Tests.csproj" /> <Project Path="probe/tests/Monitor.Probe.Tests/Monitor.Probe.Tests.csproj" />
</Folder> </Folder>
<Folder Name="/backend/src/"> <Folder Name="/backend/src/">
<Project Path="src/MoShiro.Backend.Analysis/MoShiro.Backend.Analysis.csproj" /> <Project Path="src/Monitor.Backend.Analysis/Monitor.Backend.Analysis.csproj" />
<Project Path="src/MoShiro.Backend.Api/MoShiro.Backend.Api.csproj" /> <Project Path="src/Monitor.Backend.Api/Monitor.Backend.Api.csproj" />
<Project Path="src/MoShiro.Backend.Application/MoShiro.Backend.Application.csproj" /> <Project Path="src/Monitor.Backend.Application/Monitor.Backend.Application.csproj" />
<Project Path="src/MoShiro.Backend.Gateway/MoShiro.Backend.Gateway.csproj" /> <Project Path="src/Monitor.Backend.Gateway/Monitor.Backend.Gateway.csproj" />
<Project Path="src/MoShiro.Backend.Storage.Sqlite/MoShiro.Backend.Storage.Sqlite.csproj" /> <Project Path="src/Monitor.Backend.Storage.Sqlite/Monitor.Backend.Storage.Sqlite.csproj" />
<Project Path="src/MoShiro.Backend/MoShiro.Backend.csproj" /> <Project Path="src/Monitor.Backend/Monitor.Backend.csproj" />
</Folder> </Folder>
<Folder Name="/backend/tests/"> <Folder Name="/backend/tests/">
<Project Path="tests/MoShiro.Backend.Analysis.Tests/MoShiro.Backend.Analysis.Tests.csproj" /> <Project Path="tests/Monitor.Backend.Analysis.Tests/Monitor.Backend.Analysis.Tests.csproj" />
<Project Path="tests/MoShiro.Backend.Tests/MoShiro.Backend.Tests.csproj" /> <Project Path="tests/Monitor.Backend.Tests/Monitor.Backend.Tests.csproj" />
<Project Path="tests/MoShiro.TestSupport/MoShiro.TestSupport.csproj" /> <Project Path="tests/Monitor.TestSupport/Monitor.TestSupport.csproj" />
</Folder> </Folder>
</Solution> </Solution>
+15 -15
View File
@@ -1,24 +1,24 @@
# Backend # Backend
Backend 是 MoShiro 的控制面和数据服务,负责 Agent 注册与连接、任务编排、原始测量数据持久化、指标分析,以及供前端使用的 REST API 和 SSE 实时数据流。 Backend 是 Monitor 的控制面和数据服务,负责 Agent 注册与连接、任务编排、原始测量数据持久化、指标分析,以及供前端使用的 REST API 和 SSE 实时数据流。
## 组件 ## 组件
| 路径 | 职责 | | 路径 | 职责 |
| --- | --- | | --- | --- |
| `src/MoShiro.Backend/` | 应用入口与依赖组合 | | `src/Monitor.Backend/` | 应用入口与依赖组合 |
| `src/MoShiro.Backend.Api/` | Public、Admin API 和 OpenAPI | | `src/Monitor.Backend.Api/` | Public、Admin API 和 OpenAPI |
| `src/MoShiro.Backend.Gateway/` | Fleet RPC、节点状态与测量数据接收 | | `src/Monitor.Backend.Gateway/` | Fleet RPC、节点状态与测量数据接收 |
| `src/MoShiro.Backend.Application/` | 业务用例、查询、任务和后台服务 | | `src/Monitor.Backend.Application/` | 业务用例、查询、任务和后台服务 |
| `src/MoShiro.Backend.Analysis/` | 指标计算、累加器和服务模型 | | `src/Monitor.Backend.Analysis/` | 指标计算、累加器和服务模型 |
| `src/MoShiro.Backend.Storage.Sqlite/` | 段文件与 SQLite 存储 | | `src/Monitor.Backend.Storage.Sqlite/` | 段文件与 SQLite 存储 |
| `fleet/` | 通用节点身份、注册和连接框架 | | `fleet/` | 通用节点身份、注册和连接框架 |
| `shared/` | 测量引擎、协议和数据格式 | | `shared/` | 测量引擎、协议和数据格式 |
| `tests/` | 集成测试和测试支持代码 | | `tests/` | 集成测试和测试支持代码 |
## 环境要求 ## 环境要求
使用 .NET SDK 10。仓库根目录的 `global.json` 指定所需 SDK 版本及滚动策略。 使用 .NET SDK 10。仓库根目录的 `backend/global.json` 指定所需 SDK 版本及滚动策略。
## 构建与测试 ## 构建与测试
@@ -26,17 +26,17 @@ Backend 是 MoShiro 的控制面和数据服务,负责 Agent 注册与连接
```sh ```sh
cd backend cd backend
dotnet build MoShiro.slnx -c Release dotnet build Monitor.slnx -c Release
dotnet test --solution MoShiro.slnx dotnet test --solution Monitor.slnx
``` ```
发布当前平台的 NativeAOT Backend: 发布当前平台的 NativeAOT Backend:
```sh ```sh
dotnet publish src/MoShiro.Backend -c Release -r <RID> -p:PublishAot=true dotnet publish src/Monitor.Backend -c Release -r <RID> -p:PublishAot=true
``` ```
例如 `<RID>` 可为 `win-x64`、`linux-x64` 或 `linux-arm64`。NativeAOT 发布需要对应平台的 C/C++ 工具链。仓库根目录的 `scripts/publish.sh backend <RID>` 可构建单个平台的 Backend 可执行文件;容器发布由 Woodpecker tag 流水线完成,详见 [`../docs/ops/deployment.md`](../docs/ops/deployment.md)。 例如 `<RID>` 可为 `win-x64`、`linux-x64` 或 `linux-arm64`。NativeAOT 发布需要对应平台的 C/C++ 工具链。仓库根目录的 `scripts/publish.sh backend <RID>` 可构建单个平台的 Backend 可执行文件;容器发布由 Woodpecker tag 流水线完成,详见 [`../docs/deployment.md`](../docs/deployment.md)。
正式发布通过 Woodpecker tag 流水线生成 Linux/amd64 与 Linux/arm64 OCI 镜像,容器内监听 `8080`,数据写入挂载的 `/data` 卷。Agent 与 Probe 的多平台二进制包单独发布,不包含在 Backend 镜像中。 正式发布通过 Woodpecker tag 流水线生成 Linux/amd64 与 Linux/arm64 OCI 镜像,容器内监听 `8080`,数据写入挂载的 `/data` 卷。Agent 与 Probe 的多平台二进制包单独发布,不包含在 Backend 镜像中。
@@ -46,9 +46,9 @@ dotnet publish src/MoShiro.Backend -c Release -r <RID> -p:PublishAot=true
```sh ```sh
cd backend cd backend
dotnet run --project src/MoShiro.Backend -- --urls http://localhost:5000 dotnet run --project src/Monitor.Backend -- --urls http://localhost:5000
``` ```
没有默认管理员:第一次打开面板时按页面引导自己创建管理员、注册 passkey、建立信任根,页面要的认领码写在后端日志里(或用 `MoShiro__ClaimCode` 指定)。`MoShiro__InitialAdminUser` / `MoShiro__InitialAdminPassword` 只给自动化用。数据目录、公开地址、Cookie 和 CORS 等设置通过 `MoShiro` 配置节传入;完整配置说明见 [`../docs/ops/deployment.md`](../docs/ops/deployment.md)。站点默认需要登录,可在管理端修改公开设置。 首次启动时可用 `Monitor__InitialAdminUser` 和 `Monitor__InitialAdminPassword` 设置初始管理员。数据目录、公开地址、Cookie 和 CORS 等设置通过 `Monitor` 配置节传入;完整配置说明见 [`../docs/deployment.md`](../docs/deployment.md)。站点默认需要登录,可在管理端修改公开设置。
OpenAPI 文档由构建生成到 `openapi/v1.json`。API 设计和端点说明见 [`../docs/api/api.md`](../docs/api/api.md);项目总览见仓库根目录的 [`README.md`](../README.md)。 OpenAPI 文档由构建生成到 `openapi/v1.json`。API 设计和端点说明见 [`../docs/api.md`](../docs/api.md);项目总览见仓库根目录的 [`README.md`](../README.md)。
-4
View File
@@ -8,10 +8,6 @@
<EventSourceSupport>false</EventSourceSupport> <EventSourceSupport>false</EventSourceSupport>
<DebuggerSupport>false</DebuggerSupport> <DebuggerSupport>false</DebuggerSupport>
<HttpActivityPropagationSupport>false</HttpActivityPropagationSupport> <HttpActivityPropagationSupport>false</HttpActivityPropagationSupport>
<Http3Support>false</Http3Support>
<MetricsSupport>false</MetricsSupport>
<UseSizeOptimizedLinq>true</UseSizeOptimizedLinq>
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
<ServerGarbageCollection>false</ServerGarbageCollection> <ServerGarbageCollection>false</ServerGarbageCollection>
<ConcurrentGarbageCollection>false</ConcurrentGarbageCollection> <ConcurrentGarbageCollection>false</ConcurrentGarbageCollection>
</PropertyGroup> </PropertyGroup>
+11 -15
View File
@@ -1,4 +1,4 @@
# MoShiro Agent # Monitor Agent
Agent 是部署在 VPS 或其他可达网络节点上的采集进程。它向 Backend 注册并保持连接,接受 Probe 的测量会话,执行线路任务中的 Agent 侧工作,再将两端数据合并后上报。 Agent 是部署在 VPS 或其他可达网络节点上的采集进程。它向 Backend 注册并保持连接,接受 Probe 的测量会话,执行线路任务中的 Agent 侧工作,再将两端数据合并后上报。
@@ -7,36 +7,36 @@ Agent 仅开放配置的控制和数据端口。它不提供远程终端、命
## 工作流程 ## 工作流程
```text ```text
Probe ──TLS 控制 / QUIC Datagram、TCP 测量──> Agent ──长连接与数据上报──> Backend Probe ──TLS 控制 / UDP、TCP 测量──> Agent ──长连接与数据上报──> Backend
▲ ▲
fleet-enroll 注册 fleet-enroll 注册
``` ```
## 构建 ## 构建
需要 .NET SDK 10。为应用 根目录的 `global.json` 的 SDK 设置,请从 `backend/` 目录构建: 需要 .NET SDK 10。为应用 `backend/global.json` 的 SDK 设置,请从 `backend/` 目录构建:
```sh ```sh
cd backend cd backend
dotnet build agent/src/MoShiro.Agent -c Release dotnet build agent/src/Monitor.Agent -c Release
dotnet build fleet/tools/fleet-enroll -c Release dotnet build fleet/tools/fleet-enroll -c Release
``` ```
发布 NativeAOT 程序需要对应平台的 C/C++ 工具链;`<RID>` 应与构建环境和目标平台匹配: 发布 NativeAOT 程序需要对应平台的 C/C++ 工具链;`<RID>` 应与构建环境和目标平台匹配:
```sh ```sh
dotnet publish agent/src/MoShiro.Agent -c Release -r <RID> -p:PublishAot=true dotnet publish agent/src/Monitor.Agent -c Release -r <RID> -p:PublishAot=true
dotnet publish fleet/tools/fleet-enroll -c Release -r <RID> -p:PublishAot=true dotnet publish fleet/tools/fleet-enroll -c Release -r <RID> -p:PublishAot=true
``` ```
也可在仓库根目录运行 `bash scripts/publish.sh agent <RID>` 和 `bash scripts/publish.sh fleet-enroll <RID>`。部署包和平台说明见 [`../../docs/ops/deployment.md`](../../docs/ops/deployment.md)。 也可在仓库根目录运行 `bash scripts/publish.sh agent <RID>` 和 `bash scripts/publish.sh fleet-enroll <RID>`。部署包和平台说明见 [`../../docs/deployment.md`](../../docs/deployment.md)。
## 注册与运行 ## 注册与运行
先在管理端创建加入令牌,然后在 Agent 主机上运行一次性注册工具: 先在管理端创建加入令牌,然后在 Agent 主机上运行一次性注册工具:
```sh ```sh
fleet-enroll --server https://<backend> --join <token> --name <agent-name> --identity-dir /etc/moshiro fleet-enroll --server https://<backend> --join <token> --name <agent-name> --identity-dir /etc/monitor
``` ```
注册工具会在身份目录写入节点身份和证书。准备本地 `agent.json`,其中 `fleet.server` 指向 Backend,`fleet.identity_dir` 指向上述目录;`listen`、`limits` 和 `reporting` 可按节点环境调整。配置文件不会由 Backend 修改。限制目标网段和测量预算时,应按实际网络与机器能力配置;`allowed_target_cidrs` 默认为空,不允许 Agent 执行目标探测任务。 注册工具会在身份目录写入节点身份和证书。准备本地 `agent.json`,其中 `fleet.server` 指向 Backend,`fleet.identity_dir` 指向上述目录;`listen`、`limits` 和 `reporting` 可按节点环境调整。配置文件不会由 Backend 修改。限制目标网段和测量预算时,应按实际网络与机器能力配置;`allowed_target_cidrs` 默认为空,不允许 Agent 执行目标探测任务。
@@ -44,20 +44,16 @@ fleet-enroll --server https://<backend> --join <token> --name <agent-name> --ide
启动 Agent: 启动 Agent:
```sh ```sh
moshiro-agent --config /etc/moshiro/agent.json monitor-agent --config /etc/monitor/agent.json
``` ```
容器节点的运行方式(podman + systemd)和宿主机更新器见 [`../../docs/ops/deployment.md`](../../docs/ops/deployment.md)。也可使用 OCI 镜像 `registry.oeo.one/moshiro/moshiro-agent:<version>`,自动选择 Linux amd64 或 arm64 镜像。镜像同时包含 `fleet-enroll`;可通过 `--entrypoint /usr/local/bin/fleet-enroll` 执行首次注册。容器默认读取命名 volume 中的 `/etc/moshiro/agent.json` 与身份文件;建议使用宿主网络,Linux ICMP 优先使用普通用户 ping socket,内核 ping_group_range 需允许映射后的 GID。镜像以 UID/GID 1654 运行,新命名 volume 由引擎按镜像目录初始化权限。 也可使用 OCI 镜像 `registry.oeo.one/monitor/monitor-agent:<version>`,自动选择 Linux amd64 或 arm64 镜像。镜像同时包含 `fleet-enroll`;可通过 `--entrypoint /usr/local/bin/fleet-enroll` 执行首次注册。容器默认读取 `/etc/monitor/agent.json`,需要挂载配置与身份目录;建议使用宿主网络,并按 ICMP 测量需求添加 `NET_RAW` 能力。镜像以 UID/GID 1654 运行,挂载目录必须允许该用户读取配置和身份文件。
不传 `--config` 时默认读取 `/etc/moshiro/agent.json`。Agent 默认监听控制端口 `7443/tcp`,数据端口 `7443/udp` 和 `7444/tcp`;防火墙和面板中的节点端口配置需与本地配置一致。`reporting.host_stats` 可关闭主机状态上报;启用时只上报资源使用等汇总数据,不包含主机名、进程或 IP。 不传 `--config` 时默认读取 `/etc/monitor/agent.json`。Agent 默认监听控制端口 `7443/tcp`,数据端口 `7443/udp` 和 `7444/tcp`;防火墙和面板中的节点端口配置需与本地配置一致。`reporting.host_stats` 可关闭主机状态上报;启用时只上报资源使用等汇总数据,不包含主机名、进程或 IP。
IPv6 线路需要 Agent 实际绑定 IPv6。新配置的默认 `listen.bind` 为 `::`,覆盖整个双栈接口;已有配置如果仍写 `0.0.0.0`,可改为 `::` 后重启。Compose 与 systemd 容器部署使用宿主网络,诊断反映宿主 IPv6;自定义容器网络时,Agent 同时报告容器网络以及可读取到的宿主 `/proc` IPv6 状态。诊断不上传 IP 或接口名称。
## 设计说明 ## 设计说明
- 每个 Probe 使用独立的 TLS 会话和数据密钥;Probe 断开时对应的会话资源会释放。 - 每个 Probe 使用独立的 TLS 会话和数据密钥;Probe 断开时对应的会话资源会释放。
- 数据面会校验会话认证、重放窗口和速率限制;无效数据包会静默丢弃。 - 数据面会校验会话认证、重放窗口和速率限制;无效数据包会静默丢弃。
- 测量引擎和协议由 `backend/shared/` 提供;节点身份、注册和连接由 `backend/fleet/` 提供。 - 测量引擎和协议由 `backend/shared/` 提供;节点身份、注册和连接由 `backend/fleet/` 提供。
- 详细安全边界与拓扑见 [`../../docs/design/architecture.md`](../../docs/design/architecture.md),协议说明见 [`../../docs/design/protocol.md`](../../docs/design/protocol.md),部署见 [`../../docs/ops/deployment.md`](../../docs/ops/deployment.md)。 - 详细安全边界与拓扑见 [`../../docs/architecture.md`](../../docs/architecture.md),协议说明见 [`../../docs/protocol.md`](../../docs/protocol.md),部署见 [`../../docs/deployment.md`](../../docs/deployment.md)。
Podman 系用 Quadlet(`packaging/quadlet/moshiro-agent.container`,默认自动更新),Docker 用 `packaging/compose/agent/compose.yaml`(手动更新);配置、身份和缓存放在命名 volume 中,无需宿主配置目录、keep-id 或手动 chown。首次导入和自动更新见部署文档。
@@ -1,26 +0,0 @@
<Project>
<Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" />
<Import Project="Sdk.props" Sdk="StuDev.AotAnywhere"
Condition="'$(UseAotAnywhere)' == 'true'" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<AssemblyName>moshiro-agent</AssemblyName>
<RootNamespace>MoShiro.Agent</RootNamespace>
<IsAotCompatible>true</IsAotCompatible>
<PublishAot>true</PublishAot>
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>full</TrimMode>
<StripSymbols>true</StripSymbols>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<MetadataUpdaterSupport>false</MetadataUpdaterSupport>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="MoShiro.Backend.Tests" />
<ProjectReference Include="..\..\..\fleet\src\Fleet.Link\Fleet.Link.csproj" />
<ProjectReference Include="..\..\..\fleet\src\Fleet.Hosting\Fleet.Hosting.csproj" />
<ProjectReference Include="..\..\..\shared\MoShiro.Measurement\MoShiro.Measurement.csproj" />
</ItemGroup>
<Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
<Import Project="Sdk.targets" Sdk="StuDev.AotAnywhere"
Condition="'$(UseAotAnywhere)' == 'true'" />
</Project>
@@ -1,26 +1,20 @@
using Fleet.Identity;
using System.Net; using System.Net;
using System.Text.Json; using System.Text.Json;
using Fleet.Core; using Fleet.Core;
using Fleet.Hosting; using Fleet.Hosting;
using Fleet.Link; using Fleet.Link;
using Fleet.Rpc; using Fleet.Rpc;
using MoShiro.Measurement; using Monitor.Measurement;
using MoShiro.Protocol; using Monitor.Protocol;
namespace MoShiro.Agent; namespace Monitor.Agent;
/// <summary>Agent 的组装点:数据面、点对点监听、与 Backend 的链路都在这里接起来。没有全局状态,测试可以在同一进程里启动多个。</summary> /// <summary>Agent 的组装点:数据面、点对点监听、与 Backend 的链路都在这里接起来。没有全局状态,测试可以在同一进程里启动多个。</summary>
public sealed class AgentApp : IDisposable, IAsyncDisposable public sealed class AgentApp : IDisposable
{ {
public const string Version = MoShiro.Protocol.BuildInfo.Version; public const string Version = "0.1.1";
private readonly AgentConfig _config; private readonly AgentConfig _config;
private readonly bool _ipv6Supported;
private readonly KnockGate _knock;
private readonly TrustAnchor _trust;
private readonly RestartController _restart;
private readonly RestartController? _ownedRestart;
private readonly LoadedNodeIdentity _identity; private readonly LoadedNodeIdentity _identity;
private readonly TimeProvider _time; private readonly TimeProvider _time;
private readonly INodeLog _log; private readonly INodeLog _log;
@@ -31,23 +25,17 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable
private readonly TaskCompletionSource _ready = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource _ready = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly AgentAdmission _admission; private readonly AgentAdmission _admission;
private readonly Outbox _outbox = new(64 << 20); private readonly Outbox _outbox = new(64 << 20);
private readonly RestartUpdater? _update;
private readonly BackendUplink _uplink; private readonly BackendUplink _uplink;
private readonly QuicDatagramTransport _datagrams; private readonly UdpMux _udp;
private readonly TcpAcceptor _tcp; private readonly TcpAcceptor _tcp;
private readonly PeerListener _peers; private readonly PeerListener _peers;
private readonly PeerHub _hub; private readonly PeerHub _hub;
private readonly TaskManager _tasks; private readonly TaskManager _tasks;
private readonly TestRunner _tests; private readonly TestRunner _tests;
private readonly FleetLink _link; private readonly FleetLink _link;
private int _disposed;
/// <param name="dataDirectory">配置目录(容器里的 <c>/etc/moshiro</c>):宿主机更新器和节点在这里交换清单、请求和状态。</param> public AgentApp(AgentConfig config, LoadedNodeIdentity identity, TimeProvider time, INodeLog log)
public AgentApp(AgentConfig config, LoadedNodeIdentity identity, string dataDirectory, TimeProvider time, INodeLog log, RestartController? restart = null)
{ {
_ownedRestart = restart is null ? new RestartController() : null;
_restart = restart ?? _ownedRestart!;
_update = RestartUpdater.FromEnvironment("agent", _restart.RequestNow, time, log);
_config = config; _config = config;
_identity = identity; _identity = identity;
_time = time; _time = time;
@@ -56,18 +44,9 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable
_uplink = new BackendUplink(time); _uplink = new BackendUplink(time);
IPAddress bind = IPAddress.Parse(config.Listen.Bind); IPAddress bind = IPAddress.Parse(config.Listen.Bind);
if (bind.Equals(IPAddress.IPv6Any) && !System.Net.Sockets.Socket.OSSupportsIPv6)
{
bind = IPAddress.Any;
}
_ipv6Supported = bind.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6;
_admission = new AgentAdmission(_streams); _admission = new AgentAdmission(_streams);
_datagrams = new QuicDatagramTransport(_sessions, _admission, _clock, _counters.Data, log, bind, config.Listen.EffectiveQuicPorts, identity.Certificate); _udp = new UdpMux(_sessions, _admission, _clock, _counters.Data, log, bind, config.Listen.UdpPorts);
// 预握手敲门:控制端口和数据端口共用一个关卡(限速和去重一起算);名册就是期望状态里的 peers _tcp = new TcpAcceptor(bind, config.Listen.TcpPorts, identity.Certificate, _sessions, _counters.Data);
_trust = TrustAnchor.Load(config.Fleet.IdentityDir, log);
_knock = new KnockGate(identity.PeerIdentity, RosterKeys, time);
_tcp = new TcpAcceptor(bind, config.Listen.TcpPorts, identity.Certificate, _sessions, _counters.Data, _knock);
// 监听方积木的环境:和 Probe 的拨号方共用同一套积木,区别只在这里装配的部分(准入策略、TCP 监听) // 监听方积木的环境:和 Probe 的拨号方共用同一套积木,区别只在这里装配的部分(准入策略、TCP 监听)
var env = new BlockEnv var env = new BlockEnv
@@ -75,7 +54,7 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable
Clock = _clock, Clock = _clock,
Log = log, Log = log,
Session = () => null, // 每个 Probe 会话各用自己绑定的环境(BlockEnv.WithSession) Session = () => null, // 每个 Probe 会话各用自己绑定的环境(BlockEnv.WithSession)
Datagrams = _datagrams, Udp = _udp,
TcpListen = _tcp, TcpListen = _tcp,
Admission = _admission, Admission = _admission,
Counters = _counters.Data, Counters = _counters.Data,
@@ -84,14 +63,14 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable
}; };
var chunks = new ChunkAssembler(_streams, _clock, _counters, _outbox, identity.NodeId, time, log); var chunks = new ChunkAssembler(_streams, _clock, _counters, _outbox, identity.NodeId, time, log);
var targets = new TargetRunners(chunks, identity.NodeId, _clock, time, log); var targets = new TargetRunners(chunks, identity.NodeId, _clock, time, log);
_tasks = new TaskManager(CreateGuard, _streams, env, targets, () => _datagrams.BoundPorts, () => _tcp.BoundPorts, _trust); _tasks = new TaskManager(CreateGuard, _streams, env, targets, () => _udp.BoundPorts, () => _tcp.BoundPorts);
_hub = new PeerHub(_sessions, _clock, chunks, _tasks, _uplink, time, log); _hub = new PeerHub(_sessions, _clock, chunks, _tasks, _uplink, time, log);
_tests = new TestRunner(_tasks, _sessions, _streams, env, _uplink, config.Limits.MaxConcurrentTests, transport => transport == "quic_datagram" ? _datagrams.BoundPorts : _tcp.BoundPorts, time, log); _tests = new TestRunner(_tasks, _sessions, _streams, env, _uplink, config.Limits.MaxConcurrentTests, transport => transport == "udp" ? _udp.BoundPorts : _tcp.BoundPorts, time, log);
_tasks.OnLineTasksChanged = (probe, set) => _ = _hub.PushToAsync(probe, set).AsTask(); _tasks.OnLineTasksChanged = (probe, set) => _ = _hub.PushToAsync(probe, set).AsTask();
_tasks.OnPeersChanged = _hub.CloseNotIn; _tasks.OnPeersChanged = _hub.CloseNotIn;
_hub.OnProbeLost = probe => _tests.ProbeLost(probe); _hub.OnProbeLost = probe => _tests.ProbeLost(probe);
_admission.OnEndpointChanged = probe => _ = _uplink.EmitEventAsync(AgentEventKinds.EndpointChanged, probe.DatagramRemoteEndPoint?.ToString()).AsTask(); _admission.OnEndpointChanged = probe => _ = _uplink.EmitEventAsync(AgentEventKinds.EndpointChanged, probe.UdpEndpoint?.ToString()).AsTask();
_peers = new PeerListener( _peers = new PeerListener(
new PeerListenerOptions new PeerListenerOptions
@@ -99,7 +78,6 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable
Listen = new IPEndPoint(bind, config.Listen.ControlPort), Listen = new IPEndPoint(bind, config.Listen.ControlPort),
Certificate = identity.Certificate, Certificate = identity.Certificate,
LookupPeerKey = _tasks.LookupPeerKey, LookupPeerKey = _tasks.LookupPeerKey,
Knock = _knock,
}, },
_hub.OnSessionAsync, _hub.OnSessionAsync,
log, log,
@@ -112,46 +90,20 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable
/// <summary>数据面和控制端口都开始监听之后完成(配置里的端口为 0 时,这里能读到实际端口)。</summary> /// <summary>数据面和控制端口都开始监听之后完成(配置里的端口为 0 时,这里能读到实际端口)。</summary>
public Task Ready => _ready.Task; public Task Ready => _ready.Task;
public void Dispose() public void Dispose() => _udp.Dispose();
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
_datagrams.Dispose();
_ownedRestart?.Dispose();
}
public async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
await _datagrams.DisposeAsync().ConfigureAwait(false);
_ownedRestart?.Dispose();
}
internal StreamRegistry Streams => _streams; internal StreamRegistry Streams => _streams;
public IReadOnlyList<int> QuicPorts => _datagrams.BoundPorts; public IReadOnlyList<int> UdpPorts => _udp.BoundPorts;
public IReadOnlyList<int> TcpPorts => _tcp.BoundPorts; public IReadOnlyList<int> TcpPorts => _tcp.BoundPorts;
public int ControlPort => _peers.Port; public int ControlPort => _peers.Port;
/// <summary>节点当前采用的操作员集合版本(没有固定信任根时是 0)。</summary>
public int TrustVersion => _trust.Version;
public AgentCounters Counters => _counters; public AgentCounters Counters => _counters;
public FleetLink Link => _link; public FleetLink Link => _link;
/// <summary>对等面(Probe 连进来的会话)的路由器,测试用。</summary>
public RpcRouter<PeerContext> PeerRouter => _hub.Router;
/// <summary>期望状态里是否已经有这个 Probe 的公钥(测试用:握手会按它校验)。</summary> /// <summary>期望状态里是否已经有这个 Probe 的公钥(测试用:握手会按它校验)。</summary>
public bool IsPeerKnown(string probeId, string publicKeyBase64) => public bool IsPeerKnown(string probeId, string publicKeyBase64) =>
_tasks.LookupPeerKey(probeId) is { } key && Convert.ToBase64String(key) == publicKeyBase64; _tasks.LookupPeerKey(probeId) is { } key && Convert.ToBase64String(key) == publicKeyBase64;
@@ -160,26 +112,25 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable
public async Task RunAsync(CancellationToken ct) public async Task RunAsync(CancellationToken ct)
{ {
_log.Info("moshiro agent starting", MoShiro.Protocol.BuildInfo.Fields);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct); using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct);
// 数据面和控制端口先开始监听,再连接 Backend:hello 里上报的是实际端口 // 数据面和控制端口先开始监听,再连接 Backend:hello 里上报的是实际端口
_datagrams.Bind(); _udp.Bind();
_tcp.Bind(); _tcp.Bind();
Task quic = _datagrams.RunAsync(linked.Token); Task udp = _udp.RunAsync(linked.Token);
Task tcp = _tcp.RunAsync(linked.Token); Task tcp = _tcp.RunAsync(linked.Token);
Task peers = _peers.RunAsync(linked.Token); Task peers = _peers.RunAsync(linked.Token);
await Task.WhenAny(_peers.Started.Task, peers); await Task.WhenAny(_peers.Started.Task, peers);
if (peers.IsFaulted) if (peers.IsFaulted)
{ {
await linked.CancelAsync(); await linked.CancelAsync();
await Task.WhenAll(quic, tcp, peers); // 抛出监听失败的原因(端口被占用等) await Task.WhenAll(udp, tcp, peers); // 抛出监听失败的原因(端口被占用等)
} }
_ready.TrySetResult(); _ready.TrySetResult();
_log.Info("agent listening", ("control", ControlPort), ("quic", string.Join(',', QuicPorts)), ("tcp", string.Join(',', TcpPorts))); _log.Info("agent listening", ("control", ControlPort), ("udp", string.Join(',', UdpPorts)), ("tcp", string.Join(',', TcpPorts)));
try try
{ {
await Task.WhenAll(quic, tcp, peers, _link.RunAsync(linked.Token), ReportHostAsync(linked.Token)); await Task.WhenAll(udp, tcp, peers, _link.RunAsync(linked.Token), ReportHostAsync(linked.Token));
} }
finally finally
{ {
@@ -220,88 +171,63 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable
MaxTotalPps = _config.Limits.MaxTotalPps, MaxTotalPps = _config.Limits.MaxTotalPps,
AllowedTargetCidrs = _config.Limits.AllowedTargetCidrs, AllowedTargetCidrs = _config.Limits.AllowedTargetCidrs,
MaxConcurrentTests = _config.Limits.MaxConcurrentTests, MaxConcurrentTests = _config.Limits.MaxConcurrentTests,
AllowedQuicPorts = _datagrams.BoundPorts, AllowedUdpPorts = _udp.BoundPorts,
AllowedTcpPorts = _tcp.BoundPorts, AllowedTcpPorts = _tcp.BoundPorts,
}, },
BlockCatalog.AgentKinds); BlockCatalog.AgentKinds);
private FleetLink BuildLink() private FleetLink BuildLink()
{ {
var router = new RpcRouter<LinkContext>(RpcSide.Node, MoShiroMethodTable.Table, NodeRoles.Agent); var router = new RpcRouter<LinkContext>();
HashSet<string>? allowed = _config.Policy.AllowMethods is { } list ? [.. list] : null; HashSet<string>? allowed = _config.Policy.AllowMethods is { } list ? [.. list] : null;
bool Allow(string method) => allowed is null || allowed.Contains(method); bool Allow(string method) => allowed is null || allowed.Contains(method);
if (Allow(MoShiroMethods.TestRun)) if (Allow(MonitorMethods.TestRun))
{ {
router.On( router.On(
MoShiroMethods.TestRun, MonitorMethods.TestRun,
MoShiroJsonContext.Default.TestRunParams, MonitorJsonContext.Default.TestRunParams,
MoShiroJsonContext.Default.TestRunResult, MonitorJsonContext.Default.TestRunResult,
(_, run, _) => ValueTask.FromResult(_tests.Start(run))); (_, run, _) => ValueTask.FromResult(_tests.Start(run)));
} }
if (Allow(MoShiroMethods.TestCancel)) if (Allow(MonitorMethods.TestCancel))
{ {
router.On( router.On(
MoShiroMethods.TestCancel, MonitorMethods.TestCancel,
MoShiroJsonContext.Default.TestCancelParams, MonitorJsonContext.Default.TestCancelParams,
MoShiroJsonContext.Default.TestRunResult, MonitorJsonContext.Default.TestRunResult,
(_, cancel, _) => ValueTask.FromResult(Result<TestRunResult>.Ok(new TestRunResult { Accepted = _tests.Cancel(cancel.TestId) }))); (_, cancel, _) => ValueTask.FromResult(Result<TestRunResult>.Ok(new TestRunResult { Accepted = _tests.Cancel(cancel.TestId) })));
} }
if (Allow(MoShiroMethods.UpdateOffer)) if (Allow(MonitorMethods.ProbeDisconnect))
{
router.OnNotify(MoShiroMethods.UpdateOffer, MoShiroJsonContext.Default.UpdateOfferParams, (_, offer, _) =>
{
_update?.Offer(offer);
return ValueTask.CompletedTask;
});
}
if (Allow(MoShiroMethods.Schedule))
{
router.OnNotify(MoShiroMethods.Schedule, MoShiroJsonContext.Default.ScheduleParams, (_, schedule, _) =>
{
if (!_restart.SetRemote(schedule.RestartAt))
{
_log.Warn("ignoring an invalid restart schedule from the panel");
}
return ValueTask.CompletedTask;
});
}
if (Allow(MoShiroMethods.UpdateApply))
{
router.OnNotify(MoShiroMethods.UpdateApply, MoShiroJsonContext.Default.UpdateOfferParams, (_, _, _) =>
{
_update?.RequestApply();
return ValueTask.CompletedTask;
});
}
if (Allow(MoShiroMethods.ProbeDisconnect))
{ {
router.On( router.On(
MoShiroMethods.ProbeDisconnect, MonitorMethods.ProbeDisconnect,
MoShiroJsonContext.Default.ProbeDisconnectParams, MonitorJsonContext.Default.ProbeDisconnectParams,
MoShiroJsonContext.Default.TestRunResult, MonitorJsonContext.Default.TestRunResult,
async (_, request, _) => Result<TestRunResult>.Ok(new TestRunResult { Accepted = await _hub.DisconnectAsync(request.ProbeId.Value) })); async (_, request, _) => Result<TestRunResult>.Ok(new TestRunResult { Accepted = await _hub.DisconnectAsync(request.ProbeId.Value) }));
} }
if (Allow(MonitorMethods.ProbeAgentsChanged))
{
router.OnNotify(
MonitorMethods.ProbeAgentsChanged,
MonitorJsonContext.Default.ProbeAgentsResult,
(_, list, _) => _hub.RelayAgentsAsync(list));
}
var options = new FleetLinkOptions var options = new FleetLinkOptions
{ {
Server = new Uri(_config.Fleet.Server), Server = new Uri(_config.Fleet.Server),
Identity = _identity.Identity, Identity = _identity.Identity,
ServerVerifier = _trust,
NodeId = _identity.NodeId, NodeId = _identity.NodeId,
NodeVersion = Version, NodeVersion = Version,
Capabilities = [MoShiroCapabilities.Base, MoShiroCapabilities.KindsPrefix + string.Join(',', BlockCatalog.AgentKinds)], Capabilities = [MonitorCapabilities.Base, MonitorCapabilities.KindsPrefix + string.Join(',', BlockCatalog.AgentKinds)],
HelloMeta = HelloMeta, HelloMeta = HelloMeta,
HeartbeatExtra = HeartbeatExtra, HeartbeatExtra = HeartbeatExtra,
ShutdownReason = () => _restart.NowReason == "update" ? "updating" : "stopping",
}; };
var link = new FleetLink(options, router, [new StateHandler<TaskSet>(TaskSet.StateType, MoShiroJsonContext.Default.TaskSet, _tasks.ApplyAsync)], _outbox, _time, _log); var link = new FleetLink(options, router, [new StateHandler<TaskSet>(TaskSet.StateType, MonitorJsonContext.Default.TaskSet, _tasks.ApplyAsync)], _outbox, _time, _log);
// 链路重新连上之后补报:在线的 Probe 都告诉 Backend(Backend 重启或链路抖动期间错过了 probe_connected) // 链路重新连上之后补报:在线的 Probe 都告诉 Backend(Backend 重启或链路抖动期间错过了 probe_connected)
link.OnConnected = async (_, _) => link.OnConnected = async (_, _) =>
@@ -314,22 +240,13 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable
return link; return link;
} }
/// <summary>敲门关卡的名册:期望状态里的 peers(任务管理器在构造之后才有)。</summary>
private IReadOnlyList<byte[]> RosterKeys() => _tasks.PeerKeys();
private JsonElement? HelloMeta() private JsonElement? HelloMeta()
{ {
var meta = new AgentHelloMeta var meta = new AgentHelloMeta
{ {
Owners = [.. _trust.Controllers], TlsSpki = _identity.File.TlsSpki,
Ipv6 = Ipv6Diagnostics.Sample(_ipv6Supported, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")),
Ipv6Supported = _ipv6Supported,
Addresses = [.. LocalAddresses.Sample()],
Platform = MoShiro.Measurement.PlatformDetector.Detect(),
TlsPublicKey = Convert.ToBase64String(_identity.Certificate.PublicKey.ExportSubjectPublicKeyInfo()),
ProbeTcpPort = ControlPort, ProbeTcpPort = ControlPort,
ProbeQuicPorts = [.. QuicPorts], ProbeUdpPorts = [.. UdpPorts],
LegacyProbeUdpPorts = [.. QuicPorts],
LocalLimits = new LocalLimits LocalLimits = new LocalLimits
{ {
MaxTotalMbps = _config.Limits.MaxTotalMbps, MaxTotalMbps = _config.Limits.MaxTotalMbps,
@@ -338,7 +255,7 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable
MaxConcurrentTests = _config.Limits.MaxConcurrentTests, MaxConcurrentTests = _config.Limits.MaxConcurrentTests,
}, },
}; };
return JsonSerializer.SerializeToElement(meta, MoShiroJsonContext.Default.AgentHelloMeta); return JsonSerializer.SerializeToElement(meta, MonitorJsonContext.Default.AgentHelloMeta);
} }
private long _lastIn; private long _lastIn;
@@ -353,8 +270,6 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable
long packetsOut = _counters.PacketsOut; long packetsOut = _counters.PacketsOut;
var extra = new AgentHeartbeatExtra var extra = new AgentHeartbeatExtra
{ {
RuntimeStatus = _update?.Status().State == "applying" ? NodeRuntimeStatus.Updating : NodeRuntimeStatus.Running,
Ipv6 = Ipv6Diagnostics.Sample(_ipv6Supported, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")),
Probes = [.. _hub.ConnectedProbes()], Probes = [.. _hub.ConnectedProbes()],
Sessions = _sessions.All.Count, Sessions = _sessions.All.Count,
PpsIn = _lastAtMs == 0 ? 0 : (packetsIn - _lastIn) / seconds, PpsIn = _lastAtMs == 0 ? 0 : (packetsIn - _lastIn) / seconds,
@@ -362,11 +277,10 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable
DroppedInvalid = _counters.DroppedInvalid, DroppedInvalid = _counters.DroppedInvalid,
DroppedRateLimited = _counters.DroppedRateLimited, DroppedRateLimited = _counters.DroppedRateLimited,
QueueBytes = _outbox.PendingBytes, QueueBytes = _outbox.PendingBytes,
Update = _update?.Status(),
}; };
_lastIn = packetsIn; _lastIn = packetsIn;
_lastOut = packetsOut; _lastOut = packetsOut;
_lastAtMs = nowMs; _lastAtMs = nowMs;
return JsonSerializer.SerializeToElement(extra, MoShiroJsonContext.Default.AgentHeartbeatExtra); return JsonSerializer.SerializeToElement(extra, MonitorJsonContext.Default.AgentHeartbeatExtra);
} }
} }
@@ -1,7 +1,6 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using MoShiro.Measurement;
namespace MoShiro.Agent; namespace Monitor.Agent;
/// <summary> /// <summary>
/// <c>agent.json</c>:节点本地配置,面板无法修改。其中 <c>limits</c> 是本地硬上限:期望任务集超出这些范围时, /// <c>agent.json</c>:节点本地配置,面板无法修改。其中 <c>limits</c> 是本地硬上限:期望任务集超出这些范围时,
@@ -23,10 +22,16 @@ public sealed class AgentConfig
[JsonPropertyName("reporting")] [JsonPropertyName("reporting")]
public ReportingSection Reporting { get; set; } = new(); public ReportingSection Reporting { get; set; } = new();
}
/// <summary>每天定时重启一次,例如 <c>04:00+08:00</c>(不写偏移按 UTC);不写就不重启。环境变量 <c>MOSHIRO_RESTART_AT</c> 优先。</summary> public sealed class ReportingSection
[JsonPropertyName("restart_at")] {
public string? RestartAt { get; set; } /// <summary>上报主机资源占用(CPU、内存、磁盘、流量、运行时长,只有汇总数字,没有型号和 IP)。设为 false 就一个字也不上报。</summary>
[JsonPropertyName("host_stats")]
public bool HostStats { get; set; } = true;
[JsonPropertyName("host_interval_s")]
public int HostIntervalSeconds { get; set; } = 2;
} }
public sealed class FleetSection public sealed class FleetSection
@@ -35,31 +40,20 @@ public sealed class FleetSection
public string Server { get; set; } = ""; public string Server { get; set; } = "";
[JsonPropertyName("identity_dir")] [JsonPropertyName("identity_dir")]
public string IdentityDir { get; set; } = "/etc/moshiro"; public string IdentityDir { get; set; } = "/etc/monitor";
/// <summary>节点自己要求有属主(passkey):没有就不运行。见 <c>OwnerGuard</c>。</summary>
[JsonPropertyName("require_owner")]
public bool RequireOwner { get; set; } = true;
} }
public sealed class ListenSection public sealed class ListenSection
{ {
[JsonPropertyName("bind")] [JsonPropertyName("bind")]
public string Bind { get; set; } = "::"; public string Bind { get; set; } = "0.0.0.0";
/// <summary>Probe 控制通道(TLS,点对点会话)。</summary> /// <summary>Probe 控制通道(TLS,点对点会话)。</summary>
[JsonPropertyName("control_port")] [JsonPropertyName("control_port")]
public int ControlPort { get; set; } = 7443; public int ControlPort { get; set; } = 7443;
[JsonPropertyName("quic_ports")]
public List<int> QuicPorts { get; set; } = [7443];
/// <summary>Legacy config key accepted while deployments migrate to QUIC Datagram.</summary>
[JsonPropertyName("udp_ports")] [JsonPropertyName("udp_ports")]
public List<int>? LegacyUdpPorts { get; set; } public List<int> UdpPorts { get; set; } = [7443];
[JsonIgnore]
public IReadOnlyList<int> EffectiveQuicPorts => LegacyUdpPorts ?? QuicPorts;
[JsonPropertyName("tcp_ports")] [JsonPropertyName("tcp_ports")]
public List<int> TcpPorts { get; set; } = [7444]; public List<int> TcpPorts { get; set; } = [7444];
@@ -1,9 +1,9 @@
using Fleet.Core; using Fleet.Core;
using Fleet.Link; using Fleet.Link;
using MoShiro.Measurement; using Monitor.Measurement;
using MoShiro.Protocol; using Monitor.Protocol;
namespace MoShiro.Agent; namespace Monitor.Agent;
/// <summary> /// <summary>
/// Probe 的列块到达之后:找到对应的流 → 与 Agent 在数据面记录的列合并 → 盖章(块头、时钟锚点)→ 编码 → 放进发件箱(通道 16)。 /// Probe 的列块到达之后:找到对应的流 → 与 Agent 在数据面记录的列合并 → 盖章(块头、时钟锚点)→ 编码 → 放进发件箱(通道 16)。
@@ -44,7 +44,6 @@ public sealed class ChunkAssembler
_ => null, _ => null,
}; };
header.ProbeId = new ProbeId(probe.ProbeId); header.ProbeId = new ProbeId(probe.ProbeId);
header.IpVersion = probe.IpVersion; // 地址族以认证过的控制会话为准,不接受列块自报值
KindSpec? spec = stream is null ? null : Kinds.Find(stream.Kind); KindSpec? spec = stream is null ? null : Kinds.Find(stream.Kind);
if (stream is null || spec is null || spec.Id != header.Kind) if (stream is null || spec is null || spec.Id != header.Kind)
{ {
@@ -71,6 +70,6 @@ public sealed class ChunkAssembler
/// <summary>Agent 自己采集的块(目标任务)直接发出。</summary> /// <summary>Agent 自己采集的块(目标任务)直接发出。</summary>
public void Emit(RawChunk chunk) public void Emit(RawChunk chunk)
{ {
_outbox.Enqueue(MoShiroChannels.RawChunk, chunk.Header.ChunkId.Value, RawChunkCodec.Encode(chunk)); _outbox.Enqueue(MonitorChannels.RawChunk, chunk.Header.ChunkId.Value, RawChunkCodec.Encode(chunk));
} }
} }
@@ -4,10 +4,10 @@ using System.Net.Sockets;
using Fleet.Core; using Fleet.Core;
using Fleet.Link; using Fleet.Link;
using Fleet.Rpc; using Fleet.Rpc;
using MoShiro.Measurement; using Monitor.Measurement;
using MoShiro.Protocol; using Monitor.Protocol;
namespace MoShiro.Agent; namespace Monitor.Agent;
/// <summary>进程内计数(只有计数,没有主机信息):用于心跳和面板上的节点状态。数据面的包计数由共享的 <see cref="DataPlaneCounters"/> 提供。</summary> /// <summary>进程内计数(只有计数,没有主机信息):用于心跳和面板上的节点状态。数据面的包计数由共享的 <see cref="DataPlaneCounters"/> 提供。</summary>
public sealed class AgentCounters public sealed class AgentCounters
@@ -44,7 +44,7 @@ public sealed class ProbeSession : DataSession
{ {
private readonly ConcurrentDictionary<uint, ReplayWindow> _windows = new(); private readonly ConcurrentDictionary<uint, ReplayWindow> _windows = new();
private readonly ConcurrentDictionary<ulong, byte> _nonces = new(); private readonly ConcurrentDictionary<ulong, byte> _nonces = new();
private SocketAddress? _datagramSource; private SocketAddress? _udpSource;
public ProbeSession(PeerSessionInfo info) public ProbeSession(PeerSessionInfo info)
: base(info.Credentials.SessionIdValue, info.Credentials.Key) : base(info.Credentials.SessionIdValue, info.Credentials.Key)
@@ -56,10 +56,6 @@ public sealed class ProbeSession : DataSession
public string ProbeId => Info.PeerId; public string ProbeId => Info.PeerId;
public string IpVersion => Info.Remote is IPEndPoint endpoint && endpoint.Address.AddressFamily == AddressFamily.InterNetworkV6 && !endpoint.Address.IsIPv4MappedToIPv6 ? IpVersions.V6 : IpVersions.V4;
public string PathKey => ProbeId + "~" + IpVersion;
/// <summary>Probe 的程序版本(握手里自报,只用于展示)。</summary> /// <summary>Probe 的程序版本(握手里自报,只用于展示)。</summary>
public string? Version => Info.Version; public string? Version => Info.Version;
@@ -71,19 +67,19 @@ public sealed class ProbeSession : DataSession
public RpcSession<PeerContext>? Rpc { get; set; } public RpcSession<PeerContext>? Rpc { get; set; }
/// <summary>最近一次认证通过的 UDP 包的源地址(Agent 把单向数据发往这里)。</summary> /// <summary>最近一次认证通过的 UDP 包的源地址(Agent 把单向数据发往这里)。</summary>
public override IPEndPoint? DatagramRemoteEndPoint public override IPEndPoint? UdpEndpoint
{ {
get get
{ {
SocketAddress? source = Volatile.Read(ref _datagramSource); SocketAddress? source = Volatile.Read(ref _udpSource);
return source is null ? null : (IPEndPoint)new IPEndPoint(source.Family == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any, 0).Create(source); return source is null ? null : (IPEndPoint)new IPEndPoint(IPAddress.Any, 0).Create(source);
} }
} }
/// <summary>记录 UDP 源地址;NAT 映射变化后地址变了返回 true(上报 endpoint_changed)。第一次看到地址不算变化。</summary> /// <summary>记录 UDP 源地址;NAT 映射变化后地址变了返回 true(上报 endpoint_changed)。第一次看到地址不算变化。</summary>
public bool UpdateSource(SocketAddress source) public bool UpdateSource(SocketAddress source)
{ {
SocketAddress? previous = Volatile.Read(ref _datagramSource); SocketAddress? previous = Volatile.Read(ref _udpSource);
if (previous is not null && previous.Equals(source)) if (previous is not null && previous.Equals(source))
{ {
return false; return false;
@@ -96,7 +92,7 @@ public sealed class ProbeSession : DataSession
copy[i] = source[i]; copy[i] = source[i];
} }
Volatile.Write(ref _datagramSource, copy); Volatile.Write(ref _udpSource, copy);
return previous is not null; return previous is not null;
} }
@@ -129,7 +125,7 @@ public sealed class ProbeSession : DataSession
public void ForgetStream(uint streamId) => _windows.TryRemove(streamId, out _); public void ForgetStream(uint streamId) => _windows.TryRemove(streamId, out _);
} }
/// <summary>每个 Probe 的 IPv4、IPv6 会话独立;重连只替换同地址族的旧会话。</summary> /// <summary>当前连着的 Probe 会话:每个 Probe 一个;同一个 Probe 重连时新会话取代它自己的旧会话。</summary>
public sealed class SessionRegistry : IDataSessions public sealed class SessionRegistry : IDataSessions
{ {
private readonly object _gate = new(); private readonly object _gate = new();
@@ -147,11 +143,11 @@ public sealed class SessionRegistry : IDataSessions
} }
} }
public ProbeSession? Get(string probeId, string ipVersion = IpVersions.V4) public ProbeSession? Get(string probeId)
{ {
lock (_gate) lock (_gate)
{ {
return _byProbe.GetValueOrDefault(probeId + "~" + ipVersion); return _byProbe.GetValueOrDefault(probeId);
} }
} }
@@ -160,13 +156,13 @@ public sealed class SessionRegistry : IDataSessions
{ {
lock (_gate) lock (_gate)
{ {
_byProbe.TryGetValue(session.PathKey, out ProbeSession? old); _byProbe.TryGetValue(session.ProbeId, out ProbeSession? old);
if (old is not null) if (old is not null)
{ {
_bySession.Remove(old.SessionId); _bySession.Remove(old.SessionId);
} }
_byProbe[session.PathKey] = session; _byProbe[session.ProbeId] = session;
_bySession[session.SessionId] = session; _bySession[session.SessionId] = session;
return old; return old;
} }
@@ -176,9 +172,9 @@ public sealed class SessionRegistry : IDataSessions
{ {
lock (_gate) lock (_gate)
{ {
if (_byProbe.TryGetValue(session.PathKey, out ProbeSession? current) && ReferenceEquals(current, session)) if (_byProbe.TryGetValue(session.ProbeId, out ProbeSession? current) && ReferenceEquals(current, session))
{ {
_byProbe.Remove(session.PathKey); _byProbe.Remove(session.ProbeId);
} }
if (_bySession.TryGetValue(session.SessionId, out ProbeSession? bySession) && ReferenceEquals(bySession, session)) if (_bySession.TryGetValue(session.SessionId, out ProbeSession? bySession) && ReferenceEquals(bySession, session))
@@ -312,7 +308,7 @@ public sealed class AgentAdmission : IAdmission
/// <summary>NAT 映射变化,源地址变了:上报 <c>endpoint_changed</c>。</summary> /// <summary>NAT 映射变化,源地址变了:上报 <c>endpoint_changed</c>。</summary>
public Action<ProbeSession>? OnEndpointChanged { get; set; } public Action<ProbeSession>? OnEndpointChanged { get; set; }
public Admission AdmitDatagram(DataSession session, in MeasurementDatagramHeader header, SocketAddress source, long nowUs) public Admission AdmitPacket(DataSession session, in UdpHeader header, SocketAddress source, long nowUs)
{ {
if (session is not ProbeSession probe || !_streams.TryGet(header.StreamId, out StreamInfo? stream) || !ReferenceEquals(stream.Owner, probe) || !probe.AcceptSequence(header.StreamId, header.Seq)) if (session is not ProbeSession probe || !_streams.TryGet(header.StreamId, out StreamInfo? stream) || !ReferenceEquals(stream.Owner, probe) || !probe.AcceptSequence(header.StreamId, header.Seq))
{ {
@@ -1,37 +1,28 @@
using System.Globalization; using System.Globalization;
using MoShiro.Protocol; using Monitor.Protocol;
namespace MoShiro.Measurement; namespace Monitor.Agent;
/// <summary> /// <summary>
/// Agent 和 Probe 共用。主机资源占用的只读快照:CPU 占用、内存、根分区、网卡累计流量与瞬时网速、系统运行时长。 /// 主机资源占用的只读快照:CPU 占用、内存、根分区、网卡累计流量与瞬时网速、系统运行时长。
/// 只有汇总的数字:<b>不采集 CPU 型号、主机名、网卡名和任何 IP 地址</b>,所以面板和展示页想泄漏也泄漏不出去。 /// 只有汇总的数字:<b>不采集 CPU 型号、主机名、网卡名和任何 IP 地址</b>,所以面板和展示页想泄漏也泄漏不出去。
/// Linux 读 <c>/proc</c> 和 <c>/sys</c>;容器通过 HOST_PROC、HOST_SYS、HOST_ROOT 指定宿主路径。 /// Linux 读 <c>/proc</c>;读不到的项是 null(例如 Windows 上的 CPU)。不执行任何命令,不写文件。
/// Windows 用 GetSystemTimes、GlobalMemoryStatusEx 和 NetworkInterface(见 WindowsHost);macOS 等其他系统只有磁盘和运行时长。读不到的项是 null。不执行任何命令,不写文件。
/// </summary> /// </summary>
public sealed class HostSampler public sealed class HostSampler
{ {
private static readonly string[] s_virtualPrefixes = ["lo", "veth", "docker", "podman", "br-", "virbr", "cni", "flannel", "cali", "tun", "tap"]; private static readonly string[] s_virtualPrefixes = ["lo", "veth", "docker", "br-", "virbr", "cni", "flannel", "cali", "tun", "tap"];
private readonly TimeProvider _time; private readonly TimeProvider _time;
private readonly Func<string, string?> _read; private readonly Func<string, string?> _read;
private readonly Func<(long Total, long Used)?> _disk; private readonly Func<(long Total, long Used)?> _disk;
private readonly string _procRoot;
private readonly string _sysRoot;
private readonly bool _native;
private (long Total, long Idle)? _previousCpu; private (long Total, long Idle)? _previousCpu;
private (long At, long Rx, long Tx)? _previousNet; private (long At, long Rx, long Tx)? _previousNet;
public HostSampler(TimeProvider time, Func<string, string?>? read = null, Func<(long Total, long Used)?>? disk = null, public HostSampler(TimeProvider time, Func<string, string?>? read = null, Func<(long Total, long Used)?>? disk = null)
string? procRoot = null, string? sysRoot = null, string? diskRoot = null)
{ {
_time = time; _time = time;
_native = read is null && OperatingSystem.IsWindows(); // 只有默认读法才走 Windows 的系统调用;测试注入 read 时仍按 Linux 的文本解析
_read = read ?? ReadProc; _read = read ?? ReadProc;
_procRoot = Root(procRoot, "HOST_PROC", "/proc"); _disk = disk ?? ReadRootDisk;
_sysRoot = Root(sysRoot, "HOST_SYS", "/sys");
string root = Root(diskRoot, "HOST_ROOT", OperatingSystem.IsWindows() ? Path.GetPathRoot(Environment.SystemDirectory) ?? "C:\\" : "/");
_disk = disk ?? (() => ReadRootDisk(root));
} }
public HostSnapshot Sample() public HostSnapshot Sample()
@@ -39,8 +30,7 @@ public sealed class HostSampler
long now = _time.GetTimestamp(); long now = _time.GetTimestamp();
var snapshot = new HostSnapshot { AtUtcMs = _time.GetUtcNow().ToUnixTimeMilliseconds() }; var snapshot = new HostSnapshot { AtUtcMs = _time.GetUtcNow().ToUnixTimeMilliseconds() };
(long Total, long Idle)? cpuNow = _native ? WindowsCpu() : _read(Path.Combine(_procRoot, "stat")) is { } stat ? ParseCpu(stat) : null; if (_read("/proc/stat") is { } stat && ParseCpu(stat) is { } cpu)
if (cpuNow is { } cpu)
{ {
if (_previousCpu is { } before && cpu.Total > before.Total) if (_previousCpu is { } before && cpu.Total > before.Total)
{ {
@@ -51,8 +41,7 @@ public sealed class HostSampler
_previousCpu = cpu; _previousCpu = cpu;
} }
(long Total, long Used)? memoryNow = _native ? WindowsMemory() : _read(Path.Combine(_procRoot, "meminfo")) is { } meminfo ? ParseMemory(meminfo) : null; if (_read("/proc/meminfo") is { } meminfo && ParseMemory(meminfo) is { } memory)
if (memoryNow is { } memory)
{ {
snapshot.MemTotal = memory.Total; snapshot.MemTotal = memory.Total;
snapshot.MemUsed = memory.Used; snapshot.MemUsed = memory.Used;
@@ -64,21 +53,10 @@ public sealed class HostSampler
snapshot.DiskUsed = disk.Used; snapshot.DiskUsed = disk.Used;
} }
(long Rx, long Tx, long? LinkMbps)? netNow = null; if (_read("/proc/net/dev") is { } dev && ParseNetwork(dev) is { } net)
if (_native)
{
netNow = WindowsNetwork();
}
else if (_read(Path.Combine(_procRoot, "net/dev")) is { } dev && ParseNetwork(dev) is { } parsed)
{
netNow = (parsed.Rx, parsed.Tx, ReadLinkSpeed(dev));
}
if (netNow is { } net)
{ {
snapshot.NetRxTotal = net.Rx; snapshot.NetRxTotal = net.Rx;
snapshot.NetTxTotal = net.Tx; snapshot.NetTxTotal = net.Tx;
snapshot.NetLinkMbps = net.LinkMbps;
if (_previousNet is { } before && net.Rx >= before.Rx && net.Tx >= before.Tx) if (_previousNet is { } before && net.Rx >= before.Rx && net.Tx >= before.Tx)
{ {
double seconds = Math.Max(0.001, _time.GetElapsedTime(before.At, now).TotalSeconds); double seconds = Math.Max(0.001, _time.GetElapsedTime(before.At, now).TotalSeconds);
@@ -89,20 +67,10 @@ public sealed class HostSampler
_previousNet = (now, net.Rx, net.Tx); _previousNet = (now, net.Rx, net.Tx);
} }
snapshot.UptimeSeconds = _read(Path.Combine(_procRoot, "uptime")) is { } uptime && ParseUptime(uptime) is { } up ? up : null; snapshot.UptimeSeconds = _read("/proc/uptime") is { } uptime && ParseUptime(uptime) is { } up ? up : Environment.TickCount64 / 1000;
if (!OperatingSystem.IsLinux() && snapshot.UptimeSeconds is null)
{
snapshot.UptimeSeconds = Environment.TickCount64 / 1000;
}
return snapshot; return snapshot;
} }
private static (long Total, long Idle)? WindowsCpu() => OperatingSystem.IsWindows() ? WindowsHost.Cpu() : null;
private static (long Total, long Used)? WindowsMemory() => OperatingSystem.IsWindows() ? WindowsHost.Memory() : null;
private static (long Rx, long Tx, long? LinkMbps)? WindowsNetwork() => OperatingSystem.IsWindows() ? WindowsHost.Network() : null;
/// <summary>第一行 <c>cpu user nice system idle iowait irq softirq steal …</c>:总时间与空闲时间(idle + iowait)。</summary> /// <summary>第一行 <c>cpu user nice system idle iowait irq softirq steal …</c>:总时间与空闲时间(idle + iowait)。</summary>
public static (long Total, long Idle)? ParseCpu(string stat) public static (long Total, long Idle)? ParseCpu(string stat)
{ {
@@ -197,54 +165,11 @@ public sealed class HostSampler
} }
} }
private static string Root(string? value, string variable, string fallback) private static (long Total, long Used)? ReadRootDisk()
{
value ??= Environment.GetEnvironmentVariable(variable);
return string.IsNullOrWhiteSpace(value) ? fallback : value;
}
private long? ReadLinkSpeed(string dev)
{
long total = 0;
bool any = false;
foreach (string line in dev.Split('\n'))
{
int colon = line.IndexOf(':', StringComparison.Ordinal);
if (colon < 0)
{
continue;
}
string name = line[..colon].Trim();
if (s_virtualPrefixes.Any(p => name.StartsWith(p, StringComparison.Ordinal)))
{
continue;
}
string? speed = _read(Path.Combine(_sysRoot, "class/net", name, "speed"));
if (long.TryParse(speed?.Trim(), CultureInfo.InvariantCulture, out long mbps) && mbps > 0)
{
total += mbps;
any = true;
}
else
{
return null;
}
}
return any ? total : null;
}
private static (long Total, long Used)? ReadRootDisk(string root)
{ {
try try
{ {
if (!Directory.Exists(root)) string root = OperatingSystem.IsWindows() ? Path.GetPathRoot(Environment.SystemDirectory) ?? "C:\\" : "/";
{
return null;
}
var drive = new DriveInfo(root); var drive = new DriveInfo(root);
return drive.IsReady ? (drive.TotalSize, drive.TotalSize - drive.AvailableFreeSpace) : null; return drive.IsReady ? (drive.TotalSize, drive.TotalSize - drive.AvailableFreeSpace) : null;
} }
@@ -0,0 +1,20 @@
<Project>
<Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" />
<Import Project="Sdk.props" Sdk="StuDev.AotAnywhere"
Condition="'$(RuntimeIdentifier)' == 'linux-x64' Or '$(RuntimeIdentifier)' == 'linux-arm64' Or '$(RuntimeIdentifier)' == 'linux-arm' Or '$(RuntimeIdentifier)' == 'linux-musl-x64' Or '$(RuntimeIdentifier)' == 'linux-musl-arm64' Or '$(RuntimeIdentifier)' == 'linux-musl-arm' Or '$(RuntimeIdentifier)' == 'osx-x64' Or '$(RuntimeIdentifier)' == 'osx-arm64' Or '$(RuntimeIdentifier)' == 'win-x64' Or '$(RuntimeIdentifier)' == 'win-arm64'" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<AssemblyName>monitor-agent</AssemblyName>
<RootNamespace>Monitor.Agent</RootNamespace>
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="Monitor.Backend.Tests" />
<ProjectReference Include="..\..\..\fleet\src\Fleet.Link\Fleet.Link.csproj" />
<ProjectReference Include="..\..\..\fleet\src\Fleet.Hosting\Fleet.Hosting.csproj" />
<ProjectReference Include="..\..\..\shared\Monitor.Measurement\Monitor.Measurement.csproj" />
</ItemGroup>
<Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
<Import Project="Sdk.targets" Sdk="StuDev.AotAnywhere"
Condition="'$(RuntimeIdentifier)' == 'linux-x64' Or '$(RuntimeIdentifier)' == 'linux-arm64' Or '$(RuntimeIdentifier)' == 'linux-arm' Or '$(RuntimeIdentifier)' == 'linux-musl-x64' Or '$(RuntimeIdentifier)' == 'linux-musl-arm64' Or '$(RuntimeIdentifier)' == 'linux-musl-arm' Or '$(RuntimeIdentifier)' == 'osx-x64' Or '$(RuntimeIdentifier)' == 'osx-arm64' Or '$(RuntimeIdentifier)' == 'win-x64' Or '$(RuntimeIdentifier)' == 'win-arm64'" />
</Project>
@@ -2,10 +2,10 @@ using System.Text.Json;
using Fleet.Core; using Fleet.Core;
using Fleet.Link; using Fleet.Link;
using Fleet.Rpc; using Fleet.Rpc;
using MoShiro.Measurement; using Monitor.Measurement;
using MoShiro.Protocol; using Monitor.Protocol;
namespace MoShiro.Agent; namespace Monitor.Agent;
/// <summary>Agent → Backend 的上行:事件和测试状态是尽力而为的通知(链路断开时丢弃;列块走发件箱,不会丢)。</summary> /// <summary>Agent → Backend 的上行:事件和测试状态是尽力而为的通知(链路断开时丢弃;列块走发件箱,不会丢)。</summary>
public sealed class BackendUplink public sealed class BackendUplink
@@ -31,7 +31,7 @@ public sealed class BackendUplink
} }
var parameters = new AgentEventParams { Kind = kind, AtUtcMs = _time.GetUtcNow().ToUnixTimeMilliseconds(), Detail = detail, Probe = probe }; var parameters = new AgentEventParams { Kind = kind, AtUtcMs = _time.GetUtcNow().ToUnixTimeMilliseconds(), Detail = detail, Probe = probe };
await SafeAsync(() => session.NotifyAsync(MoShiroMethods.Event, parameters, MoShiroJsonContext.Default.AgentEventParams, CancellationToken.None)); await SafeAsync(() => session.NotifyAsync(MonitorMethods.Event, parameters, MonitorJsonContext.Default.AgentEventParams, CancellationToken.None));
} }
public async ValueTask SendHostAsync(HostSnapshot snapshot) public async ValueTask SendHostAsync(HostSnapshot snapshot)
@@ -42,7 +42,7 @@ public sealed class BackendUplink
return; return;
} }
await SafeAsync(() => session.NotifyAsync(MoShiroMethods.Host, snapshot, MoShiroJsonContext.Default.HostSnapshot, CancellationToken.None)); await SafeAsync(() => session.NotifyAsync(MonitorMethods.Host, snapshot, MonitorJsonContext.Default.HostSnapshot, CancellationToken.None));
} }
public async ValueTask SendTestStatusAsync(TestId test, string status, int? phase = null, string? errorCode = null) public async ValueTask SendTestStatusAsync(TestId test, string status, int? phase = null, string? errorCode = null)
@@ -54,7 +54,19 @@ public sealed class BackendUplink
} }
var parameters = new TestStatusParams { TestId = test, Status = status, Phase = phase, ErrorCode = errorCode }; var parameters = new TestStatusParams { TestId = test, Status = status, Phase = phase, ErrorCode = errorCode };
await SafeAsync(() => session.NotifyAsync(MoShiroMethods.TestStatus, parameters, MoShiroJsonContext.Default.TestStatusParams, CancellationToken.None)); await SafeAsync(() => session.NotifyAsync(MonitorMethods.TestStatus, parameters, MonitorJsonContext.Default.TestStatusParams, CancellationToken.None));
}
/// <summary>代 Probe 向 Backend 取 Agent 列表(Probe 不认识 Backend)。链路断开时返回 fleet_node_offline。</summary>
public async ValueTask<Result<ProbeAgentsResult>> RequestProbeAgentsAsync(ProbeAgentsParams request, CancellationToken ct)
{
RpcSession<LinkContext>? session = _link?.CurrentSession;
if (session is null)
{
return Result<ProbeAgentsResult>.Fail(FleetErrors.NodeOffline, "the agent has no link to the backend");
}
return await session.RequestAsync(MonitorMethods.ProbeAgents, request, MonitorJsonContext.Default.ProbeAgentsParams, MonitorJsonContext.Default.ProbeAgentsResult, ct);
} }
private static async ValueTask SafeAsync(Func<ValueTask> send) private static async ValueTask SafeAsync(Func<ValueTask> send)
@@ -78,9 +90,8 @@ public sealed class PeerHub
{ {
private const int DedupeWindow = 1024; private const int DedupeWindow = 1024;
/// <summary>超过这么久没有收到 Probe 的任何帧就认为会话已死(Probe 每 30 秒至少发一次时钟同步)。 /// <summary>超过这么久没有收到 Probe 的任何帧就认为会话已死(Probe 每 30 秒至少发一次时钟同步)。</summary>
/// 要比线路可能的中断长得多:差的线路上几十秒甚至一两分钟的黑洞时有发生(TCP RTT 能到 30 秒),75 秒就关会话会把一次中断变成几分钟的数据空白(要重新握手,而握手在差线路上也慢)。</summary> private static readonly TimeSpan IdleLimit = TimeSpan.FromSeconds(75);
private static readonly TimeSpan IdleLimit = TimeSpan.FromSeconds(180);
private readonly RpcRouter<PeerContext> _router; private readonly RpcRouter<PeerContext> _router;
private readonly SessionRegistry _sessions; private readonly SessionRegistry _sessions;
@@ -91,12 +102,9 @@ public sealed class PeerHub
private readonly TimeProvider _time; private readonly TimeProvider _time;
private readonly INodeLog _log; private readonly INodeLog _log;
/// <summary>对等面入站方法的路由器(只读,测试用)。</summary>
public RpcRouter<PeerContext> Router => _router;
public PeerHub(SessionRegistry sessions, MonoClock clock, ChunkAssembler chunks, TaskManager tasks, BackendUplink uplink, TimeProvider time, INodeLog log) public PeerHub(SessionRegistry sessions, MonoClock clock, ChunkAssembler chunks, TaskManager tasks, BackendUplink uplink, TimeProvider time, INodeLog log)
{ {
_router = BuildRouter(); _router = BuildRouter(uplink);
_sessions = sessions; _sessions = sessions;
_clock = clock; _clock = clock;
_chunks = chunks; _chunks = chunks;
@@ -119,7 +127,7 @@ public sealed class PeerHub
var order = new Queue<Guid>(); var order = new Queue<Guid>();
rpc.OnBinary = async (frame, token) => rpc.OnBinary = async (frame, token) =>
{ {
if (!OutboxFrame.TryParse(frame, out byte channel, out Guid id, out ReadOnlyMemory<byte> payload) || channel != MoShiroChannels.ColumnBlock) if (!OutboxFrame.TryParse(frame, out byte channel, out Guid id, out ReadOnlyMemory<byte> payload) || channel != MonitorChannels.ColumnBlock)
{ {
return; return;
} }
@@ -180,7 +188,7 @@ public sealed class PeerHub
await _tasks.CloseSessionAsync(probe); await _tasks.CloseSessionAsync(probe);
_log.Info("probe disconnected", ("probe", info.PeerId)); _log.Info("probe disconnected", ("probe", info.PeerId));
OnProbeLost?.Invoke(probe); OnProbeLost?.Invoke(probe);
await _uplink.EmitEventAsync(AgentEventKinds.ProbeDisconnected, info.PeerId, Describe(probe)); await _uplink.EmitEventAsync(AgentEventKinds.ProbeDisconnected, info.PeerId);
} }
} }
@@ -212,7 +220,9 @@ public sealed class PeerHub
/// <summary>当前连着的 Probe(心跳和 Backend 重连之后的补报都用它)。</summary> /// <summary>当前连着的 Probe(心跳和 Backend 重连之后的补报都用它)。</summary>
public IReadOnlyList<ConnectedProbe> ConnectedProbes() => [.. _sessions.All.Select(Describe)]; public IReadOnlyList<ConnectedProbe> ConnectedProbes() => [.. _sessions.All.Select(Describe)];
private static ConnectedProbe Describe(ProbeSession p) => new() { Id = new ProbeId(p.ProbeId), Version = p.Version, Remote = p.RemoteAddress, SinceUtcMs = p.ConnectedAtUtcMs, IpVersion = p.IpVersion }; private static ConnectedProbe Describe(ProbeSession p) => new() { Id = new ProbeId(p.ProbeId), Version = p.Version, Remote = p.RemoteAddress, SinceUtcMs = p.ConnectedAtUtcMs };
public IReadOnlyList<string> ConnectedProbeIds() => [.. _sessions.All.Select(p => p.ProbeId)];
/// <summary>向一个 Probe 下发它的线路任务集(Probe 连上时由 <see cref="OnSessionAsync"/> 先发一份)。</summary> /// <summary>向一个 Probe 下发它的线路任务集(Probe 连上时由 <see cref="OnSessionAsync"/> 先发一份)。</summary>
public async ValueTask PushToAsync(ProbeSession probe, LineTaskSet set) => await PushAsync(probe, set, CancellationToken.None); public async ValueTask PushToAsync(ProbeSession probe, LineTaskSet set) => await PushAsync(probe, set, CancellationToken.None);
@@ -220,17 +230,13 @@ public sealed class PeerHub
/// <summary>Backend 要求断开某个 Probe(它会按退避重连,除非已经被吊销)。没有这个会话时返回 false。</summary> /// <summary>Backend 要求断开某个 Probe(它会按退避重连,除非已经被吊销)。没有这个会话时返回 false。</summary>
public async ValueTask<bool> DisconnectAsync(string probeId) public async ValueTask<bool> DisconnectAsync(string probeId)
{ {
bool disconnected = false; if (_sessions.Get(probeId)?.Rpc is not { } rpc)
foreach (ProbeSession probe in _sessions.All)
{ {
if (probe.ProbeId == probeId && probe.Rpc is { } rpc) return false;
{
await rpc.CloseAsync("disconnected", CancellationToken.None);
disconnected = true;
}
} }
return disconnected; await rpc.CloseAsync("disconnected", CancellationToken.None);
return true;
} }
/// <summary>名单(期望状态里的 peers)里已经没有的 Probe:立刻关闭它的会话(吊销生效)。</summary> /// <summary>名单(期望状态里的 peers)里已经没有的 Probe:立刻关闭它的会话(吊销生效)。</summary>
@@ -246,6 +252,27 @@ public sealed class PeerHub
} }
} }
/// <summary>Backend 的 Agent 列表变化时转交给所有 Probe(Probe 据此调整它连接的 Agent)。</summary>
public async ValueTask RelayAgentsAsync(ProbeAgentsResult list)
{
foreach (ProbeSession probe in _sessions.All)
{
if (probe.Rpc is not { } rpc)
{
continue;
}
try
{
await rpc.NotifyAsync(MonitorMethods.ProbeAgentsChanged, list, MonitorJsonContext.Default.ProbeAgentsResult, CancellationToken.None);
}
catch (Exception ex) when (ex is ObjectDisposedException or InvalidOperationException or IOException)
{
// 会话刚好结束
}
}
}
private async ValueTask PushAsync(ProbeSession probe, LineTaskSet set, CancellationToken ct) private async ValueTask PushAsync(ProbeSession probe, LineTaskSet set, CancellationToken ct)
{ {
if (probe.Rpc is null) if (probe.Rpc is null)
@@ -257,7 +284,7 @@ public sealed class PeerHub
{ {
Type = LineTaskSet.StateType, Type = LineTaskSet.StateType,
Version = set.Version, Version = set.Version,
Doc = JsonSerializer.SerializeToElement(set, MoShiroJsonContext.Default.LineTaskSet), Doc = JsonSerializer.SerializeToElement(set, MonitorJsonContext.Default.LineTaskSet),
}; };
try try
{ {
@@ -273,11 +300,16 @@ public sealed class PeerHub
} }
} }
private static RpcRouter<PeerContext> BuildRouter() => private static RpcRouter<PeerContext> BuildRouter(BackendUplink uplink) =>
new RpcRouter<PeerContext>(RpcSide.Listener, MoShiroMethodTable.Table) new RpcRouter<PeerContext>()
.On( .On(
MoShiroMethods.Clock, MonitorMethods.ProbeAgents,
MoShiroJsonContext.Default.ClockParams, MonitorJsonContext.Default.ProbeAgentsParams,
MoShiroJsonContext.Default.ClockResult, MonitorJsonContext.Default.ProbeAgentsResult,
(_, request, ct) => uplink.RequestProbeAgentsAsync(request, ct))
.On(
MonitorMethods.Clock,
MonitorJsonContext.Default.ClockParams,
MonitorJsonContext.Default.ClockResult,
(context, _, _) => ValueTask.FromResult(Result<ClockResult>.Ok(new ClockResult { AgentMonoUs = context.Clock.NowUs() }))); (context, _, _) => ValueTask.FromResult(Result<ClockResult>.Ok(new ClockResult { AgentMonoUs = context.Clock.NowUs() })));
} }
@@ -1,6 +1,6 @@
using Fleet.Core; using Fleet.Core;
using Fleet.Hosting; using Fleet.Hosting;
using MoShiro.Agent; using Monitor.Agent;
TimeProvider time = TimeProvider.System; TimeProvider time = TimeProvider.System;
INodeLog log = new ConsoleNodeLog(time); INodeLog log = new ConsoleNodeLog(time);
@@ -11,7 +11,7 @@ if (PlatformCheck.VerifyCrypto() is { } problem)
return 1; return 1;
} }
string configPath = "/etc/moshiro/agent.json"; string configPath = "/etc/monitor/agent.json";
for (int i = 0; i < args.Length - 1; i++) for (int i = 0; i < args.Length - 1; i++)
{ {
if (args[i] == "--config") if (args[i] == "--config")
@@ -22,17 +22,10 @@ for (int i = 0; i < args.Length - 1; i++)
AgentConfig config; AgentConfig config;
LoadedNodeIdentity identity; LoadedNodeIdentity identity;
RestartController restart;
try try
{ {
config = NodeConfig.Load(configPath, AgentConfigContext.Default.AgentConfig); config = NodeConfig.Load(configPath, AgentConfigContext.Default.AgentConfig);
restart = new RestartController(RestartSchedule.Resolve(config.RestartAt));
identity = LoadedNodeIdentity.Load(config.Fleet.IdentityDir); identity = LoadedNodeIdentity.Load(config.Fleet.IdentityDir);
if (!OwnerGuard.Check(config.Fleet.IdentityDir, config.Fleet.RequireOwner, log))
{
identity.Dispose();
return 1;
}
} }
catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or System.Text.Json.JsonException or System.Security.Cryptography.CryptographicException) catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or System.Text.Json.JsonException or System.Security.Cryptography.CryptographicException)
{ {
@@ -40,11 +33,10 @@ catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidO
return 1; return 1;
} }
using var restartScope = restart;
using (identity) using (identity)
{ {
await using var app = new AgentApp(config, identity, Path.GetDirectoryName(Path.GetFullPath(configPath))!, time, log, restart); using var app = new AgentApp(config, identity, time, log);
await ServiceShell.RunAsync(app.RunAsync, log, restart, time); await ServiceShell.RunAsync(app.RunAsync, log);
} }
return 0; return 0;
@@ -1,10 +1,10 @@
using System.Net; using System.Net;
using System.Text.Json; using System.Text.Json;
using Fleet.Core; using Fleet.Core;
using MoShiro.Measurement; using Monitor.Measurement;
using MoShiro.Protocol; using Monitor.Protocol;
namespace MoShiro.Agent; namespace Monitor.Agent;
/// <summary> /// <summary>
/// 目标任务(Agent 自己探测一个地址,没有对端):用和线路任务同一套积木(<see cref="BlockCatalog"/> 里的拨号方,例如 icmp_echo、connect), /// 目标任务(Agent 自己探测一个地址,没有对端):用和线路任务同一套积木(<see cref="BlockCatalog"/> 里的拨号方,例如 icmp_echo、connect),
@@ -80,7 +80,7 @@ public sealed class TargetRunners
Log = _log, Log = _log,
Session = () => null, Session = () => null,
PeerAddress = address, PeerAddress = address,
Fragments = new LocalSink(this, spec, address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 ? IpVersions.V6 : IpVersions.V4), Fragments = new LocalSink(this, spec),
}; };
IBlock block = BlockCatalog.Find(spec.Kind)!.Dialer(env, new BlockSpec(decision.Kind!, decision.Resolved!, 0, port, new FragmentScope(spec.Id, null, null))); IBlock block = BlockCatalog.Find(spec.Kind)!.Dialer(env, new BlockSpec(decision.Kind!, decision.Resolved!, 0, port, new FragmentScope(spec.Id, null, null)));
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
@@ -132,13 +132,11 @@ public sealed class TargetRunners
{ {
private readonly TargetRunners _owner; private readonly TargetRunners _owner;
private readonly TaskSpec _spec; private readonly TaskSpec _spec;
private readonly string _ipVersion;
public LocalSink(TargetRunners owner, TaskSpec spec, string ipVersion) public LocalSink(TargetRunners owner, TaskSpec spec)
{ {
_owner = owner; _owner = owner;
_spec = spec; _spec = spec;
_ipVersion = ipVersion;
} }
public void Ship(string kind, FragmentScope scope, IReadOnlyList<RawStream> streams, IReadOnlyDictionary<string, JsonElement>? parameters, long startUtcMs, int durationMs, int port) public void Ship(string kind, FragmentScope scope, IReadOnlyList<RawStream> streams, IReadOnlyDictionary<string, JsonElement>? parameters, long startUtcMs, int durationMs, int port)
@@ -151,7 +149,6 @@ public sealed class TargetRunners
TaskId = scope.Task, TaskId = scope.Task,
AgentId = _owner._agent, AgentId = _owner._agent,
Target = _spec.Target, Target = _spec.Target,
IpVersion = _ipVersion,
Params = parameters is null ? [] : new Dictionary<string, JsonElement>(parameters), Params = parameters is null ? [] : new Dictionary<string, JsonElement>(parameters),
Port = port, Port = port,
StartUtcMs = startUtcMs, StartUtcMs = startUtcMs,
@@ -1,11 +1,10 @@
using System.Text.Json; using System.Text.Json;
using Fleet.Hosting;
using Fleet.Core; using Fleet.Core;
using Fleet.Rpc; using Fleet.Rpc;
using MoShiro.Measurement; using Monitor.Measurement;
using MoShiro.Protocol; using Monitor.Protocol;
namespace MoShiro.Agent; namespace Monitor.Agent;
/// <summary> /// <summary>
/// 期望任务集(<c>task_set</c>)的调和器:每个任务先经过 <see cref="BudgetGuard"/>(参数校验 + 本地硬上限)。 /// 期望任务集(<c>task_set</c>)的调和器:每个任务先经过 <see cref="BudgetGuard"/>(参数校验 + 本地硬上限)。
@@ -24,23 +23,21 @@ public sealed class TaskManager
private readonly StreamRegistry _streams; private readonly StreamRegistry _streams;
private readonly BlockEnv _env; private readonly BlockEnv _env;
private readonly TargetRunners _targets; private readonly TargetRunners _targets;
private readonly Func<IReadOnlyList<int>> _quicPorts; private readonly Func<IReadOnlyList<int>> _udpPorts;
private readonly Func<IReadOnlyList<int>> _tcpPorts; private readonly Func<IReadOnlyList<int>> _tcpPorts;
private readonly TrustAnchor _trust;
private readonly Dictionary<ProbeSession, Dictionary<string, LineEntry>> _sessionLines = []; private readonly Dictionary<ProbeSession, Dictionary<string, LineEntry>> _sessionLines = [];
private volatile Dictionary<string, byte[]> _peers = new(StringComparer.Ordinal); private volatile Dictionary<string, byte[]> _peers = new(StringComparer.Ordinal);
private Dictionary<string, (TaskSpec Task, TaskDecision Decision)> _lineSpecs = []; private Dictionary<string, (TaskSpec Task, TaskDecision Decision)> _lineSpecs = [];
private long _version; private long _version;
private BudgetGuard? _guard; private BudgetGuard? _guard;
public TaskManager(Func<BudgetGuard> guardFactory, StreamRegistry streams, BlockEnv env, TargetRunners targets, Func<IReadOnlyList<int>> quicPorts, Func<IReadOnlyList<int>> tcpPorts, TrustAnchor? trust = null) public TaskManager(Func<BudgetGuard> guardFactory, StreamRegistry streams, BlockEnv env, TargetRunners targets, Func<IReadOnlyList<int>> udpPorts, Func<IReadOnlyList<int>> tcpPorts)
{ {
_trust = trust ?? TrustAnchor.None;
_guardFactory = guardFactory; _guardFactory = guardFactory;
_streams = streams; _streams = streams;
_env = env; _env = env;
_targets = targets; _targets = targets;
_quicPorts = quicPorts; _udpPorts = udpPorts;
_tcpPorts = tcpPorts; _tcpPorts = tcpPorts;
} }
@@ -55,9 +52,6 @@ public sealed class TaskManager
/// <summary>点对点握手时查找 Probe 的公钥(SPKI)。</summary> /// <summary>点对点握手时查找 Probe 的公钥(SPKI)。</summary>
public byte[]? LookupPeerKey(string peerId) => _peers.TryGetValue(peerId, out byte[]? key) ? key : null; public byte[]? LookupPeerKey(string peerId) => _peers.TryGetValue(peerId, out byte[]? key) ? key : null;
/// <summary>名册里全部 Probe 的公钥:敲门关卡用它们派生配对密钥。</summary>
public IReadOnlyList<byte[]> PeerKeys() => [.. _peers.Values];
/// <summary>一个新的 Probe 会话开始:为它登记全部线路任务的流并启动监听方积木,返回要下发给它的线路任务集。</summary> /// <summary>一个新的 Probe 会话开始:为它登记全部线路任务的流并启动监听方积木,返回要下发给它的线路任务集。</summary>
public LineTaskSet OpenSession(ProbeSession probe) public LineTaskSet OpenSession(ProbeSession probe)
{ {
@@ -101,8 +95,6 @@ public sealed class TaskManager
_ = ct; _ = ct;
var result = new StateSyncResult { AppliedVersion = version }; var result = new StateSyncResult { AppliedVersion = version };
// 固定了操作员创世的节点:面板带来的新版本操作员集合,从固定的创世验过才采用(名册本身由面板决定)
_trust.Update(set.TrustChain);
var peers = new Dictionary<string, byte[]>(StringComparer.Ordinal); var peers = new Dictionary<string, byte[]>(StringComparer.Ordinal);
foreach (PeerSpec peer in set.Peers) foreach (PeerSpec peer in set.Peers)
{ {
@@ -229,8 +221,7 @@ public sealed class TaskManager
{ {
Version = _version, Version = _version,
Tasks = [.. entries.Values.Select(e => e.Task)], Tasks = [.. entries.Values.Select(e => e.Task)],
QuicPorts = [.. _quicPorts()], UdpPorts = [.. _udpPorts()],
LegacyUdpPorts = [.. _quicPorts()],
TcpPorts = [.. _tcpPorts()], TcpPorts = [.. _tcpPorts()],
}; };
@@ -1,15 +1,15 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using Fleet.Core; using Fleet.Core;
using Fleet.Rpc; using Fleet.Rpc;
using MoShiro.Measurement; using Monitor.Measurement;
using MoShiro.Protocol; using Monitor.Protocol;
namespace MoShiro.Agent; namespace Monitor.Agent;
/// <summary> /// <summary>
/// 按需测试的编排(<c>moshiro.test.run</c>):Agent 是时间的主人。每个阶段: /// 按需测试的编排(<c>monitor.test.run</c>):Agent 是时间的主人。每个阶段:
/// 登记数据面的流 → <c>moshiro.test.phase</c>(Probe 准备好自己一侧的角色)→ 运行(单向数据流由 Agent 发包,回显类等待 Probe 发包) /// 登记数据面的流 → <c>monitor.test.phase</c>(Probe 准备好自己一侧的角色)→ 运行(单向数据流由 Agent 发包,回显类等待 Probe 发包)
/// → <c>moshiro.test.phase_end</c>(Probe 收尾,回复它交出的列块个数)→ 等所有列块合并上传 → <c>phase_done</c>。 /// → <c>monitor.test.phase_end</c>(Probe 收尾,回复它交出的列块个数)→ 等所有列块合并上传 → <c>phase_done</c>。
/// 同一时间只能运行 <c>max_concurrent_tests</c> 个测试;Probe 掉线或被取消时测试中止,已经上传的阶段数据保留。 /// 同一时间只能运行 <c>max_concurrent_tests</c> 个测试;Probe 掉线或被取消时测试中止,已经上传的阶段数据保留。
/// </summary> /// </summary>
public sealed class TestRunner public sealed class TestRunner
@@ -43,22 +43,17 @@ public sealed class TestRunner
public int Running => _running.Count; public int Running => _running.Count;
/// <summary>校验并接受一个测试(立即返回),实际运行在后台进行,状态通过 <c>moshiro.test.status</c> 回报。</summary> /// <summary>校验并接受一个测试(立即返回),实际运行在后台进行,状态通过 <c>monitor.test.status</c> 回报。</summary>
public Result<TestRunResult> Start(TestRunParams run) public Result<TestRunResult> Start(TestRunParams run)
{ {
if (!IpVersions.IsValid(run.IpVersion)) if (_sessions.Get(run.ProbeId.Value) is not { } probe)
{ {
return Result<TestRunResult>.Fail(MoShiroErrors.ValidationFailed, "ip_version must be ipv4 or ipv6"); return Result<TestRunResult>.Fail(MonitorErrors.ProbeOffline, $"probe {run.ProbeId.Value} is not connected to this agent");
}
if (_sessions.Get(run.ProbeId.Value, run.IpVersion) is not { } probe)
{
return Result<TestRunResult>.Fail(MoShiroErrors.ProbeOffline, $"probe {run.ProbeId.Value} is not connected to this agent");
} }
if (run.Phases.Count == 0) if (run.Phases.Count == 0)
{ {
return Result<TestRunResult>.Fail(MoShiroErrors.ValidationFailed, "a test needs at least one phase"); return Result<TestRunResult>.Fail(MonitorErrors.ValidationFailed, "a test needs at least one phase");
} }
var resolved = new List<(TestPhase Phase, TaskDecision Decision)>(); var resolved = new List<(TestPhase Phase, TaskDecision Decision)>();
@@ -69,7 +64,7 @@ public sealed class TestRunner
if (!decision.Accepted) if (!decision.Accepted)
{ {
return Result<TestRunResult>.Fail( return Result<TestRunResult>.Fail(
decision.Reason == RejectReasons.Unsupported ? FleetErrors.Unsupported : MoShiroErrors.BudgetExceeded, decision.Reason == RejectReasons.Unsupported ? FleetErrors.Unsupported : MonitorErrors.BudgetExceeded,
$"phase {phase.Kind} refused: {decision.Reason}"); $"phase {phase.Kind} refused: {decision.Reason}");
} }
@@ -89,7 +84,7 @@ public sealed class TestRunner
if (_running.Count >= _maxConcurrent || !_running.TryAdd(run.TestId.Value, running)) if (_running.Count >= _maxConcurrent || !_running.TryAdd(run.TestId.Value, running))
{ {
running.Dispose(); running.Dispose();
return Result<TestRunResult>.Fail(MoShiroErrors.TestBusy, "this agent is already running a test"); return Result<TestRunResult>.Fail(MonitorErrors.TestBusy, "this agent is already running a test");
} }
_ = Task.Run(() => RunAsync(run.TestId, resolved, running), CancellationToken.None); _ = Task.Run(() => RunAsync(run.TestId, resolved, running), CancellationToken.None);
@@ -107,7 +102,7 @@ public sealed class TestRunner
// 按需测试的数据端口由 Agent 决定:Backend 给的端口如果不是本机监听的端口(模板里是出厂默认值,每个 Agent 可以改, // 按需测试的数据端口由 Agent 决定:Backend 给的端口如果不是本机监听的端口(模板里是出厂默认值,每个 Agent 可以改,
// 而 Backend 并不知道 TCP 数据端口),就用本机监听的第一个端口。块头里记录的是实际用的端口。 // 而 Backend 并不知道 TCP 数据端口),就用本机监听的第一个端口。块头里记录的是实际用的端口。
IReadOnlyList<int> ports = _ports(kind.Blocks.Transport == "quic_datagram" ? "quic_datagram" : "tcp"); IReadOnlyList<int> ports = _ports(kind.Blocks.Transport == "udp" ? "udp" : "tcp");
bool listening = phase.Params.TryGetValue("port", out System.Text.Json.JsonElement requested) && requested.TryGetInt32(out int port) && ports.Contains(port); bool listening = phase.Params.TryGetValue("port", out System.Text.Json.JsonElement requested) && requested.TryGetInt32(out int port) && ports.Contains(port);
if (!listening) if (!listening)
{ {
@@ -159,7 +154,7 @@ public sealed class TestRunner
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
finalStatus = running.Lost ? TestStatuses.Failed : TestStatuses.Aborted; finalStatus = running.Lost ? TestStatuses.Failed : TestStatuses.Aborted;
errorCode = running.Lost ? MoShiroErrors.ProbeOffline.Value : null; errorCode = running.Lost ? MonitorErrors.ProbeOffline.Value : null;
await TryAbortProbeAsync(running.Probe, test); await TryAbortProbeAsync(running.Probe, test);
} }
catch (TestFailure failure) catch (TestFailure failure)
@@ -188,7 +183,7 @@ public sealed class TestRunner
_streams.Add(stream); _streams.Add(stream);
try try
{ {
RpcSession<PeerContext> rpc = probe.Rpc ?? throw new TestFailure(MoShiroErrors.ProbeOffline.Value); RpcSession<PeerContext> rpc = probe.Rpc ?? throw new TestFailure(MonitorErrors.ProbeOffline.Value);
var start = new TestPhaseStartParams var start = new TestPhaseStartParams
{ {
TestId = test, TestId = test,
@@ -199,7 +194,7 @@ public sealed class TestRunner
DurationSeconds = durationSeconds, DurationSeconds = durationSeconds,
Port = stream.Port, Port = stream.Port,
}; };
Result<TestPhaseStartResult> accepted = await rpc.RequestAsync(MoShiroMethods.TestPhase, start, MoShiroJsonContext.Default.TestPhaseStartParams, MoShiroJsonContext.Default.TestPhaseStartResult, ct); Result<TestPhaseStartResult> accepted = await rpc.RequestAsync(MonitorMethods.TestPhase, start, MonitorJsonContext.Default.TestPhaseStartParams, MonitorJsonContext.Default.TestPhaseStartResult, ct);
if (!accepted.TryGet(out TestPhaseStartResult? started, out FleetError? error) || !started.Accepted) if (!accepted.TryGet(out TestPhaseStartResult? started, out FleetError? error) || !started.Accepted)
{ {
throw new TestFailure(error?.Code.Value ?? FleetErrors.Unsupported.Value); throw new TestFailure(error?.Code.Value ?? FleetErrors.Unsupported.Value);
@@ -214,7 +209,7 @@ public sealed class TestRunner
} }
catch (BlockFaultException) catch (BlockFaultException)
{ {
throw new TestFailure(MoShiroErrors.ProbeOffline.Value); throw new TestFailure(MonitorErrors.ProbeOffline.Value);
} }
} }
else else
@@ -224,7 +219,7 @@ public sealed class TestRunner
long sent = stream.Running.Block.Produced; long sent = stream.Running.Block.Produced;
var end = new TestPhaseEndParams { TestId = test, Phase = index, StreamId = stream.Id, SentCount = sent }; var end = new TestPhaseEndParams { TestId = test, Phase = index, StreamId = stream.Id, SentCount = sent };
Result<TestPhaseEndResult> ended = await rpc.RequestAsync(MoShiroMethods.TestPhaseEnd, end, MoShiroJsonContext.Default.TestPhaseEndParams, MoShiroJsonContext.Default.TestPhaseEndResult, ct, s_phaseSlack); Result<TestPhaseEndResult> ended = await rpc.RequestAsync(MonitorMethods.TestPhaseEnd, end, MonitorJsonContext.Default.TestPhaseEndParams, MonitorJsonContext.Default.TestPhaseEndResult, ct, s_phaseSlack);
if (!ended.TryGet(out TestPhaseEndResult? finished, out FleetError? endError)) if (!ended.TryGet(out TestPhaseEndResult? finished, out FleetError? endError))
{ {
throw new TestFailure(endError.Code.Value); throw new TestFailure(endError.Code.Value);
@@ -259,7 +254,7 @@ public sealed class TestRunner
try try
{ {
await rpc.NotifyAsync(MoShiroMethods.TestAbort, new TestAbortParams { TestId = test }, MoShiroJsonContext.Default.TestAbortParams, CancellationToken.None); await rpc.NotifyAsync(MonitorMethods.TestAbort, new TestAbortParams { TestId = test }, MonitorJsonContext.Default.TestAbortParams, CancellationToken.None);
} }
catch (Exception ex) when (ex is ObjectDisposedException or InvalidOperationException or IOException) catch (Exception ex) when (ex is ObjectDisposedException or InvalidOperationException or IOException)
{ {
-105
View File
@@ -1,105 +0,0 @@
# Fleet:通用节点框架
「很多节点连到一个面板」这类产品都要解决同一批问题:节点怎么接入、怎么证明身份、怎么保持连接、怎么可靠上报、怎么接收配置。Fleet 把这些做成与业务无关的库,MoShiro 是它的第一个使用者;产品只写业务。Fleet 不引用任何 `MoShiro.*`,也不出现产品的名字、端口和协议前缀,由 `ArchitectureTests` 在构建时检查。
```text
┌──────────────────── 产品层(MoShiro:Protocol · Measurement · Agent · Probe · Backend)────────────────────┐
└────────────────────────────────────────────┬─────────────────────────────────────────────────────────────┘
│ 只依赖 Fleet
┌───────────────────────────── Fleet ────────┴─────────────────────────────────────────────────────────────┐
│ 节点侧(只依赖 BCL,NativeAOT) 服务端侧(ASP.NET Core) │
│ Fleet.Core Result、退避、有界队列、ID、令牌哈希 Fleet.Server.Abstractions IFleetStore 等接口 │
│ Fleet.Identity 密钥、签名、证书、KeyStore Fleet.Server.Enrollment 加入令牌、注册、审批 │
│ Fleet.Rpc JSON-RPC 信封、路由、二进制帧 Fleet.Server.Link wss 网关、在线表、状态下发 │
│ Fleet.Link 会话、认证、重连、发件箱、状态同步、点对点 │
│ Fleet.Hosting 模块宿主、配置、日志、服务外壳 工具:fleet-enroll(一次性注册) │
│ Fleet.Enrollment.Client 注册流程(给 fleet-enroll 用) │
└────────────────────────────────────────────────────────────────────────────────────────────────────────┘
```
## 设计原则
1. **与业务无关**:产品通过扩展点接入:元数据类型、RPC 方法、发件箱消息类型、期望状态文档类型。
2. **安全默认值**:Fleet 永远不提供 exec、shell、文件读写、远程下发代码这类方法;节点只响应本地注册过的方法(本地策略 `allow_methods` 还能收窄);调用对方不支持的方法在本地直接返回 `fleet_unsupported`。
3. **权限分离**:注册(`fleet-enroll`,一次性)和常驻业务进程是两个进程;常驻进程不含注册代码,也不使用 `HttpClient`。
4. **NativeAOT**:节点侧只依赖 BCL,泛型扩展点通过产品传入的 `JsonTypeInfo<T>` 序列化,不使用反射。
5. **更新不在 Fleet 里**:节点进程没有自更新能力。Quadlet 装的节点由退出后 systemd 重新拉镜像、Backend 和脚本装的节点由宿主机上的更新器验签更新,见 [`docs/ops/deployment.md`](../../docs/ops/deployment.md);纯二进制节点的更新是待办,见 [`TODO.md`](../../TODO.md)。
## 身份
| 对象 | 算法 | 保存位置 |
| --- | --- | --- |
| 节点身份密钥 | ECDSA P-256(BCL 自带) | `<identity_dir>/node.key`(0600);服务端只保存公钥 |
| 点对点 TLS 证书 | 自签名 ECDSA P-256 | `<identity_dir>/tls.pfx`;对端按 SPKI SHA-256 指纹校验 |
| 签名串 | `"fleet|v1|<purpose>|" + 各字段` | 带用途前缀,防止同一个签名在不同场景被复用 |
用途:`auth`(连接服务端)、`peer`(点对点)、`enroll`(注册时的持有证明)。产品可以追加自己的用途,不能占用 `fleet` 保留的。
## 注册
```text
管理员 ── 创建加入令牌 {labels, max_uses, expires_at, auto_approve} ──> 服务端
<── 令牌明文(只返回一次)+ 安装命令
节点:fleet-enroll --server https://panel --join jt_xxx --name jp-1 --identity-dir /etc/myapp
├─ 生成身份密钥和 TLS 证书
├─ POST 注册端点 { join_token, name, pubkey, tls_spki, proof = 签名("enroll", 令牌哈希 + pubkey), node_version, meta }
│ 服务端:校验令牌 → uses+1 → 创建节点(active 或 pending)→ 触发产品的 IEnrollmentHook
└─ 写入 <identity_dir>/node.json { node_id, server, … },然后退出
```
- 同名节点已存在时,新注册进入 `pending`,管理员选择「接管」(旧记录换成新公钥,历史数据保留)或「作为新节点」。
- 节点状态:`pending`(能连接,但不下发状态、不接收上报)、`active`、`disabled`(拒绝连接)。
## RPC
- 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 节。
- 与传输无关:同一套路由跑在 wss 上,也跑在 TLS 帧(`u32 长度 | u8 类型 | 正文`)上。
| 保留方法 | 方向 | 说明 |
| --- | --- | --- |
| `fleet.challenge` / `fleet.auth` | 服务端→节点 / 节点→服务端 | 签名认证(`auth` 用途,nonce 30 秒内有效) |
| `fleet.hello` | 节点→服务端 | 版本、能力、本地策略摘要、当前时间 → 返回时钟偏差;产品的元数据也放在这里 |
| `fleet.heartbeat` | 节点→服务端 | 每 10 秒一次,产品可以附加计数 |
| `fleet.stopping` | 节点→服务端 | 有序关闭前通知服务端,携带 `reason`(例如 `stopping` 或 `updating`),用于准确区分离线和计划停机 |
| `fleet.state.sync` | 服务端→节点 | `{version, doc}` → `{applied_version, rejected[]}` |
| `fleet.outbox.ack` | 服务端→节点 | `{ids[], duplicates[]}` |
| `fleet.revoked` | 服务端→节点 | 节点被禁用或接管,随后断开 |
## 连接
```text
connect(wss) → fleet.challenge → fleet.auth → fleet.hello → 状态同步 → 正常运行
▲ │
└──── 指数退避重连(1 s → 60 s,带抖动;401 或 revoked 时停止重试)◀─ 断开
```
本地策略:`allow_methods`、`max_frame_bytes`、`max_inflight`。
**可靠发件箱**:产品 `EnqueueAsync` → 有界队列(按字节和条数;满时 `DropOldest` 或 `Reject`)→ 发送 → 服务端收件箱(`IInbox`)持久化后 `fleet.outbox.ack` → 移除。至少一次,按 `msg_id`(UUIDv7)去重;在途数据默认限 4 MiB;丢弃会累计缺口,通过 `OnGap` 通知产品。只在内存里,不落盘。
**期望状态同步**:服务端 `IStateProvider<TDoc>` 给每个节点算 `{version, doc}`,变化时 `NotifyChanged` 推送;节点 `StateHandler<TDoc>` 调和后返回 `{applied_version, rejected[]}`。下发的是**完整快照**,天然幂等,重连后服务端总会重发当前版本。`rejected[]` 带结构化原因(`unsupported`、`budget_exceeded`、`policy_denied`),面板可以直接展示。
## 点对点会话
```text
节点 A(发起方,如 Probe)── TLS(按 SPKI 校验证书)──> 节点 B(监听方,如 Agent)
<── fleet.peer.challenge ──
── fleet.peer.auth(peer 用途,A 的私钥签名)──>
B 用服务端下发的对端公钥列表验签(经 state.sync 下发),之后复用同一套 Rpc:路由、二进制通道、发件箱
```
握手用两个方法:`fleet.peer.challenge`(监听方→拨号方,通知)和 `fleet.peer.auth`(拨号方→监听方,请求);握手完成后,两侧各自只能发起声明表里属于自己这一侧的方法。
对端公钥和允许连接的节点由产品通过期望状态下发给监听方,Fleet 只管认证和会话,拓扑由产品决定;UDP、原始 TCP 数据面由产品自己实现,Fleet 只提供会话密钥派生和首帧 MAC 的辅助函数。
## 宿主与服务端抽象
- `ModuleHost`:按依赖顺序启动模块、相反顺序停止,隔离故障,重启时退避。
- `NodeConfig`:源生成 JSON 读取,环境变量覆盖,启动时校验。
- `ConfigStore`:常驻进程里**唯一**允许写文件的地方,只能写配置目录,写入原子替换、权限 0600;父目录不存在会创建。
- `ServiceShell`:SIGTERM、Ctrl+C,systemd `sd_notify`、OpenWrt procd(前台运行)和 Windows 服务。
- 服务端接口:`IFleetStore`(节点、令牌、公钥、状态版本)、`IStateProvider<TDoc>`、`IInbox`、`INodeEventSink`、`IEnrollmentHook`。MoShiro 把 `IFleetStore` 放进自己的 `moshiro.db`,和业务数据由同一个单写入者写入。
@@ -13,8 +13,6 @@ public static class FleetErrors
public static readonly ErrorCode Unauthorized = new("fleet_unauthorized"); public static readonly ErrorCode Unauthorized = new("fleet_unauthorized");
public static readonly ErrorCode Forbidden = new("fleet_forbidden"); public static readonly ErrorCode Forbidden = new("fleet_forbidden");
public static readonly ErrorCode Unsupported = new("fleet_unsupported"); public static readonly ErrorCode Unsupported = new("fleet_unsupported");
/// <summary>方法没有声明、方向不对、类型不对或角色不允许:对方违反了协议。</summary>
public static readonly ErrorCode ProtocolViolation = new("fleet_protocol_violation");
public static readonly ErrorCode InvalidParams = new("fleet_invalid_params"); public static readonly ErrorCode InvalidParams = new("fleet_invalid_params");
public static readonly ErrorCode NotFound = new("fleet_not_found"); public static readonly ErrorCode NotFound = new("fleet_not_found");
public static readonly ErrorCode Internal = new("fleet_internal"); public static readonly ErrorCode Internal = new("fleet_internal");
+1 -1
View File
@@ -3,7 +3,7 @@ using System.Text.Json.Serialization;
namespace Fleet.Core; namespace Fleet.Core;
/// <summary>节点 ID(带类型前缀的 ULID)。MoShiro 中的 AgentId 就是 NodeId。</summary> /// <summary>节点 ID(带类型前缀的 ULID)。Monitor 中的 AgentId 就是 NodeId。</summary>
[JsonConverter(typeof(NodeIdJsonConverter))] [JsonConverter(typeof(NodeIdJsonConverter))]
public readonly record struct NodeId(string Value) public readonly record struct NodeId(string Value)
{ {
-29
View File
@@ -1,29 +0,0 @@
using System.Text.Json.Serialization;
namespace Fleet.Core;
/// <summary>passkey 对一段语句签出的断言(字段都是 base64url)。信任根的格式和验证在 Fleet.Identity 的 <c>OperatorTrust</c>。</summary>
public sealed class SignedStatementDto
{
[JsonPropertyName("statement")]
public string Statement { get; set; } = "";
[JsonPropertyName("credential_id")]
public string CredentialId { get; set; } = "";
[JsonPropertyName("authenticator_data")]
public string AuthenticatorData { get; set; } = "";
[JsonPropertyName("client_data_json")]
public string ClientDataJson { get; set; } = "";
[JsonPropertyName("signature")]
public string Signature { get; set; } = "";
}
/// <summary>操作员集合的链(创世在前):节点注册时取来,从固定的创世指纹验过才采用。</summary>
public sealed class TrustBundle
{
[JsonPropertyName("chain")]
public List<SignedStatementDto> Chain { get; set; } = [];
}
+12
View File
@@ -51,4 +51,16 @@ public static class VarInt
return false; return false;
} }
public static bool TryReadUInt32LittleEndian(ReadOnlySpan<byte> source, out uint value)
{
if (source.Length < 4)
{
value = 0;
return false;
}
value = BinaryPrimitives.ReadUInt32LittleEndian(source);
return true;
}
} }
@@ -2,17 +2,13 @@ using System.Security.Cryptography;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using Fleet.Core; using Fleet.Core;
using Fleet.Hosting; using Fleet.Hosting;
using Fleet.Identity; using Fleet.Identity;
namespace Fleet.Enrollment.Client; namespace Fleet.Enrollment.Client;
[JsonSerializable(typeof(TrustBundle))] public sealed record EnrollmentResult(string NodeId, string Status, string IdentityDirectory);
internal sealed partial class TrustBundleContext : JsonSerializerContext;
public sealed record EnrollmentResult(string NodeId, string Status, string IdentityDirectory, bool Pinned = false);
/// <summary> /// <summary>
/// 用加入令牌注册(供 <c>fleet-enroll</c> 使用,也可以嵌入产品自己的安装工具): /// 用加入令牌注册(供 <c>fleet-enroll</c> 使用,也可以嵌入产品自己的安装工具):
@@ -22,8 +18,7 @@ public sealed record EnrollmentResult(string NodeId, string Status, string Ident
public static class EnrollmentClient public static class EnrollmentClient
{ {
public static async Task<Result<EnrollmentResult>> EnrollAsync( public static async Task<Result<EnrollmentResult>> EnrollAsync(
HttpClient http, Uri server, string joinToken, string name, string identityDirectory, string nodeVersion, JsonNode? productMeta, TimeProvider time, CancellationToken ct, HttpClient http, Uri server, string joinToken, string name, string identityDirectory, string nodeVersion, JsonNode? productMeta, TimeProvider time, CancellationToken ct)
string? genesis = null, IReadOnlyList<string>? controllers = null)
{ {
string nodeFile = Path.Combine(identityDirectory, LoadedNodeIdentity.NodeFileName); string nodeFile = Path.Combine(identityDirectory, LoadedNodeIdentity.NodeFileName);
if (File.Exists(nodeFile)) if (File.Exists(nodeFile))
@@ -34,29 +29,9 @@ public static class EnrollmentClient
using NodeIdentity identity = NodeIdentity.Generate(); using NodeIdentity identity = NodeIdentity.Generate();
byte[] pfx = NodeCertificate.CreateSelfSignedPfx(name, TimeSpan.FromDays(3650), time); byte[] pfx = NodeCertificate.CreateSelfSignedPfx(name, TimeSpan.FromDays(3650), time);
byte[] spki; byte[] spki;
byte[] tlsPublicKey;
using (var certificate = NodeCertificate.LoadPfx(pfx)) using (var certificate = NodeCertificate.LoadPfx(pfx))
{ {
spki = NodeCertificate.SpkiFingerprint(certificate); spki = NodeCertificate.SpkiFingerprint(certificate);
tlsPublicKey = certificate.PublicKey.ExportSubjectPublicKeyInfo();
}
// 安装命令里带了创世指纹(--genesis)和属主(--controllers):先取操作员集合链,从固定的创世验起,
// 属主必须是集合里的凭证;对不上就不注册,也不把自己的密钥交出去。通过之后固定到本地 trust.json,Backend 之后改不了。
TrustBundle? bundle = null;
if (genesis is not null)
{
if (controllers is not { Count: > 0 })
{
// 每个节点都必须有属主:没有属主的节点没有人能授权它的敏感操作
return Result<EnrollmentResult>.Fail(FleetErrors.InvalidParams, "an owner is required: pass the passkey credential ids from the panel's command (--controllers)");
}
Result<TrustBundle> trusted = await FetchTrustAsync(http, server, joinToken, genesis, controllers ?? [], ct);
if (!trusted.TryGet(out bundle, out FleetError? trustError))
{
return Result<EnrollmentResult>.Fail(trustError);
}
} }
string pubkey = Convert.ToBase64String(identity.PublicKeySpki); string pubkey = Convert.ToBase64String(identity.PublicKeySpki);
@@ -67,7 +42,6 @@ public static class EnrollmentClient
["name"] = name, ["name"] = name,
["pubkey"] = pubkey, ["pubkey"] = pubkey,
["tls_spki"] = Convert.ToBase64String(spki), ["tls_spki"] = Convert.ToBase64String(spki),
["tls_pubkey"] = Convert.ToBase64String(tlsPublicKey),
["proof"] = Convert.ToBase64String(identity.Sign(SignaturePurpose.Enroll, tokenHash, pubkey)), ["proof"] = Convert.ToBase64String(identity.Sign(SignaturePurpose.Enroll, tokenHash, pubkey)),
["node_version"] = nodeVersion, ["node_version"] = nodeVersion,
["meta"] = productMeta, ["meta"] = productMeta,
@@ -99,46 +73,10 @@ public static class EnrollmentClient
var store = new ConfigStore(identityDirectory); var store = new ConfigStore(identityDirectory);
store.Write(LoadedNodeIdentity.KeyFileName, identity.ExportPkcs8()); store.Write(LoadedNodeIdentity.KeyFileName, identity.ExportPkcs8());
store.Write(LoadedNodeIdentity.TlsFileName, pfx); store.Write(LoadedNodeIdentity.TlsFileName, pfx);
if (bundle is not null)
{
TrustAnchor.Pin(identityDirectory, genesis!, bundle.Chain, controllers ?? []);
}
// node.json 最后写:它存在就表示注册完整完成 // node.json 最后写:它存在就表示注册完整完成
store.Write( store.Write(
LoadedNodeIdentity.NodeFileName, LoadedNodeIdentity.NodeFileName,
JsonSerializer.SerializeToUtf8Bytes(new NodeFile { NodeId = nodeId, Server = server.ToString().TrimEnd('/'), TlsSpki = Convert.ToBase64String(spki) }, NodeFileContext.Default.NodeFile)); JsonSerializer.SerializeToUtf8Bytes(new NodeFile { NodeId = nodeId, Server = server.ToString().TrimEnd('/'), TlsSpki = Convert.ToBase64String(spki) }, NodeFileContext.Default.NodeFile));
return Result<EnrollmentResult>.Ok(new EnrollmentResult(nodeId, result["status"]!.GetValue<string>(), identityDirectory, bundle is not null)); return Result<EnrollmentResult>.Ok(new EnrollmentResult(nodeId, result["status"]!.GetValue<string>(), identityDirectory));
}
private static async Task<Result<TrustBundle>> FetchTrustAsync(HttpClient http, Uri server, string joinToken, string genesis, IReadOnlyList<string> controllers, CancellationToken ct)
{
static Result<TrustBundle> Refuse(string reason) => Result<TrustBundle>.Fail(FleetErrors.ProofInvalid, "refusing to enroll: " + reason);
using var request = new StringContent(new JsonObject { ["join_token"] = joinToken }.ToJsonString(), Encoding.UTF8, "application/json");
using HttpResponseMessage response = await http.PostAsync(new Uri(server, "/api/agent/v1/enroll/trust"), request, ct);
if (!response.IsSuccessStatusCode)
{
return Result<TrustBundle>.Fail(FleetErrors.JoinTokenInvalid, $"{(int)response.StatusCode}: the panel would not give the trust chain");
}
TrustBundle? bundle = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync(ct), TrustBundleContext.Default.TrustBundle);
if (bundle is null || bundle.Chain.Count == 0)
{
return Refuse("the panel has no operator trust root set up");
}
OperatorSet? current = OperatorTrust.VerifyChain(genesis, [.. bundle.Chain.Select(c => c.ToSigned())]);
if (current is null)
{
return Refuse("the panel's operator set does not match the pinned genesis fingerprint (wrong panel, or the panel was tampered with)");
}
if (controllers.Any(c => current.Keys.All(k => k.CredentialId != c)))
{
return Refuse("an owner passkey in the install command is not in the operator set");
}
return Result<TrustBundle>.Ok(bundle);
} }
} }
+2 -54
View File
@@ -126,8 +126,7 @@ public static class PlatformCheck
/// <summary>处理 Ctrl+C 和 SIGTERM:取消令牌,让各个模块有序退出。</summary> /// <summary>处理 Ctrl+C 和 SIGTERM:取消令牌,让各个模块有序退出。</summary>
public static class ServiceShell public static class ServiceShell
{ {
/// <param name="restart">每天定时有序退出一次(见 <see cref="RestartController"/>),重新拉起由服务管理器负责。</param> public static async Task RunAsync(Func<CancellationToken, Task> run, INodeLog log)
public static async Task RunAsync(Func<CancellationToken, Task> run, INodeLog log, RestartController? restart = null, TimeProvider? time = null)
{ {
using var cts = new CancellationTokenSource(); using var cts = new CancellationTokenSource();
using PosixSignalRegistration term = PosixSignalRegistration.Create(PosixSignal.SIGTERM, context => using PosixSignalRegistration term = PosixSignalRegistration.Create(PosixSignal.SIGTERM, context =>
@@ -157,7 +156,6 @@ public static class ServiceShell
cts.Cancel(); cts.Cancel();
}); });
Task trimmer = TrimHeapAsync(cts.Token); Task trimmer = TrimHeapAsync(cts.Token);
Task scheduled = restart is null ? Task.CompletedTask : WaitForRestartAsync(restart, time ?? TimeProvider.System, log, cts);
try try
{ {
await run(cts.Token); await run(cts.Token);
@@ -170,45 +168,6 @@ public static class ServiceShell
{ {
await cts.CancelAsync(); await cts.CancelAsync();
await trimmer; await trimmer;
await scheduled;
}
}
private static async Task WaitForRestartAsync(RestartController restart, TimeProvider time, INodeLog log, CancellationTokenSource cts)
{
while (!cts.IsCancellationRequested)
{
// 先取令牌、再取设置:中间被改动的话令牌会被取消,下一轮重新排
CancellationToken changed = restart.ChangeToken;
RestartSchedule? schedule = restart.Current;
using CancellationTokenSource wait = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, changed, restart.NowToken);
try
{
if (schedule is null)
{
await Task.Delay(Timeout.Infinite, wait.Token);
continue;
}
DateTimeOffset next = schedule.NextAfter(time.GetUtcNow() + RestartSchedule.MinimumUptime);
log.Info("restart scheduled", ("at", next.ToString("O", System.Globalization.CultureInfo.InvariantCulture)), ("source", restart.IsLocal ? "local" : "panel"));
await Task.Delay(next - time.GetUtcNow(), time, wait.Token);
}
catch (OperationCanceledException)
{
if (restart.NowReason is { } reason)
{
log.Info("stopping", ("result", reason));
await cts.CancelAsync();
return;
}
continue; // 进程在退出,或者设置被改了:循环条件和下一轮会处理
}
log.Info("stopping", ("result", "scheduled_restart"));
await cts.CancelAsync();
return;
} }
} }
@@ -284,8 +243,7 @@ public sealed class ConfigStore
throw new InvalidOperationException("config files must stay inside the config directory"); throw new InvalidOperationException("config files must stay inside the config directory");
} }
// 相对路径里的子目录(例如 update/)也要建:新建的命名卷里只有镜像自带的目录 Directory.CreateDirectory(_directory);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
string temp = path + ".tmp"; string temp = path + ".tmp";
#pragma warning disable RS0030 // 纯采集边界的唯一豁免点:ConfigStore 只写配置目录,先写临时文件再原子替换 #pragma warning disable RS0030 // 纯采集边界的唯一豁免点:ConfigStore 只写配置目录,先写临时文件再原子替换
File.WriteAllBytes(temp, content); File.WriteAllBytes(temp, content);
@@ -338,15 +296,6 @@ public sealed class LoadedNodeIdentity : IDisposable
public X509Certificate2 Certificate { get; } public X509Certificate2 Certificate { get; }
/// <summary>
/// 对等面的签名身份:TLS 证书的那把密钥,和连服务端用的 <see cref="Identity"/> 是两把独立的密钥,
/// 各自只用于自己的用途(一把泄露不影响另一把)。
/// </summary>
public NodeIdentity PeerIdentity => _peerIdentity ??= NodeIdentity.FromPkcs8(
(Certificate.GetECDsaPrivateKey() ?? throw new InvalidDataException("the node certificate has no ECDSA private key")).ExportPkcs8PrivateKey());
private NodeIdentity? _peerIdentity;
public static LoadedNodeIdentity Load(string directory) public static LoadedNodeIdentity Load(string directory)
{ {
if (!System.IO.File.Exists(Path.Combine(directory, KeyFileName)) || !System.IO.File.Exists(Path.Combine(directory, TlsFileName))) if (!System.IO.File.Exists(Path.Combine(directory, KeyFileName)) || !System.IO.File.Exists(Path.Combine(directory, TlsFileName)))
@@ -363,7 +312,6 @@ public sealed class LoadedNodeIdentity : IDisposable
public void Dispose() public void Dispose()
{ {
Identity.Dispose(); Identity.Dispose();
_peerIdentity?.Dispose();
Certificate.Dispose(); Certificate.Dispose();
} }
} }
@@ -1,48 +0,0 @@
using Fleet.Core;
namespace Fleet.Hosting;
/// <summary>
/// 节点自己要求有属主(passkey):Agent 和 Probe 共用这一份,启动时检查。
/// <para>
/// 属主是安装命令带来、注册时固定在 <c>trust.json</c> 里的 passkey 凭证 id(必须是操作员集合里的凭证)。节点据此知道自己绑定在哪个 passkey 上,
/// 才敢把更多的接口和操作交给它(extension 会话、加密内容都只认属主的 passkey);没有属主的节点没有人能授权它的敏感能力,所以不运行。
/// 这是节点本地的强制,不依赖面板:面板被攻破也改不了节点已经固定的属主。
/// </para>
/// </summary>
public static class OwnerGuard
{
/// <summary>设为 <c>1</c> 放行没有属主的节点(只给没有信任根的老面板)。</summary>
public const string AllowEnvironmentVariable = "MOSHIRO_ALLOW_NO_OWNER";
/// <summary>没问题返回 null;否则是要告诉运维的原因。</summary>
public static string? Problem(TrustAnchor trust, bool require)
{
if (!require || Environment.GetEnvironmentVariable(AllowEnvironmentVariable) == "1")
{
return null;
}
if (!trust.Pinned)
{
return "this node has no owner: it was not enrolled with a trust root and owner passkeys; enroll it again with the command from the panel (or set " + AllowEnvironmentVariable + "=1 for a panel without a trust root)";
}
return trust.Controllers.Count == 0
? "none of this node's owner passkeys is in the operator set any more; enroll it again with a command from the panel"
: null;
}
/// <summary>读身份目录里固定的信任根,检查属主;不行就记一条错误并返回 false(调用方应当退出)。</summary>
public static bool Check(string identityDirectory, bool require, INodeLog log)
{
TrustAnchor trust = TrustAnchor.Load(identityDirectory, log);
if (Problem(trust, require) is { } problem)
{
log.Error("startup failed", ("err", problem));
return false;
}
return true;
}
}
@@ -1,102 +0,0 @@
namespace Fleet.Hosting;
/// <summary>
/// 当前生效的定时重启:节点本地的设置优先;本地没有写时用面板下发的,面板改了之后立即重新排下一次,不需要重启进程。
/// </summary>
public sealed class RestartController : IDisposable
{
private readonly RestartSchedule? _local;
private readonly object _gate = new();
private RestartSchedule? _remote;
private readonly CancellationTokenSource _now = new();
private CancellationTokenSource _changed = new();
private string? _nowReason;
public RestartController(RestartSchedule? local = null)
{
_local = local;
}
/// <summary>本地有设置时为 true:面板下发的不生效。</summary>
public bool IsLocal => _local is not null;
public RestartSchedule? Current
{
get
{
lock (_gate)
{
return _local ?? _remote;
}
}
}
/// <summary>当前设置被改动时会被取消的令牌;先取令牌、再取 <see cref="Current"/>,就不会漏掉中间的改动。</summary>
public CancellationToken ChangeToken
{
get
{
lock (_gate)
{
return _changed.Token;
}
}
}
/// <summary>应用要求现在就有序退出(比如管理员请求更新);退出的原因,没有就是 null。</summary>
public string? NowReason
{
get
{
lock (_gate)
{
return _nowReason;
}
}
}
/// <summary>应用要求现在就退出的令牌。</summary>
public CancellationToken NowToken => _now.Token;
/// <summary>要求进程现在就有序退出,由服务管理器拉起;不管有没有设置定时重启。</summary>
public void RequestNow(string reason)
{
lock (_gate)
{
_nowReason ??= reason;
}
_now.Cancel();
}
/// <summary>面板下发的设置;为空表示面板关闭了定时重启。返回 false 表示文本不合法(节点忽略它)。</summary>
public bool SetRemote(string? text)
{
RestartSchedule? schedule = null;
if (!string.IsNullOrWhiteSpace(text) && !RestartSchedule.TryParse(text, out schedule))
{
return false;
}
CancellationTokenSource previous;
lock (_gate)
{
_remote = schedule;
previous = _changed;
_changed = new CancellationTokenSource();
}
previous.Cancel();
previous.Dispose();
return true;
}
public void Dispose()
{
lock (_gate)
{
_changed.Dispose();
_now.Dispose();
}
}
}
@@ -1,105 +0,0 @@
using System.Globalization;
namespace Fleet.Hosting;
/// <summary>
/// 每天固定时刻有序退出一次,由 systemd、容器引擎或 procd 的「总是重启」拉起。写法 <c>HH:mm</c>(按 UTC)、
/// <c>HH:mm±hh:mm</c> 或 <c>HH:mm</c> 加 <c>Z</c>:容器里没有时区数据库,所以偏移要写明,例如 <c>04:00+08:00</c>。
/// 进程自己退出,不需要任何执行外部命令的能力。
/// </summary>
public sealed class RestartSchedule
{
/// <summary>环境变量,设置了就覆盖配置文件里的值。</summary>
public const string EnvironmentVariable = "MOSHIRO_RESTART_AT";
/// <summary>刚启动的一段时间内不会触发:重启之后不会因为时刻还没过去而再重启一次,也避免重启风暴。</summary>
public static readonly TimeSpan MinimumUptime = TimeSpan.FromMinutes(10);
private readonly TimeSpan _time;
private readonly TimeSpan _offset;
private RestartSchedule(TimeSpan time, TimeSpan offset)
{
_time = time;
_offset = offset;
}
public static bool TryParse(string? text, out RestartSchedule? schedule)
{
schedule = null;
string value = text?.Trim() ?? "";
// HH:mm,后面可以跟 Z 或 ±hh:mm
if (value.Length < 5 || value[2] != ':' ||
!TryTwoDigits(value, 0, out int hour) || !TryTwoDigits(value, 3, out int minute) || hour > 23 || minute > 59)
{
return false;
}
TimeSpan offset = TimeSpan.Zero;
string rest = value[5..];
if (rest.Length > 0 && rest != "Z")
{
if (rest.Length != 6 || rest[0] is not ('+' or '-') || rest[3] != ':' ||
!TryTwoDigits(rest, 1, out int offsetHours) || !TryTwoDigits(rest, 4, out int offsetMinutes) || offsetMinutes > 59)
{
return false;
}
offset = new TimeSpan(offsetHours, offsetMinutes, 0);
if (offset > TimeSpan.FromHours(14))
{
return false;
}
if (rest[0] == '-')
{
offset = -offset;
}
}
schedule = new RestartSchedule(new TimeSpan(hour, minute, 0), offset);
return true;
}
private static bool TryTwoDigits(string text, int start, out int value)
{
value = 0;
if (!char.IsAsciiDigit(text[start]) || !char.IsAsciiDigit(text[start + 1]))
{
return false;
}
value = (text[start] - '0') * 10 + (text[start + 1] - '0');
return true;
}
/// <summary>
/// 环境变量优先,其次配置文件的值;都没有就不定时重启。写错了抛 <see cref="InvalidOperationException"/>,
/// 启动时就能看到,而不是悄悄不生效。
/// </summary>
public static RestartSchedule? Resolve(string? configured)
{
string? text = Environment.GetEnvironmentVariable(EnvironmentVariable);
if (string.IsNullOrWhiteSpace(text))
{
text = configured;
}
if (string.IsNullOrWhiteSpace(text))
{
return null;
}
return TryParse(text, out RestartSchedule? schedule)
? schedule
: throw new InvalidOperationException($"restart_at must look like 04:00, 04:00Z or 04:00+08:00 (got '{text}')");
}
/// <summary>严格晚于 <paramref name="now"/> 的下一个触发时刻。</summary>
public DateTimeOffset NextAfter(DateTimeOffset now)
{
DateTimeOffset local = now.ToOffset(_offset);
DateTimeOffset candidate = new DateTimeOffset(local.Year, local.Month, local.Day, 0, 0, 0, _offset) + _time;
return candidate <= local ? candidate.AddDays(1) : candidate;
}
}
@@ -1,170 +0,0 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Fleet.Core;
using Fleet.Identity;
namespace Fleet.Hosting;
/// <summary><c>trust.json</c>:安装时固定的创世指纹和属主,加见过的最新操作员集合链。</summary>
public sealed class TrustFile
{
[JsonPropertyName("genesis")]
public string Genesis { get; set; } = "";
[JsonPropertyName("chain")]
public List<SignedStatementDto> Chain { get; set; } = [];
/// <summary>属主:允许授权本节点 extension 会话(web ssh 等)的 passkey 凭证 id,安装时固定,必须是操作员集合里的凭证。</summary>
[JsonPropertyName("controllers")]
public List<string> Controllers { get; set; } = [];
}
[JsonSerializable(typeof(TrustFile))]
public sealed partial class TrustFileContext : JsonSerializerContext;
/// <summary>
/// 节点的信任根:安装命令里带来的创世指纹和属主,在注册时固定到本地,之后 Backend 改不了。
/// 操作员集合的新版本随期望状态下发,节点只接受从固定的创世出发、签名完整、版本更新的链。
/// 属主只是被固定和保存:节点现在没有 extension,等做 extension 时由节点本地强制(见 docs/security/access.md 第 9 节)。
/// </summary>
public sealed class TrustAnchor : IServerVerifier
{
public const string FileName = "trust.json";
private readonly ConfigStore? _store;
private readonly INodeLog _log;
private readonly object _gate = new();
private readonly TrustFile? _file;
private OperatorSet? _current;
private TrustAnchor(ConfigStore? store, TrustFile? file, OperatorSet? current, INodeLog log)
{
_store = store;
_file = file;
_current = current;
_log = log;
}
/// <summary>没有固定的创世(旧节点,或没有指纹的安装)。</summary>
public static TrustAnchor None { get; } = new(null, null, null, NullNodeLog.Instance);
public bool Pinned => _file is not null;
public string? Genesis => _file?.Genesis;
/// <summary>当前生效的操作员集合版本;没有固定时为 0。</summary>
public int Version
{
get
{
lock (_gate)
{
return _current?.Version ?? 0;
}
}
}
/// <summary>当前操作员集合里登记了服务器密钥:服务器必须在认证挑战里证明自己,链路用应用层加密。</summary>
public bool Required
{
get
{
lock (_gate)
{
return _current?.Servers is { Count: > 0 };
}
}
}
/// <summary>服务器的签名是不是当前操作员集合里登记的某把服务器密钥签的。</summary>
public bool Verify(string nonce, ReadOnlySpan<byte> serverEphemeralSpki, ReadOnlySpan<byte> signature)
{
IReadOnlyList<byte[]> servers;
lock (_gate)
{
servers = _current?.Servers ?? [];
}
foreach (byte[] server in servers)
{
if (ServerHello.Verify(server, nonce, serverEphemeralSpki, signature))
{
return true;
}
}
return false;
}
/// <summary>安装时固定的属主里,仍然在当前操作员集合里的那些(从集合里去掉的凭证不再有效)。</summary>
public IReadOnlyList<string> Controllers
{
get
{
lock (_gate)
{
return _file is null || _current is null ? [] : [.. _file.Controllers.Where(c => _current.Keys.Any(k => k.CredentialId == c))];
}
}
}
public static TrustAnchor Load(string identityDirectory, INodeLog log)
{
string path = Path.Combine(identityDirectory, FileName);
if (!File.Exists(path))
{
return new TrustAnchor(new ConfigStore(identityDirectory), null, null, log);
}
TrustFile file = NodeConfig.Load(path, TrustFileContext.Default.TrustFile);
OperatorSet? current = OperatorTrust.VerifyChain(file.Genesis, [.. file.Chain.Select(c => c.ToSigned())]);
if (current is null)
{
// 固定的创世和存下来的链对不上:不能当成「没有固定」放过,直接让节点起不来比悄悄失去保护好
throw new InvalidDataException($"{path} does not verify against its pinned genesis; remove the node's identity and enroll again");
}
return new TrustAnchor(new ConfigStore(identityDirectory), file, current, log);
}
/// <summary>注册时写下:固定的创世、验证过的链、属主。</summary>
public static void Pin(string identityDirectory, string genesis, IReadOnlyList<SignedStatementDto> chain, IReadOnlyList<string> controllers) =>
new ConfigStore(identityDirectory).Write(FileName, JsonSerializer.SerializeToUtf8Bytes(new TrustFile { Genesis = genesis, Chain = [.. chain], Controllers = [.. controllers] }, TrustFileContext.Default.TrustFile));
/// <summary>期望状态里带来的链:从固定的创世验起,比现有的新才采用并保存。返回当前链的版本(没有固定时为 0)。</summary>
public int Update(IReadOnlyList<SignedStatementDto>? chain)
{
lock (_gate)
{
if (_file is null || chain is not { Count: > 0 })
{
return _current?.Version ?? 0;
}
OperatorSet? candidate = OperatorTrust.VerifyChain(_file.Genesis, [.. chain.Select(c => c.ToSigned())]);
if (candidate is null)
{
_log.Warn("the operator set from the panel does not verify against the pinned genesis; ignoring it");
return _current!.Version;
}
if (candidate.Version > _current!.Version)
{
_file.Chain = [.. chain];
_current = candidate;
try
{
_store?.Write(FileName, JsonSerializer.SerializeToUtf8Bytes(_file, TrustFileContext.Default.TrustFile));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_log.Warn("could not save trust.json", ("err", ex.Message));
}
_log.Info("operator set updated", ("version", candidate.Version));
}
return _current.Version;
}
}
}
-80
View File
@@ -1,80 +0,0 @@
using System.Buffers.Binary;
using System.Security.Cryptography;
using System.Text;
namespace Fleet.Identity;
/// <summary>
/// 预握手敲门:TCP 连接建立之后、TLS 握手之前,拨号方先发 32 字节 <c>nonce(16) | tag(16)</c>,
/// <c>tag = HMAC-SHA256(K_pair, 时间窗 | nonce)</c> 截断到 16 字节。监听方核对不过就什么都不写、直接断开,
/// 所以扫描器拿不到证书、TLS 参数和 JARM 指纹。<c>K_pair</c> 只有名册里的成员算得出:双方身份密钥做静态 ECDH,再经 HKDF 加用途标签派生。
/// TCP 的 SYN-ACK 由内核发出,端口在扫描器眼里仍是「开着但不说话」,不是「没监听」。
/// </summary>
public static class Knock
{
public const int PacketBytes = 32;
public const int NonceBytes = 16;
public const int TagBytes = 16;
/// <summary>时间窗长度;接受前后各一个窗,容忍时钟偏差。</summary>
public const int WindowSeconds = 30;
private const string Label = "moshiro-knock-v1";
/// <summary>两把身份密钥的配对密钥。info 里的两个公钥按字节序排列,所以两边算出的是同一个值。</summary>
public static byte[] DeriveKey(NodeIdentity own, ReadOnlySpan<byte> peerSpki)
{
byte[] secret = own.AgreeWith(peerSpki);
try
{
ReadOnlySpan<byte> mine = own.PublicKeySpki;
bool mineFirst = mine.SequenceCompareTo(peerSpki) <= 0;
byte[] info = [.. Encoding.UTF8.GetBytes(Label), .. (mineFirst ? mine : peerSpki), .. (mineFirst ? peerSpki : mine)];
return HKDF.DeriveKey(HashAlgorithmName.SHA256, secret, 32, null, info);
}
finally
{
CryptographicOperations.ZeroMemory(secret);
}
}
public static byte[] Build(ReadOnlySpan<byte> key, long nowMs)
{
byte[] packet = new byte[PacketBytes];
RandomNumberGenerator.Fill(packet.AsSpan(0, NonceBytes));
Tag(key, WindowOf(nowMs), packet.AsSpan(0, NonceBytes), packet.AsSpan(NonceBytes));
return packet;
}
/// <summary>tag 在前一个、当前、后一个时间窗里有一个对得上就通过。不管重放(重放由 <see cref="KnockGate"/> 按 nonce 去重)。</summary>
public static bool Check(ReadOnlySpan<byte> key, ReadOnlySpan<byte> packet, long nowMs)
{
if (packet.Length != PacketBytes)
{
return false;
}
long window = WindowOf(nowMs);
Span<byte> expected = stackalloc byte[TagBytes];
bool ok = false;
for (long w = window - 1; w <= window + 1; w++)
{
Tag(key, w, packet[..NonceBytes], expected);
ok |= CryptographicOperations.FixedTimeEquals(expected, packet[NonceBytes..]);
}
return ok;
}
private static long WindowOf(long nowMs) => nowMs / 1000 / WindowSeconds;
private static void Tag(ReadOnlySpan<byte> key, long window, ReadOnlySpan<byte> nonce, Span<byte> destination)
{
Span<byte> data = stackalloc byte[8 + NonceBytes];
BinaryPrimitives.WriteInt64BigEndian(data, window);
nonce.CopyTo(data[8..]);
Span<byte> full = stackalloc byte[32];
HMACSHA256.HashData(key, data, full);
full[..TagBytes].CopyTo(destination);
}
}
@@ -1,113 +0,0 @@
using System.Collections.Concurrent;
using System.Net;
using System.Security.Cryptography;
namespace Fleet.Identity;
/// <summary>
/// 监听方的敲门关卡:新连接的前 32 字节必须是名册里某个成员的敲门包,否则不写任何字节、直接断开。
/// 每个来源地址限速(陌生人用随机数据也要消耗 HMAC 计算),nonce 在时间窗内去重(防重放)。
/// 名册由调用方提供(对端公钥列表);配对密钥按公钥缓存,名册变了自然只算新的。
/// </summary>
public sealed class KnockGate
{
private static readonly TimeSpan s_readTimeout = TimeSpan.FromSeconds(3);
private readonly NodeIdentity _own;
private readonly Func<IReadOnlyList<byte[]>> _peers;
private readonly TimeProvider _time;
private readonly int _attemptsPerPeriod;
private readonly long _periodMs;
private readonly ConcurrentDictionary<string, byte[]> _keys = new();
private readonly object _gate = new();
private readonly Dictionary<string, long> _seenNonces = [];
private readonly Dictionary<string, (long WindowStartMs, int Count)> _sources = [];
private long _lastSweepMs;
public KnockGate(NodeIdentity own, Func<IReadOnlyList<byte[]>> peers, TimeProvider time, int attemptsPerPeriod = 300, TimeSpan? period = null)
{
_own = own;
_peers = peers;
_time = time;
_attemptsPerPeriod = attemptsPerPeriod;
_periodMs = (long)(period ?? TimeSpan.FromSeconds(10)).TotalMilliseconds;
}
/// <summary>读 32 字节并核对。通过返回 true,流从敲门包之后继续(TLS 或明文首帧);不通过返回 false,调用方直接关闭连接,不要写任何东西。</summary>
public async ValueTask<bool> AdmitAsync(Stream stream, IPAddress? source, CancellationToken ct)
{
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
if (!TryCountSource(source, now))
{
return false;
}
byte[] packet = new byte[Knock.PacketBytes];
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(s_readTimeout);
try
{
await stream.ReadExactlyAsync(packet, timeout.Token);
}
catch (Exception ex) when (ex is EndOfStreamException or OperationCanceledException or IOException or ObjectDisposedException)
{
return false;
}
return Accept(packet, now);
}
/// <summary>核对一个敲门包(不读流):名册里有成员的密钥对得上,并且 nonce 没用过。</summary>
public bool Accept(ReadOnlySpan<byte> packet, long nowMs)
{
bool matched = false;
foreach (byte[] peer in _peers())
{
byte[] key = _keys.GetOrAdd(Convert.ToBase64String(peer), _ => Knock.DeriveKey(_own, peer));
matched |= Knock.Check(key, packet, nowMs);
}
if (!matched)
{
return false;
}
string nonce = Convert.ToHexString(packet[..Knock.NonceBytes]);
lock (_gate)
{
Sweep(nowMs);
return _seenNonces.TryAdd(nonce, nowMs + (Knock.WindowSeconds * 3 * 1000L));
}
}
private bool TryCountSource(IPAddress? source, long nowMs)
{
string key = source is null ? "" : source.IsIPv4MappedToIPv6 ? source.MapToIPv4().ToString() : source.ToString();
lock (_gate)
{
Sweep(nowMs);
(long start, int count) = _sources.TryGetValue(key, out var entry) && nowMs - entry.WindowStartMs < _periodMs ? entry : (nowMs, 0);
_sources[key] = (start, count + 1);
return count < _attemptsPerPeriod;
}
}
private void Sweep(long nowMs)
{
if (nowMs - _lastSweepMs < 5000 && _seenNonces.Count < 4096 && _sources.Count < 4096)
{
return;
}
_lastSweepMs = nowMs;
foreach (string nonce in _seenNonces.Where(p => p.Value <= nowMs).Select(p => p.Key).ToList())
{
_seenNonces.Remove(nonce);
}
foreach (string source in _sources.Where(p => nowMs - p.Value.WindowStartMs >= _periodMs).Select(p => p.Key).ToList())
{
_sources.Remove(source);
}
}
}
@@ -22,7 +22,7 @@ public static class NodeCertificate
return certificate.Export(X509ContentType.Pfx); return certificate.Export(X509ContentType.Pfx);
} }
public static X509Certificate2 LoadPfx(byte[] pfx) => X509CertificateLoader.LoadPkcs12(pfx, null, X509KeyStorageFlags.Exportable); public static X509Certificate2 LoadPfx(byte[] pfx) => X509CertificateLoader.LoadPkcs12(pfx, null);
/// <summary>证书公钥(SPKI)的 SHA-256:对端用它来固定这张证书。</summary> /// <summary>证书公钥(SPKI)的 SHA-256:对端用它来固定这张证书。</summary>
public static byte[] SpkiFingerprint(X509Certificate2 certificate) => public static byte[] SpkiFingerprint(X509Certificate2 certificate) =>
@@ -7,9 +7,6 @@ namespace Fleet.Identity;
public static class SignaturePurpose public static class SignaturePurpose
{ {
public const string Auth = "auth"; public const string Auth = "auth";
/// <summary>安全链路的认证:签名同时覆盖服务器和节点的临时公钥,把这次认证和这条加密链路绑在一起。</summary>
public const string AuthSecure = "auth2";
public const string Peer = "peer"; public const string Peer = "peer";
public const string Enroll = "enroll"; public const string Enroll = "enroll";
} }
@@ -44,16 +41,6 @@ public sealed class NodeIdentity : IDisposable
public byte[] ExportPkcs8() => _key.ExportPkcs8PrivateKey(); public byte[] ExportPkcs8() => _key.ExportPkcs8PrivateKey();
/// <summary>与对端公钥(SPKI)做静态 ECDH,返回原始共享秘密。只用于派生对称密钥(敲门),必须再经 KDF,不能直接当密钥用。</summary>
public byte[] AgreeWith(ReadOnlySpan<byte> peerSpki)
{
using ECDiffieHellman own = ECDiffieHellman.Create();
own.ImportPkcs8PrivateKey(_key.ExportPkcs8PrivateKey(), out _);
using ECDiffieHellman peer = ECDiffieHellman.Create();
peer.ImportSubjectPublicKeyInfo(peerSpki, out _);
return own.DeriveRawSecretAgreement(peer.PublicKey);
}
public byte[] Sign(string purpose, params string[] fields) => public byte[] Sign(string purpose, params string[] fields) =>
_key.SignData(Encoding.UTF8.GetBytes(SignatureString.Build(purpose, fields)), HashAlgorithmName.SHA256, _key.SignData(Encoding.UTF8.GetBytes(SignatureString.Build(purpose, fields)), HashAlgorithmName.SHA256,
DSASignatureFormat.IeeeP1363FixedFieldConcatenation); DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
@@ -1,171 +0,0 @@
using System.Buffers.Text;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
namespace Fleet.Identity;
/// <summary>passkey(WebAuthn)对一段文本签出的断言:<c>challenge = SHA-256(Statement 的 UTF-8)</c>。四个字段都是 base64url。</summary>
public sealed record SignedStatement(string Statement, string CredentialId, string AuthenticatorData, string ClientDataJson, string Signature);
/// <summary>一个操作员凭证:passkey 的凭证 id 和公钥(SPKI)。</summary>
public sealed record OperatorKey(string CredentialId, byte[] PublicKeySpki);
/// <summary>
/// 操作员集合的一个版本。<c>RpId</c> 和 <c>Origins</c> 也在里面:节点验 WebAuthn 断言要用,而它们被操作员的签名覆盖,Backend 改不了。
/// 创世版本(1)由它自己的某把凭证签名,指纹(<see cref="OperatorTrust.Fingerprint"/>)在安装时固定到节点;之后每个版本由上一版里的凭证签名。
/// </summary>
/// <param name="Servers">服务器密钥(SPKI):操作员授权的 Backend 签名密钥。集合里登记了服务器密钥之后,节点要求服务器在认证挑战里用它签名,
/// 并在这条链路上用应用层加密,所以 DNS 或证书层面的冒充拿不出这把密钥就骗不了节点。空 = 还没有启用。</param>
public sealed record OperatorSet(int Version, string Previous, string RpId, IReadOnlyList<string> Origins, IReadOnlyList<OperatorKey> Keys, IReadOnlyList<byte[]>? Servers = null);
/// <summary>
/// 信任根:操作员(passkey)→ 操作员集合 → 节点本地固定的属主。设计见 docs/security/access.md 第 8 节。
/// 安装命令里带着创世指纹和属主(passkey 的凭证 id):节点把它们固定在本地,之后只认从创世出发、由操作员 passkey 签过名的集合版本,
/// 属主必须是集合里的凭证。Backend 能转发集合,但签不出新的版本,也改不了节点已经固定的东西。
/// 全是纯函数,只用 BCL,节点和 Backend 共用。语句是 <c>key=value</c> 的文本,第一行是带版本的标题;签名覆盖整段文本。
/// </summary>
public static class OperatorTrust
{
public const string SetHeader = "moshiro-opset-v1";
public const string Genesis = "0";
public static string Encode(OperatorSet set)
{
var text = new StringBuilder();
text.Append(SetHeader).Append('\n')
.Append("version=").Append(set.Version.ToString(CultureInfo.InvariantCulture)).Append('\n')
.Append("prev=").Append(set.Previous).Append('\n')
.Append("rp=").Append(set.RpId).Append('\n')
.Append("origins=").Append(string.Join(',', set.Origins));
foreach (OperatorKey key in set.Keys)
{
text.Append("\nkey=").Append(key.CredentialId).Append(':').Append(Convert.ToBase64String(key.PublicKeySpki));
}
foreach (byte[] server in set.Servers ?? [])
{
text.Append("\nserver=").Append(Convert.ToBase64String(server));
}
return text.ToString();
}
/// <summary>严格解析:多一行、少一行、顺序不对都算坏。</summary>
public static OperatorSet? ParseSet(string text)
{
string[] lines = text.Split('\n');
if (lines.Length < 6 || lines[0] != SetHeader || !TryValue(lines[1], "version", out string version) || !int.TryParse(version, NumberStyles.None, CultureInfo.InvariantCulture, out int number) || number < 1
|| !TryValue(lines[2], "prev", out string previous) || !TryValue(lines[3], "rp", out string rp) || rp.Length == 0 || !TryValue(lines[4], "origins", out string origins) || origins.Length == 0)
{
return null;
}
var keys = new List<OperatorKey>();
var servers = new List<byte[]>();
foreach (string line in lines.Skip(5))
{
if (servers.Count == 0 && TryValue(line, "key", out string value) && value.IndexOf(':', StringComparison.Ordinal) is > 0 and int split && TryBase64(value[(split + 1)..], out byte[] spki))
{
keys.Add(new OperatorKey(value[..split], spki));
}
else if (TryValue(line, "server", out string serverText) && TryBase64(serverText, out byte[] serverKey))
{
servers.Add(serverKey);
}
else
{
return null;
}
}
return keys.Count == 0 || (number == 1) != (previous == Genesis) ? null : new OperatorSet(number, previous, rp, origins.Split(','), keys, servers);
}
/// <summary>一段语句的指纹(SHA-256,小写十六进制)。创世版本的指纹就是安装命令里固定的那个。</summary>
public static string Fingerprint(string statement) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(statement)));
/// <summary>
/// 验证操作员集合的链:第一项是创世版本(指纹等于 <paramref name="pinnedGenesis"/>,自签),其后每一项由上一版里的凭证签名、版本加一、<c>prev</c> 指向上一版的指纹、域名不变。
/// 返回最后一版;不对返回 null。
/// </summary>
public static OperatorSet? VerifyChain(string pinnedGenesis, IReadOnlyList<SignedStatement> chain)
{
if (chain.Count == 0 || !string.Equals(Fingerprint(chain[0].Statement), pinnedGenesis, StringComparison.OrdinalIgnoreCase))
{
return null;
}
OperatorSet? current = null;
string? previousFingerprint = null;
foreach (SignedStatement link in chain)
{
OperatorSet? next = ParseSet(link.Statement);
if (next is null)
{
return null;
}
if (current is null)
{
if (next.Version != 1 || !VerifyOperator(link, next))
{
return null;
}
}
else if (next.Version != current.Version + 1 || next.Previous != previousFingerprint || next.RpId != current.RpId || !VerifyOperator(link, current))
{
return null;
}
current = next;
previousFingerprint = Fingerprint(link.Statement);
}
return current;
}
/// <summary>断言是不是这个集合里某把凭证对 <c>Statement</c> 签的(要求用户验证;挑战是语句的 SHA-256)。</summary>
public static bool VerifyOperator(SignedStatement signed, OperatorSet set)
{
OperatorKey? key = set.Keys.FirstOrDefault(k => k.CredentialId == signed.CredentialId);
if (key is null || !TryDecode(signed.AuthenticatorData, out byte[] authData) || !TryDecode(signed.ClientDataJson, out byte[] clientData) || !TryDecode(signed.Signature, out byte[] signature))
{
return false;
}
return WebAuthnAssertion.Verify(key.PublicKeySpki, authData, clientData, signature, set.RpId, SHA256.HashData(Encoding.UTF8.GetBytes(signed.Statement)), set.Origins.Contains, requireUserVerification: true, out _);
}
private static bool TryValue(string line, string key, out string value)
{
value = "";
if (!line.StartsWith(key + "=", StringComparison.Ordinal))
{
return false;
}
value = line[(key.Length + 1)..];
return true;
}
private static bool TryBase64(string text, out byte[] data)
{
try
{
data = Convert.FromBase64String(text);
return data.Length > 0;
}
catch (FormatException)
{
data = [];
return false;
}
}
private static bool TryDecode(string text, out byte[] data)
{
data = [];
return Base64Url.IsValid(text) && (data = Base64Url.DecodeFromChars(text)).Length > 0;
}
}
@@ -1,173 +0,0 @@
using System.Buffers.Binary;
using System.Security.Cryptography;
using System.Text;
namespace Fleet.Identity;
/// <summary>
/// 节点这一侧用来确认「对面确实是我的操作员授权过的 Backend」的校验器:<c>Required</c> 为真(当前操作员集合里登记了服务器密钥)
/// 时,服务器的挑战必须带着对得上的签名,否则节点不继续认证、也不交出任何东西。
/// </summary>
public interface IServerVerifier
{
bool Required { get; }
/// <summary>服务器用它的服务器密钥对「挑战随机数加它这次连接的临时公钥」的签名。</summary>
bool Verify(string nonce, ReadOnlySpan<byte> serverEphemeralSpki, ReadOnlySpan<byte> signature);
}
/// <summary>服务器在挑战里给出的身份证明:服务器密钥(操作员签进集合)对「随机数加临时公钥」签名。纯函数。</summary>
public static class ServerHello
{
/// <summary>被签名的内容;服务器那一侧只拿到这个、用自己的密钥签(服务器密钥不一定是 BCL 的 ECDsa,可以在别处)。</summary>
public static byte[] Payload(string nonce, ReadOnlySpan<byte> serverEphemeralSpki) =>
Encoding.UTF8.GetBytes("fleet|v1|server|" + nonce + "|" + Convert.ToBase64String(serverEphemeralSpki));
public static byte[] Sign(ECDsa serverKey, string nonce, ReadOnlySpan<byte> serverEphemeralSpki) =>
serverKey.SignData(Payload(nonce, serverEphemeralSpki), HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
public static bool Verify(ReadOnlySpan<byte> serverKeySpki, string nonce, ReadOnlySpan<byte> serverEphemeralSpki, ReadOnlySpan<byte> signature)
{
try
{
using ECDsa key = ECDsa.Create();
key.ImportSubjectPublicKeyInfo(serverKeySpki, out _);
return key.VerifyData(Payload(nonce, serverEphemeralSpki), signature, HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
}
catch (CryptographicException)
{
return false;
}
}
}
/// <summary>
/// 节点和 Backend 之间控制链路的应用层加密:两边的临时 ECDH 密钥协商出会话密钥,每个方向一把,AES-256-GCM,每帧带计数器。
/// 所以即使 TLS 在中间被终止(Cloudflare、被冒充的证书),中间人没有服务器密钥,就拿不到明文,也改不了任何一帧。
/// 帧格式:<c>计数器(8,大端) ‖ 密文 ‖ 标签(16)</c>;明文是 <c>帧类型(1) ‖ 内容</c>;计数器必须严格递增,重放、乱序、丢帧、篡改都会解密失败。
/// </summary>
public sealed class LinkCipher : IDisposable
{
public const int Overhead = 8 + 16;
private const byte NodeToServer = 0;
private const byte ServerToNode = 1;
private readonly AesGcm _send;
private readonly AesGcm _receive;
private readonly byte _sendDirection;
private readonly byte _receiveDirection;
private readonly object _gate = new();
private long _sendCounter;
private long _receiveCounter;
private LinkCipher(byte[] sendKey, byte[] receiveKey, byte sendDirection, byte receiveDirection)
{
_send = new AesGcm(sendKey, 16);
_receive = new AesGcm(receiveKey, 16);
_sendDirection = sendDirection;
_receiveDirection = receiveDirection;
}
public static LinkCipher ForNode(EphemeralKey own, ReadOnlySpan<byte> serverEphemeralSpki, string nonce)
{
(byte[] n2s, byte[] s2n) = Derive(own, serverEphemeralSpki, own.PublicKeySpki, serverEphemeralSpki, nonce);
return new LinkCipher(n2s, s2n, NodeToServer, ServerToNode);
}
public static LinkCipher ForServer(EphemeralKey own, ReadOnlySpan<byte> nodeEphemeralSpki, string nonce)
{
(byte[] n2s, byte[] s2n) = Derive(own, nodeEphemeralSpki, nodeEphemeralSpki, own.PublicKeySpki, nonce);
return new LinkCipher(s2n, n2s, ServerToNode, NodeToServer);
}
private static (byte[] NodeToServerKey, byte[] ServerToNodeKey) Derive(EphemeralKey own, ReadOnlySpan<byte> remote, ReadOnlySpan<byte> nodeEphemeral, ReadOnlySpan<byte> serverEphemeral, string nonce)
{
byte[] secret = own.AgreeRaw(remote);
try
{
byte[] info = [.. Encoding.UTF8.GetBytes("fleet|v1|link|"), .. SHA256.HashData(serverEphemeral), .. SHA256.HashData(nodeEphemeral)];
byte[] material = HKDF.DeriveKey(HashAlgorithmName.SHA256, secret, 64, Encoding.UTF8.GetBytes(nonce), info);
return (material[..32], material[32..]);
}
finally
{
CryptographicOperations.ZeroMemory(secret);
}
}
/// <summary>加密一帧。</summary>
public byte[] Seal(byte kind, ReadOnlySpan<byte> data)
{
long counter;
lock (_gate)
{
counter = _sendCounter++;
}
byte[] output = new byte[Overhead + 1 + data.Length];
BinaryPrimitives.WriteInt64BigEndian(output, counter);
byte[] plaintext = new byte[1 + data.Length];
plaintext[0] = kind;
data.CopyTo(plaintext.AsSpan(1));
_send.Encrypt(Nonce(counter), plaintext, output.AsSpan(8, plaintext.Length), output.AsSpan(8 + plaintext.Length, 16), Aad(_sendDirection, counter));
return output;
}
/// <summary>解密一帧;计数器不是期望的下一个、或者标签不对,都返回 false。</summary>
public bool TryOpen(ReadOnlySpan<byte> frame, out byte kind, out byte[] data)
{
kind = 0;
data = [];
if (frame.Length < Overhead + 1)
{
return false;
}
long counter = BinaryPrimitives.ReadInt64BigEndian(frame);
lock (_gate)
{
if (counter != _receiveCounter)
{
return false;
}
int length = frame.Length - Overhead;
byte[] plaintext = new byte[length];
try
{
_receive.Decrypt(Nonce(counter), frame.Slice(8, length), frame.Slice(8 + length, 16), plaintext, Aad(_receiveDirection, counter));
}
catch (CryptographicException)
{
return false;
}
_receiveCounter++;
kind = plaintext[0];
data = plaintext[1..];
return true;
}
}
private static byte[] Nonce(long counter)
{
byte[] nonce = new byte[12];
BinaryPrimitives.WriteInt64BigEndian(nonce.AsSpan(4), counter);
return nonce;
}
private static byte[] Aad(byte direction, long counter)
{
byte[] aad = new byte[9];
aad[0] = direction;
BinaryPrimitives.WriteInt64BigEndian(aad.AsSpan(1), counter);
return aad;
}
public void Dispose()
{
_send.Dispose();
_receive.Dispose();
}
}
@@ -16,14 +16,6 @@ public sealed class EphemeralKey : IDisposable
public byte[] PublicKeySpki => _key.ExportSubjectPublicKeyInfo(); public byte[] PublicKeySpki => _key.ExportSubjectPublicKeyInfo();
/// <summary>与对端的临时公钥做 ECDH,返回原始共享秘密;必须再经 KDF 才能当密钥用。</summary>
public byte[] AgreeRaw(ReadOnlySpan<byte> remotePublicKeySpki)
{
using ECDiffieHellman remote = ECDiffieHellman.Create();
remote.ImportSubjectPublicKeyInfo(remotePublicKeySpki, out _);
return _key.DeriveRawSecretAgreement(remote.PublicKey);
}
/// <summary> /// <summary>
/// 与对端的临时公钥协商,再经 HKDF-SHA256 派生会话凭据。 /// 与对端的临时公钥协商,再经 HKDF-SHA256 派生会话凭据。
/// 盐是服务端的随机数,info 绑定双方的临时公钥,防止凭据被挪用到别的握手上。 /// 盐是服务端的随机数,info 绑定双方的临时公钥,防止凭据被挪用到别的握手上。
@@ -1,11 +0,0 @@
using Fleet.Core;
namespace Fleet.Identity;
public static class TrustMapping
{
public static SignedStatement ToSigned(this SignedStatementDto dto) => new(dto.Statement, dto.CredentialId, dto.AuthenticatorData, dto.ClientDataJson, dto.Signature);
public static SignedStatementDto ToDto(this SignedStatement signed) =>
new() { Statement = signed.Statement, CredentialId = signed.CredentialId, AuthenticatorData = signed.AuthenticatorData, ClientDataJson = signed.ClientDataJson, Signature = signed.Signature };
}
@@ -1,85 +0,0 @@
using System.Buffers.Text;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace Fleet.Identity;
/// <summary>
/// 验证一次 WebAuthn 断言(<c>navigator.credentials.get</c> 的结果)。只支持 ES256(P-256 的 ECDSA),纯函数,只用 BCL:
/// 面板用它验证 passkey 登录,节点验证操作员集合的签名也用它(docs/security/access.md 第 8 节)。
/// 不解析 CBOR——注册时的 attestation 解析只在 Backend 里。
/// </summary>
public static class WebAuthnAssertion
{
public const byte FlagUserPresent = 0x01;
public const byte FlagUserVerified = 0x04;
/// <param name="publicKeySpki">注册时保存的凭证公钥(SPKI)。</param>
/// <param name="expectedChallenge">这次请求里服务端或签名人规定的挑战原文(不是 base64url)。</param>
/// <param name="originAllowed">校验 <c>clientDataJSON.origin</c>。</param>
/// <param name="signCount">authenticatorData 里的签名计数器。</param>
public static bool Verify(
ReadOnlySpan<byte> publicKeySpki, ReadOnlySpan<byte> authenticatorData, ReadOnlySpan<byte> clientDataJson, ReadOnlySpan<byte> signature,
string rpId, ReadOnlySpan<byte> expectedChallenge, Func<string, bool> originAllowed, bool requireUserVerification, out uint signCount)
{
signCount = 0;
if (authenticatorData.Length < 37 || !CryptographicOperations.FixedTimeEquals(authenticatorData[..32], SHA256.HashData(Encoding.UTF8.GetBytes(rpId))))
{
return false;
}
byte flags = authenticatorData[32];
if ((flags & FlagUserPresent) == 0 || (requireUserVerification && (flags & FlagUserVerified) == 0))
{
return false;
}
if (!ClientDataValid(clientDataJson, "webauthn.get", expectedChallenge, originAllowed))
{
return false;
}
try
{
using ECDsa key = ECDsa.Create();
key.ImportSubjectPublicKeyInfo(publicKeySpki, out _);
byte[] signed = [.. authenticatorData, .. SHA256.HashData(clientDataJson)];
if (!key.VerifyData(signed, signature, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence))
{
return false;
}
}
catch (CryptographicException)
{
return false;
}
signCount = (uint)((authenticatorData[33] << 24) | (authenticatorData[34] << 16) | (authenticatorData[35] << 8) | authenticatorData[36]);
return true;
}
/// <summary><c>clientDataJSON</c> 的 type、challenge、origin 都要对得上。注册和登录共用。</summary>
public static bool ClientDataValid(ReadOnlySpan<byte> clientDataJson, string expectedType, ReadOnlySpan<byte> expectedChallenge, Func<string, bool> originAllowed)
{
try
{
using JsonDocument document = JsonDocument.Parse(clientDataJson.ToArray());
JsonElement root = document.RootElement;
if (root.ValueKind != JsonValueKind.Object
|| root.GetProperty("type").GetString() != expectedType
|| root.GetProperty("origin").GetString() is not { } origin || !originAllowed(origin)
|| root.GetProperty("challenge").GetString() is not { } challenge)
{
return false;
}
byte[] decoded = Base64Url.DecodeFromChars(challenge);
return CryptographicOperations.FixedTimeEquals(decoded, expectedChallenge);
}
catch (Exception ex) when (ex is JsonException or KeyNotFoundException or FormatException or InvalidOperationException)
{
return false;
}
}
}
+9 -98
View File
@@ -49,7 +49,7 @@ public sealed class FleetLinkOptions
public string NodeVersion { get; init; } = "0.0.0"; public string NodeVersion { get; init; } = "0.0.0";
/// <summary>框架之外的能力,例如 <c>moshiro.v1</c> 与 <c>moshiro.kinds:udp_echo,tcp_echo</c>。</summary> /// <summary>框架之外的能力,例如 <c>monitor.v1</c> 与 <c>monitor.kinds:udp_echo,tcp_echo</c>。</summary>
public IReadOnlyList<string> Capabilities { get; init; } = []; public IReadOnlyList<string> Capabilities { get; init; } = [];
/// <summary>附加到 <c>fleet.hello</c> 的产品元数据。</summary> /// <summary>附加到 <c>fleet.hello</c> 的产品元数据。</summary>
@@ -58,9 +58,6 @@ public sealed class FleetLinkOptions
/// <summary>附加到 <c>fleet.heartbeat</c> 的进程内计数(不能包含主机信息)。</summary> /// <summary>附加到 <c>fleet.heartbeat</c> 的进程内计数(不能包含主机信息)。</summary>
public Func<JsonElement?>? HeartbeatExtra { get; init; } public Func<JsonElement?>? HeartbeatExtra { get; init; }
/// <summary>Optional reason for an orderly client shutdown, so the server can distinguish updates from service stops.</summary>
public Func<string>? ShutdownReason { get; init; }
public TimeSpan HeartbeatInterval { get; init; } = TimeSpan.FromSeconds(10); public TimeSpan HeartbeatInterval { get; init; } = TimeSpan.FromSeconds(10);
public int MaxFrameBytes { get; init; } = 16 << 20; public int MaxFrameBytes { get; init; } = 16 << 20;
@@ -69,12 +66,6 @@ public sealed class FleetLinkOptions
public long MaxInflightBytes { get; init; } = 4 << 20; public long MaxInflightBytes { get; init; } = 4 << 20;
public Backoff Backoff { get; init; } = new(); public Backoff Backoff { get; init; } = new();
/// <summary>
/// 校验服务器身份:<see cref="IServerVerifier.Required"/> 为真(节点固定的操作员集合里登记了服务器密钥)时,
/// 服务器必须在挑战里证明自己,之后这条链路用应用层加密;否则按旧的方式(没有固定信任根的节点)。
/// </summary>
public IServerVerifier? ServerVerifier { get; init; }
} }
/// <summary> /// <summary>
@@ -86,7 +77,6 @@ public sealed class FleetLink
private const string RpcPath = "/api/agent/v1/rpc"; private const string RpcPath = "/api/agent/v1/rpc";
private readonly FleetLinkOptions _options; private readonly FleetLinkOptions _options;
private volatile bool _secure;
private readonly RpcRouter<LinkContext> _router; private readonly RpcRouter<LinkContext> _router;
private readonly Dictionary<string, IStateHandler> _handlers; private readonly Dictionary<string, IStateHandler> _handlers;
private readonly TimeProvider _time; private readonly TimeProvider _time;
@@ -115,9 +105,6 @@ public sealed class FleetLink
public RpcSession<LinkContext>? CurrentSession => _session; public RpcSession<LinkContext>? CurrentSession => _session;
/// <summary>入站方法的路由器(只读,测试用它核对声明表和实际注册的处理函数)。</summary>
public RpcRouter<LinkContext> Router => _router;
/// <summary>被服务端撤销或禁用:不会再重连。</summary> /// <summary>被服务端撤销或禁用:不会再重连。</summary>
public bool Revoked => _stopped; public bool Revoked => _stopped;
@@ -163,9 +150,6 @@ public sealed class FleetLink
} }
} }
/// <summary>当前这条链路是不是应用层加密的(服务器证明了身份)。</summary>
public bool IsSecure => _secure && IsConnected;
private async Task<string> RunOnceAsync(CancellationToken ct) private async Task<string> RunOnceAsync(CancellationToken ct)
{ {
using var socket = new ClientWebSocket(); using var socket = new ClientWebSocket();
@@ -173,7 +157,7 @@ public sealed class FleetLink
socket.Options.KeepAliveTimeout = TimeSpan.FromSeconds(20); // 对端 20 秒没有回 Pong 就判定连接已死 socket.Options.KeepAliveTimeout = TimeSpan.FromSeconds(20); // 对端 20 秒没有回 Pong 就判定连接已死
var builder = new UriBuilder(_options.Server) { Scheme = _options.Server.Scheme == "https" ? "wss" : "ws", Path = RpcPath }; var builder = new UriBuilder(_options.Server) { Scheme = _options.Server.Scheme == "https" ? "wss" : "ws", Path = RpcPath };
await socket.ConnectAsync(builder.Uri, ct); await socket.ConnectAsync(builder.Uri, ct);
await using var transport = new SecureTransport(new WsTransport(socket, _options.MaxFrameBytes)); await using var transport = new WsTransport(socket, _options.MaxFrameBytes);
string? failure = await AuthenticateAsync(transport, ct); string? failure = await AuthenticateAsync(transport, ct);
if (failure is not null) if (failure is not null)
@@ -181,11 +165,8 @@ public sealed class FleetLink
return failure; return failure;
} }
_secure = transport.Secure;
var session = new RpcSession<LinkContext>(transport, _router, new LinkContext(this), _time); var session = new RpcSession<LinkContext>(transport, _router, new LinkContext(this), _time);
// Keep the active WebSocket receive alive until the planned shutdown marker and Close frame have been sent. using CancellationTokenSource sessionCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
using var sessionCts = new CancellationTokenSource();
Task run = session.RunAsync(sessionCts.Token); Task run = session.RunAsync(sessionCts.Token);
try try
{ {
@@ -196,7 +177,7 @@ public sealed class FleetLink
NowUtcMs = _time.GetUtcNow().ToUnixTimeMilliseconds(), NowUtcMs = _time.GetUtcNow().ToUnixTimeMilliseconds(),
Meta = _options.HelloMeta?.Invoke(), Meta = _options.HelloMeta?.Invoke(),
}; };
Result<HelloResult> helloResult = await session.RequestAsync(FleetMethods.Hello, hello, FleetRpcContext.Default.HelloParams, FleetRpcContext.Default.HelloResult, ct); Result<HelloResult> helloResult = await session.RequestAsync(FleetMethods.Hello, hello, FleetRpcContext.Default.HelloParams, FleetRpcContext.Default.HelloResult, sessionCts.Token);
if (!helloResult.IsSuccess) if (!helloResult.IsSuccess)
{ {
return "hello failed: " + helloResult.Error.Code; return "hello failed: " + helloResult.Error.Code;
@@ -212,23 +193,7 @@ public sealed class FleetLink
await OnConnected(session, sessionCts.Token); await OnConnected(session, sessionCts.Token);
} }
Task cancellation = Task.Delay(Timeout.Infinite, ct); Task finished = await Task.WhenAny(run, pump, heartbeat);
Task finished = await Task.WhenAny(run, pump, heartbeat, cancellation);
if (ct.IsCancellationRequested)
{
try
{
using var closeTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(1), _time);
string reason = _options.ShutdownReason?.Invoke() ?? "stopping";
await session.NotifyAsync(FleetMethods.Stopping, new StoppingParams { Reason = reason }, FleetRpcContext.Default.StoppingParams, closeTimeout.Token);
await session.CloseAsync(reason, closeTimeout.Token);
}
catch (Exception ex) when (ex is OperationCanceledException or WebSocketException or IOException or ObjectDisposedException or InvalidOperationException)
{
// The socket may already be gone; shutdown must still complete.
}
}
await sessionCts.CancelAsync(); await sessionCts.CancelAsync();
await Task.WhenAll(Quiet(run), Quiet(pump), Quiet(heartbeat)); await Task.WhenAll(Quiet(run), Quiet(pump), Quiet(heartbeat));
return finished == run ? "closed by server" : "link task failed"; return finished == run ? "closed by server" : "link task failed";
@@ -243,7 +208,7 @@ public sealed class FleetLink
} }
/// <summary>挑战 → 签名 → 认证结果。失败返回原因;服务端禁用该节点时不再重试。</summary> /// <summary>挑战 → 签名 → 认证结果。失败返回原因;服务端禁用该节点时不再重试。</summary>
private async Task<string?> AuthenticateAsync(SecureTransport transport, CancellationToken ct) private async Task<string?> AuthenticateAsync(WsTransport transport, CancellationToken ct)
{ {
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15), _time); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15), _time);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token); using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
@@ -254,44 +219,9 @@ public sealed class FleetLink
} }
ChallengeParams nonce = challenge.Params.Value.Deserialize(FleetRpcContext.Default.ChallengeParams)!; ChallengeParams nonce = challenge.Params.Value.Deserialize(FleetRpcContext.Default.ChallengeParams)!;
string signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.Auth, _options.NodeId.Value, nonce.Nonce));
// 固定了操作员集合、而集合里登记了服务器密钥:服务器必须证明自己,否则不继续,也不交出任何东西 RpcMessage request = RpcMessage.Request(1, FleetMethods.Auth, new AuthParams { NodeId = _options.NodeId.Value, Signature = signature }, FleetRpcContext.Default.AuthParams);
EphemeralKey? ephemeral = null;
AuthParams authParams;
try
{
if (_options.ServerVerifier is { Required: true } verifier)
{
if (!TryDecodeBase64(nonce.ServerEphemeral, out byte[] serverEphemeral) || !TryDecodeBase64(nonce.ServerSignature, out byte[] serverSignature)
|| !verifier.Verify(nonce.Nonce, serverEphemeral, serverSignature))
{
return "the server could not prove its identity (it is not the server your operator set authorizes)";
}
ephemeral = new EphemeralKey();
string serverEphemeralText = Convert.ToBase64String(serverEphemeral);
string nodeEphemeralText = Convert.ToBase64String(ephemeral.PublicKeySpki);
authParams = new AuthParams
{
NodeId = _options.NodeId.Value,
Signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.AuthSecure, _options.NodeId.Value, nonce.Nonce, serverEphemeralText, nodeEphemeralText)),
NodeEphemeral = nodeEphemeralText,
};
RpcMessage secureRequest = RpcMessage.Request(1, FleetMethods.Auth, authParams, FleetRpcContext.Default.AuthParams);
await transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(secureRequest, FleetRpcContext.Default.RpcMessage)), linked.Token);
transport.Enable(LinkCipher.ForNode(ephemeral, serverEphemeral, nonce.Nonce));
}
else
{
authParams = new AuthParams { NodeId = _options.NodeId.Value, Signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.Auth, _options.NodeId.Value, nonce.Nonce)) };
RpcMessage request = RpcMessage.Request(1, FleetMethods.Auth, authParams, FleetRpcContext.Default.AuthParams);
await transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(request, FleetRpcContext.Default.RpcMessage)), linked.Token); await transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(request, FleetRpcContext.Default.RpcMessage)), linked.Token);
}
}
finally
{
ephemeral?.Dispose();
}
while (true) while (true)
{ {
@@ -326,26 +256,7 @@ public sealed class FleetLink
} }
} }
private static bool TryDecodeBase64(string? text, out byte[] data) private static async Task<RpcMessage?> ReceiveMessageAsync(WsTransport transport, CancellationToken ct)
{
data = [];
if (string.IsNullOrEmpty(text))
{
return false;
}
try
{
data = Convert.FromBase64String(text);
return true;
}
catch (FormatException)
{
return false;
}
}
private static async Task<RpcMessage?> ReceiveMessageAsync(SecureTransport transport, CancellationToken ct)
{ {
RpcFrame? frame = await transport.ReceiveAsync(ct); RpcFrame? frame = await transport.ReceiveAsync(ct);
return frame is { Kind: FrameKind.Text } f ? JsonSerializer.Deserialize(f.Data.Span, FleetRpcContext.Default.RpcMessage) : null; return frame is { Kind: FrameKind.Text } f ? JsonSerializer.Deserialize(f.Data.Span, FleetRpcContext.Default.RpcMessage) : null;
+10 -38
View File
@@ -22,15 +22,11 @@ public sealed class PeerListenerOptions
/// <summary>按对端 ID 查找允许连接的公钥(SPKI)。不在列表里的对端一律拒绝(期望状态里的 peers)。</summary> /// <summary>按对端 ID 查找允许连接的公钥(SPKI)。不在列表里的对端一律拒绝(期望状态里的 peers)。</summary>
public required Func<string, byte[]?> LookupPeerKey { get; init; } public required Func<string, byte[]?> LookupPeerKey { get; init; }
/// <summary>预握手敲门:每个连接先过这一关(名册里的 Probe 才算得出敲门包),不通过就直接断开,什么都不写。</summary>
public required KnockGate Knock { get; init; }
public int MaxFrameBytes { get; init; } = 8 << 20; public int MaxFrameBytes { get; init; } = 8 << 20;
public int MaxSessions { get; init; } = 8; public int MaxSessions { get; init; } = 8;
/// <summary>敲门、TLS、挑战应答整个握手的上限。线路差的时候(RTT 好几秒、还丢包)握手本来就要很久,10 秒做不完就会一直连不上;敲门已经挡住了不认识的连接,放宽不会放大被占连接的风险。</summary> public TimeSpan HandshakeTimeout { get; init; } = TimeSpan.FromSeconds(10);
public TimeSpan HandshakeTimeout { get; init; } = PeerTimings.Handshake;
} }
/// <summary> /// <summary>
@@ -61,11 +57,6 @@ public sealed class PeerListener
public async Task RunAsync(CancellationToken ct) public async Task RunAsync(CancellationToken ct)
{ {
var listener = new TcpListener(_options.Listen); var listener = new TcpListener(_options.Listen);
if (_options.Listen.Address.Equals(IPAddress.IPv6Any))
{
listener.Server.DualMode = true;
}
listener.Start(); listener.Start();
Port = ((IPEndPoint)listener.LocalEndpoint).Port; Port = ((IPEndPoint)listener.LocalEndpoint).Port;
Started.TrySetResult(); Started.TrySetResult();
@@ -102,13 +93,7 @@ public sealed class PeerListener
PeerSockets.EnableKeepAlive(client.Client); PeerSockets.EnableKeepAlive(client.Client);
using var timeout = new CancellationTokenSource(_options.HandshakeTimeout, _time); using var timeout = new CancellationTokenSource(_options.HandshakeTimeout, _time);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token); using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
Stream stream = client.GetStream(); var tls = new SslStream(client.GetStream(), leaveInnerStreamOpen: false);
if (!await _options.Knock.AdmitAsync(stream, (client.Client.RemoteEndPoint as IPEndPoint)?.Address, linked.Token))
{
return;
}
var tls = new SslStream(stream, leaveInnerStreamOpen: false);
await tls.AuthenticateAsServerAsync( await tls.AuthenticateAsServerAsync(
new SslServerAuthenticationOptions { ServerCertificate = _options.Certificate, ClientCertificateRequired = false, EnabledSslProtocols = System.Security.Authentication.SslProtocols.Tls12 | System.Security.Authentication.SslProtocols.Tls13 }, new SslServerAuthenticationOptions { ServerCertificate = _options.Certificate, ClientCertificateRequired = false, EnabledSslProtocols = System.Security.Authentication.SslProtocols.Tls12 | System.Security.Authentication.SslProtocols.Tls13 },
linked.Token); linked.Token);
@@ -210,8 +195,7 @@ public sealed class PeerListener
} }
/// <summary>发起点对点会话(Probe 一侧):按 SPKI 指纹固定对端证书,用身份私钥签名,派生同样的数据面凭据。</summary> /// <summary>发起点对点会话(Probe 一侧):按 SPKI 指纹固定对端证书,用身份私钥签名,派生同样的数据面凭据。</summary>
/// <summary>点对点会话的套接字:开启 TCP keepalive(空闲 30 秒后每 10 秒探测,连续 6 次无响应才断开,约 90 秒),主机真的没了的时候连接不会一直挂着; /// <summary>点对点会话的套接字:开启 TCP keepalive(空闲 15 秒后每 5 秒探测,连续 3 次无响应就断开,约 30 秒),主机丢失或路径被切断时连接不会一直挂着。</summary>
/// 但线路短暂中断(几十秒的黑洞)不能把会话杀掉——线路中断本来就是要测的东西,会话得扛过去,恢复之后接着测。</summary>
internal static class PeerSockets internal static class PeerSockets
{ {
public static void EnableKeepAlive(Socket socket) public static void EnableKeepAlive(Socket socket)
@@ -219,9 +203,9 @@ internal static class PeerSockets
try try
{ {
socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true); socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true);
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, 30); socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, 15);
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, 10); socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, 5);
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, 6); socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, 3);
} }
catch (Exception ex) when (ex is SocketException or ArgumentException or PlatformNotSupportedException) catch (Exception ex) when (ex is SocketException or ArgumentException or PlatformNotSupportedException)
{ {
@@ -230,32 +214,20 @@ internal static class PeerSockets
} }
} }
/// <summary>点对点会话的时间常数,Probe 和 Agent 两侧共用。</summary>
public static class PeerTimings
{
/// <summary>建立会话(连接、敲门、TLS、挑战应答)的总时限。</summary>
public static readonly TimeSpan Handshake = TimeSpan.FromSeconds(30);
}
public static class PeerConnector public static class PeerConnector
{ {
public static async Task<(PeerSessionInfo Info, TlsFrameTransport Transport)> ConnectAsync( public static async Task<(PeerSessionInfo Info, TlsFrameTransport Transport)> ConnectAsync(
string host, int port, byte[] agentPublicKey, NodeIdentity identity, string peerId, string? version, int maxFrameBytes, TimeProvider time, CancellationToken ct) string host, int port, byte[] expectedSpkiSha256, NodeIdentity identity, string peerId, string? version, int maxFrameBytes, TimeProvider time, CancellationToken ct)
{ {
// 证书按公钥的指纹固定;敲门密钥由双方身份密钥派生 var client = new TcpClient { NoDelay = true };
byte[] expectedSpkiSha256 = NodeIdentity.Fingerprint(agentPublicKey);
TcpClient client = IPAddress.TryParse(host, out IPAddress? address) ? new TcpClient(address.AddressFamily) : new TcpClient();
client.NoDelay = true;
PeerSockets.EnableKeepAlive(client.Client); PeerSockets.EnableKeepAlive(client.Client);
try try
{ {
using var timeout = new CancellationTokenSource(PeerTimings.Handshake, time); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10), time);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token); using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
await client.ConnectAsync(host, port, linked.Token); await client.ConnectAsync(host, port, linked.Token);
Stream stream = client.GetStream();
await stream.WriteAsync(Knock.Build(Knock.DeriveKey(identity, agentPublicKey), time.GetUtcNow().ToUnixTimeMilliseconds()), linked.Token);
var tls = new SslStream( var tls = new SslStream(
stream, leaveInnerStreamOpen: false, client.GetStream(), leaveInnerStreamOpen: false,
(_, certificate, _, _) => certificate is not null && SpkiMatches(certificate, expectedSpkiSha256)); (_, certificate, _, _) => certificate is not null && SpkiMatches(certificate, expectedSpkiSha256));
await tls.AuthenticateAsClientAsync(new SslClientAuthenticationOptions { TargetHost = host }, linked.Token); await tls.AuthenticateAsClientAsync(new SslClientAuthenticationOptions { TargetHost = host }, linked.Token);
var transport = new TlsFrameTransport(tls, maxFrameBytes); var transport = new TlsFrameTransport(tls, maxFrameBytes);
@@ -1,57 +0,0 @@
using Fleet.Identity;
using Fleet.Rpc;
namespace Fleet.Link;
/// <summary>
/// 认证阶段是明文,认证通过之后换成应用层加密(<see cref="LinkCipher"/>):此后每帧都是加密的二进制帧,明文帧一律拒绝。
/// 认证阶段服务器的拒绝回复可能是明文(还没有协商好密钥),所以第一帧加密的回复到达之前允许一次明文文本帧。
/// </summary>
internal sealed class SecureTransport : IRpcTransport
{
private readonly IRpcTransport _inner;
private LinkCipher? _cipher;
private bool _allowPlaintext = true;
public SecureTransport(IRpcTransport inner)
{
_inner = inner;
}
public bool Secure => _cipher is not null;
public void Enable(LinkCipher cipher) => _cipher = cipher;
public ValueTask SendAsync(RpcFrame frame, CancellationToken ct) =>
_cipher is null ? _inner.SendAsync(frame, ct) : _inner.SendAsync(new RpcFrame(FrameKind.Binary, _cipher.Seal((byte)frame.Kind, frame.Data.Span)), ct);
public async ValueTask<RpcFrame?> ReceiveAsync(CancellationToken ct)
{
RpcFrame? frame = await _inner.ReceiveAsync(ct);
if (frame is not { } received || _cipher is null)
{
return frame;
}
if (received.Kind == FrameKind.Binary && _cipher.TryOpen(received.Data.Span, out byte kind, out byte[] data))
{
_allowPlaintext = false;
return new RpcFrame((FrameKind)kind, data);
}
if (_allowPlaintext && received.Kind == FrameKind.Text)
{
return received; // 认证被拒绝时服务器还没有协商好密钥,回复是明文
}
throw new IOException("a frame on the secure link failed to authenticate");
}
public ValueTask CloseAsync(string reason, CancellationToken ct) => _inner.CloseAsync(reason, ct);
public async ValueTask DisposeAsync()
{
_cipher?.Dispose();
await _inner.DisposeAsync();
}
}
+4 -44
View File
@@ -10,36 +10,15 @@ public static class FleetMethods
public const string Auth = "fleet.auth"; public const string Auth = "fleet.auth";
public const string Hello = "fleet.hello"; public const string Hello = "fleet.hello";
public const string Heartbeat = "fleet.heartbeat"; public const string Heartbeat = "fleet.heartbeat";
public const string Stopping = "fleet.stopping";
public const string StateSync = "fleet.state.sync"; public const string StateSync = "fleet.state.sync";
public const string OutboxAck = "fleet.outbox.ack"; public const string OutboxAck = "fleet.outbox.ack";
public const string Revoked = "fleet.revoked"; public const string Revoked = "fleet.revoked";
} }
/// <summary> /// <summary>二进制通道:0–15 保留给框架,产品从 16 开始。</summary>
/// 框架自己的方法声明,两个平面各一份。认证和握手阶段由连接代码直接处理的(<c>Inline</c>)不经过路由器。 public static class FleetChannels
/// 同一个方法名在两个平面里可以有不同的发起方:<c>fleet.state.sync</c> 在节点面是服务端发给节点,在对等面是 Agent 发给 Probe。
/// </summary>
public static class FleetMethodTable
{ {
public static readonly MethodTable Table = new( public const byte FirstProductChannel = 16;
[
// 节点面
MethodSpec.Notification(FleetMethods.Challenge, RpcSide.Server).AsInline(),
MethodSpec.Request(FleetMethods.Auth, RpcSide.Node).AsInline(),
MethodSpec.Request(FleetMethods.Hello, RpcSide.Node).AsInline(),
MethodSpec.Notification(FleetMethods.Heartbeat, RpcSide.Node).AsInline(),
MethodSpec.Notification(FleetMethods.Stopping, RpcSide.Node).AsInline(),
MethodSpec.Request(FleetMethods.StateSync, RpcSide.Server),
MethodSpec.Notification(FleetMethods.OutboxAck, RpcSide.Server),
MethodSpec.Notification(FleetMethods.Revoked, RpcSide.Server),
// 对等面
MethodSpec.Notification(PeerMethods.Challenge, RpcSide.Listener).AsInline(),
MethodSpec.Request(PeerMethods.Auth, RpcSide.Dialer).AsInline(),
MethodSpec.Request(FleetMethods.StateSync, RpcSide.Listener),
MethodSpec.Notification(FleetMethods.OutboxAck, RpcSide.Listener),
]);
} }
public sealed class ChallengeParams public sealed class ChallengeParams
@@ -49,14 +28,6 @@ public sealed class ChallengeParams
[JsonPropertyName("expires_at_ms")] [JsonPropertyName("expires_at_ms")]
public long ExpiresAtMs { get; set; } public long ExpiresAtMs { get; set; }
/// <summary>服务器这次连接的临时 ECDH 公钥(SPKI,base64);服务器有身份密钥时才有。</summary>
[JsonPropertyName("server_eph")]
public string? ServerEphemeral { get; set; }
/// <summary>服务器密钥对「随机数加临时公钥」的签名(base64):节点据此确认对面是操作员授权的服务器。</summary>
[JsonPropertyName("server_sig")]
public string? ServerSignature { get; set; }
} }
public sealed class AuthParams public sealed class AuthParams
@@ -67,10 +38,6 @@ public sealed class AuthParams
/// <summary>对 <c>fleet|v1|auth|{node_id}|{nonce}</c> 的签名(base64)。</summary> /// <summary>对 <c>fleet|v1|auth|{node_id}|{nonce}</c> 的签名(base64)。</summary>
[JsonPropertyName("signature")] [JsonPropertyName("signature")]
public string Signature { get; set; } = ""; public string Signature { get; set; } = "";
/// <summary>节点的临时 ECDH 公钥(SPKI,base64):有它就是安全链路,签名覆盖双方的临时公钥,认证结果起所有帧都是加密的。</summary>
[JsonPropertyName("node_eph")]
public string? NodeEphemeral { get; set; }
} }
public sealed class AuthResult public sealed class AuthResult
@@ -91,7 +58,7 @@ public sealed class HelloParams
[JsonPropertyName("now_utc_ms")] [JsonPropertyName("now_utc_ms")]
public long NowUtcMs { get; set; } public long NowUtcMs { get; set; }
/// <summary>产品自定义的元数据(例如 MoShiro 的 tls_spki、probe_ports、local_limits 摘要)。</summary> /// <summary>产品自定义的元数据(例如 Monitor 的 tls_spki、probe_ports、local_limits 摘要)。</summary>
[JsonPropertyName("meta")] [JsonPropertyName("meta")]
public JsonElement? Meta { get; set; } public JsonElement? Meta { get; set; }
} }
@@ -158,12 +125,6 @@ public sealed class RevokedParams
public string Reason { get; set; } = ""; public string Reason { get; set; } = "";
} }
public sealed class StoppingParams
{
[JsonPropertyName("reason")]
public string Reason { get; set; } = "stopping";
}
[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] [JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
[JsonSerializable(typeof(RpcMessage))] [JsonSerializable(typeof(RpcMessage))]
[JsonSerializable(typeof(ChallengeParams))] [JsonSerializable(typeof(ChallengeParams))]
@@ -172,7 +133,6 @@ public sealed class StoppingParams
[JsonSerializable(typeof(HelloParams))] [JsonSerializable(typeof(HelloParams))]
[JsonSerializable(typeof(HelloResult))] [JsonSerializable(typeof(HelloResult))]
[JsonSerializable(typeof(HeartbeatParams))] [JsonSerializable(typeof(HeartbeatParams))]
[JsonSerializable(typeof(StoppingParams))]
[JsonSerializable(typeof(StateSyncParams))] [JsonSerializable(typeof(StateSyncParams))]
[JsonSerializable(typeof(StateSyncResult))] [JsonSerializable(typeof(StateSyncResult))]
[JsonSerializable(typeof(OutboxAckParams))] [JsonSerializable(typeof(OutboxAckParams))]
-120
View File
@@ -1,120 +0,0 @@
namespace Fleet.Rpc;
/// <summary>连接的哪一侧。节点面:服务端和节点;对等面:拨号方(Probe)和监听方(Agent)。</summary>
public enum RpcSide
{
Server,
Node,
Dialer,
Listener,
}
public enum RpcPlane
{
Node,
Peer,
}
public enum MethodKind
{
Request,
Notification,
}
public static class RpcSides
{
public static RpcPlane PlaneOf(RpcSide side) => side is RpcSide.Server or RpcSide.Node ? RpcPlane.Node : RpcPlane.Peer;
public static RpcSide Opposite(RpcSide side) => side switch
{
RpcSide.Server => RpcSide.Node,
RpcSide.Node => RpcSide.Server,
RpcSide.Dialer => RpcSide.Listener,
_ => RpcSide.Dialer,
};
}
/// <summary>
/// 一个方法的声明:<b>只有 <see cref="From"/> 能发起它</b>(不存在双向方法),类型是请求还是通知,哪些节点角色可以参与。
/// <see cref="Inline"/> 表示它在认证或握手阶段由连接代码直接处理,不经过路由器。
/// </summary>
public sealed record MethodSpec(string Name, RpcSide From, MethodKind Kind, IReadOnlyList<string>? Roles = null, bool Inline = false)
{
public RpcPlane Plane => RpcSides.PlaneOf(From);
/// <summary>接收方。</summary>
public RpcSide To => RpcSides.Opposite(From);
public bool AllowsRole(string? role) => Roles is null || (role is not null && Roles.Contains(role));
public static MethodSpec Request(string name, RpcSide from, params string[] roles) => new(name, from, MethodKind.Request, roles.Length == 0 ? null : roles);
public static MethodSpec Notification(string name, RpcSide from, params string[] roles) => new(name, from, MethodKind.Notification, roles.Length == 0 ? null : roles);
public MethodSpec AsInline() => this with { Inline = true };
}
/// <summary>二进制通道的声明:谁能发、哪些角色可以发。通道 0–15 保留给框架,产品从 16 开始。</summary>
public sealed record ChannelSpec(byte Channel, RpcSide From, IReadOnlyList<string>? Roles = null)
{
public RpcPlane Plane => RpcSides.PlaneOf(From);
public bool AllowsRole(string? role) => Roles is null || (role is not null && Roles.Contains(role));
}
/// <summary>
/// 协议方法和二进制通道的声明表,单一真源。路由器用它在注册时拒绝方向、类型、角色不符的处理函数,
/// 在收发时拒绝没有声明或不该由对方发起的方法。同一个方法名在不同平面里可以有不同的发起方,所以按(平面,名字)查找。
/// </summary>
public sealed class MethodTable
{
private readonly Dictionary<(RpcPlane, string), MethodSpec> _methods = [];
private readonly Dictionary<(RpcPlane, byte), ChannelSpec> _channels = [];
public MethodTable(IEnumerable<MethodSpec> methods, IEnumerable<ChannelSpec>? channels = null)
{
foreach (MethodSpec spec in methods)
{
if (!_methods.TryAdd((spec.Plane, spec.Name), spec))
{
throw new ArgumentException($"method {spec.Name} is declared twice in the {spec.Plane} plane");
}
}
foreach (ChannelSpec spec in channels ?? [])
{
if (!_channels.TryAdd((spec.Plane, spec.Channel), spec))
{
throw new ArgumentException($"channel {spec.Channel} is declared twice in the {spec.Plane} plane");
}
}
}
public IEnumerable<MethodSpec> Methods => _methods.Values;
public IEnumerable<ChannelSpec> Channels => _channels.Values;
public MethodSpec? Find(RpcPlane plane, string method) => _methods.GetValueOrDefault((plane, method));
public ChannelSpec? FindChannel(RpcPlane plane, byte channel) => _channels.GetValueOrDefault((plane, channel));
/// <summary>合并多张表(框架的加产品的)。</summary>
public static MethodTable Combine(params MethodTable[] tables) =>
new(tables.SelectMany(t => t.Methods), tables.SelectMany(t => t.Channels));
/// <summary>
/// 对账:<paramref name="receiver"/> 这一侧、<paramref name="role"/> 这个角色应该处理、但没有注册处理函数的方法
/// (认证阶段直接处理的 <see cref="MethodSpec.Inline"/> 方法除外)。<paramref name="role"/> 为空表示不限角色(服务端要处理所有角色发来的)。
/// </summary>
public IReadOnlyList<string> MissingHandlers(RpcSide receiver, string? role, IEnumerable<string> registered)
{
HashSet<string> have = [.. registered];
return
[
.. Methods
.Where(m => m.To == receiver && !m.Inline && (role is null || m.AllowsRole(role)) && !have.Contains(m.Name))
.Select(m => m.Name)
.Order(StringComparer.Ordinal),
];
}
}
+7 -127
View File
@@ -4,17 +4,7 @@ using Fleet.Core;
namespace Fleet.Rpc; namespace Fleet.Rpc;
/// <summary>对端违反了协议:方法没有声明、方向不对、请求和通知用反了、或者角色不允许。</summary> /// <summary>方法路由,与传输方式无关。<typeparamref name="TContext"/> 由传输层提供(例如调用方节点)。</summary>
public readonly record struct RpcViolation(string Method, string Reason);
/// <summary>一次分发的结果:要回的响应(通知为空),以及对端有没有违规。</summary>
public readonly record struct RpcDispatch(RpcMessage? Response, RpcViolation? Violation);
/// <summary>
/// 方法路由,与传输方式无关,也是协议方向的<b>唯一卡口</b>:节点和服务端、Probe 和 Agent 的入站分发都经过这里。
/// <typeparamref name="TContext"/> 由传输层提供(例如调用方节点)。路由器知道自己在连接的哪一侧(<see cref="Side"/>)和声明表,
/// 注册处理函数时核对方向、类型和角色,分发时拒绝没有声明或不该由对方发起的方法,发送时核对自己有没有资格发起。
/// </summary>
public sealed class RpcRouter<TContext> public sealed class RpcRouter<TContext>
{ {
private delegate ValueTask<Result<JsonElement?>> Route(TContext context, JsonElement? parameters, CancellationToken ct); private delegate ValueTask<Result<JsonElement?>> Route(TContext context, JsonElement? parameters, CancellationToken ct);
@@ -23,28 +13,12 @@ public sealed class RpcRouter<TContext>
private readonly Dictionary<string, Route> _routes = new(StringComparer.Ordinal); private readonly Dictionary<string, Route> _routes = new(StringComparer.Ordinal);
private readonly Action<string, Exception>? _onUnhandled; private readonly Action<string, Exception>? _onUnhandled;
private readonly string? _localRole;
private readonly Func<TContext, string?>? _callerRole;
/// <param name="side">本路由器在连接的哪一侧。</param> public RpcRouter(Action<string, Exception>? onUnhandled = null)
/// <param name="table">方法声明表。</param>
/// <param name="localRole">本节点的角色(节点侧和对等面用):声明限定了角色的方法,角色不符就不能注册。</param>
/// <param name="callerRole">从调用上下文取出对端的角色(服务端用):声明限定了角色的方法,角色不符就拒绝。</param>
public RpcRouter(RpcSide side, MethodTable table, string? localRole = null, Func<TContext, string?>? callerRole = null, Action<string, Exception>? onUnhandled = null)
{ {
Side = side;
Table = table;
_localRole = localRole;
_callerRole = callerRole;
_onUnhandled = onUnhandled; _onUnhandled = onUnhandled;
} }
public RpcSide Side { get; }
public MethodTable Table { get; }
private RpcPlane Plane => RpcSides.PlaneOf(Side);
public bool Has(string method) => _routes.ContainsKey(method); public bool Has(string method) => _routes.ContainsKey(method);
public IReadOnlyCollection<string> Methods => _routes.Keys; public IReadOnlyCollection<string> Methods => _routes.Keys;
@@ -55,7 +29,6 @@ public sealed class RpcRouter<TContext>
JsonTypeInfo<TResult> resultInfo, JsonTypeInfo<TResult> resultInfo,
Func<TContext, TParams, CancellationToken, ValueTask<Result<TResult>>> handler) Func<TContext, TParams, CancellationToken, ValueTask<Result<TResult>>> handler)
{ {
Declare(method, MethodKind.Request);
_routes[method] = async (context, raw, ct) => _routes[method] = async (context, raw, ct) =>
{ {
if (!TryDeserialize(raw, paramsInfo, out TParams? p)) if (!TryDeserialize(raw, paramsInfo, out TParams? p))
@@ -76,7 +49,6 @@ public sealed class RpcRouter<TContext>
JsonTypeInfo<TParams> paramsInfo, JsonTypeInfo<TParams> paramsInfo,
Func<TContext, TParams, CancellationToken, ValueTask> handler) Func<TContext, TParams, CancellationToken, ValueTask> handler)
{ {
Declare(method, MethodKind.Notification);
_routes[method] = async (context, raw, ct) => _routes[method] = async (context, raw, ct) =>
{ {
if (!TryDeserialize(raw, paramsInfo, out TParams? p)) if (!TryDeserialize(raw, paramsInfo, out TParams? p))
@@ -94,26 +66,12 @@ public sealed class RpcRouter<TContext>
/// 处理一条请求或通知。请求返回响应;通知返回 null(失败时只丢弃)。 /// 处理一条请求或通知。请求返回响应;通知返回 null(失败时只丢弃)。
/// 处理函数里的异常属于程序缺陷:在这个边界捕获,回复 internal 错误,并交给 <c>onUnhandled</c> 记录。 /// 处理函数里的异常属于程序缺陷:在这个边界捕获,回复 internal 错误,并交给 <c>onUnhandled</c> 记录。
/// </summary> /// </summary>
public async ValueTask<RpcMessage?> DispatchAsync(TContext context, RpcMessage message, CancellationToken ct) => public async ValueTask<RpcMessage?> DispatchAsync(TContext context, RpcMessage message, CancellationToken ct)
(await DispatchCheckedAsync(context, message, ct)).Response;
/// <summary>
/// 先核对声明(方向、类型、角色),再分发。违规的消息不会到达处理函数,返回的结果里带 <see cref="RpcViolation"/>,
/// 由会话决定怎么办(计数、超过上限就断开连接)。声明过、方向对、但这一侧没有处理函数的方法不算违规,只是不支持。
/// </summary>
public async ValueTask<RpcDispatch> DispatchCheckedAsync(TContext context, RpcMessage message, CancellationToken ct)
{ {
string method = message.Method ?? ""; string method = message.Method ?? "";
if (Check(context, message) is { } violation)
{
return new RpcDispatch(
message.Id is null ? null : RpcMessage.Failure(message.Id, new FleetError(FleetErrors.ProtocolViolation, violation.Reason)),
violation);
}
if (!_routes.TryGetValue(method, out Route? route)) if (!_routes.TryGetValue(method, out Route? route))
{ {
return new RpcDispatch(message.Id is null ? null : RpcMessage.Failure(message.Id, new FleetError(FleetErrors.Unsupported, $"unknown method {method}")), null); return message.Id is null ? null : RpcMessage.Failure(message.Id, new FleetError(FleetErrors.Unsupported, $"unknown method {method}"));
} }
Result<JsonElement?> result; Result<JsonElement?> result;
@@ -133,90 +91,12 @@ public sealed class RpcRouter<TContext>
if (message.Id is null) if (message.Id is null)
{ {
return new RpcDispatch(null, null);
}
return new RpcDispatch(
result.TryGet(out JsonElement? value, out FleetError? error)
? new RpcMessage { Id = message.Id, Result = value ?? s_emptyObject }
: RpcMessage.Failure(message.Id, error),
null);
}
/// <summary>入站检查:这条消息是不是对方有资格发起的、类型对不对、对方的角色允不允许。</summary>
public RpcViolation? Check(TContext context, RpcMessage message)
{
string method = message.Method ?? "";
MethodSpec? spec = Table.Find(Plane, method);
if (spec is null)
{
return new RpcViolation(method, $"method {method} is not declared");
}
if (spec.To != Side)
{
return new RpcViolation(method, $"{method} cannot be initiated by the {RpcSides.Opposite(Side)} side");
}
MethodKind kind = message.Id is null ? MethodKind.Notification : MethodKind.Request;
if (spec.Kind != kind)
{
return new RpcViolation(method, $"{method} is a {spec.Kind.ToString().ToLowerInvariant()}");
}
if (_callerRole is not null && !spec.AllowsRole(_callerRole(context)))
{
return new RpcViolation(method, $"{method} is not allowed for this node role");
}
return null; return null;
} }
/// <summary> return result.TryGet(out JsonElement? value, out FleetError? error)
/// 发送前检查:本侧有没有资格发起这个方法、类型对不对、对端的角色允不允许(服务端发给节点时传入节点的角色)。 ? new RpcMessage { Id = message.Id, Result = value ?? s_emptyObject }
/// 违反是程序缺陷,抛 <see cref="InvalidOperationException"/>。 : RpcMessage.Failure(message.Id, error);
/// </summary>
public void CheckSend(string method, MethodKind kind, string? peerRole = null)
{
MethodSpec? spec = Table.Find(Plane, method);
if (spec is null)
{
throw new InvalidOperationException($"method {method} is not declared");
}
if (spec.From != Side)
{
throw new InvalidOperationException($"{method} cannot be initiated by the {Side} side");
}
if (spec.Kind != kind)
{
throw new InvalidOperationException($"{method} is a {spec.Kind.ToString().ToLowerInvariant()}");
}
if (peerRole is not null && !spec.AllowsRole(peerRole))
{
throw new InvalidOperationException($"{method} cannot be sent to a {peerRole}");
}
}
private void Declare(string method, MethodKind kind)
{
MethodSpec spec = Table.Find(Plane, method) ?? throw new InvalidOperationException($"method {method} is not declared in the method table");
if (spec.To != Side)
{
throw new InvalidOperationException($"{method} is initiated by the {spec.From} side and cannot be handled on the {Side} side");
}
if (spec.Kind != kind)
{
throw new InvalidOperationException($"{method} is declared as a {spec.Kind.ToString().ToLowerInvariant()}");
}
if (_localRole is not null && !spec.AllowsRole(_localRole))
{
throw new InvalidOperationException($"{method} is not for the {_localRole} role");
}
} }
private static bool TryDeserialize<T>(JsonElement? raw, JsonTypeInfo<T> info, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out T? value) private static bool TryDeserialize<T>(JsonElement? raw, JsonTypeInfo<T> info, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out T? value)
+6 -27
View File
@@ -34,9 +34,6 @@ public sealed class RpcSessionOptions
/// <summary>待处理的入站请求上限;超过时接收循环等待(背压)。</summary> /// <summary>待处理的入站请求上限;超过时接收循环等待(背压)。</summary>
public int InboundQueue { get; set; } = 256; public int InboundQueue { get; set; } = 256;
/// <summary>对端违反协议(方法没有声明、方向、类型或角色不对)的次数上限;超过就断开会话。</summary>
public int MaxViolations { get; set; } = 20;
} }
/// <summary> /// <summary>
@@ -56,8 +53,6 @@ public sealed class RpcSession<TContext> : IAsyncDisposable
private readonly CancellationTokenSource _stop = new(); private readonly CancellationTokenSource _stop = new();
private long _nextId; private long _nextId;
private long _lastReceive; private long _lastReceive;
private int _violations;
private bool _violated;
public RpcSession(IRpcTransport transport, RpcRouter<TContext> router, TContext context, TimeProvider time, RpcSessionOptions? options = null) public RpcSession(IRpcTransport transport, RpcRouter<TContext> router, TContext context, TimeProvider time, RpcSessionOptions? options = null)
{ {
@@ -86,7 +81,7 @@ public sealed class RpcSession<TContext> : IAsyncDisposable
while (!linked.IsCancellationRequested) while (!linked.IsCancellationRequested)
{ {
RpcFrame? frame = await _transport.ReceiveAsync(linked.Token); RpcFrame? frame = await _transport.ReceiveAsync(linked.Token);
if (frame is null || Volatile.Read(ref _violated)) if (frame is null)
{ {
return; return;
} }
@@ -189,16 +184,8 @@ public sealed class RpcSession<TContext> : IAsyncDisposable
public ValueTask NotifyAsync<TParams>(string method, TParams parameters, JsonTypeInfo<TParams> paramsInfo, CancellationToken ct) => public ValueTask NotifyAsync<TParams>(string method, TParams parameters, JsonTypeInfo<TParams> paramsInfo, CancellationToken ct) =>
SendAsync(RpcMessage.Notification(method, parameters, paramsInfo), ct); SendAsync(RpcMessage.Notification(method, parameters, paramsInfo), ct);
public ValueTask SendAsync(RpcMessage message, CancellationToken ct) public ValueTask SendAsync(RpcMessage message, CancellationToken ct) =>
{ _transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(message, FleetRpcContext.Default.RpcMessage)), ct);
if (message.Method is not null)
{
// 请求和通知只能发本侧有资格发起的方法(响应没有方法名,不检查)
_router.CheckSend(message.Method, message.Id is null ? MethodKind.Notification : MethodKind.Request);
}
return _transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(message, FleetRpcContext.Default.RpcMessage)), ct);
}
public ValueTask SendBinaryAsync(ReadOnlyMemory<byte> frame, CancellationToken ct) => public ValueTask SendBinaryAsync(ReadOnlyMemory<byte> frame, CancellationToken ct) =>
_transport.SendAsync(new RpcFrame(FrameKind.Binary, frame), ct); _transport.SendAsync(new RpcFrame(FrameKind.Binary, frame), ct);
@@ -218,18 +205,10 @@ public sealed class RpcSession<TContext> : IAsyncDisposable
{ {
await foreach (RpcMessage message in _inbound.Reader.ReadAllAsync(ct)) await foreach (RpcMessage message in _inbound.Reader.ReadAllAsync(ct))
{ {
RpcDispatch outcome = await _router.DispatchCheckedAsync(_context, message, ct); RpcMessage? response = await _router.DispatchAsync(_context, message, ct);
if (outcome.Response is not null) if (response is not null)
{ {
await SendAsync(outcome.Response, ct); await SendAsync(response, ct);
}
if (outcome.Violation is not null && ++_violations > _options.MaxViolations)
{
// 只关闭传输,不取消接收:接收循环在下一次读到帧或对端确认关闭时正常结束
Volatile.Write(ref _violated, true);
await _transport.CloseAsync("protocol violations", CancellationToken.None);
return;
} }
} }
} }
@@ -2,12 +2,9 @@ namespace Fleet.Server;
public sealed class FleetServerOptions public sealed class FleetServerOptions
{ {
/// <summary>节点 ID 的类型前缀,MoShiro 使用 <c>agt</c>。</summary> /// <summary>节点 ID 的类型前缀,Monitor 使用 <c>agt</c>。</summary>
public string NodeIdPrefix { get; set; } = "node"; public string NodeIdPrefix { get; set; } = "node";
/// <summary>加入令牌的「种类」对应的节点 ID 前缀(例如 <c>probe</c> → <c>prb</c>);没有列出的种类用 <see cref="NodeIdPrefix"/>。</summary>
public Dictionary<string, string> NodeIdPrefixes { get; } = new(StringComparer.Ordinal);
public int MaxFrameBytes { get; set; } = 16 << 20; public int MaxFrameBytes { get; set; } = 16 << 20;
public TimeSpan ChallengeTtl { get; set; } = TimeSpan.FromSeconds(30); public TimeSpan ChallengeTtl { get; set; } = TimeSpan.FromSeconds(30);
@@ -15,7 +12,4 @@ public sealed class FleetServerOptions
public TimeSpan HeartbeatTimeout { get; set; } = TimeSpan.FromSeconds(45); public TimeSpan HeartbeatTimeout { get; set; } = TimeSpan.FromSeconds(45);
public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(15); public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(15);
/// <summary>节点违反协议(方法没有声明、方向、类型或角色不对)的次数上限;超过就断开这条连接。</summary>
public int MaxViolations { get; set; } = 20;
} }
@@ -2,7 +2,7 @@ using Fleet.Core;
namespace Fleet.Server; namespace Fleet.Server;
/// <summary>Fleet 服务端需要的存储。产品可以自己实现(MoShiro 把它放进 moshiro.db)。</summary> /// <summary>Fleet 服务端需要的存储。产品可以自己实现(Monitor 把它放进 monitor.db)。</summary>
public interface IFleetStore public interface IFleetStore
{ {
ValueTask<JoinTokenRecord?> FindJoinTokenAsync(ReadOnlyMemory<byte> tokenSha256, CancellationToken ct); ValueTask<JoinTokenRecord?> FindJoinTokenAsync(ReadOnlyMemory<byte> tokenSha256, CancellationToken ct);
@@ -31,10 +31,7 @@ public sealed record NewNode(
IReadOnlyList<string> Labels, IReadOnlyList<string> Labels,
string? MetaJson, string? MetaJson,
string? NodeVersion, string? NodeVersion,
long NowMs, long NowMs);
string Kind = "",
byte[]? TlsPublicKey = null,
string? AddedBy = null);
public sealed record JoinTokenRecord( public sealed record JoinTokenRecord(
string Id, string Id,
@@ -46,8 +43,6 @@ public sealed record JoinTokenRecord(
int Uses, int Uses,
long ExpiresAtMs, long ExpiresAtMs,
long? RevokedAtMs, long? RevokedAtMs,
long CreatedAtMs, long CreatedAtMs);
string Kind = "",
string? CreatedBy = null);
public sealed record Presence(bool Online, long AtMs, string? NodeVersion, IReadOnlyList<string> Capabilities, string? HelloMetaJson); public sealed record Presence(bool Online, long AtMs, string? NodeVersion, IReadOnlyList<string> Capabilities, string? HelloMetaJson);
@@ -6,24 +6,6 @@ namespace Fleet.Server;
public sealed record EnrollmentContext(string? RemoteIp, JsonElement? ProductMeta); public sealed record EnrollmentContext(string? RemoteIp, JsonElement? ProductMeta);
/// <summary>
/// 服务器的身份密钥:操作员用 passkey 把它的公钥签进操作员集合。有它的时候,认证挑战会带上服务器的签名,节点据此确认对面是真的服务器,
/// 并在这条链路上用应用层加密。没有注册就是旧的行为(没有服务器身份)。
/// </summary>
public interface IServerIdentity
{
byte[] PublicKeySpki { get; }
/// <summary>对一段内容签名(<c>Fleet.Identity.ServerHello.Payload</c> 给出的)。</summary>
byte[] Sign(ReadOnlySpan<byte> payload);
}
/// <summary>操作员集合的链(passkey 签的,创世在前)。没有初始化信任根时是空的。节点注册时用它确认「这个面板确实是我的操作员的」。</summary>
public interface IOperatorTrustSource
{
ValueTask<IReadOnlyList<SignedStatementDto>> ChainAsync(CancellationToken ct);
}
/// <summary>产品挂钩:节点注册成功之后调用。</summary> /// <summary>产品挂钩:节点注册成功之后调用。</summary>
public interface IEnrollmentHook public interface IEnrollmentHook
{ {
@@ -38,7 +20,7 @@ public enum NodeEventKind
Heartbeat, Heartbeat,
} }
public sealed record NodeEvent(NodeEventKind Kind, NodeRecord Node, long AtMs, HelloParams? Hello = null, JsonElement? Extra = null, string? Reason = null); public sealed record NodeEvent(NodeEventKind Kind, NodeRecord Node, long AtMs, HelloParams? Hello = null, JsonElement? Extra = null);
public interface INodeEventSink public interface INodeEventSink
{ {
@@ -19,13 +19,6 @@ public sealed class EnrollRequest
[JsonPropertyName("tls_spki")] [JsonPropertyName("tls_spki")]
public string TlsSpki { get; set; } = ""; public string TlsSpki { get; set; } = "";
/// <summary>
/// 同一把 TLS 密钥的完整公钥(SPKI,base64),必须和 <see cref="TlsSpki"/> 对得上。节点用这把密钥做对等面的签名认证
/// (和连服务端用的身份密钥是两把独立的密钥),对端要验签就需要完整的公钥,而不只是指纹。
/// </summary>
[JsonPropertyName("tls_pubkey")]
public string TlsPublicKey { get; set; } = "";
/// <summary>持有证明:对 <c>fleet|v1|enroll|{sha256_hex(join_token)}|{pubkey}</c> 的签名(base64)。</summary> /// <summary>持有证明:对 <c>fleet|v1|enroll|{sha256_hex(join_token)}|{pubkey}</c> 的签名(base64)。</summary>
[JsonPropertyName("proof")] [JsonPropertyName("proof")]
public string Proof { get; set; } = ""; public string Proof { get; set; } = "";
@@ -37,12 +30,6 @@ public sealed class EnrollRequest
public JsonElement? Meta { get; set; } public JsonElement? Meta { get; set; }
} }
public sealed class TrustRequest
{
[JsonPropertyName("join_token")]
public string JoinToken { get; set; } = "";
}
public sealed class EnrollResponse public sealed class EnrollResponse
{ {
[JsonPropertyName("node_id")] [JsonPropertyName("node_id")]
@@ -75,7 +62,5 @@ public sealed class FleetProblem
[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] [JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
[JsonSerializable(typeof(EnrollRequest))] [JsonSerializable(typeof(EnrollRequest))]
[JsonSerializable(typeof(EnrollResponse))] [JsonSerializable(typeof(EnrollResponse))]
[JsonSerializable(typeof(TrustRequest))]
[JsonSerializable(typeof(Fleet.Core.TrustBundle))]
[JsonSerializable(typeof(FleetProblem))] [JsonSerializable(typeof(FleetProblem))]
internal sealed partial class EnrollmentJsonContext : JsonSerializerContext; internal sealed partial class EnrollmentJsonContext : JsonSerializerContext;
@@ -20,42 +20,9 @@ public static class FleetEnrollmentEndpoints
.WithTags("agent") .WithTags("agent")
.WithSummary("Enroll a node with a join token") .WithSummary("Enroll a node with a join token")
.DisableAntiforgery(); .DisableAntiforgery();
// 注册之前取信任链:节点先确认这个面板的操作员集合和委托确实是它固定的创世签出来的,再把自己的密钥交出去
app.MapPost(path + "/trust", HandleTrustAsync)
.WithName("fleet_enroll_trust")
.WithTags("agent")
.WithSummary("The operator set chain and the grant of a join token, for the node to verify before it enrolls")
.DisableAntiforgery();
return app; return app;
} }
private static async Task<IResult> HandleTrustAsync(HttpContext http, FleetEnrollmentService service)
{
TrustRequest? request;
try
{
request = await JsonSerializer.DeserializeAsync(http.Request.Body, EnrollmentJsonContext.Default.TrustRequest, http.RequestAborted);
}
catch (JsonException)
{
request = null;
}
if (request is null || string.IsNullOrEmpty(request.JoinToken))
{
return Problem(StatusCodes.Status400BadRequest, FleetErrors.InvalidParams, "join_token is required");
}
Result<TrustBundle> result = await service.GetTrustAsync(request.JoinToken, http.RequestAborted);
if (!result.TryGet(out TrustBundle? bundle, out FleetError? error))
{
return Problem(error.Code == FleetErrors.JoinTokenExpired ? StatusCodes.Status410Gone : StatusCodes.Status401Unauthorized, error.Code, error.Message);
}
http.Response.Headers.CacheControl = "no-store";
return Results.Json(bundle, EnrollmentJsonContext.Default.TrustBundle);
}
private static async Task<IResult> HandleAsync(HttpContext http, FleetEnrollmentService service) private static async Task<IResult> HandleAsync(HttpContext http, FleetEnrollmentService service)
{ {
EnrollRequest? request; EnrollRequest? request;
@@ -9,15 +9,13 @@ public sealed partial class FleetEnrollmentService
{ {
private readonly IFleetStore _store; private readonly IFleetStore _store;
private readonly IEnumerable<IEnrollmentHook> _hooks; private readonly IEnumerable<IEnrollmentHook> _hooks;
private readonly IEnumerable<IOperatorTrustSource> _trust;
private readonly FleetServerOptions _options; private readonly FleetServerOptions _options;
private readonly TimeProvider _time; private readonly TimeProvider _time;
public FleetEnrollmentService(IFleetStore store, IEnumerable<IEnrollmentHook> hooks, IEnumerable<IOperatorTrustSource> trust, FleetServerOptions options, TimeProvider time) public FleetEnrollmentService(IFleetStore store, IEnumerable<IEnrollmentHook> hooks, FleetServerOptions options, TimeProvider time)
{ {
_store = store; _store = store;
_hooks = hooks; _hooks = hooks;
_trust = trust;
_options = options; _options = options;
_time = time; _time = time;
} }
@@ -31,16 +29,9 @@ public sealed partial class FleetEnrollmentService
if (!TryDecode(request.PublicKey, out byte[] publicKey) || !NodeIdentity.IsValidPublicKey(publicKey) if (!TryDecode(request.PublicKey, out byte[] publicKey) || !NodeIdentity.IsValidPublicKey(publicKey)
|| !TryDecode(request.TlsSpki, out byte[] tlsSpki) || tlsSpki.Length != 32 || !TryDecode(request.TlsSpki, out byte[] tlsSpki) || tlsSpki.Length != 32
|| !TryDecode(request.TlsPublicKey, out byte[] tlsPublicKey) || !NodeIdentity.IsValidPublicKey(tlsPublicKey)
|| !TryDecode(request.Proof, out byte[] proof)) || !TryDecode(request.Proof, out byte[] proof))
{ {
return Result<EnrollResponse>.Fail(FleetErrors.InvalidParams, "pubkey, tls_spki, tls_pubkey or proof is malformed"); return Result<EnrollResponse>.Fail(FleetErrors.InvalidParams, "pubkey, tls_spki or proof is malformed");
}
// 指纹和完整公钥必须是同一把密钥,否则对端按指纹固定证书、按公钥验签,会各认各的
if (!System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(NodeIdentity.Fingerprint(tlsPublicKey), tlsSpki))
{
return Result<EnrollResponse>.Fail(FleetErrors.InvalidParams, "tls_spki does not match tls_pubkey");
} }
byte[] tokenHash = TokenHash.Sha256(request.JoinToken); byte[] tokenHash = TokenHash.Sha256(request.JoinToken);
@@ -70,9 +61,8 @@ public sealed partial class FleetEnrollmentService
bool nameTaken = await _store.FindEstablishedNodeByNameAsync(request.Name, ct) is not null; bool nameTaken = await _store.FindEstablishedNodeByNameAsync(request.Name, ct) is not null;
NodeStatus status = token.AutoApprove && !nameTaken ? NodeStatus.Active : NodeStatus.Pending; NodeStatus status = token.AutoApprove && !nameTaken ? NodeStatus.Active : NodeStatus.Pending;
string prefix = token.Kind.Length > 0 && _options.NodeIdPrefixes.TryGetValue(token.Kind, out string? kindPrefix) ? kindPrefix : _options.NodeIdPrefix;
var node = new NewNode( var node = new NewNode(
new NodeId(Ulid.NewPrefixed(prefix, _time)), new NodeId(Ulid.NewPrefixed(_options.NodeIdPrefix, _time)),
request.Name, request.Name,
status, status,
publicKey, publicKey,
@@ -80,10 +70,7 @@ public sealed partial class FleetEnrollmentService
token.Labels, token.Labels,
request.Meta?.GetRawText(), request.Meta?.GetRawText(),
request.NodeVersion, request.NodeVersion,
nowMs, nowMs);
token.Kind,
tlsPublicKey,
token.CreatedBy);
Result<NodeRecord> created = await _store.CreateNodeAsync(node, ct); Result<NodeRecord> created = await _store.CreateNodeAsync(node, ct);
if (!created.TryGet(out NodeRecord? record, out FleetError? error)) if (!created.TryGet(out NodeRecord? record, out FleetError? error))
@@ -104,30 +91,6 @@ public sealed partial class FleetEnrollmentService
}); });
} }
/// <summary>节点注册之前取:操作员集合的链。码要是有效的;不能用的码不给。</summary>
public async ValueTask<Result<TrustBundle>> GetTrustAsync(string joinToken, CancellationToken ct)
{
JoinTokenRecord? token = await _store.FindJoinTokenAsync(TokenHash.Sha256(joinToken), ct);
long nowMs = _time.GetUtcNow().ToUnixTimeMilliseconds();
if (token is null || token.RevokedAtMs is not null || token.Uses >= token.MaxUses)
{
return Result<TrustBundle>.Fail(FleetErrors.JoinTokenInvalid, "join token is invalid");
}
if (token.ExpiresAtMs <= nowMs)
{
return Result<TrustBundle>.Fail(FleetErrors.JoinTokenExpired, "join token has expired");
}
var bundle = new TrustBundle();
foreach (IOperatorTrustSource source in _trust)
{
bundle.Chain.AddRange(await source.ChainAsync(ct));
}
return Result<TrustBundle>.Ok(bundle);
}
private static bool TryDecode(string text, out byte[] data) private static bool TryDecode(string text, out byte[] data)
{ {
try try
@@ -23,10 +23,8 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier
IEnumerable<IStateProvider> stateProviders, IEnumerable<IStateProvider> stateProviders,
IEnumerable<IInbox> inboxes, IEnumerable<IInbox> inboxes,
IEnumerable<INodeEventSink> sinks, IEnumerable<INodeEventSink> sinks,
FleetRouter router, FleetRouter router)
IServerIdentity? serverIdentity = null)
{ {
ServerIdentity = serverIdentity;
Store = store; Store = store;
Options = options; Options = options;
Time = time; Time = time;
@@ -35,14 +33,10 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier
Inboxes = inboxes.ToDictionary(i => i.Channel); Inboxes = inboxes.ToDictionary(i => i.Channel);
Sinks = [.. sinks]; Sinks = [.. sinks];
Router = router.Inner; Router = router.Inner;
RoleOf = router.RoleOf;
} }
internal IFleetStore Store { get; } internal IFleetStore Store { get; }
/// <summary>服务器的身份密钥;没有时节点连接用旧的明文认证。</summary>
internal IServerIdentity? ServerIdentity { get; }
internal FleetServerOptions Options { get; } internal FleetServerOptions Options { get; }
internal TimeProvider Time { get; } internal TimeProvider Time { get; }
@@ -57,8 +51,6 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier
internal RpcRouter<NodeCallContext> Router { get; } internal RpcRouter<NodeCallContext> Router { get; }
internal Func<NodeRecord, string?>? RoleOf { get; }
public IReadOnlyCollection<NodeId> OnlineNodes => [.. _connections.Keys]; public IReadOnlyCollection<NodeId> OnlineNodes => [.. _connections.Keys];
public bool IsOnline(NodeId node) => _connections.ContainsKey(node); public bool IsOnline(NodeId node) => _connections.ContainsKey(node);
@@ -169,18 +161,10 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier
/// <summary>产品注册的 RPC 方法路由。</summary> /// <summary>产品注册的 RPC 方法路由。</summary>
public sealed class FleetRouter public sealed class FleetRouter
{ {
public FleetRouter(RpcRouter<NodeCallContext> inner, Func<NodeRecord, string?>? roleOf) public FleetRouter(RpcRouter<NodeCallContext> inner)
{ {
Inner = inner; Inner = inner;
RoleOf = roleOf;
} }
internal RpcRouter<NodeCallContext> Inner { get; } internal RpcRouter<NodeCallContext> Inner { get; }
internal Func<NodeRecord, string?>? RoleOf { get; }
/// <summary>已注册的入站方法名(只读,测试用它核对声明表)。</summary>
public IReadOnlyCollection<string> Methods => Inner.Methods;
public MethodTable Table => Inner.Table;
} }
@@ -19,9 +19,6 @@ internal static partial class FleetLog
[LoggerMessage(Level = LogLevel.Warning, Message = "state push failed node={Node} type={Type} err={Err}")] [LoggerMessage(Level = LogLevel.Warning, Message = "state push failed node={Node} type={Type} err={Err}")]
public static partial void StatePushFailed(ILogger logger, string node, string type, string err); public static partial void StatePushFailed(ILogger logger, string node, string type, string err);
[LoggerMessage(Level = LogLevel.Warning, Message = "protocol violation node={Node} method={Method} reason={Reason}")]
public static partial void ProtocolViolation(ILogger logger, string node, string method, string reason);
[LoggerMessage(Level = LogLevel.Error, Message = "unhandled error in handler method={Method}")] [LoggerMessage(Level = LogLevel.Error, Message = "unhandled error in handler method={Method}")]
public static partial void HandlerFailed(ILogger logger, Exception exception, string method); public static partial void HandlerFailed(ILogger logger, Exception exception, string method);
@@ -12,8 +12,6 @@ namespace Fleet.Server;
public sealed class FleetServerBuilder public sealed class FleetServerBuilder
{ {
private readonly List<Action<RpcRouter<NodeCallContext>, IServiceProvider>> _routeConfigurators = []; private readonly List<Action<RpcRouter<NodeCallContext>, IServiceProvider>> _routeConfigurators = [];
private MethodTable? _methods;
private Func<NodeRecord, string?>? _roleOf;
internal FleetServerBuilder(IServiceCollection services) internal FleetServerBuilder(IServiceCollection services)
{ {
@@ -24,21 +22,6 @@ public sealed class FleetServerBuilder
internal IReadOnlyList<Action<RpcRouter<NodeCallContext>, IServiceProvider>> RouteConfigurators => _routeConfigurators; internal IReadOnlyList<Action<RpcRouter<NodeCallContext>, IServiceProvider>> RouteConfigurators => _routeConfigurators;
internal MethodTable Methods => _methods ?? throw new InvalidOperationException("call WithMethods(...) when adding the Fleet server: the method table is required");
internal Func<NodeRecord, string?>? RoleOf => _roleOf;
/// <summary>
/// 方法声明表(框架的加产品的,见 <see cref="MethodTable.Combine"/>)和节点的角色解析(例如按节点 ID 前缀区分 Agent 和 Probe)。必须设置:
/// 路由器据此核对每个处理函数的方向、类型和角色,并拒绝没有声明或不该由节点发起的方法。
/// </summary>
public FleetServerBuilder WithMethods(MethodTable methods, Func<NodeRecord, string?> roleOf)
{
_methods = methods;
_roleOf = roleOf;
return this;
}
public FleetServerBuilder AddState<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TProvider>() public FleetServerBuilder AddState<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TProvider>()
where TProvider : class, IStateProvider where TProvider : class, IStateProvider
{ {
@@ -67,7 +50,7 @@ public sealed class FleetServerBuilder
return this; return this;
} }
/// <summary>注册产品的 RPC 方法(<c>moshiro.*</c> 等)。处理函数通过 <see cref="IServiceProvider"/> 在调用时解析服务,避免构造期的循环依赖。</summary> /// <summary>注册产品的 RPC 方法(<c>monitor.*</c> 等)。处理函数通过 <see cref="IServiceProvider"/> 在调用时解析服务,避免构造期的循环依赖。</summary>
public FleetServerBuilder MapMethods(Action<RpcRouter<NodeCallContext>, IServiceProvider> configure) public FleetServerBuilder MapMethods(Action<RpcRouter<NodeCallContext>, IServiceProvider> configure)
{ {
_routeConfigurators.Add(configure); _routeConfigurators.Add(configure);
@@ -88,18 +71,13 @@ public static class FleetServerExtensions
services.AddSingleton(provider => services.AddSingleton(provider =>
{ {
ILogger logger = provider.GetRequiredService<ILoggerFactory>().CreateLogger("Fleet.Rpc"); ILogger logger = provider.GetRequiredService<ILoggerFactory>().CreateLogger("Fleet.Rpc");
Func<NodeRecord, string?>? roleOf = builder.RoleOf; var router = new RpcRouter<NodeCallContext>((method, ex) => FleetLog.HandlerFailed(logger, ex, method));
var router = new RpcRouter<NodeCallContext>(
RpcSide.Server,
builder.Methods,
callerRole: roleOf is null ? null : context => roleOf(context.Node),
onUnhandled: (method, ex) => FleetLog.HandlerFailed(logger, ex, method));
foreach (Action<RpcRouter<NodeCallContext>, IServiceProvider> configurator in builder.RouteConfigurators) foreach (Action<RpcRouter<NodeCallContext>, IServiceProvider> configurator in builder.RouteConfigurators)
{ {
configurator(router, provider); configurator(router, provider);
} }
return new FleetRouter(router, roleOf); return new FleetRouter(router);
}); });
services.AddSingleton(provider => new FleetLinkHost( services.AddSingleton(provider => new FleetLinkHost(
provider.GetRequiredService<IFleetStore>(), provider.GetRequiredService<IFleetStore>(),
@@ -109,8 +87,7 @@ public static class FleetServerExtensions
provider.GetServices<IStateProvider>(), provider.GetServices<IStateProvider>(),
provider.GetServices<IInbox>(), provider.GetServices<IInbox>(),
provider.GetServices<INodeEventSink>(), provider.GetServices<INodeEventSink>(),
provider.GetRequiredService<FleetRouter>(), provider.GetRequiredService<FleetRouter>()));
provider.GetService<IServerIdentity>()));
services.AddSingleton<IFleetConnections>(provider => provider.GetRequiredService<FleetLinkHost>()); services.AddSingleton<IFleetConnections>(provider => provider.GetRequiredService<FleetLinkHost>());
services.AddSingleton<IStateNotifier>(provider => provider.GetRequiredService<FleetLinkHost>()); services.AddSingleton<IStateNotifier>(provider => provider.GetRequiredService<FleetLinkHost>());
return builder; return builder;
@@ -1,119 +0,0 @@
using System.Net.WebSockets;
using System.Security.Cryptography;
using System.Text.Json;
using Fleet.Core;
using Fleet.Identity;
using Fleet.Rpc;
namespace Fleet.Server;
/// <summary>认证:挑战、签名校验、拒绝。</summary>
internal sealed partial class NodeConnection
{
private async Task<bool> AuthenticateAsync(CancellationToken ct)
{
string nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
long expiresAtMs = _host.Time.GetUtcNow().Add(_host.Options.ChallengeTtl).ToUnixTimeMilliseconds();
// 有服务器身份密钥:挑战里带上这次连接的临时公钥和服务器的签名,节点据此确认服务器、协商加密链路
using var serverEphemeral = _host.ServerIdentity is null ? null : new EphemeralKey();
var challenge = new ChallengeParams { Nonce = nonce, ExpiresAtMs = expiresAtMs };
string? serverEphemeralText = null;
if (_host.ServerIdentity is { } identity && serverEphemeral is not null)
{
serverEphemeralText = Convert.ToBase64String(serverEphemeral.PublicKeySpki);
challenge.ServerEphemeral = serverEphemeralText;
challenge.ServerSignature = Convert.ToBase64String(identity.Sign(ServerHello.Payload(nonce, serverEphemeral.PublicKeySpki)));
}
await SendAsync(RpcMessage.Notification(FleetMethods.Challenge, challenge, FleetRpcContext.Default.ChallengeParams), ct);
using var timeout = new CancellationTokenSource(s_authTimeout, _host.Time);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
Frame? frame;
try
{
frame = await ReceiveFrameAsync(linked.Token);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
await RejectAsync(null, "auth timeout", ct);
return false;
}
if (frame is not { IsText: true } || !TryParse(frame.Data, out RpcMessage? message) || message.Method != FleetMethods.Auth || message.Id is null)
{
await RejectAsync(null, "expected fleet.auth", ct);
return false;
}
AuthParams? auth = null;
try
{
auth = message.Params?.Deserialize(FleetRpcContext.Default.AuthParams);
}
catch (JsonException)
{
// 按缺失处理
}
NodeRecord? node = auth is null ? null : await _host.Store.GetNodeAsync(new NodeId(auth.NodeId), ct);
// 安全链路:节点的签名覆盖双方的临时公钥(AuthSecure),验过之后这条连接上的所有帧都是加密的
byte[] nodeEphemeral = [];
bool secure = auth?.NodeEphemeral is not null && serverEphemeral is not null && serverEphemeralText is not null && TryDecode(auth.NodeEphemeral, out nodeEphemeral);
bool signatureOk = node is not null
&& _host.Time.GetUtcNow().ToUnixTimeMilliseconds() <= expiresAtMs
&& TryDecode(auth!.Signature, out byte[] signature)
&& (secure
? NodeIdentity.Verify(node.PublicKey, signature, SignaturePurpose.AuthSecure, auth.NodeId, nonce, serverEphemeralText!, auth.NodeEphemeral!)
: auth.NodeEphemeral is null && NodeIdentity.Verify(node.PublicKey, signature, SignaturePurpose.Auth, auth.NodeId, nonce));
if (signatureOk && secure)
{
_cipher = LinkCipher.ForServer(serverEphemeral!, nodeEphemeral, nonce);
}
if (!signatureOk)
{
// 节点不存在与签名错误返回同样的错误,不泄露节点是否存在。
await RejectAsync(message.Id, "authentication failed", ct);
return false;
}
_node = node;
if (node!.Status == NodeStatus.Disabled)
{
await SendAsync(RpcMessage.Failure(message.Id, new FleetError(FleetErrors.NodeDisabled, "node is disabled")), ct);
await RevokeAsync("disabled", ct);
return false;
}
_session = new NodeSessionInfo(node.Id, _host.Time.GetUtcNow().ToUnixTimeMilliseconds(), node.NodeVersion, [], null);
await SendAsync(
RpcMessage.Success(message.Id.Value, new AuthResult { Status = StatusName(node.Status) }, FleetRpcContext.Default.AuthResult),
ct);
return true;
}
private async Task RejectAsync(long? id, string reason, CancellationToken ct)
{
FleetLog.AuthRejected(_host.Logger, reason);
_closeReason ??= reason;
if (id is not null)
{
await SendAsync(RpcMessage.Failure(id, new FleetError(FleetErrors.Unauthorized, "authentication failed")), ct);
}
if (_socket.State == WebSocketState.Open)
{
try
{
await _socket.CloseOutputAsync(WebSocketCloseStatus.PolicyViolation, "unauthorized", ct);
}
catch (WebSocketException)
{
// 对端已经断开
}
}
}
}
@@ -1,251 +0,0 @@
using System.Text.Json;
using Fleet.Core;
using Fleet.Rpc;
namespace Fleet.Server;
/// <summary>接收:读帧、分发消息、hello、心跳、二进制通道(收件箱)。</summary>
internal sealed partial class NodeConnection
{
private async Task ReceiveLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
using var idle = new CancellationTokenSource(_host.Options.HeartbeatTimeout, _host.Time);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, idle.Token);
Frame? frame;
try
{
frame = await ReceiveFrameAsync(linked.Token);
}
catch (OperationCanceledException) when (idle.IsCancellationRequested && !ct.IsCancellationRequested)
{
_closeReason ??= "heartbeat timeout";
return;
}
if (frame is null)
{
_closeReason ??= "closed by peer";
return;
}
if (frame.IsText)
{
if (TryParse(frame.Data, out RpcMessage? message))
{
await HandleMessageAsync(message, ct);
}
}
else
{
await HandleBinaryAsync(frame.Data, ct);
}
}
}
private async Task HandleMessageAsync(RpcMessage message, CancellationToken ct)
{
if (message.IsResponse)
{
if (_pending.TryGetValue(message.Id!.Value, out TaskCompletionSource<RpcMessage>? waiter))
{
waiter.TrySetResult(message);
}
return;
}
switch (message.Method)
{
case FleetMethods.Hello:
await HandleHelloAsync(message, ct);
return;
case FleetMethods.Heartbeat:
await HandleHeartbeatAsync(message, ct);
return;
case FleetMethods.Stopping:
try
{
string? reason = message.Params?.Deserialize(FleetRpcContext.Default.StoppingParams)?.Reason;
_closeReason = reason is "updating" ? "updating" : "stopping";
}
catch (JsonException)
{
_closeReason = "stopping";
}
return;
}
if (_node!.Status != NodeStatus.Active)
{
if (message.Id is not null)
{
await SendAsync(RpcMessage.Failure(message.Id, new FleetError(FleetErrors.NodePending, "node is not approved yet")), ct);
}
return;
}
var context = new NodeCallContext(_node, _session!, _host);
RpcDispatch outcome;
try
{
outcome = await _host.Router.DispatchCheckedAsync(context, message, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
if (outcome.Response is not null)
{
await SendAsync(outcome.Response, ct);
}
await NoteViolationAsync(outcome.Violation);
}
/// <summary>记录节点的违规;超过上限就断开这条连接。</summary>
private async Task NoteViolationAsync(RpcViolation? violation)
{
if (violation is not { } v)
{
return;
}
FleetLog.ProtocolViolation(_host.Logger, _node!.Id.Value, v.Method, v.Reason);
if (++_violations > _host.Options.MaxViolations)
{
await CloseAsync("protocol violations");
}
}
private async Task HandleHelloAsync(RpcMessage message, CancellationToken ct)
{
HelloParams? hello = null;
try
{
hello = message.Params?.Deserialize(FleetRpcContext.Default.HelloParams);
}
catch (JsonException)
{
// 按缺失处理
}
if (hello is null)
{
if (message.Id is not null)
{
await SendAsync(RpcMessage.Failure(message.Id, new FleetError(FleetErrors.InvalidParams, "invalid hello")), ct);
}
return;
}
long nowMs = _host.Time.GetUtcNow().ToUnixTimeMilliseconds();
_session = _session! with
{
NodeVersion = hello.NodeVersion,
Capabilities = hello.Capabilities,
HelloMetaJson = hello.Meta?.GetRawText(),
};
_node = _node! with { NodeVersion = hello.NodeVersion };
_helloDone = true;
await UpdatePresenceAsync(online: true, ct);
await FireAsync(NodeEventKind.Hello, ct, hello);
if (message.Id is not null)
{
await SendAsync(
RpcMessage.Success(
message.Id.Value,
new HelloResult { ServerTimeMs = nowMs, ClockOffsetMs = hello.NowUtcMs - nowMs },
FleetRpcContext.Default.HelloResult),
ct);
}
SignalStateChanged();
}
private async Task HandleHeartbeatAsync(RpcMessage message, CancellationToken ct)
{
JsonElement? extra = null;
try
{
extra = message.Params?.Deserialize(FleetRpcContext.Default.HeartbeatParams)?.Extra;
}
catch (JsonException)
{
// 忽略格式不正确的附加字段
}
long now = _host.Time.GetTimestamp();
if (_host.Time.GetElapsedTime(Interlocked.Read(ref _lastPresenceTicks), now) >= s_presenceRefresh)
{
await UpdatePresenceAsync(online: true, ct);
}
await FireAsync(NodeEventKind.Heartbeat, ct, hello: null, extra);
if (message.Id is not null)
{
await SendAsync(new RpcMessage { Id = message.Id, Result = JsonSerializer.SerializeToElement(new EmptyObject(), FleetEmptyObject.Info) }, ct);
}
}
private async Task HandleBinaryAsync(byte[] data, CancellationToken ct)
{
if (_node!.Status != NodeStatus.Active || !OutboxFrame.TryParse(data, out byte channel, out Guid msgId, out ReadOnlyMemory<byte> payload))
{
return;
}
// 通道也要声明:节点只能往声明过、并且允许它的角色使用的通道发
ChannelSpec? spec = _host.Router.Table.FindChannel(RpcPlane.Node, channel);
if (spec is null || spec.From != RpcSide.Node || !spec.AllowsRole(_host.RoleOf?.Invoke(_node)))
{
await NoteViolationAsync(new RpcViolation($"channel {channel}", $"channel {channel} is not declared for this node"));
return;
}
if (!_host.Inboxes.TryGetValue(channel, out IInbox? inbox))
{
return;
}
InboxOutcome outcome;
try
{
outcome = await inbox.HandleAsync(_node, msgId, payload, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
// 没有持久化就不确认,节点会重发。
FleetLog.InboxFailed(_host.Logger, ex, _node.Id.Value, channel);
return;
}
if (outcome == InboxOutcome.Rejected)
{
return;
}
var ack = new OutboxAckParams();
string id = msgId.ToString();
if (outcome == InboxOutcome.Duplicate)
{
ack.Duplicates.Add(id);
}
else
{
ack.Ids.Add(id);
}
await SendAsync(RpcMessage.Notification(FleetMethods.OutboxAck, ack, FleetRpcContext.Default.OutboxAckParams), ct);
}
}
@@ -1,128 +0,0 @@
using System.Net.WebSockets;
using System.Text.Json;
using Fleet.Core;
using Fleet.Rpc;
namespace Fleet.Server;
/// <summary>发给节点:关闭、吊销、发送一条消息、发起请求并等响应。</summary>
internal sealed partial class NodeConnection
{
/// <summary>
/// 服务端发起的关闭:发送 Close 帧,等对端确认之后接收循环自然结束。
/// 注意不能直接取消接收的令牌——取消 <c>ReceiveAsync</c> 会中止整个 WebSocket,对端拿不到关闭原因;取消只作为超时兜底。
/// </summary>
public async ValueTask CloseAsync(string reason)
{
_closeReason ??= reason;
try
{
await _sendLock.WaitAsync(CancellationToken.None);
}
catch (ObjectDisposedException)
{
return;
}
try
{
if (_socket.State == WebSocketState.Open)
{
await _socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, reason, CancellationToken.None);
}
}
catch (WebSocketException)
{
// 对端已经断开
}
finally
{
_sendLock.Release();
}
_lifetime.CancelAfter(s_closeTimeout);
}
public async ValueTask RevokeAsync(string reason, CancellationToken ct)
{
await SendAsync(RpcMessage.Notification(FleetMethods.Revoked, new RevokedParams { Reason = reason }, FleetRpcContext.Default.RevokedParams), ct);
await CloseAsync(reason);
}
public async ValueTask<bool> SendAsync(RpcMessage message, CancellationToken ct)
{
if (message.Method is not null)
{
// 服务端只能发自己有资格发起的方法,并且对端的角色要允许(响应没有方法名,不检查)
string? peerRole = _node is null ? null : _host.RoleOf?.Invoke(_node);
_host.Router.CheckSend(message.Method, message.Id is null ? MethodKind.Notification : MethodKind.Request, peerRole);
}
byte[] bytes = JsonSerializer.SerializeToUtf8Bytes(message, FleetRpcContext.Default.RpcMessage);
try
{
await _sendLock.WaitAsync(ct);
}
catch (ObjectDisposedException)
{
return false;
}
try
{
if (_socket.State != WebSocketState.Open)
{
return false;
}
if (_cipher is { } cipher)
{
await _socket.SendAsync(cipher.Seal(0, bytes), WebSocketMessageType.Binary, endOfMessage: true, ct);
}
else
{
await _socket.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, ct);
}
return true;
}
catch (WebSocketException)
{
return false;
}
finally
{
_sendLock.Release();
}
}
public async ValueTask<Result<JsonElement>> RequestAsync(string method, JsonElement parameters, CancellationToken ct)
{
long id = Interlocked.Increment(ref _nextRequestId);
var waiter = new TaskCompletionSource<RpcMessage>(TaskCreationOptions.RunContinuationsAsynchronously);
_pending[id] = waiter;
try
{
var request = new RpcMessage { Id = id, Method = method, Params = parameters };
if (!await SendAsync(request, ct))
{
return Result<JsonElement>.Fail(FleetErrors.NodeOffline, "node is not connected");
}
using var timeout = new CancellationTokenSource(_host.Options.RequestTimeout, _host.Time);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
RpcMessage response = await waiter.Task.WaitAsync(linked.Token);
return response.Error is not null
? Result<JsonElement>.Fail(response.Error.ToFleetError())
: Result<JsonElement>.Ok(response.Result ?? default);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
return Result<JsonElement>.Fail(FleetErrors.Timeout, $"{method} timed out");
}
finally
{
_pending.TryRemove(id, out _);
}
}
}
@@ -1,123 +0,0 @@
using System.Text.Json;
using Fleet.Core;
using Fleet.Rpc;
namespace Fleet.Server;
/// <summary>状态:期望状态推送、在线状态、节点事件。</summary>
internal sealed partial class NodeConnection
{
public void SignalStateChanged() => _stateSignal.Writer.TryWrite(true);
private async Task StatePushLoopAsync(CancellationToken ct)
{
while (await _stateSignal.Reader.WaitToReadAsync(ct))
{
_stateSignal.Reader.TryRead(out _);
bool complete = await PushStateAsync(ct);
if (!complete)
{
await Task.Delay(s_statePushRetry, _host.Time, ct);
SignalStateChanged();
}
}
}
private async Task<bool> PushStateAsync(CancellationToken ct)
{
NodeRecord? fresh = await _host.Store.GetNodeAsync(_node!.Id, ct);
if (fresh is null)
{
return true;
}
_node = fresh with { NodeVersion = _node.NodeVersion ?? fresh.NodeVersion };
if (fresh.Status == NodeStatus.Disabled)
{
await RevokeAsync("disabled", ct);
return true;
}
if (fresh.Status != NodeStatus.Active || !_helloDone)
{
// pending 不下发状态;还没有 hello 的连接等 hello 之后再推送。
return true;
}
bool complete = true;
foreach (IStateProvider provider in _host.StateProviders)
{
StateDocument? document = await provider.GetAsync(_node, ct);
if (document is null || (_lastPushedVersion.TryGetValue(provider.Type, out long last) && last == document.Version))
{
continue;
}
Result<JsonElement> raw = await RequestAsync(
FleetMethods.StateSync,
JsonSerializer.SerializeToElement(
new StateSyncParams { Type = provider.Type, Version = document.Version, Doc = document.Doc },
FleetRpcContext.Default.StateSyncParams),
ct);
if (!raw.TryGet(out JsonElement value, out FleetError? error))
{
FleetLog.StatePushFailed(_host.Logger, _node.Id.Value, provider.Type, error.Code.Value);
complete = false;
continue;
}
StateSyncResult? result = null;
try
{
result = value.Deserialize(FleetRpcContext.Default.StateSyncResult);
}
catch (JsonException)
{
// 按缺失处理
}
if (result is null)
{
complete = false;
continue;
}
_lastPushedVersion[provider.Type] = document.Version;
await provider.OnAppliedAsync(_node, document.Version, result, ct);
}
return complete;
}
private async Task UpdatePresenceAsync(bool online, CancellationToken ct)
{
Interlocked.Exchange(ref _lastPresenceTicks, _host.Time.GetTimestamp());
await _host.Store.UpdatePresenceAsync(
_node!.Id,
new Presence(online, _host.Time.GetUtcNow().ToUnixTimeMilliseconds(), _session?.NodeVersion, _session?.Capabilities ?? [], _session?.HelloMetaJson),
ct);
}
private async Task FireAsync(NodeEventKind kind, CancellationToken ct, HelloParams? hello = null, JsonElement? extra = null)
{
var nodeEvent = new NodeEvent(kind, _node!, _host.Time.GetUtcNow().ToUnixTimeMilliseconds(), hello, extra, kind == NodeEventKind.Offline ? _closeReason : null);
foreach (INodeEventSink sink in _host.Sinks)
{
try
{
await sink.OnEventAsync(nodeEvent, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
// 产品的事件处理失败不能影响连接。
FleetLog.SinkFailed(_host.Logger, ex, kind.ToString(), _node!.Id.Value);
}
}
}
}
@@ -1,99 +0,0 @@
using System.Buffers;
using System.Net.WebSockets;
using System.Text.Json;
using Fleet.Rpc;
namespace Fleet.Server;
/// <summary>线上的帧:WebSocket 收帧、解析、状态名。</summary>
internal sealed partial class NodeConnection
{
private async ValueTask<Frame?> ReceiveFrameAsync(CancellationToken ct)
{
var writer = new ArrayBufferWriter<byte>(1024);
while (true)
{
Memory<byte> buffer = writer.GetMemory(4096);
ValueWebSocketReceiveResult result = await _socket.ReceiveAsync(buffer, ct);
if (result.MessageType == WebSocketMessageType.Close)
{
_closeReason ??= _socket.CloseStatusDescription;
return null;
}
writer.Advance(result.Count);
if (writer.WrittenCount > _host.Options.MaxFrameBytes)
{
_closeReason ??= "frame too large";
await _socket.CloseOutputAsync(WebSocketCloseStatus.MessageTooBig, "frame too large", CancellationToken.None);
return null;
}
if (result.EndOfMessage)
{
if (_cipher is { } cipher)
{
// 加密链路上只接受通过认证的二进制帧;明文、被改过的、重放的、乱序的一律断开
if (result.MessageType != WebSocketMessageType.Binary || !cipher.TryOpen(writer.WrittenSpan, out byte kind, out byte[] plain))
{
_closeReason ??= "frame failed authentication";
await _socket.CloseOutputAsync(WebSocketCloseStatus.PolicyViolation, "frame failed authentication", CancellationToken.None);
return null;
}
return new Frame(kind == 0, plain);
}
return new Frame(result.MessageType == WebSocketMessageType.Text, writer.WrittenSpan.ToArray());
}
}
}
private static bool TryParse(byte[] data, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out RpcMessage? message)
{
try
{
message = JsonSerializer.Deserialize(data, FleetRpcContext.Default.RpcMessage);
return message is not null;
}
catch (JsonException)
{
message = null;
return false;
}
}
private static bool TryDecode(string text, out byte[] data)
{
try
{
data = Convert.FromBase64String(text);
return true;
}
catch (FormatException)
{
data = [];
return false;
}
}
private static string StatusName(NodeStatus status) => status switch
{
NodeStatus.Active => "active",
NodeStatus.Pending => "pending",
_ => "disabled",
};
private sealed record Frame(bool IsText, byte[] Data);
}
[System.Text.Json.Serialization.JsonSerializable(typeof(EmptyObject))]
internal sealed partial class FleetEmptyObjectContext : System.Text.Json.Serialization.JsonSerializerContext;
internal sealed class EmptyObject;
internal static class FleetEmptyObject
{
public static System.Text.Json.Serialization.Metadata.JsonTypeInfo<EmptyObject> Info => FleetEmptyObjectContext.Default.EmptyObject;
}
@@ -1,6 +1,9 @@
using System.Buffers;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Net.WebSockets; using System.Net.WebSockets;
using System.Security.Cryptography;
using System.Threading.Channels; using System.Threading.Channels;
using System.Text.Json;
using Fleet.Core; using Fleet.Core;
using Fleet.Identity; using Fleet.Identity;
using Fleet.Rpc; using Fleet.Rpc;
@@ -11,7 +14,7 @@ namespace Fleet.Server;
/// 一个节点的 WebSocket 会话:挑战 → 签名认证 → hello → 状态推送 / 收件箱 / 产品 RPC。 /// 一个节点的 WebSocket 会话:挑战 → 签名认证 → hello → 状态推送 / 收件箱 / 产品 RPC。
/// 收到的消息按顺序处理,所以产品的处理函数不能等待“向同一个节点发起的请求”,否则会死锁。 /// 收到的消息按顺序处理,所以产品的处理函数不能等待“向同一个节点发起的请求”,否则会死锁。
/// </summary> /// </summary>
internal sealed partial class NodeConnection : IDisposable internal sealed class NodeConnection : IDisposable
{ {
private static readonly TimeSpan s_authTimeout = TimeSpan.FromSeconds(15); private static readonly TimeSpan s_authTimeout = TimeSpan.FromSeconds(15);
private static readonly TimeSpan s_presenceRefresh = TimeSpan.FromSeconds(60); private static readonly TimeSpan s_presenceRefresh = TimeSpan.FromSeconds(60);
@@ -25,14 +28,12 @@ internal sealed partial class NodeConnection : IDisposable
private readonly Channel<bool> _stateSignal = Channel.CreateBounded<bool>(new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropWrite }); private readonly Channel<bool> _stateSignal = Channel.CreateBounded<bool>(new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropWrite });
private readonly Dictionary<string, long> _lastPushedVersion = new(StringComparer.Ordinal); private readonly Dictionary<string, long> _lastPushedVersion = new(StringComparer.Ordinal);
private readonly CancellationTokenSource _lifetime = new(); private readonly CancellationTokenSource _lifetime = new();
private LinkCipher? _cipher;
private long _nextRequestId; private long _nextRequestId;
private long _lastPresenceTicks; private long _lastPresenceTicks;
private NodeRecord? _node; private NodeRecord? _node;
private NodeSessionInfo? _session; private NodeSessionInfo? _session;
private string? _closeReason; private string? _closeReason;
private bool _helloDone; private bool _helloDone;
private int _violations;
public NodeConnection(FleetLinkHost host, WebSocket socket) public NodeConnection(FleetLinkHost host, WebSocket socket)
{ {
@@ -136,8 +137,593 @@ internal sealed partial class NodeConnection : IDisposable
public void Dispose() public void Dispose()
{ {
_cipher?.Dispose();
_lifetime.Dispose(); _lifetime.Dispose();
_sendLock.Dispose(); _sendLock.Dispose();
} }
public void SignalStateChanged() => _stateSignal.Writer.TryWrite(true);
/// <summary>
/// 服务端发起的关闭:发送 Close 帧,等对端确认之后接收循环自然结束。
/// 注意不能直接取消接收的令牌——取消 <c>ReceiveAsync</c> 会中止整个 WebSocket,对端拿不到关闭原因;取消只作为超时兜底。
/// </summary>
public async ValueTask CloseAsync(string reason)
{
_closeReason ??= reason;
try
{
await _sendLock.WaitAsync(CancellationToken.None);
}
catch (ObjectDisposedException)
{
return;
}
try
{
if (_socket.State == WebSocketState.Open)
{
await _socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, reason, CancellationToken.None);
}
}
catch (WebSocketException)
{
// 对端已经断开
}
finally
{
_sendLock.Release();
}
_lifetime.CancelAfter(s_closeTimeout);
}
public async ValueTask RevokeAsync(string reason, CancellationToken ct)
{
await SendAsync(RpcMessage.Notification(FleetMethods.Revoked, new RevokedParams { Reason = reason }, FleetRpcContext.Default.RevokedParams), ct);
await CloseAsync(reason);
}
public async ValueTask<bool> SendAsync(RpcMessage message, CancellationToken ct)
{
byte[] bytes = JsonSerializer.SerializeToUtf8Bytes(message, FleetRpcContext.Default.RpcMessage);
try
{
await _sendLock.WaitAsync(ct);
}
catch (ObjectDisposedException)
{
return false;
}
try
{
if (_socket.State != WebSocketState.Open)
{
return false;
}
await _socket.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, ct);
return true;
}
catch (WebSocketException)
{
return false;
}
finally
{
_sendLock.Release();
}
}
public async ValueTask<Result<JsonElement>> RequestAsync(string method, JsonElement parameters, CancellationToken ct)
{
long id = Interlocked.Increment(ref _nextRequestId);
var waiter = new TaskCompletionSource<RpcMessage>(TaskCreationOptions.RunContinuationsAsynchronously);
_pending[id] = waiter;
try
{
var request = new RpcMessage { Id = id, Method = method, Params = parameters };
if (!await SendAsync(request, ct))
{
return Result<JsonElement>.Fail(FleetErrors.NodeOffline, "node is not connected");
}
using var timeout = new CancellationTokenSource(_host.Options.RequestTimeout, _host.Time);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
RpcMessage response = await waiter.Task.WaitAsync(linked.Token);
return response.Error is not null
? Result<JsonElement>.Fail(response.Error.ToFleetError())
: Result<JsonElement>.Ok(response.Result ?? default);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
return Result<JsonElement>.Fail(FleetErrors.Timeout, $"{method} timed out");
}
finally
{
_pending.TryRemove(id, out _);
}
}
// ───────────────────────── 认证 ─────────────────────────
private async Task<bool> AuthenticateAsync(CancellationToken ct)
{
string nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
long expiresAtMs = _host.Time.GetUtcNow().Add(_host.Options.ChallengeTtl).ToUnixTimeMilliseconds();
await SendAsync(
RpcMessage.Notification(FleetMethods.Challenge, new ChallengeParams { Nonce = nonce, ExpiresAtMs = expiresAtMs }, FleetRpcContext.Default.ChallengeParams),
ct);
using var timeout = new CancellationTokenSource(s_authTimeout, _host.Time);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
Frame? frame;
try
{
frame = await ReceiveFrameAsync(linked.Token);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
await RejectAsync(null, "auth timeout", ct);
return false;
}
if (frame is not { IsText: true } || !TryParse(frame.Data, out RpcMessage? message) || message.Method != FleetMethods.Auth || message.Id is null)
{
await RejectAsync(null, "expected fleet.auth", ct);
return false;
}
AuthParams? auth = null;
try
{
auth = message.Params?.Deserialize(FleetRpcContext.Default.AuthParams);
}
catch (JsonException)
{
// 按缺失处理
}
NodeRecord? node = auth is null ? null : await _host.Store.GetNodeAsync(new NodeId(auth.NodeId), ct);
bool signatureOk = node is not null
&& _host.Time.GetUtcNow().ToUnixTimeMilliseconds() <= expiresAtMs
&& TryDecode(auth!.Signature, out byte[] signature)
&& NodeIdentity.Verify(node.PublicKey, signature, SignaturePurpose.Auth, auth.NodeId, nonce);
if (!signatureOk)
{
// 节点不存在与签名错误返回同样的错误,不泄露节点是否存在。
await RejectAsync(message.Id, "authentication failed", ct);
return false;
}
_node = node;
if (node!.Status == NodeStatus.Disabled)
{
await SendAsync(RpcMessage.Failure(message.Id, new FleetError(FleetErrors.NodeDisabled, "node is disabled")), ct);
await RevokeAsync("disabled", ct);
return false;
}
_session = new NodeSessionInfo(node.Id, _host.Time.GetUtcNow().ToUnixTimeMilliseconds(), node.NodeVersion, [], null);
await SendAsync(
RpcMessage.Success(message.Id.Value, new AuthResult { Status = StatusName(node.Status) }, FleetRpcContext.Default.AuthResult),
ct);
return true;
}
private async Task RejectAsync(long? id, string reason, CancellationToken ct)
{
FleetLog.AuthRejected(_host.Logger, reason);
_closeReason ??= reason;
if (id is not null)
{
await SendAsync(RpcMessage.Failure(id, new FleetError(FleetErrors.Unauthorized, "authentication failed")), ct);
}
if (_socket.State == WebSocketState.Open)
{
try
{
await _socket.CloseOutputAsync(WebSocketCloseStatus.PolicyViolation, "unauthorized", ct);
}
catch (WebSocketException)
{
// 对端已经断开
}
}
}
// ───────────────────────── 接收循环 ─────────────────────────
private async Task ReceiveLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
using var idle = new CancellationTokenSource(_host.Options.HeartbeatTimeout, _host.Time);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, idle.Token);
Frame? frame;
try
{
frame = await ReceiveFrameAsync(linked.Token);
}
catch (OperationCanceledException) when (idle.IsCancellationRequested && !ct.IsCancellationRequested)
{
_closeReason ??= "heartbeat timeout";
return;
}
if (frame is null)
{
_closeReason ??= "closed by peer";
return;
}
if (frame.IsText)
{
if (TryParse(frame.Data, out RpcMessage? message))
{
await HandleMessageAsync(message, ct);
}
}
else
{
await HandleBinaryAsync(frame.Data, ct);
}
}
}
private async Task HandleMessageAsync(RpcMessage message, CancellationToken ct)
{
if (message.IsResponse)
{
if (_pending.TryGetValue(message.Id!.Value, out TaskCompletionSource<RpcMessage>? waiter))
{
waiter.TrySetResult(message);
}
return;
}
switch (message.Method)
{
case FleetMethods.Hello:
await HandleHelloAsync(message, ct);
return;
case FleetMethods.Heartbeat:
await HandleHeartbeatAsync(message, ct);
return;
}
if (_node!.Status != NodeStatus.Active)
{
if (message.Id is not null)
{
await SendAsync(RpcMessage.Failure(message.Id, new FleetError(FleetErrors.NodePending, "node is not approved yet")), ct);
}
return;
}
var context = new NodeCallContext(_node, _session!, _host);
RpcMessage? response;
try
{
response = await _host.Router.DispatchAsync(context, message, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
if (response is not null)
{
await SendAsync(response, ct);
}
}
private async Task HandleHelloAsync(RpcMessage message, CancellationToken ct)
{
HelloParams? hello = null;
try
{
hello = message.Params?.Deserialize(FleetRpcContext.Default.HelloParams);
}
catch (JsonException)
{
// 按缺失处理
}
if (hello is null)
{
if (message.Id is not null)
{
await SendAsync(RpcMessage.Failure(message.Id, new FleetError(FleetErrors.InvalidParams, "invalid hello")), ct);
}
return;
}
long nowMs = _host.Time.GetUtcNow().ToUnixTimeMilliseconds();
_session = _session! with
{
NodeVersion = hello.NodeVersion,
Capabilities = hello.Capabilities,
HelloMetaJson = hello.Meta?.GetRawText(),
};
_node = _node! with { NodeVersion = hello.NodeVersion };
_helloDone = true;
await UpdatePresenceAsync(online: true, ct);
await FireAsync(NodeEventKind.Hello, ct, hello);
if (message.Id is not null)
{
await SendAsync(
RpcMessage.Success(
message.Id.Value,
new HelloResult { ServerTimeMs = nowMs, ClockOffsetMs = hello.NowUtcMs - nowMs },
FleetRpcContext.Default.HelloResult),
ct);
}
SignalStateChanged();
}
private async Task HandleHeartbeatAsync(RpcMessage message, CancellationToken ct)
{
JsonElement? extra = null;
try
{
extra = message.Params?.Deserialize(FleetRpcContext.Default.HeartbeatParams)?.Extra;
}
catch (JsonException)
{
// 忽略格式不正确的附加字段
}
long now = _host.Time.GetTimestamp();
if (_host.Time.GetElapsedTime(Interlocked.Read(ref _lastPresenceTicks), now) >= s_presenceRefresh)
{
await UpdatePresenceAsync(online: true, ct);
}
await FireAsync(NodeEventKind.Heartbeat, ct, hello: null, extra);
if (message.Id is not null)
{
await SendAsync(new RpcMessage { Id = message.Id, Result = JsonSerializer.SerializeToElement(new EmptyObject(), FleetEmptyObject.Info) }, ct);
}
}
private async Task HandleBinaryAsync(byte[] data, CancellationToken ct)
{
if (_node!.Status != NodeStatus.Active
|| !OutboxFrame.TryParse(data, out byte channel, out Guid msgId, out ReadOnlyMemory<byte> payload)
|| !_host.Inboxes.TryGetValue(channel, out IInbox? inbox))
{
return;
}
InboxOutcome outcome;
try
{
outcome = await inbox.HandleAsync(_node, msgId, payload, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
// 没有持久化就不确认,节点会重发。
FleetLog.InboxFailed(_host.Logger, ex, _node.Id.Value, channel);
return;
}
if (outcome == InboxOutcome.Rejected)
{
return;
}
var ack = new OutboxAckParams();
string id = msgId.ToString();
if (outcome == InboxOutcome.Duplicate)
{
ack.Duplicates.Add(id);
}
else
{
ack.Ids.Add(id);
}
await SendAsync(RpcMessage.Notification(FleetMethods.OutboxAck, ack, FleetRpcContext.Default.OutboxAckParams), ct);
}
// ───────────────────────── 状态推送 ─────────────────────────
private async Task StatePushLoopAsync(CancellationToken ct)
{
while (await _stateSignal.Reader.WaitToReadAsync(ct))
{
_stateSignal.Reader.TryRead(out _);
bool complete = await PushStateAsync(ct);
if (!complete)
{
await Task.Delay(s_statePushRetry, _host.Time, ct);
SignalStateChanged();
}
}
}
private async Task<bool> PushStateAsync(CancellationToken ct)
{
NodeRecord? fresh = await _host.Store.GetNodeAsync(_node!.Id, ct);
if (fresh is null)
{
return true;
}
_node = fresh with { NodeVersion = _node.NodeVersion ?? fresh.NodeVersion };
if (fresh.Status == NodeStatus.Disabled)
{
await RevokeAsync("disabled", ct);
return true;
}
if (fresh.Status != NodeStatus.Active || !_helloDone)
{
// pending 不下发状态;还没有 hello 的连接等 hello 之后再推送。
return true;
}
bool complete = true;
foreach (IStateProvider provider in _host.StateProviders)
{
StateDocument? document = await provider.GetAsync(_node, ct);
if (document is null || (_lastPushedVersion.TryGetValue(provider.Type, out long last) && last == document.Version))
{
continue;
}
Result<JsonElement> raw = await RequestAsync(
FleetMethods.StateSync,
JsonSerializer.SerializeToElement(
new StateSyncParams { Type = provider.Type, Version = document.Version, Doc = document.Doc },
FleetRpcContext.Default.StateSyncParams),
ct);
if (!raw.TryGet(out JsonElement value, out FleetError? error))
{
FleetLog.StatePushFailed(_host.Logger, _node.Id.Value, provider.Type, error.Code.Value);
complete = false;
continue;
}
StateSyncResult? result = null;
try
{
result = value.Deserialize(FleetRpcContext.Default.StateSyncResult);
}
catch (JsonException)
{
// 按缺失处理
}
if (result is null)
{
complete = false;
continue;
}
_lastPushedVersion[provider.Type] = document.Version;
await provider.OnAppliedAsync(_node, document.Version, result, ct);
}
return complete;
}
// ───────────────────────── 帮助方法 ─────────────────────────
private async Task UpdatePresenceAsync(bool online, CancellationToken ct)
{
Interlocked.Exchange(ref _lastPresenceTicks, _host.Time.GetTimestamp());
await _host.Store.UpdatePresenceAsync(
_node!.Id,
new Presence(online, _host.Time.GetUtcNow().ToUnixTimeMilliseconds(), _session?.NodeVersion, _session?.Capabilities ?? [], _session?.HelloMetaJson),
ct);
}
private async Task FireAsync(NodeEventKind kind, CancellationToken ct, HelloParams? hello = null, JsonElement? extra = null)
{
var nodeEvent = new NodeEvent(kind, _node!, _host.Time.GetUtcNow().ToUnixTimeMilliseconds(), hello, extra);
foreach (INodeEventSink sink in _host.Sinks)
{
try
{
await sink.OnEventAsync(nodeEvent, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
// 产品的事件处理失败不能影响连接。
FleetLog.SinkFailed(_host.Logger, ex, kind.ToString(), _node!.Id.Value);
}
}
}
private async ValueTask<Frame?> ReceiveFrameAsync(CancellationToken ct)
{
var writer = new ArrayBufferWriter<byte>(1024);
while (true)
{
Memory<byte> buffer = writer.GetMemory(4096);
ValueWebSocketReceiveResult result = await _socket.ReceiveAsync(buffer, ct);
if (result.MessageType == WebSocketMessageType.Close)
{
return null;
}
writer.Advance(result.Count);
if (writer.WrittenCount > _host.Options.MaxFrameBytes)
{
_closeReason ??= "frame too large";
await _socket.CloseOutputAsync(WebSocketCloseStatus.MessageTooBig, "frame too large", CancellationToken.None);
return null;
}
if (result.EndOfMessage)
{
return new Frame(result.MessageType == WebSocketMessageType.Text, writer.WrittenSpan.ToArray());
}
}
}
private static bool TryParse(byte[] data, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out RpcMessage? message)
{
try
{
message = JsonSerializer.Deserialize(data, FleetRpcContext.Default.RpcMessage);
return message is not null;
}
catch (JsonException)
{
message = null;
return false;
}
}
private static bool TryDecode(string text, out byte[] data)
{
try
{
data = Convert.FromBase64String(text);
return true;
}
catch (FormatException)
{
data = [];
return false;
}
}
private static string StatusName(NodeStatus status) => status switch
{
NodeStatus.Active => "active",
NodeStatus.Pending => "pending",
_ => "disabled",
};
private sealed record Frame(bool IsText, byte[] Data);
}
[System.Text.Json.Serialization.JsonSerializable(typeof(EmptyObject))]
internal sealed partial class FleetEmptyObjectContext : System.Text.Json.Serialization.JsonSerializerContext;
internal sealed class EmptyObject;
internal static class FleetEmptyObject
{
public static System.Text.Json.Serialization.Metadata.JsonTypeInfo<EmptyObject> Info => FleetEmptyObjectContext.Default.EmptyObject;
} }
@@ -9,7 +9,6 @@
<ProjectReference Include="..\..\src\Fleet.Identity\Fleet.Identity.csproj" /> <ProjectReference Include="..\..\src\Fleet.Identity\Fleet.Identity.csproj" />
<ProjectReference Include="..\..\src\Fleet.Rpc\Fleet.Rpc.csproj" /> <ProjectReference Include="..\..\src\Fleet.Rpc\Fleet.Rpc.csproj" />
<ProjectReference Include="..\..\src\Fleet.Link\Fleet.Link.csproj" /> <ProjectReference Include="..\..\src\Fleet.Link\Fleet.Link.csproj" />
<ProjectReference Include="..\..\src\Fleet.Hosting\Fleet.Hosting.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Using Include="Xunit" /> <Using Include="Xunit" />
@@ -67,7 +67,7 @@ public sealed class IdentityAndRpcTests
[Fact] [Fact]
public async Task Router_DispatchesTypedRequests_AndReportsStructuredErrors() public async Task Router_DispatchesTypedRequests_AndReportsStructuredErrors()
{ {
var router = new RpcRouter<string>(RpcSide.Listener, TestTables.Of(TestTables.Req("test.add"), TestTables.Req("test.boom"), TestTables.Req("test.nope"))) var router = new RpcRouter<string>()
.On("test.add", EchoContext.Default.AddParams, EchoContext.Default.AddResult, .On("test.add", EchoContext.Default.AddParams, EchoContext.Default.AddResult,
(caller, p, _) => ValueTask.FromResult(p.A < 0 ? Result<AddResult>.Fail(FleetErrors.Forbidden, "negative") : Result<AddResult>.Ok(new AddResult { Sum = p.A + p.B, Caller = caller }))) (caller, p, _) => ValueTask.FromResult(p.A < 0 ? Result<AddResult>.Fail(FleetErrors.Forbidden, "negative") : Result<AddResult>.Ok(new AddResult { Sum = p.A + p.B, Caller = caller })))
.On<AddParams, AddResult>("test.boom", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => throw new InvalidOperationException("secret detail")); .On<AddParams, AddResult>("test.boom", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => throw new InvalidOperationException("secret detail"));
@@ -92,7 +92,7 @@ public sealed class IdentityAndRpcTests
public async Task Router_HandlerExceptions_AreContained_AndNeverLeakDetails() public async Task Router_HandlerExceptions_AreContained_AndNeverLeakDetails()
{ {
string? logged = null; string? logged = null;
var router = new RpcRouter<string>(RpcSide.Listener, TestTables.Of(TestTables.Req("test.boom")), onUnhandled: (method, ex) => logged = method + ":" + ex.Message) var router = new RpcRouter<string>((method, ex) => logged = method + ":" + ex.Message)
.On<AddParams, AddResult>("test.boom", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => throw new InvalidOperationException("secret detail")); .On<AddParams, AddResult>("test.boom", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => throw new InvalidOperationException("secret detail"));
RpcMessage reply = (await router.DispatchAsync("a", Request(1, "test.boom", """{"a":1,"b":1}"""), CancellationToken.None))!; RpcMessage reply = (await router.DispatchAsync("a", Request(1, "test.boom", """{"a":1,"b":1}"""), CancellationToken.None))!;
@@ -105,7 +105,7 @@ public sealed class IdentityAndRpcTests
public async Task Router_Notifications_ProduceNoResponse_EvenOnFailure() public async Task Router_Notifications_ProduceNoResponse_EvenOnFailure()
{ {
int calls = 0; int calls = 0;
var router = new RpcRouter<string>(RpcSide.Listener, TestTables.Of(TestTables.Note("test.note"), TestTables.Note("test.unknown"))).OnNotify("test.note", EchoContext.Default.AddParams, (_, _, _) => var router = new RpcRouter<string>().OnNotify("test.note", EchoContext.Default.AddParams, (_, _, _) =>
{ {
calls++; calls++;
return ValueTask.CompletedTask; return ValueTask.CompletedTask;
@@ -1,112 +0,0 @@
using System.Net;
using Fleet.Identity;
namespace Fleet.Tests;
public sealed class KnockTests
{
private sealed class Clock : TimeProvider
{
public DateTimeOffset Now { get; set; } = new(2026, 10, 7, 12, 0, 0, TimeSpan.Zero);
public override DateTimeOffset GetUtcNow() => Now;
}
[Fact]
public void PairKey_IsTheSameOnBothSides_AndDiffersPerPair()
{
using NodeIdentity probe = NodeIdentity.Generate();
using NodeIdentity agent = NodeIdentity.Generate();
using NodeIdentity other = NodeIdentity.Generate();
byte[] fromProbe = Knock.DeriveKey(probe, agent.PublicKeySpki);
byte[] fromAgent = Knock.DeriveKey(agent, probe.PublicKeySpki);
Assert.Equal(fromProbe, fromAgent);
Assert.Equal(32, fromProbe.Length);
Assert.NotEqual(fromProbe, Knock.DeriveKey(other, agent.PublicKeySpki));
}
[Fact]
public void Packet_IsAcceptedWithinTheWindowAndOneWindowOfSkew_ButNotBeyond()
{
byte[] key = new byte[32];
long now = new DateTimeOffset(2026, 10, 7, 12, 0, 15, TimeSpan.Zero).ToUnixTimeMilliseconds();
byte[] packet = Knock.Build(key, now);
Assert.Equal(Knock.PacketBytes, packet.Length);
Assert.True(Knock.Check(key, packet, now));
Assert.True(Knock.Check(key, packet, now + (Knock.WindowSeconds * 1000)));
Assert.True(Knock.Check(key, packet, now - (Knock.WindowSeconds * 1000)));
Assert.False(Knock.Check(key, packet, now + (3 * Knock.WindowSeconds * 1000)));
Assert.False(Knock.Check(key, packet, now - (3 * Knock.WindowSeconds * 1000)));
}
[Fact]
public void Packet_WithAWrongKeyATamperedByteOrTheWrongLength_IsRejected()
{
byte[] key = new byte[32];
long now = 1_700_000_000_000;
byte[] packet = Knock.Build(key, now);
Assert.False(Knock.Check(new byte[32] { 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, packet, now));
foreach (int index in new[] { 0, 15, 16, 31 })
{
byte[] tampered = (byte[])packet.Clone();
tampered[index] ^= 0x01;
Assert.False(Knock.Check(key, tampered, now), $"byte {index}");
}
Assert.False(Knock.Check(key, packet.AsSpan(0, 31), now));
Assert.False(Knock.Check(key, [], now));
}
[Fact]
public void Gate_AdmitsRosterMembersOnce_AndRejectsReplaysStrangersAndExpiredPackets()
{
using NodeIdentity agent = NodeIdentity.Generate();
using NodeIdentity probe = NodeIdentity.Generate();
using NodeIdentity stranger = NodeIdentity.Generate();
var roster = new List<byte[]> { probe.PublicKeySpki };
var gate = new KnockGate(agent, () => roster, new Clock());
long now = 1_700_000_000_000;
byte[] packet = Knock.Build(Knock.DeriveKey(probe, agent.PublicKeySpki), now);
Assert.True(gate.Accept(packet, now));
Assert.False(gate.Accept(packet, now), "the same nonce cannot be used twice");
Assert.False(gate.Accept(Knock.Build(Knock.DeriveKey(stranger, agent.PublicKeySpki), now), now), "not on the roster");
Assert.False(gate.Accept(Knock.Build(Knock.DeriveKey(probe, agent.PublicKeySpki), now - 10 * 60_000), now), "expired");
// 名册变了:新成员马上能进,被移除的马上进不来
using NodeIdentity second = NodeIdentity.Generate();
roster.Add(second.PublicKeySpki);
Assert.True(gate.Accept(Knock.Build(Knock.DeriveKey(second, agent.PublicKeySpki), now), now));
roster.RemoveAt(0);
Assert.False(gate.Accept(Knock.Build(Knock.DeriveKey(probe, agent.PublicKeySpki), now), now));
}
[Fact]
public async Task Gate_RateLimitsEachSource_AndReadsAtMost32Bytes()
{
using NodeIdentity agent = NodeIdentity.Generate();
using NodeIdentity probe = NodeIdentity.Generate();
var clock = new Clock();
var gate = new KnockGate(agent, () => [probe.PublicKeySpki], clock, attemptsPerPeriod: 3);
IPAddress noisy = IPAddress.Parse("203.0.113.9");
IPAddress quiet = IPAddress.Parse("203.0.113.10");
byte[] key = Knock.DeriveKey(probe, agent.PublicKeySpki);
for (int i = 0; i < 3; i++)
{
Assert.False(await gate.AdmitAsync(new MemoryStream(new byte[Knock.PacketBytes]), noisy, default));
}
// 超过限额:连有效的敲门包也不读、不算
byte[] valid = Knock.Build(key, clock.Now.ToUnixTimeMilliseconds());
Assert.False(await gate.AdmitAsync(new MemoryStream(valid), noisy, default));
Assert.True(await gate.AdmitAsync(new MemoryStream(valid), quiet, default));
// 过了统计周期,恢复
clock.Now += TimeSpan.FromSeconds(11);
Assert.True(await gate.AdmitAsync(new MemoryStream(Knock.Build(key, clock.Now.ToUnixTimeMilliseconds())), noisy, default));
// 流在 32 字节之前就结束:拒绝,不抛异常
Assert.False(await gate.AdmitAsync(new MemoryStream(new byte[10]), quiet, default));
}
}
+18 -79
View File
@@ -227,9 +227,8 @@ public sealed class LinkTests
{ {
(TlsFrameTransport left, TlsFrameTransport right) = await PairAsync(); (TlsFrameTransport left, TlsFrameTransport right) = await PairAsync();
RpcSession<string>? rightSession = null; RpcSession<string>? rightSession = null;
MethodTable table = TestTables.Of(TestTables.Req("left.double", RpcSide.Listener), TestTables.Req("right.relay"), TestTables.Req("right.unhandled")); var leftRouter = new RpcRouter<string>().On("left.double", LinkJson.Default.Number, LinkJson.Default.Number, (_, p, _) => ValueTask.FromResult(Result<Number>.Ok(new Number { Value = p.Value * 2 })));
var leftRouter = new RpcRouter<string>(RpcSide.Dialer, table).On("left.double", LinkJson.Default.Number, LinkJson.Default.Number, (_, p, _) => ValueTask.FromResult(Result<Number>.Ok(new Number { Value = p.Value * 2 }))); var rightRouter = new RpcRouter<string>().On(
var rightRouter = new RpcRouter<string>(RpcSide.Listener, table).On(
"right.relay", LinkJson.Default.Number, LinkJson.Default.Number, "right.relay", LinkJson.Default.Number, LinkJson.Default.Number,
async (_, p, ct) => async (_, p, ct) =>
{ {
@@ -249,7 +248,7 @@ public sealed class LinkTests
Result<Number> result = await leftSession.RequestAsync("right.relay", new Number { Value = 21 }, LinkJson.Default.Number, LinkJson.Default.Number, default); Result<Number> result = await leftSession.RequestAsync("right.relay", new Number { Value = 21 }, LinkJson.Default.Number, LinkJson.Default.Number, default);
Assert.Equal(43, result.Value.Value); Assert.Equal(43, result.Value.Value);
Result<JsonElement> unknown = await leftSession.RequestAsync("right.unhandled", null, default); Result<JsonElement> unknown = await leftSession.RequestAsync("nope", null, default);
Assert.Equal("fleet_unsupported", unknown.Error.Code.Value); Assert.Equal("fleet_unsupported", unknown.Error.Code.Value);
await leftSession.CloseAsync("bye", default); await leftSession.CloseAsync("bye", default);
@@ -263,13 +262,13 @@ public sealed class LinkTests
{ {
(TlsFrameTransport left, TlsFrameTransport right) = await PairAsync(); (TlsFrameTransport left, TlsFrameTransport right) = await PairAsync();
await using var silent = right; // 对端不读也不回 await using var silent = right; // 对端不读也不回
await using var session = new RpcSession<string>(left, new RpcRouter<string>(RpcSide.Dialer, TestTables.Of(TestTables.Req("test.any"))), "x", TimeProvider.System, new RpcSessionOptions { RequestTimeout = TimeSpan.FromMilliseconds(200) }); await using var session = new RpcSession<string>(left, new RpcRouter<string>(), "x", TimeProvider.System, new RpcSessionOptions { RequestTimeout = TimeSpan.FromMilliseconds(200) });
Task run = session.RunAsync(default); Task run = session.RunAsync(default);
Result<JsonElement> timedOut = await session.RequestAsync("test.any", null, default); Result<JsonElement> timedOut = await session.RequestAsync("any", null, default);
Assert.Equal(FleetErrors.Timeout, timedOut.Error.Code); Assert.Equal(FleetErrors.Timeout, timedOut.Error.Code);
Task<Result<JsonElement>> pending = session.RequestAsync("test.any", null, default, TimeSpan.FromSeconds(30)).AsTask(); Task<Result<JsonElement>> pending = session.RequestAsync("any", null, default, TimeSpan.FromSeconds(30)).AsTask();
await silent.CloseAsync("gone", default); await silent.CloseAsync("gone", default);
Assert.Equal(FleetErrors.Timeout, (await pending.WaitAsync(TimeSpan.FromSeconds(5))).Error.Code); Assert.Equal(FleetErrors.Timeout, (await pending.WaitAsync(TimeSpan.FromSeconds(5))).Error.Code);
await run.WaitAsync(TimeSpan.FromSeconds(5)); await run.WaitAsync(TimeSpan.FromSeconds(5));
@@ -280,20 +279,12 @@ public sealed class LinkTests
private sealed class PeerFixture : IAsyncDisposable private sealed class PeerFixture : IAsyncDisposable
{ {
private readonly CancellationTokenSource _stop = new(); private readonly CancellationTokenSource _stop = new();
private readonly NodeIdentity _identity;
public PeerFixture(Func<string, byte[]?> lookup, Func<PeerSessionInfo, IRpcTransport, CancellationToken, Task>? onSession = null) public PeerFixture(Func<string, byte[]?> lookup, Func<PeerSessionInfo, IRpcTransport, CancellationToken, Task>? onSession = null)
{ {
Certificate = NodeCertificate.LoadPfx(NodeCertificate.CreateSelfSignedPfx("agent", TimeSpan.FromDays(30), TimeProvider.System)); Certificate = NodeCertificate.LoadPfx(NodeCertificate.CreateSelfSignedPfx("agent", TimeSpan.FromDays(30), TimeProvider.System));
_identity = NodeIdentity.FromPkcs8(Certificate.GetECDsaPrivateKey()!.ExportPkcs8PrivateKey());
Listener = new PeerListener( Listener = new PeerListener(
new PeerListenerOptions new PeerListenerOptions { Listen = new IPEndPoint(IPAddress.Loopback, 0), Certificate = Certificate, LookupPeerKey = lookup },
{
Listen = new IPEndPoint(IPAddress.Loopback, 0),
Certificate = Certificate,
LookupPeerKey = lookup,
Knock = new KnockGate(_identity, () => KnockPeers, TimeProvider.System),
},
onSession ?? ((_, transport, ct) => KeepOpenAsync(transport, ct)), onSession ?? ((_, transport, ct) => KeepOpenAsync(transport, ct)),
NullNodeLog.Instance, TimeProvider.System); NullNodeLog.Instance, TimeProvider.System);
Task = Listener.RunAsync(_stop.Token); Task = Listener.RunAsync(_stop.Token);
@@ -307,17 +298,10 @@ public sealed class LinkTests
public byte[] Spki => NodeCertificate.SpkiFingerprint(Certificate); public byte[] Spki => NodeCertificate.SpkiFingerprint(Certificate);
/// <summary>Agent 的 TLS 证书公钥:Probe 用它固定证书、派生敲门密钥。</summary>
public byte[] PublicKey => Certificate.PublicKey.ExportSubjectPublicKeyInfo();
/// <summary>名册:能敲开门的 Probe 公钥。</summary>
public List<byte[]> KnockPeers { get; } = [];
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
await _stop.CancelAsync(); await _stop.CancelAsync();
await Task; await Task;
_identity.Dispose();
Certificate.Dispose(); Certificate.Dispose();
_stop.Dispose(); _stop.Dispose();
} }
@@ -340,10 +324,9 @@ public sealed class LinkTests
seen.TrySetResult(info); seen.TrySetResult(info);
return Task.Delay(Timeout.Infinite, ct).ContinueWith(_ => { }, CancellationToken.None); return Task.Delay(Timeout.Infinite, ct).ContinueWith(_ => { }, CancellationToken.None);
}); });
agent.KnockPeers.Add(probe.PublicKeySpki);
await agent.Listener.Started.Task; await agent.Listener.Started.Task;
(PeerSessionInfo client, TlsFrameTransport transport) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_1", null, 1 << 20, TimeProvider.System, default); (PeerSessionInfo client, TlsFrameTransport transport) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
await using (transport) await using (transport)
{ {
PeerSessionInfo server = await seen.Task.WaitAsync(TimeSpan.FromSeconds(5)); PeerSessionInfo server = await seen.Task.WaitAsync(TimeSpan.FromSeconds(5));
@@ -359,18 +342,17 @@ public sealed class LinkTests
using NodeIdentity probe = NodeIdentity.Generate(); using NodeIdentity probe = NodeIdentity.Generate();
using NodeIdentity impostor = NodeIdentity.Generate(); using NodeIdentity impostor = NodeIdentity.Generate();
await using var agent = new PeerFixture(id => id == "prb_1" ? probe.PublicKeySpki : null); await using var agent = new PeerFixture(id => id == "prb_1" ? probe.PublicKeySpki : null);
agent.KnockPeers.AddRange([probe.PublicKeySpki, impostor.PublicKeySpki]); // 敲门通过之后,名册外的 ID 和伪造的签名仍然被拒绝
await agent.Listener.Started.Task; await agent.Listener.Started.Task;
InvalidOperationException unknown = await Assert.ThrowsAsync<InvalidOperationException>(async () => InvalidOperationException unknown = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_unknown", null, 1 << 20, TimeProvider.System, default)); await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_unknown", null, 1 << 20, TimeProvider.System, default));
InvalidOperationException forged = await Assert.ThrowsAsync<InvalidOperationException>(async () => InvalidOperationException forged = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, impostor, "prb_1", null, 1 << 20, TimeProvider.System, default)); await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, impostor, "prb_1", null, 1 << 20, TimeProvider.System, default));
Assert.Equal(unknown.Message, forged.Message); Assert.Equal(unknown.Message, forged.Message);
Assert.Contains("fleet_unauthorized", forged.Message); Assert.Contains("fleet_unauthorized", forged.Message);
// 正主仍然可以连接 // 正主仍然可以连接
(_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_1", null, 1 << 20, TimeProvider.System, default); (_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
await ok.DisposeAsync(); await ok.DisposeAsync();
} }
@@ -379,21 +361,18 @@ public sealed class LinkTests
{ {
using NodeIdentity probe = NodeIdentity.Generate(); using NodeIdentity probe = NodeIdentity.Generate();
await using var agent = new PeerFixture(_ => probe.PublicKeySpki); await using var agent = new PeerFixture(_ => probe.PublicKeySpki);
agent.KnockPeers.Add(probe.PublicKeySpki);
await agent.Listener.Started.Task; await agent.Listener.Started.Task;
// 公钥不对:敲门密钥对不上,Agent 直接断开(拿到的不是证书,连不是它的证书都看不到) byte[] wrong = SHA256.HashData("another certificate"u8);
using NodeIdentity another = NodeIdentity.Generate(); await Assert.ThrowsAnyAsync<AuthenticationException>(async () =>
await Assert.ThrowsAnyAsync<Exception>(async () => await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, wrong, probe, "prb_1", null, 1 << 20, TimeProvider.System, default));
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, another.PublicKeySpki, probe, "prb_1", null, 1 << 20, TimeProvider.System, default));
} }
[Fact] [Fact]
public async Task PeerSession_CarriesRpcBothWays_AfterTheHandshake() public async Task PeerSession_CarriesRpcBothWays_AfterTheHandshake()
{ {
using NodeIdentity probe = NodeIdentity.Generate(); using NodeIdentity probe = NodeIdentity.Generate();
MethodTable table = TestTables.Of(TestTables.Req("agent.echo")); var router = new RpcRouter<string>().On("agent.echo", LinkJson.Default.Number, LinkJson.Default.Number, (_, p, _) => ValueTask.FromResult(Result<Number>.Ok(new Number { Value = p.Value + 1 })));
var router = new RpcRouter<string>(RpcSide.Listener, table).On("agent.echo", LinkJson.Default.Number, LinkJson.Default.Number, (_, p, _) => ValueTask.FromResult(Result<Number>.Ok(new Number { Value = p.Value + 1 })));
await using var agent = new PeerFixture( await using var agent = new PeerFixture(
_ => probe.PublicKeySpki, _ => probe.PublicKeySpki,
async (info, transport, ct) => async (info, transport, ct) =>
@@ -401,11 +380,10 @@ public sealed class LinkTests
await using var session = new RpcSession<string>(transport, router, info.PeerId, TimeProvider.System); await using var session = new RpcSession<string>(transport, router, info.PeerId, TimeProvider.System);
await session.RunAsync(ct); await session.RunAsync(ct);
}); });
agent.KnockPeers.Add(probe.PublicKeySpki);
await agent.Listener.Started.Task; await agent.Listener.Started.Task;
(_, TlsFrameTransport transport) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_1", null, 1 << 20, TimeProvider.System, default); (_, TlsFrameTransport transport) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
await using var client = new RpcSession<string>(transport, new RpcRouter<string>(RpcSide.Dialer, table), "probe", TimeProvider.System); await using var client = new RpcSession<string>(transport, new RpcRouter<string>(), "probe", TimeProvider.System);
Task run = client.RunAsync(default); Task run = client.RunAsync(default);
Result<Number> result = await client.RequestAsync("agent.echo", new Number { Value = 41 }, LinkJson.Default.Number, LinkJson.Default.Number, default); Result<Number> result = await client.RequestAsync("agent.echo", new Number { Value = 41 }, LinkJson.Default.Number, LinkJson.Default.Number, default);
Assert.Equal(42, result.Value.Value); Assert.Equal(42, result.Value.Value);
@@ -418,7 +396,6 @@ public sealed class LinkTests
{ {
using NodeIdentity probe = NodeIdentity.Generate(); using NodeIdentity probe = NodeIdentity.Generate();
await using var agent = new PeerFixture(_ => probe.PublicKeySpki); await using var agent = new PeerFixture(_ => probe.PublicKeySpki);
agent.KnockPeers.Add(probe.PublicKeySpki);
await agent.Listener.Started.Task; await agent.Listener.Started.Task;
// 不说 TLS 的连接:握手失败,监听继续 // 不说 TLS 的连接:握手失败,监听继续
@@ -428,45 +405,7 @@ public sealed class LinkTests
await garbage.GetStream().WriteAsync("GET / HTTP/1.1\r\n\r\n"u8.ToArray()); await garbage.GetStream().WriteAsync("GET / HTTP/1.1\r\n\r\n"u8.ToArray());
} }
(_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_1", null, 1 << 20, TimeProvider.System, default); (_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
await ok.DisposeAsync();
}
[Fact]
public async Task PeerListener_WritesNothingToStrangers_NotEvenATlsServerHello()
{
using NodeIdentity probe = NodeIdentity.Generate();
await using var agent = new PeerFixture(_ => probe.PublicKeySpki);
agent.KnockPeers.Add(probe.PublicKeySpki);
await agent.Listener.Started.Task;
// 一个像样的 TLS ClientHello 开头、随机数据、什么都不发:都拿不到任何字节(连接被直接关闭)
foreach (byte[] payload in new[] { new byte[] { 0x16, 0x03, 0x01, 0x00, 0x05, 0x01, 0x00, 0x00, 0x01, 0x00 }.Concat(RandomNumberGenerator.GetBytes(64)).ToArray(), RandomNumberGenerator.GetBytes(32), [] })
{
using var scanner = new TcpClient();
await scanner.ConnectAsync(IPAddress.Loopback, agent.Listener.Port);
NetworkStream stream = scanner.GetStream();
if (payload.Length > 0)
{
await stream.WriteAsync(payload);
}
byte[] buffer = new byte[64];
int read;
try
{
read = await stream.ReadAsync(buffer).AsTask().WaitAsync(TimeSpan.FromSeconds(10));
}
catch (IOException)
{
read = 0; // 连接被重置也是「什么都没收到」
}
Assert.Equal(0, read);
}
// 名册里的 Probe 仍然进得来
(_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
await ok.DisposeAsync(); await ok.DisposeAsync();
} }
@@ -1,200 +0,0 @@
using System.Text.Json;
using System.Threading.Channels;
using Fleet.Core;
using Fleet.Rpc;
namespace Fleet.Tests;
/// <summary>协议方向声明:路由器是唯一卡口,注册、分发、发送三处都核对声明表。</summary>
public sealed class MethodDirectionTests
{
private static readonly MethodTable s_table = TestTables.Of(
MethodSpec.Request("t.ping", RpcSide.Node),
MethodSpec.Notification("t.note", RpcSide.Node, "agent"),
MethodSpec.Request("t.push", RpcSide.Server),
MethodSpec.Request("t.inline", RpcSide.Node).AsInline(),
// 同一个方法名在对等面里另有发起方:按(平面,名字)区分
MethodSpec.Request("t.ping", RpcSide.Listener));
private static readonly JsonElement s_empty = JsonDocument.Parse("{}").RootElement.Clone();
private static RpcMessage Request(string method) => new() { Id = 1, Method = method, Params = s_empty };
private static RpcMessage Notification(string method) => new() { Method = method, Params = s_empty };
private static RpcRouter<string> Server(string? localRole = null) => new(RpcSide.Server, s_table, localRole, callerRole: role => role);
[Fact]
public void Registration_RefusesUndeclaredMethods_WrongDirection_WrongKind_AndWrongRole()
{
RpcRouter<string> server = Server();
Assert.Throws<InvalidOperationException>(() => server.On("t.unknown", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => throw new NotSupportedException()));
// t.push 是服务端发给节点的:服务端这一侧不能处理它
Assert.Throws<InvalidOperationException>(() => server.On("t.push", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => throw new NotSupportedException()));
// t.ping 声明成请求:不能按通知注册
Assert.Throws<InvalidOperationException>(() => server.OnNotify("t.ping", EchoContext.Default.AddParams, (_, _, _) => ValueTask.CompletedTask));
// 节点这一侧:t.note 只给 agent 角色,probe 注册不了;t.push 是它该处理的
var probe = new RpcRouter<string>(RpcSide.Node, s_table, "probe");
probe.On("t.push", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => throw new NotSupportedException());
Assert.Throws<InvalidOperationException>(() => new RpcRouter<string>(RpcSide.Server, s_table, "probe").OnNotify("t.note", EchoContext.Default.AddParams, (_, _, _) => ValueTask.CompletedTask));
server.On("t.ping", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => ValueTask.FromResult(Result<AddResult>.Ok(new AddResult())));
}
[Fact]
public async Task Dispatch_RefusesWhatThePeerMayNotInitiate_AndReportsAViolation()
{
int calls = 0;
RpcRouter<string> server = Server()
.On("t.ping", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) =>
{
calls++;
return ValueTask.FromResult(Result<AddResult>.Ok(new AddResult()));
})
.OnNotify("t.note", EchoContext.Default.AddParams, (_, _, _) =>
{
calls++;
return ValueTask.CompletedTask;
});
// 正常
RpcDispatch ok = await server.DispatchCheckedAsync("agent", Request("t.ping"), default);
Assert.Null(ok.Violation);
Assert.NotNull(ok.Response!.Result);
// 没有声明的方法
RpcDispatch undeclared = await server.DispatchCheckedAsync("agent", Request("t.nope"), default);
Assert.NotNull(undeclared.Violation);
Assert.Equal("fleet_protocol_violation", undeclared.Response!.Error!.Data!.Code);
// 节点冒充服务端:t.push 只能由服务端发起
RpcDispatch backwards = await server.DispatchCheckedAsync("agent", Request("t.push"), default);
Assert.NotNull(backwards.Violation);
Assert.Equal("fleet_protocol_violation", backwards.Response!.Error!.Data!.Code);
// 请求和通知用反了
Assert.NotNull((await server.DispatchCheckedAsync("agent", Notification("t.ping"), default)).Violation);
RpcDispatch asRequest = await server.DispatchCheckedAsync("agent", Request("t.note"), default);
Assert.NotNull(asRequest.Violation);
Assert.NotNull(asRequest.Response);
// 违规的消息到不了处理函数;通知违规不回响应
Assert.Equal(1, calls);
RpcDispatch silentViolation = await server.DispatchCheckedAsync("agent", Notification("t.nope"), default);
Assert.NotNull(silentViolation.Violation);
Assert.Null(silentViolation.Response);
}
[Fact]
public async Task Dispatch_ADeclaredMethodWithoutAHandler_IsUnsupported_NotAViolation()
{
RpcDispatch outcome = await Server().DispatchCheckedAsync("agent", Request("t.ping"), default);
Assert.Null(outcome.Violation);
Assert.Equal("fleet_unsupported", outcome.Response!.Error!.Data!.Code);
}
[Fact]
public async Task Dispatch_ChecksTheCallersRole()
{
RpcRouter<string> server = Server().OnNotify("t.note", EchoContext.Default.AddParams, (_, _, _) => ValueTask.CompletedTask);
Assert.Null((await server.DispatchCheckedAsync("agent", Notification("t.note"), default)).Violation);
Assert.NotNull((await server.DispatchCheckedAsync("probe", Notification("t.note"), default)).Violation);
Assert.NotNull((await server.DispatchCheckedAsync(null!, Notification("t.note"), default)).Violation);
}
[Fact]
public void CheckSend_RefusesWhatThisSideMayNotInitiate()
{
RpcRouter<string> server = Server();
server.CheckSend("t.push", MethodKind.Request);
Assert.Throws<InvalidOperationException>(() => server.CheckSend("t.ping", MethodKind.Request)); // 那是节点发起的
Assert.Throws<InvalidOperationException>(() => server.CheckSend("t.push", MethodKind.Notification)); // 类型不对
Assert.Throws<InvalidOperationException>(() => server.CheckSend("t.unknown", MethodKind.Request));
// 对端的角色也要允许
var node = new RpcRouter<string>(RpcSide.Node, s_table, "agent");
node.CheckSend("t.note", MethodKind.Notification, "agent");
Assert.Throws<InvalidOperationException>(() => node.CheckSend("t.note", MethodKind.Notification, "probe"));
}
[Fact]
public void MethodTable_RefusesDuplicates_AndReportsMissingHandlers()
{
Assert.Throws<ArgumentException>(() => TestTables.Of(TestTables.Req("a"), TestTables.Note("a")));
Assert.Throws<ArgumentException>(() => new MethodTable([], [new ChannelSpec(16, RpcSide.Node), new ChannelSpec(16, RpcSide.Server)]));
// 服务端要处理 t.ping 和 t.note;t.inline 在握手阶段直接处理,不算缺
Assert.Equal(["t.note", "t.ping"], s_table.MissingHandlers(RpcSide.Server, null, []));
Assert.Equal(["t.ping"], s_table.MissingHandlers(RpcSide.Server, "probe", []));
Assert.Equal(["t.note"], s_table.MissingHandlers(RpcSide.Server, "agent", ["t.ping"]));
Assert.Empty(s_table.MissingHandlers(RpcSide.Server, "agent", ["t.ping", "t.note"]));
// 同名方法在对等面的接收方是拨号方?不:t.ping 在对等面由监听方发起,拨号方处理
Assert.Equal(["t.ping"], s_table.MissingHandlers(RpcSide.Dialer, null, []));
}
[Fact]
public async Task Session_ClosesTheConnection_AfterTooManyViolations()
{
(ChannelTransport a, ChannelTransport b) = ChannelTransport.Pair();
RpcRouter<string> server = Server().On("t.ping", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => ValueTask.FromResult(Result<AddResult>.Ok(new AddResult())));
await using var session = new RpcSession<string>(b, server, "agent", TimeProvider.System, new RpcSessionOptions { MaxViolations = 3 });
Task run = session.RunAsync(default);
for (int i = 1; i <= 6; i++)
{
await a.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(new RpcMessage { Id = i, Method = "t.nope", Params = s_empty }, FleetRpcContext.Default.RpcMessage)), default);
}
// 违规的请求先收到失败响应,超过上限之后连接被关闭
int failures = 0;
while (await a.ReceiveAsync(default).AsTask().WaitAsync(TimeSpan.FromSeconds(5)) is { } frame)
{
if (JsonSerializer.Deserialize(frame.Data.Span, FleetRpcContext.Default.RpcMessage)?.Error?.Data?.Code == "fleet_protocol_violation")
{
failures++;
}
}
Assert.InRange(failures, 3, 6);
await a.CloseAsync("done", default); // 对端确认关闭之后,会话的接收循环正常结束
await run.WaitAsync(TimeSpan.FromSeconds(5));
}
/// <summary>进程内的一对传输:一端发的帧从另一端收到;关闭时对端收到 null。</summary>
private sealed class ChannelTransport : IRpcTransport
{
private readonly Channel<RpcFrame?> _in;
private readonly Channel<RpcFrame?> _out;
private ChannelTransport(Channel<RpcFrame?> input, Channel<RpcFrame?> output)
{
_in = input;
_out = output;
}
public static (ChannelTransport A, ChannelTransport B) Pair()
{
Channel<RpcFrame?> ab = Channel.CreateUnbounded<RpcFrame?>();
Channel<RpcFrame?> ba = Channel.CreateUnbounded<RpcFrame?>();
return (new ChannelTransport(ba, ab), new ChannelTransport(ab, ba));
}
public ValueTask SendAsync(RpcFrame frame, CancellationToken ct)
{
_out.Writer.TryWrite(frame);
return ValueTask.CompletedTask;
}
public async ValueTask<RpcFrame?> ReceiveAsync(CancellationToken ct) =>
await _in.Reader.WaitToReadAsync(ct) && _in.Reader.TryRead(out RpcFrame? frame) ? frame : null;
public ValueTask CloseAsync(string reason, CancellationToken ct)
{
_out.Writer.TryWrite(null);
return ValueTask.CompletedTask;
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}
@@ -1,123 +0,0 @@
using System.Buffers.Text;
using System.Security.Cryptography;
using System.Text;
using Fleet.Identity;
namespace Fleet.Tests;
/// <summary>软件 passkey:只做断言(navigator.credentials.get 的结果),用来测信任链。</summary>
internal sealed class TestAuthenticator : IDisposable
{
public const string RpId = "panel.example.com";
public const string Origin = "https://panel.example.com";
private readonly ECDsa _key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
private uint _counter;
public string CredentialId { get; } = Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(32));
public OperatorKey Key => new(CredentialId, _key.ExportSubjectPublicKeyInfo());
public SignedStatement Sign(string statement, byte flags = 0x05, string origin = Origin)
{
byte[] challenge = SHA256.HashData(Encoding.UTF8.GetBytes(statement));
_counter++;
byte[] authData = [.. SHA256.HashData(Encoding.UTF8.GetBytes(RpId)), flags, (byte)(_counter >> 24), (byte)(_counter >> 16), (byte)(_counter >> 8), (byte)_counter];
byte[] clientData = Encoding.UTF8.GetBytes($"{{\"type\":\"webauthn.get\",\"challenge\":\"{Base64Url.EncodeToString(challenge)}\",\"origin\":\"{origin}\",\"crossOrigin\":false}}");
byte[] signature = _key.SignData([.. authData, .. SHA256.HashData(clientData)], HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence);
return new SignedStatement(statement, CredentialId, Base64Url.EncodeToString(authData), Base64Url.EncodeToString(clientData), Base64Url.EncodeToString(signature));
}
public void Dispose() => _key.Dispose();
}
public sealed class OperatorTrustTests : IDisposable
{
private readonly TestAuthenticator _alice = new();
private readonly TestAuthenticator _bob = new();
public void Dispose()
{
_alice.Dispose();
_bob.Dispose();
}
private OperatorSet Genesis(params TestAuthenticator[] keys) =>
new(1, OperatorTrust.Genesis, TestAuthenticator.RpId, [TestAuthenticator.Origin], [.. keys.Select(k => k.Key)]);
private SignedStatement SignedGenesis(OperatorSet set, TestAuthenticator signer) => signer.Sign(OperatorTrust.Encode(set));
[Fact]
public void TheGenesis_IsPinnedByItsFingerprint_AndMustBeSelfSigned()
{
OperatorSet set = Genesis(_alice);
SignedStatement genesis = SignedGenesis(set, _alice);
string pin = OperatorTrust.Fingerprint(genesis.Statement);
Assert.NotNull(OperatorTrust.VerifyChain(pin, [genesis]));
Assert.Null(OperatorTrust.VerifyChain(new string('0', 64), [genesis])); // 指纹对不上:Backend 换了一份创世
Assert.Null(OperatorTrust.VerifyChain(pin, [SignedGenesis(set, _bob)])); // 不是集合里的凭证签的(也改不了指纹)
Assert.Null(OperatorTrust.VerifyChain(pin, []));
}
[Fact]
public void ABackendThatSwapsTheKeys_CannotMakeAChainTheNodeAccepts()
{
SignedStatement real = SignedGenesis(Genesis(_alice), _alice);
string pin = OperatorTrust.Fingerprint(real.Statement);
using var attacker = new TestAuthenticator();
SignedStatement forged = SignedGenesis(Genesis(attacker), attacker); // 自洽的假创世,但指纹不同
Assert.Null(OperatorTrust.VerifyChain(pin, [forged]));
// 在真创世后面接一个攻击者签的更新:上一版里没有他的凭证
var update = new OperatorSet(2, OperatorTrust.Fingerprint(real.Statement), TestAuthenticator.RpId, [TestAuthenticator.Origin], [_alice.Key, attacker.Key]);
Assert.Null(OperatorTrust.VerifyChain(pin, [real, attacker.Sign(OperatorTrust.Encode(update))]));
}
[Fact]
public void AnUpdate_NeedsAKeyOfThePreviousVersion_AndFollowsTheChain()
{
SignedStatement genesis = SignedGenesis(Genesis(_alice), _alice);
string pin = OperatorTrust.Fingerprint(genesis.Statement);
var second = new OperatorSet(2, OperatorTrust.Fingerprint(genesis.Statement), TestAuthenticator.RpId, [TestAuthenticator.Origin], [_alice.Key, _bob.Key]);
SignedStatement signedSecond = _alice.Sign(OperatorTrust.Encode(second));
OperatorSet? current = OperatorTrust.VerifyChain(pin, [genesis, signedSecond]);
Assert.Equal(2, current?.Version);
Assert.Equal(2, current?.Keys.Count);
// 新加的 bob 可以签下一版(上一版里有他)
var third = new OperatorSet(3, OperatorTrust.Fingerprint(signedSecond.Statement), TestAuthenticator.RpId, [TestAuthenticator.Origin], [_bob.Key]);
Assert.Equal(3, OperatorTrust.VerifyChain(pin, [genesis, signedSecond, _bob.Sign(OperatorTrust.Encode(third))])?.Version);
// 版本号跳了、prev 不对、域名换了:都不行
var skipped = second with { Version = 3 };
Assert.Null(OperatorTrust.VerifyChain(pin, [genesis, _alice.Sign(OperatorTrust.Encode(skipped))]));
var wrongPrev = second with { Previous = new string('a', 64) };
Assert.Null(OperatorTrust.VerifyChain(pin, [genesis, _alice.Sign(OperatorTrust.Encode(wrongPrev))]));
var otherRp = second with { RpId = "evil.example" };
Assert.Null(OperatorTrust.VerifyChain(pin, [genesis, _alice.Sign(OperatorTrust.Encode(otherRp))]));
}
[Fact]
public void AnAssertion_NeedsUserVerification_TheRightOrigin_AndTheExactStatement()
{
OperatorSet set = Genesis(_alice);
Assert.True(OperatorTrust.VerifyOperator(_alice.Sign("hello"), set));
Assert.False(OperatorTrust.VerifyOperator(_alice.Sign("hello", flags: 0x01), set)); // 没有用户验证
Assert.False(OperatorTrust.VerifyOperator(_alice.Sign("hello", origin: "https://evil.example"), set));
Assert.False(OperatorTrust.VerifyOperator(_alice.Sign("hello") with { Statement = "hellO" }, set)); // 语句被改
Assert.False(OperatorTrust.VerifyOperator(_bob.Sign("hello"), set)); // 不在集合里
}
[Fact]
public void Parsing_IsStrict()
{
Assert.Null(OperatorTrust.ParseSet("moshiro-opset-v2\nversion=1"));
OperatorSet set = Genesis(_alice);
Assert.Equal(set.Keys[0].CredentialId, OperatorTrust.ParseSet(OperatorTrust.Encode(set))!.Keys[0].CredentialId);
Assert.Null(OperatorTrust.ParseSet(OperatorTrust.Encode(set with { Previous = "abc" }))); // 创世的 prev 必须是 0
Assert.Null(OperatorTrust.ParseSet(OperatorTrust.Encode(set) + "\nnot-a-key"));
}
}
@@ -1,70 +0,0 @@
using Fleet.Core;
using Fleet.Hosting;
using Fleet.Identity;
namespace Fleet.Tests;
/// <summary>节点自己要求有属主(passkey):Agent 和 Probe 共用这一份,没有属主就不运行。</summary>
public sealed class OwnerGuardTests : IDisposable
{
private readonly TestAuthenticator _alice = new();
private readonly string _directory = Path.Combine(Path.GetTempPath(), "moshiro-owner-" + Guid.NewGuid().ToString("N"));
public OwnerGuardTests()
{
Directory.CreateDirectory(_directory);
}
public void Dispose()
{
_alice.Dispose();
Directory.Delete(_directory, recursive: true);
}
private void Pin(params string[] controllers)
{
var set = new OperatorSet(1, OperatorTrust.Genesis, TestAuthenticator.RpId, [TestAuthenticator.Origin], [_alice.Key]);
SignedStatement genesis = _alice.Sign(OperatorTrust.Encode(set));
TrustAnchor.Pin(_directory, OperatorTrust.Fingerprint(genesis.Statement), [genesis.ToDto()], controllers);
}
[Fact]
public void ANodeEnrolledWithoutATrustRoot_HasNoOwner_AndDoesNotRun()
{
TrustAnchor none = TrustAnchor.Load(_directory, NullNodeLog.Instance);
Assert.NotNull(OwnerGuard.Problem(none, require: true));
Assert.False(OwnerGuard.Check(_directory, require: true, NullNodeLog.Instance));
}
[Fact]
public void ANodePinnedToAnOwnerPasskey_Runs()
{
Pin(_alice.CredentialId);
TrustAnchor pinned = TrustAnchor.Load(_directory, NullNodeLog.Instance);
Assert.Null(OwnerGuard.Problem(pinned, require: true));
Assert.True(OwnerGuard.Check(_directory, require: true, NullNodeLog.Instance));
Assert.Equal([_alice.CredentialId], pinned.Controllers);
}
[Fact]
public void APinnedNodeWhoseOwnerIsNoLongerInTheOperatorSet_DoesNotRun()
{
Pin("some-passkey-that-is-not-in-the-set");
TrustAnchor pinned = TrustAnchor.Load(_directory, NullNodeLog.Instance);
Assert.Empty(pinned.Controllers);
Assert.NotNull(OwnerGuard.Problem(pinned, require: true));
}
[Fact]
public void TheRequirementCanBeTurnedOff_ForAPanelWithoutATrustRoot()
{
TrustAnchor none = TrustAnchor.Load(_directory, NullNodeLog.Instance);
Assert.Null(OwnerGuard.Problem(none, require: false));
}
}
@@ -1,266 +0,0 @@
using Fleet.Core;
using Fleet.Hosting;
namespace Fleet.Tests;
public sealed class RestartScheduleTests
{
private static RestartSchedule Parse(string text)
{
Assert.True(RestartSchedule.TryParse(text, out RestartSchedule? schedule), text);
return schedule!;
}
[Theory]
[InlineData("04:00")]
[InlineData("04:00Z")]
[InlineData("04:00+08:00")]
[InlineData("23:59-05:30")]
[InlineData(" 00:00 ")]
public void Parse_AcceptsTimeWithOptionalOffset(string text) => Parse(text);
[Theory]
[InlineData("")]
[InlineData("4:00")]
[InlineData("24:00")]
[InlineData("04:60")]
[InlineData("04:00+8:00")]
[InlineData("04:00+15:00")]
[InlineData("04:00 UTC")]
[InlineData("0400")]
[InlineData("04:00+08:60")]
public void Parse_RejectsEverythingElse(string text) => Assert.False(RestartSchedule.TryParse(text, out _));
[Fact]
public void NextAfter_IsStrictlyLater_AndRollsToTomorrow()
{
RestartSchedule utc = Parse("04:00");
Assert.Equal(new DateTimeOffset(2026, 10, 7, 4, 0, 0, TimeSpan.Zero), utc.NextAfter(new DateTimeOffset(2026, 10, 7, 3, 59, 59, TimeSpan.Zero)));
Assert.Equal(new DateTimeOffset(2026, 10, 8, 4, 0, 0, TimeSpan.Zero), utc.NextAfter(new DateTimeOffset(2026, 10, 7, 4, 0, 0, TimeSpan.Zero)));
Assert.Equal(new DateTimeOffset(2026, 10, 8, 4, 0, 0, TimeSpan.Zero), utc.NextAfter(new DateTimeOffset(2026, 10, 7, 20, 0, 0, TimeSpan.Zero)));
}
[Fact]
public void NextAfter_UsesTheWrittenOffset()
{
// 04:00+08:00 就是 UTC 前一天 20:00
RestartSchedule china = Parse("04:00+08:00");
Assert.Equal(new DateTimeOffset(2026, 10, 6, 20, 0, 0, TimeSpan.Zero), china.NextAfter(new DateTimeOffset(2026, 10, 6, 19, 0, 0, TimeSpan.Zero)));
Assert.Equal(new DateTimeOffset(2026, 10, 7, 20, 0, 0, TimeSpan.Zero), china.NextAfter(new DateTimeOffset(2026, 10, 6, 20, 0, 0, TimeSpan.Zero)));
}
[Fact]
public void NextAfter_WithMinimumUptime_DoesNotRestartAgainRightAfterARestart()
{
RestartSchedule schedule = Parse("04:00");
// 04:00:03 重启后启动:加上最短运行时间再算,下一次是明天,而不是几秒后
var started = new DateTimeOffset(2026, 10, 7, 4, 0, 3, TimeSpan.Zero);
Assert.Equal(new DateTimeOffset(2026, 10, 8, 4, 0, 0, TimeSpan.Zero), schedule.NextAfter(started + RestartSchedule.MinimumUptime));
// 03:55 启动:离 04:00 只有 5 分钟,不够最短运行时间,顺延到明天
var early = new DateTimeOffset(2026, 10, 7, 3, 55, 0, TimeSpan.Zero);
Assert.Equal(new DateTimeOffset(2026, 10, 8, 4, 0, 0, TimeSpan.Zero), schedule.NextAfter(early + RestartSchedule.MinimumUptime));
}
[Fact]
public void Resolve_ReportsABadValueInsteadOfIgnoringIt()
{
Assert.Null(RestartSchedule.Resolve(null));
Assert.Null(RestartSchedule.Resolve(" "));
InvalidOperationException error = Assert.Throws<InvalidOperationException>(() => RestartSchedule.Resolve("tomorrow"));
Assert.Contains("restart_at", error.Message);
}
[Fact]
public async Task ServiceShell_StopsAtTheScheduledTime()
{
var time = new ImmediateTimeProvider(new DateTimeOffset(2026, 10, 7, 12, 0, 0, TimeSpan.Zero));
var log = new ListLog();
bool stoppedByToken = false;
// 业务循环只在被取消时返回;定时重启到点以后令牌被取消,RunAsync 正常返回
await ServiceShell.RunAsync(async ct =>
{
try
{
await Task.Delay(Timeout.Infinite, ct);
}
catch (OperationCanceledException)
{
stoppedByToken = true;
throw;
}
}, log, new RestartController(Parse("04:00")), time).WaitAsync(TimeSpan.FromSeconds(10));
Assert.True(stoppedByToken);
Assert.Contains(log.Lines, l => l.Contains("scheduled_restart", StringComparison.Ordinal));
Assert.Contains(log.Lines, l => l.Contains("restart scheduled", StringComparison.Ordinal));
}
[Fact]
public async Task ServiceShell_PicksUpAScheduleThePanelSendsLater()
{
var time = new ImmediateTimeProvider(new DateTimeOffset(2026, 10, 7, 12, 0, 0, TimeSpan.Zero));
var log = new ListLog();
using var controller = new RestartController();
Task shell = ServiceShell.RunAsync(ct => Task.Delay(Timeout.Infinite, ct), log, controller, time);
await Task.Delay(300);
Assert.False(shell.IsCompleted, "no schedule yet: the node keeps running");
Assert.True(controller.SetRemote("04:30+08:00"));
await shell.WaitAsync(TimeSpan.FromSeconds(10));
Assert.Contains(log.Lines, l => l.Contains("scheduled_restart", StringComparison.Ordinal));
Assert.Contains(log.Lines, l => l.Contains("source=panel", StringComparison.Ordinal));
}
[Fact]
public async Task ServiceShell_ClearingTheScheduleCancelsTheRestart()
{
var time = new ManualTimeProvider(new DateTimeOffset(2026, 10, 7, 12, 0, 0, TimeSpan.Zero));
var log = new ListLog();
using var controller = new RestartController();
controller.SetRemote("04:30+08:00");
Task shell = ServiceShell.RunAsync(ct => Task.Delay(Timeout.Infinite, ct), log, controller, time);
await Task.Delay(300);
controller.SetRemote(null); // 面板关掉了
await Task.Delay(300);
Assert.False(shell.IsCompleted);
Assert.Contains(log.Lines, l => l.Contains("restart scheduled", StringComparison.Ordinal));
Assert.DoesNotContain(log.Lines, l => l.Contains("scheduled_restart", StringComparison.Ordinal));
// 清理:把进程停掉
Assert.True(controller.SetRemote("04:30+08:00"));
// 新的计时器是异步注册的:反复推进,直到进程退出
for (int i = 0; i < 100 && !shell.IsCompleted; i++)
{
time.Advance();
await Task.Delay(50);
}
await shell.WaitAsync(TimeSpan.FromSeconds(10));
}
[Fact]
public void Controller_LocalSettingWinsOverThePanel_AndBadTextIsRejected()
{
using var controller = new RestartController(Parse("04:00"));
Assert.True(controller.IsLocal);
Assert.True(controller.SetRemote("05:00"));
Assert.Equal(new DateTimeOffset(2026, 10, 7, 4, 0, 0, TimeSpan.Zero), controller.Current!.NextAfter(new DateTimeOffset(2026, 10, 7, 0, 0, 0, TimeSpan.Zero)));
using var remoteOnly = new RestartController();
Assert.Null(remoteOnly.Current);
Assert.False(remoteOnly.SetRemote("whenever"));
Assert.Null(remoteOnly.Current);
Assert.True(remoteOnly.SetRemote("05:00"));
Assert.NotNull(remoteOnly.Current);
Assert.True(remoteOnly.SetRemote(null));
Assert.Null(remoteOnly.Current);
}
/// <summary>计时器马上触发:用来验证「到点」之后的行为,不用真的等。</summary>
private sealed class ImmediateTimeProvider : TimeProvider
{
private readonly DateTimeOffset _now;
public ImmediateTimeProvider(DateTimeOffset now) => _now = now;
public override DateTimeOffset GetUtcNow() => _now;
public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
{
// TrimHeapAsync 这类每 30 秒一次的循环也会走到这里;只让「很长」的延迟(定时重启,至少 10 分钟)马上触发
var timer = new ImmediateTimer(callback, state);
if (dueTime >= RestartSchedule.MinimumUptime)
{
ThreadPool.QueueUserWorkItem(_ => timer.Fire());
}
return timer;
}
}
/// <summary>计时器只在 <see cref="Advance"/> 时触发:用来验证「还没到点」时不会退出。</summary>
private sealed class ManualTimeProvider : TimeProvider
{
private readonly DateTimeOffset _now;
private readonly List<ImmediateTimer> _timers = [];
public ManualTimeProvider(DateTimeOffset now) => _now = now;
public override DateTimeOffset GetUtcNow() => _now;
public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
{
var timer = new ImmediateTimer(callback, state);
lock (_timers)
{
_timers.Add(timer);
}
return timer;
}
public void Advance()
{
ImmediateTimer[] due;
lock (_timers)
{
due = [.. _timers];
_timers.Clear();
}
foreach (ImmediateTimer timer in due)
{
timer.Fire();
}
}
}
private sealed class ImmediateTimer : ITimer
{
private readonly TimerCallback _callback;
private readonly object? _state;
public ImmediateTimer(TimerCallback callback, object? state)
{
_callback = callback;
_state = state;
}
public void Fire() => _callback(_state);
public bool Change(TimeSpan dueTime, TimeSpan period) => true;
public void Dispose()
{
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
private sealed class ListLog : INodeLog
{
private readonly List<string> _lines = [];
public IReadOnlyList<string> Lines
{
get
{
lock (_lines)
{
return [.. _lines];
}
}
}
public void Write(NodeLogLevel level, string message, params (string Key, object? Value)[] fields)
{
lock (_lines)
{
_lines.Add(message + " " + string.Join(' ', fields.Select(f => f.Key + "=" + f.Value)));
}
}
}
}
@@ -1,114 +0,0 @@
using System.Security.Cryptography;
using Fleet.Identity;
namespace Fleet.Tests;
public sealed class SecureChannelTests
{
private const string Nonce = "n0nce-from-the-server";
private static (LinkCipher Node, LinkCipher Server) Pair(string nodeNonce = Nonce, string serverNonce = Nonce)
{
using var node = new EphemeralKey();
using var server = new EphemeralKey();
return (LinkCipher.ForNode(node, server.PublicKeySpki, nodeNonce), LinkCipher.ForServer(server, node.PublicKeySpki, serverNonce));
}
[Fact]
public void TheServerProof_VerifiesOnlyForTheRightKey_Nonce_AndEphemeralKey()
{
using ECDsa serverKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
using ECDsa other = ECDsa.Create(ECCurve.NamedCurves.nistP256);
using var eph = new EphemeralKey();
using var otherEph = new EphemeralKey();
byte[] spki = serverKey.ExportSubjectPublicKeyInfo();
byte[] proof = ServerHello.Sign(serverKey, Nonce, eph.PublicKeySpki);
Assert.True(ServerHello.Verify(spki, Nonce, eph.PublicKeySpki, proof));
Assert.False(ServerHello.Verify(other.ExportSubjectPublicKeyInfo(), Nonce, eph.PublicKeySpki, proof)); // 别人的服务器密钥
Assert.False(ServerHello.Verify(spki, "another-nonce", eph.PublicKeySpki, proof)); // 挪到别的挑战上
Assert.False(ServerHello.Verify(spki, Nonce, otherEph.PublicKeySpki, proof)); // 中间人换了自己的临时公钥
Assert.False(ServerHello.Verify(spki, Nonce, eph.PublicKeySpki, proof.AsSpan(0, proof.Length - 1)));
Assert.False(ServerHello.Verify([1, 2, 3], Nonce, eph.PublicKeySpki, proof)); // 公钥不合法也不抛异常
}
[Fact]
public void BothDirections_RoundTrip_AndKeepTheFrameKind()
{
(LinkCipher node, LinkCipher server) = Pair();
using LinkCipher n = node;
using LinkCipher s = server;
byte[] toServer = n.Seal(0, "hello server"u8);
Assert.True(s.TryOpen(toServer, out byte kind, out byte[] data));
Assert.Equal(0, kind);
Assert.Equal("hello server"u8.ToArray(), data);
byte[] big = RandomNumberGenerator.GetBytes(300_000);
byte[] toNode = s.Seal(1, big);
Assert.True(n.TryOpen(toNode, out kind, out data));
Assert.Equal(1, kind);
Assert.Equal(big, data);
Assert.Equal(big.Length + LinkCipher.Overhead + 1, toNode.Length);
}
[Fact]
public void AReplayedReorderedTamperedOrReflectedFrame_IsRefused()
{
(LinkCipher node, LinkCipher server) = Pair();
using LinkCipher n = node;
using LinkCipher s = server;
byte[] first = n.Seal(0, "one"u8);
byte[] second = n.Seal(0, "two"u8);
Assert.False(s.TryOpen(second, out _, out _)); // 乱序(丢了第一帧)
Assert.True(s.TryOpen(first, out _, out _));
Assert.False(s.TryOpen(first, out _, out _)); // 重放
Assert.True(s.TryOpen(second, out _, out _));
byte[] tampered = n.Seal(0, "three"u8);
tampered[12] ^= 0x01;
Assert.False(s.TryOpen(tampered, out _, out _)); // 改了一位
byte[] fromNode = n.Seal(0, "four"u8);
(LinkCipher otherNode, LinkCipher otherServer) = Pair();
using LinkCipher on = otherNode;
using LinkCipher os = otherServer;
Assert.False(os.TryOpen(fromNode, out _, out _)); // 另一条连接的密钥打不开
byte[] fromServer = s.Seal(0, "reflected"u8);
Assert.False(s.TryOpen(fromServer, out _, out _)); // 方向不同:服务器发的东西不能原样塞回给服务器
Assert.False(n.TryOpen([1, 2, 3], out _, out _));
}
[Fact]
public void ADifferentNonce_GivesDifferentKeys()
{
(LinkCipher node, LinkCipher server) = Pair("nonce-a", "nonce-b"); // 两边对不上:没人能解开对方的东西
using LinkCipher n = node;
using LinkCipher s = server;
Assert.False(s.TryOpen(n.Seal(0, "x"u8), out _, out _));
}
[Fact]
public void AnOperatorSet_CarriesTheServerKeys_ThroughTheChain()
{
using var alice = new TestAuthenticator();
using ECDsa serverKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
byte[] spki = serverKey.ExportSubjectPublicKeyInfo();
var set = new OperatorSet(1, OperatorTrust.Genesis, TestAuthenticator.RpId, [TestAuthenticator.Origin], [alice.Key], [spki]);
string text = OperatorTrust.Encode(set);
OperatorSet parsed = OperatorTrust.ParseSet(text)!;
Assert.Equal(spki, Assert.Single(parsed.Servers!));
Assert.Equal(text, OperatorTrust.Encode(parsed)); // 编码和解析互逆,指纹稳定
SignedStatement genesis = alice.Sign(text);
OperatorSet verified = OperatorTrust.VerifyChain(OperatorTrust.Fingerprint(text), [genesis])!;
Assert.Equal(spki, Assert.Single(verified.Servers!));
// 服务器密钥只能写在凭证后面;顺序不对、内容不是 base64 都算坏
Assert.Null(OperatorTrust.ParseSet(text + "\nkey=late:AAAA"));
Assert.Null(OperatorTrust.ParseSet(text + "\nserver=***"));
}
}
@@ -1,13 +0,0 @@
using Fleet.Rpc;
namespace Fleet.Tests;
/// <summary>测试里用的方法声明:除非另有说明,方法都由拨号方发给监听方。</summary>
internal static class TestTables
{
public static MethodTable Of(params MethodSpec[] specs) => new(specs);
public static MethodSpec Req(string name, RpcSide from = RpcSide.Dialer) => MethodSpec.Request(name, from);
public static MethodSpec Note(string name, RpcSide from = RpcSide.Dialer) => MethodSpec.Notification(name, from);
}
+4 -13
View File
@@ -2,7 +2,7 @@ using System.Text.Json.Nodes;
using Fleet.Core; using Fleet.Core;
using Fleet.Enrollment.Client; using Fleet.Enrollment.Client;
// fleet-enroll --server https://panel --join jt_xxx --name jp-1 --identity-dir /etc/moshiro [--meta '{"probe_tcp_port":7443,...}'] [--version 1.0] // fleet-enroll --server https://panel --join jt_xxx --name jp-1 --identity-dir /etc/monitor [--meta '{"probe_tcp_port":7443,...}'] [--version 1.0]
// 一次性运行:生成身份、注册、写入身份目录,然后退出。 // 一次性运行:生成身份、注册、写入身份目录,然后退出。
string? Arg(string name) => args.SkipWhile(a => a != name).Skip(1).FirstOrDefault(); string? Arg(string name) => args.SkipWhile(a => a != name).Skip(1).FirstOrDefault();
@@ -12,7 +12,7 @@ string? name = Arg("--name");
string? directory = Arg("--identity-dir"); string? directory = Arg("--identity-dir");
if (server is null || join is null || name is null || directory is null) if (server is null || join is null || name is null || directory is null)
{ {
Console.Error.WriteLine("usage: fleet-enroll --server <url> --join <token> --name <node name> --identity-dir <dir> [--meta <json>] [--version <v>] [--genesis <trust root fingerprint> --controllers <passkey credential ids, comma separated>]"); Console.Error.WriteLine("usage: fleet-enroll --server <url> --join <token> --name <node name> --identity-dir <dir> [--meta <json>] [--version <v>]");
return 2; return 2;
} }
@@ -30,23 +30,14 @@ if (Arg("--meta") is { } metaText)
} }
} }
string? genesis = Arg("--genesis");
string[] controllers = (Arg("--controllers") ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (genesis is not null && controllers.Length == 0)
{
Console.Error.WriteLine("--genesis needs --controllers: every node has an owner (the passkey credential ids from the panel's command)");
return 2;
}
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
Result<EnrollmentResult> result = await EnrollmentClient.EnrollAsync( Result<EnrollmentResult> result = await EnrollmentClient.EnrollAsync(
http, new Uri(server), join, name, directory, Arg("--version") ?? "0.0.0", meta, TimeProvider.System, CancellationToken.None, genesis, controllers); http, new Uri(server), join, name, directory, Arg("--version") ?? "0.0.0", meta, TimeProvider.System, CancellationToken.None);
if (!result.TryGet(out EnrollmentResult? enrolled, out FleetError? error)) if (!result.TryGet(out EnrollmentResult? enrolled, out FleetError? error))
{ {
Console.Error.WriteLine($"enrollment failed ({error.Code}): {error.Message}"); Console.Error.WriteLine($"enrollment failed ({error.Code}): {error.Message}");
return 1; return 1;
} }
Console.WriteLine($"enrolled as {enrolled.NodeId} ({enrolled.Status}); identity written to {enrolled.IdentityDirectory}" + (enrolled.Pinned ? "; operator trust pinned" : "; operator trust NOT pinned (no --genesis)")); Console.WriteLine($"enrolled as {enrolled.NodeId} ({enrolled.Status}); identity written to {enrolled.IdentityDirectory}");
return 0; return 0;
@@ -1,36 +1,17 @@
<Project> <Project>
<Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" /> <Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" />
<Import Project="Sdk.props" Sdk="StuDev.AotAnywhere" <Import Project="Sdk.props" Sdk="StuDev.AotAnywhere"
Condition="'$(UseAotAnywhere)' == 'true'" /> Condition="'$(RuntimeIdentifier)' == 'linux-x64' Or '$(RuntimeIdentifier)' == 'linux-arm64' Or '$(RuntimeIdentifier)' == 'linux-arm' Or '$(RuntimeIdentifier)' == 'linux-musl-x64' Or '$(RuntimeIdentifier)' == 'linux-musl-arm64' Or '$(RuntimeIdentifier)' == 'linux-musl-arm' Or '$(RuntimeIdentifier)' == 'osx-x64' Or '$(RuntimeIdentifier)' == 'osx-arm64' Or '$(RuntimeIdentifier)' == 'win-x64' Or '$(RuntimeIdentifier)' == 'win-arm64'" />
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<AssemblyName>fleet-enroll</AssemblyName> <AssemblyName>fleet-enroll</AssemblyName>
<RootNamespace>FleetEnroll</RootNamespace> <RootNamespace>FleetEnroll</RootNamespace>
<IsAotCompatible>true</IsAotCompatible> <IsAotCompatible>true</IsAotCompatible>
<PublishAot>true</PublishAot>
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>full</TrimMode>
<StripSymbols>true</StripSymbols>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<MetadataUpdaterSupport>false</MetadataUpdaterSupport>
<OptimizationPreference>Size</OptimizationPreference>
<UseSystemResourceKeys>true</UseSystemResourceKeys>
<StackTraceSupport>false</StackTraceSupport>
<EventSourceSupport>false</EventSourceSupport>
<DebuggerSupport>false</DebuggerSupport>
<HttpActivityPropagationSupport>false</HttpActivityPropagationSupport>
<Http3Support>false</Http3Support>
<MetricsSupport>false</MetricsSupport>
<UseSizeOptimizedLinq>true</UseSizeOptimizedLinq>
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
<IlcFoldIdenticalMethodBodies>true</IlcFoldIdenticalMethodBodies>
<ServerGarbageCollection>false</ServerGarbageCollection>
<ConcurrentGarbageCollection>false</ConcurrentGarbageCollection>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\src\Fleet.Enrollment.Client\Fleet.Enrollment.Client.csproj" /> <ProjectReference Include="..\..\src\Fleet.Enrollment.Client\Fleet.Enrollment.Client.csproj" />
</ItemGroup> </ItemGroup>
<Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" /> <Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
<Import Project="Sdk.targets" Sdk="StuDev.AotAnywhere" <Import Project="Sdk.targets" Sdk="StuDev.AotAnywhere"
Condition="'$(UseAotAnywhere)' == 'true'" /> Condition="'$(RuntimeIdentifier)' == 'linux-x64' Or '$(RuntimeIdentifier)' == 'linux-arm64' Or '$(RuntimeIdentifier)' == 'linux-arm' Or '$(RuntimeIdentifier)' == 'linux-musl-x64' Or '$(RuntimeIdentifier)' == 'linux-musl-arm64' Or '$(RuntimeIdentifier)' == 'linux-musl-arm' Or '$(RuntimeIdentifier)' == 'osx-x64' Or '$(RuntimeIdentifier)' == 'osx-arm64' Or '$(RuntimeIdentifier)' == 'win-x64' Or '$(RuntimeIdentifier)' == 'win-arm64'" />
</Project> </Project>
View File
File renamed without changes.
+282 -3480
View File
File diff suppressed because it is too large. Load diff
-4
View File
@@ -8,10 +8,6 @@
<EventSourceSupport>false</EventSourceSupport> <EventSourceSupport>false</EventSourceSupport>
<DebuggerSupport>false</DebuggerSupport> <DebuggerSupport>false</DebuggerSupport>
<HttpActivityPropagationSupport>false</HttpActivityPropagationSupport> <HttpActivityPropagationSupport>false</HttpActivityPropagationSupport>
<Http3Support>false</Http3Support>
<MetricsSupport>false</MetricsSupport>
<UseSizeOptimizedLinq>true</UseSizeOptimizedLinq>
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
<ServerGarbageCollection>false</ServerGarbageCollection> <ServerGarbageCollection>false</ServerGarbageCollection>
<ConcurrentGarbageCollection>false</ConcurrentGarbageCollection> <ConcurrentGarbageCollection>false</ConcurrentGarbageCollection>
</PropertyGroup> </PropertyGroup>
+17 -30
View File
@@ -1,61 +1,48 @@
# MoShiro Probe # Monitor Probe
Probe 是网络测量的发起端,通常运行在家宽或其他需要观测的网络出口。它连接 Backend(身份、Agent 列表、状态)和 Agent(测量),执行线路任务,并把本端采集的原始数据交给 Agent 汇总上报。Probe 不提供远程命令或文件管理功能。 Probe 是网络测量的发起端,通常运行在家宽或其他需要观测的网络出口。它连接 Agent、执行线路任务,并把本端采集的原始数据交给 Agent 汇总上报。Probe 不直接连接 Backend,也不提供远程命令或文件管理功能。
## 工作流程 ## 工作流程
```text ```text
Backend ──任务──> Agent <──测量会话──> Probe Backend ──任务与节点列表──> Agent <──测量会话──> Probe
│ ▲ │ │ │ │
│ └──汇总并上报数据 <───┘ │ └──汇总并上报数据 <───┘
└─────Agent 列表、状态(Fleet 长连接)───────────┘
``` ```
Probe 用 `probe.json` 读取面板地址(`fleet.server`)、身份目录(`fleet.identity_dir`,由 `fleet-enroll` 注册时生成)和本地测试限制。连上面板之后,面板下发要连接的 Agent 列表;Probe 将列表缓存到同目录的 `agents.json`,面板暂时连不上时靠它继续测量。身份目录里有私钥,请限制访问权限,不要提交到版本控制。 Probe 使用面板生成的 `probe.json` 读取自身身份、首次连接用的 Agent 地址和本地测试限制。成功连接后,Agent 会同步可用节点列表;Probe 将列表缓存到同目录的 `agents.json`。`probe.json` 含私钥,请限制文件访问权限,不要提交到版本控制。
Agent 测量连接失败时,Probe 按 1、2、4、8、16 秒递增等待,再以 32 秒间隔重试 5 次,之后最多每 60 秒重试一次;连接恢复后退避重新从 1 秒开始。管理端把没有任何已启用 Agent 目标的 Probe 标为「暂停探测」,不会将链路断开误显示为已连接。
容器节点的运行方式(podman + systemd)和宿主机更新器见 [`../../docs/ops/deployment.md`](../../docs/ops/deployment.md);Probe 也可以用 OCI 镜像 `registry.oeo.one/moshiro/moshiro-probe:<version>`,容器默认读取 `/etc/moshiro/probe.json`,命名 volume 保存身份配置和缓存,镜像以 UID/GID 1654 运行。
## 构建 ## 构建
需要 .NET SDK 10。为应用 根目录的 `global.json` 的 SDK 设置,请从 `backend/` 目录构建: 需要 .NET SDK 10。为应用 `backend/global.json` 的 SDK 设置,请从 `backend/` 目录构建:
```sh ```sh
cd backend cd backend
dotnet build probe/src/MoShiro.Probe -c Release dotnet build probe/src/Monitor.Probe -c Release
``` ```
发布 NativeAOT 程序需要对应平台的 C/C++ 工具链;`<RID>` 应与构建环境和目标平台匹配: 发布 NativeAOT 程序需要对应平台的 C/C++ 工具链;`<RID>` 应与构建环境和目标平台匹配:
```sh ```sh
dotnet publish probe/src/MoShiro.Probe -c Release -r <RID> -p:PublishAot=true dotnet publish probe/src/Monitor.Probe -c Release -r <RID> -p:PublishAot=true
``` ```
也可在仓库根目录运行 `bash scripts/publish.sh probe <RID>`,再使用 `scripts/package.sh probe <RID>` 生成部署包。支持平台和安装流程见 [`../../docs/ops/deployment.md`](../../docs/ops/deployment.md)。 也可在仓库根目录运行 `bash scripts/publish.sh probe <RID>`,再使用 `scripts/package.sh probe <RID>` 生成部署包。支持平台和安装流程见 [`../../docs/deployment.md`](../../docs/deployment.md)。
## 配置与运行 ## 配置与运行
在管理端添加 Probe(生成 probe 角色的加入令牌),在目标机上注册(密钥由 Probe 自己生成): 在管理端创建 Probe 并下载一次性身份配置,将文件保存为目标机上的 `/etc/monitor/probe.json`。随后运行:
```sh ```sh
fleet-enroll --server https://panel.example.com --join <加入令牌> --name home --identity-dir /etc/moshiro monitor-probe --config /etc/monitor/probe.json
``` ```
写 `/etc/moshiro/probe.json`:`{"fleet":{"server":"https://panel.example.com","identity_dir":"/etc/moshiro"}}`(`install.sh probe --server … --join … --name …` 会一并完成)。随后运行: 不传 `--config` 时默认读取 `/etc/monitor/probe.json`。配置中的 `limits` 是本地硬上限,Backend 无法远程放宽;路由器等低性能设备可调低测试码率和包速率,避免 Probe 自身成为测量瓶颈。部署时可使用仓库的 `packaging/` 文件和安装脚本。
```sh 也可使用 OCI 镜像 `registry.oeo.one/monitor/monitor-probe:<version>`,自动选择 Linux amd64 或 arm64 镜像。容器默认读取 `/etc/monitor/probe.json`,并在同一目录维护 `agents.json` 缓存,因此挂载目录必须允许 UID/GID 1654 读写。建议使用宿主网络,并按 ICMP 测量需求添加 `NET_RAW` 能力。
moshiro-probe --config /etc/moshiro/probe.json
```
不传 `--config` 时默认读取 `/etc/moshiro/probe.json`。配置中的 `limits` 是本地硬上限,Backend 无法远程放宽;路由器等低性能设备可调低测试码率和包速率,避免 Probe 自身成为测量瓶颈。部署时可使用仓库的 `packaging/` 文件和安装脚本。
也可使用 OCI 镜像 `registry.oeo.one/moshiro/moshiro-probe:<version>`,自动选择 Linux amd64 或 arm64 镜像。容器默认读取 `/etc/moshiro/probe.json`,并在同一目录维护 `agents.json` 缓存,配置与缓存存放在命名 volume 中。建议使用宿主网络;当前独立 ICMP 目标任务由 Agent 执行。
## 设计说明 ## 设计说明
- Probe 只执行编译进程序的测量模块。它不落盘测量数据,也不能执行命令或自行更新(更新由宿主机上的更新器完成,Probe 只写清单和请求文件)。 - Probe 只执行编译进程序的测量模块。它不认识 Backend,不落盘测量数据,也不能执行命令或自行更新。
- 测量模块由 `backend/shared/MoShiro.Measurement/` 与 Agent 共用;Probe 负责发起连接、调度任务和记录本端数据。 - 测量模块由 `backend/shared/Monitor.Measurement/` 与 Agent 共用;Probe 负责发起连接、调度任务和记录本端数据。
- 测量模型和协议细节见 [`../../docs/design/measurement.md`](../../docs/design/measurement.md) 与 [`../../docs/design/protocol.md`](../../docs/design/protocol.md);整体架构见 [`../../docs/design/architecture.md`](../../docs/design/architecture.md)。 - 测量模型和协议细节见 [`../../docs/measurement.md`](../../docs/measurement.md) 与 [`../../docs/protocol.md`](../../docs/protocol.md);整体架构见 [`../../docs/architecture.md`](../../docs/architecture.md)。
Podman 系用 Quadlet(`packaging/quadlet/moshiro-probe.container`,默认自动更新),Docker 用 `packaging/compose/probe/compose.yaml`(手动更新);配置、身份和缓存放在命名 volume 中,无需宿主配置目录、keep-id 或手动 chown。首次导入和自动更新见部署文档。
@@ -1,31 +0,0 @@
namespace MoShiro.Probe;
/// <summary>Reconnect cadence for a single Probe-to-Agent path: 1, 2, 4, 8, 16, then five 32-second waits, then once per minute.</summary>
public sealed class ProbeReconnectBackoff
{
private static readonly TimeSpan[] s_delays =
[
TimeSpan.FromSeconds(1),
TimeSpan.FromSeconds(2),
TimeSpan.FromSeconds(4),
TimeSpan.FromSeconds(8),
TimeSpan.FromSeconds(16),
TimeSpan.FromSeconds(32),
TimeSpan.FromSeconds(32),
TimeSpan.FromSeconds(32),
TimeSpan.FromSeconds(32),
TimeSpan.FromSeconds(32),
];
private int _attempt;
public int Attempts => Volatile.Read(ref _attempt);
public TimeSpan Next()
{
int attempt = Interlocked.Increment(ref _attempt) - 1;
return attempt < s_delays.Length ? s_delays[attempt] : TimeSpan.FromMinutes(1);
}
public void Reset() => Interlocked.Exchange(ref _attempt, 0);
}
@@ -1,26 +0,0 @@
<Project>
<Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" />
<Import Project="Sdk.props" Sdk="StuDev.AotAnywhere"
Condition="'$(UseAotAnywhere)' == 'true'" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<AssemblyName>moshiro-probe</AssemblyName>
<RootNamespace>MoShiro.Probe</RootNamespace>
<IsAotCompatible>true</IsAotCompatible>
<PublishAot>true</PublishAot>
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>full</TrimMode>
<StripSymbols>true</StripSymbols>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<MetadataUpdaterSupport>false</MetadataUpdaterSupport>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="MoShiro.Backend.Tests" />
<ProjectReference Include="..\..\..\fleet\src\Fleet.Link\Fleet.Link.csproj" />
<ProjectReference Include="..\..\..\fleet\src\Fleet.Hosting\Fleet.Hosting.csproj" />
<ProjectReference Include="..\..\..\shared\MoShiro.Measurement\MoShiro.Measurement.csproj" />
</ItemGroup>
<Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
<Import Project="Sdk.targets" Sdk="StuDev.AotAnywhere"
Condition="'$(UseAotAnywhere)' == 'true'" />
</Project>
-314
View File
@@ -1,314 +0,0 @@
using Fleet.Core;
using Fleet.Hosting;
using System.Collections.Concurrent;
using System.Text.Json;
using Fleet.Identity;
using Fleet.Link;
using Fleet.Rpc;
using MoShiro.Measurement;
using MoShiro.Protocol;
namespace MoShiro.Probe;
/// <summary>
/// Probe 的组装点:到面板的长连接(身份、Agent 列表、状态)+ 每个 Agent 一个测量连接(认证、重连、角色)。没有全局状态,测试可以在同一进程里启动多个。
/// Agent 列表变化时只调和连接:新增的建立,移除的断开,地址或证书指纹变化的重建,其余不受影响。
/// </summary>
public sealed class ProbeApp : IDisposable
{
public const string Version = MoShiro.Protocol.BuildInfo.Version;
private readonly ConnectionEnv _env;
private readonly Dictionary<string, Running> _connections = [];
private readonly ConcurrentDictionary<string, long> _pendingRetries = new(StringComparer.Ordinal);
private readonly SemaphoreSlim _changed = new(0);
private readonly INodeLog _log;
private readonly FleetLink _link;
private readonly ReportingSection _reporting;
private readonly TimeProvider _time;
/// <param name="identity">注册时由节点自己生成:<c>Identity</c> 用来连面板,<c>PeerIdentity</c> 是另一把独立的密钥,只用来连 Agent。</param>
public ProbeApp(ProbeFile file, LoadedNodeIdentity identity, string configDirectory, TimeProvider time, INodeLog log, RestartController? restart = null)
{
_log = log;
_time = time;
_reporting = file.Reporting;
var trust = TrustAnchor.Load(file.Fleet.IdentityDir, log);
var directory = new AgentDirectory(new ConfigStore(configDirectory), configDirectory, log, trust);
_env = new ConnectionEnv(identity.PeerIdentity, identity.NodeId.Value, file.Limits, directory, new MonoClock(time), time, log);
directory.Changed += () => _changed.Release();
RestartUpdater? update = RestartUpdater.FromEnvironment("probe", reason => restart?.RequestNow(reason), time, log);
var options = new FleetLinkOptions
{
Server = new Uri(file.Fleet.Server),
Identity = identity.Identity,
ServerVerifier = trust,
NodeId = identity.NodeId,
NodeVersion = Version,
Capabilities = [MoShiroCapabilities.Base],
ShutdownReason = () => restart?.NowReason == "update" ? "updating" : "stopping",
HeartbeatExtra = () =>
{
UpdateStatus? updateStatus = update?.Status();
NodeRuntimeStatus runtimeStatus = updateStatus?.State == "applying" ? NodeRuntimeStatus.Updating : _env.Directory.Agents.Count == 0 ? NodeRuntimeStatus.ProbePaused : NodeRuntimeStatus.Running;
return JsonSerializer.SerializeToElement(new ProbeHeartbeatExtra { RuntimeStatus = runtimeStatus, Update = updateStatus, Ipv6 = Ipv6Diagnostics.Sample(null, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")), QuicLinks = SnapshotQuicLinks() }, MoShiroJsonContext.Default.ProbeHeartbeatExtra);
},
HelloMeta = () => JsonSerializer.SerializeToElement(new ProbeHelloMeta { Owners = [.. trust.Controllers], Platform = PlatformDetector.Detect(), Ipv6 = Ipv6Diagnostics.Sample(null, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")) }, MoShiroJsonContext.Default.ProbeHelloMeta),
};
var list = new StateHandler<ProbeAgentList>(
ProbeAgentList.StateType,
MoShiroJsonContext.Default.ProbeAgentList,
(version, doc, _) =>
{
directory.Apply(doc);
return ValueTask.FromResult(new StateSyncResult { AppliedVersion = version });
});
var router = new RpcRouter<LinkContext>(RpcSide.Node, MoShiroMethodTable.Table, NodeRoles.Probe)
.OnNotify(MoShiroMethods.ProbeLinkRetry, MoShiroJsonContext.Default.ProbeLinkRetryParams, (_, retry, _) =>
{
RetryAgent(retry.AgentId.Value);
return ValueTask.CompletedTask;
})
.OnNotify(MoShiroMethods.UpdateOffer, MoShiroJsonContext.Default.UpdateOfferParams, (_, offer, _) =>
{
update?.Offer(offer);
return ValueTask.CompletedTask;
})
.OnNotify(MoShiroMethods.UpdateApply, MoShiroJsonContext.Default.UpdateOfferParams, (_, _, _) =>
{
update?.RequestApply();
return ValueTask.CompletedTask;
})
.OnNotify(MoShiroMethods.Schedule, MoShiroJsonContext.Default.ScheduleParams, (_, schedule, _) =>
{
if (restart is not null && !restart.SetRemote(schedule.RestartAt))
{
log.Warn("ignoring an invalid restart schedule from the panel");
}
return ValueTask.CompletedTask;
});
_link = new FleetLink(options, router, [list], new Outbox(1 << 20), time, log);
}
/// <summary>到面板的连接(测试和状态查看用)。</summary>
public FleetLink Link => _link;
public AgentDirectory Directory => _env.Directory;
/// <summary>某个 Agent 的会话是否已经建立(测试和状态查看用)。</summary>
public bool IsConnected(string agentId, string ipVersion = IpVersions.V4)
{
lock (_connections)
{
return _connections.TryGetValue(agentId + "~" + ipVersion, out Running? running) && running.Connection.IsConnected;
}
}
public AgentConnection? Connection(string agentId, string ipVersion = IpVersions.V4)
{
lock (_connections)
{
return _connections.TryGetValue(agentId + "~" + ipVersion, out Running? running) ? running.Connection : null;
}
}
private void RetryAgent(string agentId, bool queueIfMissing = true)
{
AgentConnection[] matches;
lock (_connections)
{
matches = [.. _connections.Values.Select(r => r.Connection).Where(c => c.Endpoint.AgentId == agentId)];
}
if (matches.Length == 0)
{
if (queueIfMissing)
{
_pendingRetries[agentId] = _time.GetUtcNow().AddSeconds(30).ToUnixTimeMilliseconds();
}
_log.Warn("manual link retry requested but no matching Agent path is configured", ("agent", agentId));
return;
}
_pendingRetries.TryRemove(agentId, out _);
foreach (AgentConnection connection in matches)
{
connection.RetryNow();
}
}
private void RetryPending()
{
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
foreach ((string agentId, long expiresAt) in _pendingRetries.ToArray())
{
if (expiresAt <= now)
{
_pendingRetries.TryRemove(agentId, out _);
}
else
{
RetryAgent(agentId, queueIfMissing: false);
}
}
}
private List<ProbeQuicLinkHealth> SnapshotQuicLinks()
{
lock (_connections)
{
return [.. _connections.Values.Select(r => r.Connection.QuicHealth())];
}
}
public async Task RunAsync(CancellationToken ct)
{
_log.Info("moshiro probe starting", MoShiro.Protocol.BuildInfo.Fields);
if (_env.Directory.Agents.Count == 0)
{
_log.Info("no agents known yet, waiting for the panel to send the list");
}
Task link = _link.RunAsync(ct);
Task host = ReportHostAsync(ct);
try
{
while (!ct.IsCancellationRequested)
{
await ReconcileAsync(_env.Directory.Agents, ct);
RetryPending();
await _changed.WaitAsync(ct);
}
}
catch (OperationCanceledException)
{
// 停止
}
finally
{
await ReconcileAsync([], CancellationToken.None);
await Task.WhenAny(Task.WhenAll(link, host), Task.Delay(TimeSpan.FromSeconds(5), CancellationToken.None));
}
}
/// <summary>每隔几秒把主机资源占用的汇总数字发给面板,和 Agent 一样(没有型号、主机名、网卡名和 IP);链路断开时丢弃,重连之后下一次就补上。</summary>
private async Task ReportHostAsync(CancellationToken ct)
{
if (!_reporting.HostStats)
{
return;
}
var sampler = new HostSampler(_time);
sampler.Sample(); // 第一次只用来建立 CPU 和网速的基线
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(Math.Clamp(_reporting.HostIntervalSeconds, 1, 60)), _time);
try
{
while (await timer.WaitForNextTickAsync(ct))
{
if (_link.CurrentSession is { } session)
{
try
{
await session.NotifyAsync(MoShiroMethods.Host, sampler.Sample(), MoShiroJsonContext.Default.HostSnapshot, CancellationToken.None);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_log.Warn($"could not report host status: {ex.Message}");
}
}
}
}
catch (OperationCanceledException)
{
// 停止
}
}
public void Dispose()
{
_env.Identity.Dispose();
_changed.Dispose();
}
private async Task ReconcileAsync(IReadOnlyList<KnownAgent> agents, CancellationToken ct)
{
var wanted = agents.ToDictionary(a => a.PathKey);
var stopped = new List<Running>();
lock (_connections)
{
foreach ((string id, Running running) in _connections.ToList())
{
if (!wanted.TryGetValue(id, out KnownAgent? endpoint) || !AgentDirectory.Equal(endpoint, running.Connection.Endpoint))
{
stopped.Add(running);
_connections.Remove(id);
}
}
}
foreach (Running running in stopped)
{
await running.StopAsync();
}
if (ct.IsCancellationRequested)
{
return;
}
lock (_connections)
{
foreach ((string id, KnownAgent endpoint) in wanted)
{
if (!_connections.ContainsKey(id))
{
_connections[id] = Running.Start(new AgentConnection(endpoint, _env));
}
}
}
}
private sealed class Running : IDisposable
{
private readonly CancellationTokenSource _cts = new();
private Task _run = Task.CompletedTask;
private Running(AgentConnection connection)
{
Connection = connection;
}
public AgentConnection Connection { get; }
public static Running Start(AgentConnection connection)
{
var running = new Running(connection);
running._run = Task.Run(() => connection.RunAsync(running._cts.Token), CancellationToken.None);
return running;
}
public async Task StopAsync()
{
await _cts.CancelAsync();
try
{
await _run.WaitAsync(TimeSpan.FromSeconds(10));
}
catch (Exception ex) when (ex is OperationCanceledException or TimeoutException)
{
// 没有及时退出:不再等
}
Dispose();
}
public void Dispose()
{
Connection.Dispose();
_cts.Dispose();
}
}
}
@@ -1,97 +0,0 @@
using System.Text.Json.Serialization;
using MoShiro.Measurement;
using MoShiro.Protocol;
namespace MoShiro.Probe;
/// <summary>
/// <c>probe.json</c>:本地配置,和 Agent 一样由 <c>fleet-enroll</c> 注册之后写出身份目录(节点自己生成的两把密钥),这里只放面板地址和本地硬上限。
/// <c>limits</c> 是本地硬上限,面板无法修改;在路由器上建议调低,避免 Probe 自己的 CPU 成为瓶颈、反过来影响测量。
/// </summary>
public sealed class ProbeFile
{
[JsonPropertyName("fleet")]
public ProbeFleetSection Fleet { get; set; } = new();
/// <summary>每天定时重启一次,例如 <c>04:00+08:00</c>(不写偏移按 UTC);不写就不重启。环境变量 <c>MOSHIRO_RESTART_AT</c> 优先;这里是本地配置,从面板重新下载 probe.json 会丢掉它,所以容器里推荐用环境变量。</summary>
[JsonPropertyName("restart_at")]
public string? RestartAt { get; set; }
[JsonPropertyName("limits")]
public ProbeLimits Limits { get; set; } = new();
/// <summary>上报主机资源占用,和 Agent 一样(只有汇总数字,没有型号和 IP);默认开,设 <c>host_stats: false</c> 就一个字也不上报。</summary>
[JsonPropertyName("reporting")]
public ReportingSection Reporting { get; set; } = new();
}
public sealed class ProbeFleetSection
{
/// <summary>面板的地址(http 或 https)。Probe 直接连它:身份、Agent 列表、更新都从这里来。</summary>
[JsonPropertyName("server")]
public string Server { get; set; } = "";
/// <summary><c>fleet-enroll</c> 写出的身份目录:<c>node.json</c>、<c>node.key</c>(连面板的密钥)、<c>tls.pfx</c>(对等面的密钥)。</summary>
[JsonPropertyName("identity_dir")]
public string IdentityDir { get; set; } = "/etc/moshiro/probe-id";
/// <summary>节点自己要求有属主(passkey):没有就不运行。见 <c>OwnerGuard</c>(Agent 和 Probe 共用)。</summary>
[JsonPropertyName("require_owner")]
public bool RequireOwner { get; set; } = true;
}
public sealed class KnownAgent
{
[JsonPropertyName("ip_version")]
public string IpVersion { get; set; } = IpVersions.V4;
[JsonIgnore]
public string PathKey => AgentId + "~" + IpVersion;
[JsonPropertyName("agent_id")]
public string AgentId { get; set; } = "";
[JsonPropertyName("host")]
public string Host { get; set; } = "";
[JsonPropertyName("tcp_port")]
public int TcpPort { get; set; }
[JsonPropertyName("quic_ports")]
public List<int> QuicPorts { get; set; } = [7443];
[JsonPropertyName("udp_ports")]
public List<int>? LegacyUdpPorts { get; set; }
[JsonIgnore]
public IReadOnlyList<int> EffectiveQuicPorts => LegacyUdpPorts ?? QuicPorts;
/// <summary>Agent 的 TLS 证书公钥(SPKI,base64):证书按它的 SHA-256 指纹固定,敲门密钥由它派生。</summary>
[JsonPropertyName("public_key")]
public string PublicKey { get; set; } = "";
}
public sealed class ProbeLimits
{
/// <summary>按需测试里单个阶段允许的最大码率(Mbit/s)。</summary>
[JsonPropertyName("max_test_mbps")]
public double MaxTestMbps { get; set; } = 100;
/// <summary>按需测试里单个阶段允许的最大包速率。</summary>
[JsonPropertyName("max_total_pps")]
public long MaxTotalPps { get; set; } = 20_000;
}
[JsonSerializable(typeof(ProbeFile))]
[JsonSerializable(typeof(AgentsCache))]
public sealed partial class ProbeConfigContext : JsonSerializerContext;
/// <summary><c>agents.json</c>:最近一次从面板同步到的 Agent 列表(配置,不是测量数据);面板连不上时 Probe 靠它继续测量。</summary>
public sealed class AgentsCache
{
[JsonPropertyName("version")]
public long Version { get; set; }
[JsonPropertyName("agents")]
public List<KnownAgent> Agents { get; set; } = [];
}
@@ -1,62 +0,0 @@
using Fleet.Core;
using Fleet.Hosting;
using MoShiro.Probe;
TimeProvider time = TimeProvider.System;
INodeLog log = new ConsoleNodeLog(time);
if (PlatformCheck.VerifyCrypto() is { } problem)
{
log.Error("startup failed", ("err", problem));
return 1;
}
string configPath = "/etc/moshiro/probe.json";
for (int i = 0; i < args.Length - 1; i++)
{
if (args[i] == "--config")
{
configPath = args[i + 1];
}
}
ProbeFile file;
LoadedNodeIdentity identity;
RestartController restart;
try
{
file = NodeConfig.Load(configPath, ProbeConfigContext.Default.ProbeFile);
restart = new RestartController(RestartSchedule.Resolve(file.RestartAt));
if (!Uri.TryCreate(file.Fleet.Server, UriKind.Absolute, out _))
{
throw new InvalidOperationException("probe.json needs fleet.server (the panel address)");
}
identity = LoadedNodeIdentity.Load(file.Fleet.IdentityDir);
if (!identity.NodeId.Value.StartsWith(MoShiro.Protocol.ProbeId.Prefix, StringComparison.Ordinal))
{
identity.Dispose();
throw new InvalidOperationException($"the identity in {file.Fleet.IdentityDir} is not a probe's: enroll with a probe join token");
}
if (!OwnerGuard.Check(file.Fleet.IdentityDir, file.Fleet.RequireOwner, log))
{
identity.Dispose();
return 1;
}
}
catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or System.Text.Json.JsonException or System.Security.Cryptography.CryptographicException)
{
log.Error("startup failed", ("err", ex.Message));
return 1;
}
using var restartScope = restart;
string directory = Path.GetDirectoryName(Path.GetFullPath(configPath))!;
using (identity)
{
using var app = new ProbeApp(file, identity, directory, time, log, restart);
await ServiceShell.RunAsync(app.RunAsync, log, restart, time);
}
return 0;
@@ -6,50 +6,35 @@ using Fleet.Core;
using Fleet.Identity; using Fleet.Identity;
using Fleet.Link; using Fleet.Link;
using Fleet.Rpc; using Fleet.Rpc;
using MoShiro.Measurement; using Monitor.Measurement;
using MoShiro.Protocol; using Monitor.Protocol;
namespace MoShiro.Probe; namespace Monitor.Probe;
/// <summary>所有连接共用的东西。</summary> /// <summary>所有连接共用的东西。</summary>
public sealed record ConnectionEnv(NodeIdentity Identity, string ProbeId, ProbeLimits Limits, AgentDirectory Directory, MonoClock Clock, TimeProvider Time, INodeLog Log); public sealed record ConnectionEnv(NodeIdentity Identity, string ProbeId, ProbeLimits Limits, AgentDirectory Directory, MonoClock Clock, TimeProvider Time, INodeLog Log);
/// <summary> /// <summary>
/// 与一个 Agent 的点对点会话:认证握手 → 同步时钟 → 按 Agent 下发的 <c>line_task_set</c> 运行角色 → /// 与一个 Agent 的点对点会话:认证握手 → 同步时钟 → 取 Agent 列表 → 按 Agent 下发的 <c>line_task_set</c> 运行角色 →
/// 每 10 秒把已完成的行切成列块放进发件箱 → 响应按需测试的阶段。会话断开时所有角色停止(凭据属于会话),按退避重连。 /// 每 10 秒把已完成的行切成列块放进发件箱 → 响应按需测试的阶段。会话断开时所有角色停止(凭据属于会话),按退避重连。
/// 会话内的所有状态(套接字、发件箱、角色)随会话一起创建和释放。 /// 会话内的所有状态(套接字、发件箱、角色)随会话一起创建和释放。
/// </summary> /// </summary>
public sealed class AgentConnection : IDisposable public sealed class AgentConnection
{ {
private static readonly TimeSpan s_flushInterval = TimeSpan.FromSeconds(ProtocolLimits.ChunkSeconds); private static readonly TimeSpan s_flushInterval = TimeSpan.FromSeconds(ProtocolLimits.ChunkSeconds);
private static readonly TimeSpan s_clockInterval = TimeSpan.FromSeconds(30); private static readonly TimeSpan s_clockInterval = TimeSpan.FromSeconds(30);
private static readonly RpcRouter<ConnectionSession> s_router = BuildRouter(); private static readonly RpcRouter<ConnectionSession> s_router = BuildRouter();
/// <summary>对等面入站方法的路由器(只读,测试用)。</summary>
public static RpcRouter<ConnectionSession> Router => s_router;
private readonly ConnectionEnv _env; private readonly ConnectionEnv _env;
private volatile ConnectionSession? _session; private volatile ConnectionSession? _session;
private int _previousQuicSourcePort;
private QuicDatagramTransport? _activeDatagrams;
private readonly object _handshakeGate = new();
private readonly Queue<double> _handshakeSamplesMs = new();
private readonly SemaphoreSlim _retrySignal = new(0, 1);
private ProbeLinkAttemptState _attemptState = ProbeLinkAttemptState.Connecting;
private string? _lastError;
private DateTimeOffset? _retryAt;
private long _handshakeAttempts;
private long _handshakeSuccesses;
private long _handshakeFailures;
private long _handshakeTimeouts;
public AgentConnection(KnownAgent endpoint, ConnectionEnv env) public AgentConnection(BootstrapAgent endpoint, ConnectionEnv env)
{ {
Endpoint = endpoint; Endpoint = endpoint;
_env = env; _env = env;
} }
public KnownAgent Endpoint { get; } public BootstrapAgent Endpoint { get; }
public bool IsConnected => _session is not null; public bool IsConnected => _session is not null;
@@ -57,116 +42,28 @@ public sealed class AgentConnection : IDisposable
public long ClockOffsetUs => _session?.Clock.OffsetUs ?? 0; public long ClockOffsetUs => _session?.Clock.OffsetUs ?? 0;
/// <summary>管理端手动配对时,跳过当前退避等待,马上再拨一次。</summary>
public void RetryNow()
{
if (IsConnected)
{
return;
}
try
{
_retrySignal.Release();
}
catch (SemaphoreFullException)
{
// 已经有一次手动重试排队。
}
catch (ObjectDisposedException)
{
// 对应线路正在被移除。
}
}
public void Dispose() => _retrySignal.Dispose();
public ProbeQuicLinkHealth QuicHealth()
{
lock (_handshakeGate)
{
double? p95 = null;
if (_handshakeSamplesMs.Count > 0)
{
double[] sorted = [.. _handshakeSamplesMs.Order()];
p95 = sorted[(int)Math.Ceiling(sorted.Length * 0.95) - 1];
}
QuicTransportSnapshot? transport = Volatile.Read(ref _activeDatagrams)?.GetTransportSnapshot();
return new ProbeQuicLinkHealth
{
AgentId = Endpoint.AgentId,
IpVersion = Endpoint.IpVersion,
Connected = IsConnected,
State = _attemptState,
LastError = _lastError,
RetryAttempt = _lastRetryAttempt,
RetryInMs = _attemptState == ProbeLinkAttemptState.RetryWait && _retryAt is { } retryAt
? Math.Max(0, (long)(retryAt - _env.Time.GetUtcNow()).TotalMilliseconds)
: null,
HandshakeAttempts = _handshakeAttempts,
HandshakeSuccesses = _handshakeSuccesses,
HandshakeFailures = _handshakeFailures,
HandshakeTimeouts = _handshakeTimeouts,
HandshakeP95Ms = p95,
HandshakeSampleCount = _handshakeSamplesMs.Count,
TransportPacketLossRate = transport?.PacketLossRate,
TransportPacketsSent = transport?.PacketsSent,
TransportCongestionEvents = transport?.CongestionEvents,
TransportEcnCongestionEvents = transport?.EcnCongestionEvents,
PathMtu = transport?.PathMtu,
};
}
}
private int _lastRetryAttempt;
public async Task RunAsync(CancellationToken ct) public async Task RunAsync(CancellationToken ct)
{ {
var backoff = new ProbeReconnectBackoff(); var backoff = new Backoff(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(20));
while (!ct.IsCancellationRequested) while (!ct.IsCancellationRequested)
{ {
SetAttempt(ProbeLinkAttemptState.Connecting, null, null, 0);
try try
{ {
string reason = await RunOnceAsync(backoff, ct); string reason = await RunOnceAsync(backoff, ct);
SetAttempt(ProbeLinkAttemptState.RetryWait, reason, null, backoff.Attempts);
_env.Log.Warn("agent session ended", ("agent", Endpoint.AgentId), ("err", reason)); _env.Log.Warn("agent session ended", ("agent", Endpoint.AgentId), ("err", reason));
} }
catch (OperationCanceledException) when (ct.IsCancellationRequested) catch (OperationCanceledException) when (ct.IsCancellationRequested)
{ {
return; return;
} }
catch (Exception ex) catch (Exception ex) when (ex is IOException or System.Net.Sockets.SocketException or InvalidOperationException or System.Security.Authentication.AuthenticationException or OperationCanceledException or FormatException)
{ {
SetAttempt(ProbeLinkAttemptState.RetryWait, $"{ex.GetType().Name}: {ex.Message}", null, backoff.Attempts + 1); _env.Log.Warn("agent connection failed", ("agent", Endpoint.AgentId), ("err", ex.Message));
_env.Log.Error("agent connection attempt failed; retry will continue", ("agent", Endpoint.AgentId), ("exception", ex.GetType().FullName), ("err", ex.Message));
} }
try try
{ {
TimeSpan delay = backoff.Next(); await Task.Delay(backoff.Next(), _env.Time, ct);
lock (_handshakeGate)
{
_attemptState = ProbeLinkAttemptState.RetryWait;
_lastRetryAttempt = backoff.Attempts;
_retryAt = _env.Time.GetUtcNow() + delay;
}
using var waitCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
Task delayTask = Task.Delay(delay, _env.Time, waitCts.Token);
Task retryTask = _retrySignal.WaitAsync(waitCts.Token);
Task completed = await Task.WhenAny(delayTask, retryTask);
await waitCts.CancelAsync();
if (completed == retryTask)
{
await retryTask;
_env.Log.Info("manual link retry requested", ("agent", Endpoint.AgentId));
}
else
{
await delayTask;
try { await retryTask; }
catch (OperationCanceledException) { }
}
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
@@ -175,39 +72,14 @@ public sealed class AgentConnection : IDisposable
} }
} }
private async Task<string> RunOnceAsync(ProbeReconnectBackoff backoff, CancellationToken ct) private async Task<string> RunOnceAsync(Backoff backoff, CancellationToken ct)
{ {
AddressFamily family = Endpoint.IpVersion == IpVersions.V6 ? AddressFamily.InterNetworkV6 : AddressFamily.InterNetwork;
using var resolveTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(10), _env.Time);
using CancellationTokenSource resolveCt = CancellationTokenSource.CreateLinkedTokenSource(ct, resolveTimeout.Token);
IPAddress[] addresses = IPAddress.TryParse(Endpoint.Host, out IPAddress? literal)
? [literal]
: await Dns.GetHostAddressesAsync(Endpoint.Host, family, resolveCt.Token);
IPAddress? address = addresses.FirstOrDefault(a => a.AddressFamily == family && !a.IsIPv4MappedToIPv6);
if (address is null)
{
throw new SocketException((int)SocketError.HostNotFound);
}
byte[] agentKey = Convert.FromBase64String(Endpoint.PublicKey);
(PeerSessionInfo info, TlsFrameTransport transport) = await PeerConnector.ConnectAsync( (PeerSessionInfo info, TlsFrameTransport transport) = await PeerConnector.ConnectAsync(
address.ToString(), Endpoint.TcpPort, agentKey, _env.Identity, _env.ProbeId, ProbeApp.Version, 8 << 20, _env.Time, ct); Endpoint.Host, Endpoint.TcpPort, Convert.FromBase64String(Endpoint.Spki), _env.Identity, _env.ProbeId, ProbeApp.Version, 8 << 20, _env.Time, ct);
var data = new DataSession(info.Credentials.SessionIdValue, info.Credentials.Key);
var remote = (IPEndPoint)info.Remote!; var remote = (IPEndPoint)info.Remote!;
IReadOnlyList<int> quicPorts = Endpoint.EffectiveQuicPorts; var data = new DataSession(info.Credentials.SessionIdValue, info.Credentials.Key);
int quicPort = quicPorts.Count == 0 ? 7443 : quicPorts[0]; using var udp = new UdpMux(new SingleSession(data), null, _env.Clock, new DataPlaneCounters(), _env.Log, remote.AddressFamily == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any, []);
await using var datagrams = new QuicDatagramTransport( udp.Bind();
data,
_env.Clock,
new DataPlaneCounters(),
_env.Log,
new IPEndPoint(remote.Address, quicPort),
agentKey,
Volatile.Read(ref _previousQuicSourcePort));
Volatile.Write(ref _activeDatagrams, datagrams);
datagrams.Bind();
using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
var outbox = new Outbox(8 << 20); var outbox = new Outbox(8 << 20);
var clock = new ClockEstimator(); var clock = new ClockEstimator();
var shipper = new FragmentShipper(outbox, new NodeId(Endpoint.AgentId), new ProbeId(_env.ProbeId), clock, _env.Time); var shipper = new FragmentShipper(outbox, new NodeId(Endpoint.AgentId), new ProbeId(_env.ProbeId), clock, _env.Time);
@@ -216,10 +88,9 @@ public sealed class AgentConnection : IDisposable
Clock = _env.Clock, Clock = _env.Clock,
Log = _env.Log, Log = _env.Log,
Session = () => data, Session = () => data,
Datagrams = datagrams, Udp = udp,
PeerAddress = remote.Address, PeerAddress = remote.Address,
PeerSpkiSha256 = NodeIdentity.Fingerprint(agentKey), PeerSpkiSha256 = Convert.FromBase64String(Endpoint.Spki),
KnockKey = Knock.DeriveKey(_env.Identity, agentKey),
Fragments = shipper, Fragments = shipper,
MaxPacketsPerSecond = _env.Limits.MaxTotalPps, MaxPacketsPerSecond = _env.Limits.MaxTotalPps,
MaxBulkMbps = _env.Limits.MaxTestMbps, MaxBulkMbps = _env.Limits.MaxTestMbps,
@@ -228,58 +99,18 @@ public sealed class AgentConnection : IDisposable
var rpc = new RpcSession<ConnectionSession>(transport, s_router, session, _env.Time); var rpc = new RpcSession<ConnectionSession>(transport, s_router, session, _env.Time);
rpc.OnBinary = (_, _) => ValueTask.CompletedTask; rpc.OnBinary = (_, _) => ValueTask.CompletedTask;
using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
Task run = rpc.RunAsync(cts.Token); Task run = rpc.RunAsync(cts.Token);
Task receive = datagrams.RunAsync(cts.Token); Task receive = udp.RunAsync(cts.Token);
Task pump = PumpAsync(rpc, outbox, cts.Token); Task pump = PumpAsync(rpc, outbox, cts.Token);
try try
{ {
// 先同步时钟(块头里要带偏移);Agent 随后推送线路任务集 // 先同步时钟(块头里要带偏移),再取 Agent 列表;Agent 随后推送线路任务集
await SyncClockAsync(rpc, clock, rounds: 5, cts.Token); await SyncClockAsync(rpc, clock, rounds: 5, cts.Token);
lock (_handshakeGate)
{
_handshakeAttempts++;
}
try
{
SetAttempt(ProbeLinkAttemptState.Connecting, null, null, 0);
await datagrams.ConnectAsync(cts.Token);
lock (_handshakeGate)
{
_handshakeSuccesses++;
if (datagrams.HandshakeDurationMs is { } elapsed)
{
_handshakeSamplesMs.Enqueue(elapsed);
while (_handshakeSamplesMs.Count > 128)
{
_handshakeSamplesMs.Dequeue();
}
}
}
}
catch (Exception ex) when (!(ex is OperationCanceledException && cts.IsCancellationRequested))
{
lock (_handshakeGate)
{
_handshakeFailures++;
if (ex is TimeoutException)
{
_handshakeTimeouts++;
}
}
throw;
}
if (datagrams.BoundPorts.Count > 0)
{
Volatile.Write(ref _previousQuicSourcePort, datagrams.BoundPorts[0]);
}
backoff.Reset(); backoff.Reset();
_retrySignal.Wait(0, CancellationToken.None); // 若“立即重试”恰好赶在本次拨号成功前到达,本次成功已满足请求。
_session = session; _session = session;
SetAttempt(ProbeLinkAttemptState.Connected, null, null, 0);
_env.Log.Info("agent session up", ("agent", Endpoint.AgentId), ("offset_us", clock.OffsetUs)); _env.Log.Info("agent session up", ("agent", Endpoint.AgentId), ("offset_us", clock.OffsetUs));
await RefreshDirectoryAsync(rpc, cts.Token);
Task maintenance = MaintainAsync(rpc, session, clock, cts.Token); Task maintenance = MaintainAsync(rpc, session, clock, cts.Token);
Task finished = await Task.WhenAny(run, receive, pump, maintenance); Task finished = await Task.WhenAny(run, receive, pump, maintenance);
await cts.CancelAsync(); await cts.CancelAsync();
@@ -289,7 +120,6 @@ public sealed class AgentConnection : IDisposable
finally finally
{ {
_session = null; _session = null;
Volatile.Write(ref _activeDatagrams, null);
await cts.CancelAsync(); await cts.CancelAsync();
await session.StopAllAsync(); await session.StopAllAsync();
await Quiet(run); await Quiet(run);
@@ -299,20 +129,6 @@ public sealed class AgentConnection : IDisposable
} }
} }
private void SetAttempt(ProbeLinkAttemptState state, string? error, DateTimeOffset? retryAt, int attempt)
{
lock (_handshakeGate)
{
_attemptState = state;
if (error is not null || state == ProbeLinkAttemptState.Connected)
{
_lastError = error;
}
_retryAt = retryAt;
_lastRetryAttempt = attempt;
}
}
private static async Task Quiet(Task task) private static async Task Quiet(Task task)
{ {
try try
@@ -339,7 +155,7 @@ public sealed class AgentConnection : IDisposable
for (int i = 0; i < rounds; i++) for (int i = 0; i < rounds; i++)
{ {
long sent = _env.Clock.NowUs(); long sent = _env.Clock.NowUs();
Result<ClockResult> reply = await rpc.RequestAsync(MoShiroMethods.Clock, new ClockParams { ProbeMonoUs = sent }, MoShiroJsonContext.Default.ClockParams, MoShiroJsonContext.Default.ClockResult, ct); Result<ClockResult> reply = await rpc.RequestAsync(MonitorMethods.Clock, new ClockParams { ProbeMonoUs = sent }, MonitorJsonContext.Default.ClockParams, MonitorJsonContext.Default.ClockResult, ct);
if (reply.TryGet(out ClockResult? value, out _)) if (reply.TryGet(out ClockResult? value, out _))
{ {
clock.Add(sent, value.AgentMonoUs, _env.Clock.NowUs()); clock.Add(sent, value.AgentMonoUs, _env.Clock.NowUs());
@@ -347,6 +163,20 @@ public sealed class AgentConnection : IDisposable
} }
} }
private async Task RefreshDirectoryAsync(RpcSession<ConnectionSession> rpc, CancellationToken ct)
{
Result<ProbeAgentsResult> list = await rpc.RequestAsync(
MonitorMethods.ProbeAgents, new ProbeAgentsParams { HaveVersion = _env.Directory.Version }, MonitorJsonContext.Default.ProbeAgentsParams, MonitorJsonContext.Default.ProbeAgentsResult, ct);
if (list.TryGet(out ProbeAgentsResult? agents, out FleetError? error))
{
_env.Directory.Apply(agents);
}
else
{
_env.Log.Warn("could not fetch the agent list", ("agent", Endpoint.AgentId), ("err", error.Code));
}
}
/// <summary>切块(每 10 秒)和时钟同步(每 30 秒)。</summary> /// <summary>切块(每 10 秒)和时钟同步(每 30 秒)。</summary>
private async Task MaintainAsync(RpcSession<ConnectionSession> rpc, ConnectionSession session, ClockEstimator clock, CancellationToken ct) private async Task MaintainAsync(RpcSession<ConnectionSession> rpc, ConnectionSession session, ClockEstimator clock, CancellationToken ct)
{ {
@@ -364,7 +194,7 @@ public sealed class AgentConnection : IDisposable
} }
private static RpcRouter<ConnectionSession> BuildRouter() => private static RpcRouter<ConnectionSession> BuildRouter() =>
new RpcRouter<ConnectionSession>(RpcSide.Dialer, MoShiroMethodTable.Table) new RpcRouter<ConnectionSession>()
.On( .On(
FleetMethods.StateSync, FleetMethods.StateSync,
FleetRpcContext.Default.StateSyncParams, FleetRpcContext.Default.StateSyncParams,
@@ -374,7 +204,7 @@ public sealed class AgentConnection : IDisposable
var result = new StateSyncResult { AppliedVersion = sync.Version }; var result = new StateSyncResult { AppliedVersion = sync.Version };
if (sync.Type == LineTaskSet.StateType) if (sync.Type == LineTaskSet.StateType)
{ {
LineTaskSet? set = sync.Doc.Deserialize(MoShiroJsonContext.Default.LineTaskSet); LineTaskSet? set = sync.Doc.Deserialize(MonitorJsonContext.Default.LineTaskSet);
if (set is null) if (set is null)
{ {
return Result<StateSyncResult>.Fail(FleetErrors.InvalidParams, "line_task_set could not be parsed"); return Result<StateSyncResult>.Fail(FleetErrors.InvalidParams, "line_task_set could not be parsed");
@@ -386,19 +216,27 @@ public sealed class AgentConnection : IDisposable
return Result<StateSyncResult>.Ok(result); return Result<StateSyncResult>.Ok(result);
}) })
.On( .On(
MoShiroMethods.TestPhase, MonitorMethods.TestPhase,
MoShiroJsonContext.Default.TestPhaseStartParams, MonitorJsonContext.Default.TestPhaseStartParams,
MoShiroJsonContext.Default.TestPhaseStartResult, MonitorJsonContext.Default.TestPhaseStartResult,
(session, start, _) => ValueTask.FromResult(Result<TestPhaseStartResult>.Ok(new TestPhaseStartResult { Accepted = session.StartPhase(start) }))) (session, start, _) => ValueTask.FromResult(Result<TestPhaseStartResult>.Ok(new TestPhaseStartResult { Accepted = session.StartPhase(start) })))
.On( .On(
MoShiroMethods.TestPhaseEnd, MonitorMethods.TestPhaseEnd,
MoShiroJsonContext.Default.TestPhaseEndParams, MonitorJsonContext.Default.TestPhaseEndParams,
MoShiroJsonContext.Default.TestPhaseEndResult, MonitorJsonContext.Default.TestPhaseEndResult,
async (session, end, ct) => Result<TestPhaseEndResult>.Ok(new TestPhaseEndResult { Fragments = await session.EndPhaseAsync(end, ct) })) async (session, end, ct) => Result<TestPhaseEndResult>.Ok(new TestPhaseEndResult { Fragments = await session.EndPhaseAsync(end, ct) }))
.OnNotify( .OnNotify(
MoShiroMethods.TestAbort, MonitorMethods.TestAbort,
MoShiroJsonContext.Default.TestAbortParams, MonitorJsonContext.Default.TestAbortParams,
async (session, abort, _) => await session.AbortAsync(abort.TestId)) async (session, abort, _) => await session.AbortAsync(abort.TestId))
.OnNotify(
MonitorMethods.ProbeAgentsChanged,
MonitorJsonContext.Default.ProbeAgentsResult,
(session, list, _) =>
{
session.Env.Directory.Apply(list);
return ValueTask.CompletedTask;
})
.OnNotify( .OnNotify(
FleetMethods.OutboxAck, FleetMethods.OutboxAck,
FleetRpcContext.Default.OutboxAckParams, FleetRpcContext.Default.OutboxAckParams,
@@ -1,12 +1,13 @@
using System.Text.Json; using System.Text.Json;
using Fleet.Core; using Fleet.Core;
using Fleet.Hosting; using Fleet.Hosting;
using MoShiro.Protocol; using Monitor.Protocol;
namespace MoShiro.Probe; namespace Monitor.Probe;
/// <summary> /// <summary>
/// Probe 要连接的 Agent 列表:由面板作为期望状态(<c>probe_agents</c>)直接下发;面板连不上时用上一次保存的 <c>agents.json</c>。 /// Probe 要连接的 Agent 列表。Probe 不认识 Backend:列表先取自 <c>probe.json</c> 的 bootstrap(或上一次保存的 <c>agents.json</c>),
/// 连上任意一个 Agent 之后,由它代为向 Backend 查询(<c>monitor.probe.agents</c>),之后的变化由 Agent 推送(<c>agents_changed</c>)。
/// 列表变化时用 <see cref="ConfigStore"/> 写回 <c>agents.json</c>(这是配置,不是测量数据;只在变化时写,保护路由器的闪存)。 /// 列表变化时用 <see cref="ConfigStore"/> 写回 <c>agents.json</c>(这是配置,不是测量数据;只在变化时写,保护路由器的闪存)。
/// </summary> /// </summary>
public sealed class AgentDirectory public sealed class AgentDirectory
@@ -16,27 +17,29 @@ public sealed class AgentDirectory
private readonly object _gate = new(); private readonly object _gate = new();
private readonly ConfigStore _store; private readonly ConfigStore _store;
private readonly INodeLog _log; private readonly INodeLog _log;
private readonly TrustAnchor _trust; private List<BootstrapAgent> _agents;
private List<KnownAgent> _agents = [];
private long _version; private long _version;
public AgentDirectory(ConfigStore store, string configDirectory, INodeLog log, TrustAnchor? trust = null) public AgentDirectory(ConfigStore store, string configDirectory, ProbeFile file, INodeLog log)
{ {
_trust = trust ?? TrustAnchor.None;
_store = store; _store = store;
_log = log; _log = log;
_agents = [.. file.Bootstrap];
string cache = Path.Combine(configDirectory, FileName); string cache = Path.Combine(configDirectory, FileName);
if (File.Exists(cache)) if (File.Exists(cache))
{ {
try try
{ {
AgentsCache saved = NodeConfig.Load(cache, ProbeConfigContext.Default.AgentsCache); AgentsCache saved = NodeConfig.Load(cache, ProbeConfigContext.Default.AgentsCache);
if (saved.Agents.Count > 0)
{
_agents = saved.Agents; _agents = saved.Agents;
_version = saved.Version; _version = saved.Version;
} }
}
catch (Exception ex) when (ex is IOException or InvalidDataException or JsonException or InvalidOperationException) catch (Exception ex) when (ex is IOException or InvalidDataException or JsonException or InvalidOperationException)
{ {
_log.Warn("agents.json is unreadable, waiting for the panel", ("err", ex.Message)); _log.Warn("agents.json is unreadable, using the bootstrap list", ("err", ex.Message));
} }
} }
} }
@@ -55,7 +58,7 @@ public sealed class AgentDirectory
} }
} }
public IReadOnlyList<KnownAgent> Agents public IReadOnlyList<BootstrapAgent> Agents
{ {
get get
{ {
@@ -66,24 +69,10 @@ public sealed class AgentDirectory
} }
} }
/// <summary>应用面板下发的列表。版本不比当前新的忽略(乱序到达的旧推送)。</summary> /// <summary>应用 Agent 转来的列表。版本不比当前新的忽略(乱序到达的旧推送)。</summary>
public void Apply(ProbeAgentList result) public void Apply(ProbeAgentsResult result)
{ {
var next = new List<KnownAgent>(); var next = result.Agents.Select(a => new BootstrapAgent { AgentId = a.AgentId.Value, Host = a.Host, TcpPort = a.TcpPort, Spki = a.Spki }).ToList();
_trust.Update(result.TrustChain);
foreach (AgentEndpoint agent in result.Agents)
{
List<int> ports = agent.QuicPorts.Count > 0 ? agent.QuicPorts : agent.LegacyUdpPorts ?? [];
if (!string.IsNullOrWhiteSpace(agent.Host))
{
next.Add(new KnownAgent { AgentId = agent.AgentId.Value, Host = agent.Host, TcpPort = agent.TcpPort, QuicPorts = [.. ports], PublicKey = agent.PublicKey });
}
if (!string.IsNullOrWhiteSpace(agent.Ipv6Host))
{
next.Add(new KnownAgent { AgentId = agent.AgentId.Value, Host = agent.Ipv6Host, TcpPort = agent.TcpPort, QuicPorts = [.. ports], PublicKey = agent.PublicKey, IpVersion = IpVersions.V6 });
}
}
lock (_gate) lock (_gate)
{ {
if (result.Version < _version || (result.Version == _version && Same(next, _agents))) if (result.Version < _version || (result.Version == _version && Same(next, _agents)))
@@ -108,9 +97,9 @@ public sealed class AgentDirectory
Changed?.Invoke(); Changed?.Invoke();
} }
private static bool Same(List<KnownAgent> a, List<KnownAgent> b) => private static bool Same(List<BootstrapAgent> a, List<BootstrapAgent> b) =>
a.Count == b.Count && a.Zip(b).All(p => Equal(p.First, p.Second)); a.Count == b.Count && a.Zip(b).All(p => Equal(p.First, p.Second));
public static bool Equal(KnownAgent a, KnownAgent b) => public static bool Equal(BootstrapAgent a, BootstrapAgent b) =>
a.AgentId == b.AgentId && a.IpVersion == b.IpVersion && a.Host == b.Host && a.TcpPort == b.TcpPort && a.QuicPorts.SequenceEqual(b.QuicPorts) && a.PublicKey == b.PublicKey; a.AgentId == b.AgentId && a.Host == b.Host && a.TcpPort == b.TcpPort && a.Spki == b.Spki;
} }
@@ -1,13 +1,13 @@
using System.Text.Json; using System.Text.Json;
using Fleet.Core; using Fleet.Core;
using Fleet.Link; using Fleet.Link;
using MoShiro.Measurement; using Monitor.Measurement;
using MoShiro.Protocol; using Monitor.Protocol;
namespace MoShiro.Probe; namespace Monitor.Probe;
/// <summary> /// <summary>
/// 估计 Agent 单调时钟 − Probe 单调时钟:每次 <c>moshiro.clock</c> 往返取中点,在最近 8 次里取往返最短的一次 /// 估计 Agent 单调时钟 − Probe 单调时钟:每次 <c>monitor.clock</c> 往返取中点,在最近 8 次里取往返最短的一次
/// (往返越短,对称性假设的误差越小)。Probe 的列块头里带着这个偏移,Backend 据此对齐两端的时间戳。 /// (往返越短,对称性假设的误差越小)。Probe 的列块头里带着这个偏移,Backend 据此对齐两端的时间戳。
/// </summary> /// </summary>
public sealed class ClockEstimator public sealed class ClockEstimator
@@ -82,6 +82,6 @@ public sealed class FragmentShipper : IFragmentSink
} }
} }
_outbox.Enqueue(MoShiroChannels.ColumnBlock, header.ChunkId.Value, RawChunkCodec.Encode(new RawChunk(header, streams))); _outbox.Enqueue(MonitorChannels.ColumnBlock, header.ChunkId.Value, RawChunkCodec.Encode(new RawChunk(header, streams)));
} }
} }
@@ -0,0 +1,20 @@
<Project>
<Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" />
<Import Project="Sdk.props" Sdk="StuDev.AotAnywhere"
Condition="'$(RuntimeIdentifier)' == 'linux-x64' Or '$(RuntimeIdentifier)' == 'linux-arm64' Or '$(RuntimeIdentifier)' == 'linux-arm' Or '$(RuntimeIdentifier)' == 'linux-musl-x64' Or '$(RuntimeIdentifier)' == 'linux-musl-arm64' Or '$(RuntimeIdentifier)' == 'linux-musl-arm' Or '$(RuntimeIdentifier)' == 'osx-x64' Or '$(RuntimeIdentifier)' == 'osx-arm64' Or '$(RuntimeIdentifier)' == 'win-x64' Or '$(RuntimeIdentifier)' == 'win-arm64'" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<AssemblyName>monitor-probe</AssemblyName>
<RootNamespace>Monitor.Probe</RootNamespace>
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="Monitor.Backend.Tests" />
<ProjectReference Include="..\..\..\fleet\src\Fleet.Link\Fleet.Link.csproj" />
<ProjectReference Include="..\..\..\fleet\src\Fleet.Hosting\Fleet.Hosting.csproj" />
<ProjectReference Include="..\..\..\shared\Monitor.Measurement\Monitor.Measurement.csproj" />
</ItemGroup>
<Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
<Import Project="Sdk.targets" Sdk="StuDev.AotAnywhere"
Condition="'$(RuntimeIdentifier)' == 'linux-x64' Or '$(RuntimeIdentifier)' == 'linux-arm64' Or '$(RuntimeIdentifier)' == 'linux-arm' Or '$(RuntimeIdentifier)' == 'linux-musl-x64' Or '$(RuntimeIdentifier)' == 'linux-musl-arm64' Or '$(RuntimeIdentifier)' == 'linux-musl-arm' Or '$(RuntimeIdentifier)' == 'osx-x64' Or '$(RuntimeIdentifier)' == 'osx-arm64' Or '$(RuntimeIdentifier)' == 'win-x64' Or '$(RuntimeIdentifier)' == 'win-arm64'" />
</Project>
+154
View File
@@ -0,0 +1,154 @@
using Fleet.Core;
using Fleet.Hosting;
using Fleet.Identity;
using Monitor.Measurement;
namespace Monitor.Probe;
/// <summary>
/// Probe 的组装点:Agent 列表 → 每个 Agent 一个连接(认证、重连、角色)。没有全局状态,测试可以在同一进程里启动多个。
/// 列表变化时只调和连接:新增的建立,移除的断开,地址或证书指纹变化的重建,其余不受影响。
/// </summary>
public sealed class ProbeApp : IDisposable
{
public const string Version = "0.1.1";
private readonly ConnectionEnv _env;
private readonly Dictionary<string, Running> _connections = [];
private readonly SemaphoreSlim _changed = new(0);
private readonly INodeLog _log;
public ProbeApp(ProbeFile file, string configDirectory, TimeProvider time, INodeLog log)
{
_log = log;
var directory = new AgentDirectory(new ConfigStore(configDirectory), configDirectory, file, log);
NodeIdentity identity = NodeIdentity.FromPkcs8(Convert.FromBase64String(file.PrivateKey));
_env = new ConnectionEnv(identity, file.ProbeId, file.Limits, directory, new MonoClock(time), time, log);
directory.Changed += () => _changed.Release();
}
public AgentDirectory Directory => _env.Directory;
/// <summary>某个 Agent 的会话是否已经建立(测试和状态查看用)。</summary>
public bool IsConnected(string agentId)
{
lock (_connections)
{
return _connections.TryGetValue(agentId, out Running? running) && running.Connection.IsConnected;
}
}
public AgentConnection? Connection(string agentId)
{
lock (_connections)
{
return _connections.TryGetValue(agentId, out Running? running) ? running.Connection : null;
}
}
public async Task RunAsync(CancellationToken ct)
{
if (_env.Directory.Agents.Count == 0)
{
_log.Warn("no agents known yet: probe.json has an empty bootstrap list");
}
try
{
while (!ct.IsCancellationRequested)
{
await ReconcileAsync(_env.Directory.Agents, ct);
await _changed.WaitAsync(ct);
}
}
catch (OperationCanceledException)
{
// 停止
}
finally
{
await ReconcileAsync([], CancellationToken.None);
}
}
public void Dispose()
{
_env.Identity.Dispose();
_changed.Dispose();
}
private async Task ReconcileAsync(IReadOnlyList<BootstrapAgent> agents, CancellationToken ct)
{
var wanted = agents.ToDictionary(a => a.AgentId);
var stopped = new List<Running>();
lock (_connections)
{
foreach ((string id, Running running) in _connections.ToList())
{
if (!wanted.TryGetValue(id, out BootstrapAgent? endpoint) || !AgentDirectory.Equal(endpoint, running.Connection.Endpoint))
{
stopped.Add(running);
_connections.Remove(id);
}
}
}
foreach (Running running in stopped)
{
await running.StopAsync();
}
if (ct.IsCancellationRequested)
{
return;
}
lock (_connections)
{
foreach ((string id, BootstrapAgent endpoint) in wanted)
{
if (!_connections.ContainsKey(id))
{
_connections[id] = Running.Start(new AgentConnection(endpoint, _env));
}
}
}
}
private sealed class Running : IDisposable
{
private readonly CancellationTokenSource _cts = new();
private Task _run = Task.CompletedTask;
private Running(AgentConnection connection)
{
Connection = connection;
}
public AgentConnection Connection { get; }
public static Running Start(AgentConnection connection)
{
var running = new Running(connection);
running._run = Task.Run(() => connection.RunAsync(running._cts.Token), CancellationToken.None);
return running;
}
public async Task StopAsync()
{
await _cts.CancelAsync();
try
{
await _run.WaitAsync(TimeSpan.FromSeconds(10));
}
catch (Exception ex) when (ex is OperationCanceledException or TimeoutException)
{
// 没有及时退出:不再等
}
Dispose();
}
public void Dispose() => _cts.Dispose();
}
}
@@ -0,0 +1,65 @@
using System.Text.Json.Serialization;
namespace Monitor.Probe;
/// <summary>
/// <c>probe.json</c>:面板 <c>POST /api/admin/probe/identity</c> 生成的文件(只能下载一次)。
/// <c>limits</c> 是本地硬上限,面板无法修改;在路由器上建议调低,避免 Probe 自己的 CPU 成为瓶颈、反过来影响测量。
/// </summary>
public sealed class ProbeFile
{
[JsonPropertyName("probe_id")]
public string ProbeId { get; set; } = "";
/// <summary>PKCS#8,base64。</summary>
[JsonPropertyName("private_key")]
public string PrivateKey { get; set; } = "";
/// <summary>首次连接用的 Agent;连上之后以 <c>agents.json</c>(最新的列表)为准。</summary>
[JsonPropertyName("bootstrap")]
public List<BootstrapAgent> Bootstrap { get; set; } = [];
[JsonPropertyName("limits")]
public ProbeLimits Limits { get; set; } = new();
}
public sealed class BootstrapAgent
{
[JsonPropertyName("agent_id")]
public string AgentId { get; set; } = "";
[JsonPropertyName("host")]
public string Host { get; set; } = "";
[JsonPropertyName("tcp_port")]
public int TcpPort { get; set; }
/// <summary>Agent 的 TLS 证书指纹(SHA-256,base64)。</summary>
[JsonPropertyName("spki")]
public string Spki { get; set; } = "";
}
public sealed class ProbeLimits
{
/// <summary>按需测试里单个阶段允许的最大码率(Mbit/s)。</summary>
[JsonPropertyName("max_test_mbps")]
public double MaxTestMbps { get; set; } = 100;
/// <summary>按需测试里单个阶段允许的最大包速率。</summary>
[JsonPropertyName("max_total_pps")]
public long MaxTotalPps { get; set; } = 20_000;
}
[JsonSerializable(typeof(ProbeFile))]
[JsonSerializable(typeof(AgentsCache))]
public sealed partial class ProbeConfigContext : JsonSerializerContext;
/// <summary><c>agents.json</c>:最近一次从 Agent 同步到的列表(配置,不是测量数据)。</summary>
public sealed class AgentsCache
{
[JsonPropertyName("version")]
public long Version { get; set; }
[JsonPropertyName("agents")]
public List<BootstrapAgent> Agents { get; set; } = [];
}
Loaded 100 of 595 files, more files were not shown because too many files have changed in this diff. Show more