30 Commits
Author SHA1 Message Date
justme e2e5e437cb docs(deploy): document QUIC runtime and port requirements [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/verify Pipeline failed
ci/woodpecker/push/dev-image unknown status
2026-10-10 03:00:47 +08:00
justme b25b111134 feat(display): add compact QUIC and host health views 2026-10-10 03:00:23 +08:00
justme b964dc2bbc feat(quic): replace probe UDP data plane with QUIC datagrams 2026-10-10 02:59:56 +08:00
justme 4609048865 docs: merge the API guidelines into api.md; drop the one-off trial reports; fix the node-update description (Quadlet restart vs host updater); tidy TODO [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
2026-10-09 22:31:11 +08:00
justme 900bbde46a ci: run the slow e2e tests after the image builds (a parallel run got the backend AOT compile OOM-killed) 2026-10-09 15:24:07 +08:00
justme 2d31bd091e ci: gate on the fast tests (about 20s); slow end-to-end tests run in parallel with the image build [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/push/e2e Pipeline was successful
ci/woodpecker/push/dev-image Pipeline failed
2026-10-09 14:33:03 +08:00
justme c9c21f8056 ci: fix the failed-test retry (wildcard filter, ignore zero-test exit code, two attempts) [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
2026-10-09 14:09:54 +08:00
justme b2b63c065d ci: sync packaging and docs into the test cache; retry failed tests once [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/verify Pipeline failed
ci/woodpecker/push/dev-image unknown status
2026-10-09 14:02:42 +08:00
justme 0873acf66a fix(link): ride out short path blackouts: 30s handshake, 180s idle limit, 90s keepalive, faster probe reconnect [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/verify Pipeline failed
ci/woodpecker/push/dev-image unknown status
2026-10-09 13:46:59 +08:00
justme be04045c8c ci: incremental backend tests, cached web dependencies, web build in parallel with the tests
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/verify Pipeline failed
2026-10-09 13:39:55 +08:00
justme 006814823b docs: main-only workflow, CI-filled versions, dev version format [CI]
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
2026-10-09 13:20:30 +08:00
justme 7ff0d0f4e6 feat: print build info at startup; version placeholders filled in by CI [CI SKIP] 2026-10-09 13:20:30 +08:00
justme a9f78f99df ci: version from the tag or the commits since it; dev images from main gated on tests; recognise -dev. tags [CI SKIP] 2026-10-09 13:20:30 +08:00
justme a3338e1cf3 fix(display): background quality as a three-way choice (blurred, sharp, original) [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
2026-10-09 04:57:01 +08:00
justme 044ce0c339 feat(display): background photo groups with blurred, compressed and original versions; veil, sway and blur options in the appearance panel [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
2026-10-09 04:50:11 +08:00
justme 725fe17a7d chore: release 0.4.0 [CI]
ci/woodpecker/tag/windows-release Pipeline was successful
ci/woodpecker/push/main-amd64-build Pipeline was successful
ci/woodpecker/tag/linux-release Pipeline was successful
ci/woodpecker/tag/gitea-release Pipeline was successful
2026-10-09 03:54:56 +08:00
justme e49990776b feat(display): falling snow in three depth layers with wind, mouse and bullet time; appearance panel; dark glass tinted from the background [CI SKIP] 2026-10-09 03:54:49 +08:00
justme f8e8bb8acb feat(display): pointer-sway background with a dark variant; acrylic glass tokens [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
2026-10-09 03:09:14 +08:00
justme 70f20d38d0 feat(display): site name and avatar in the top bar, content aligned left [CI SKIP] 2026-10-09 03:08:54 +08:00
justme 7ff03a9628 fix(display): a hovered card no longer covers the top bar [CI SKIP] 2026-10-09 03:08:53 +08:00
justme 5dce387c87 ci: build a dev image to test automatic node updates [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
2026-10-09 02:10:38 +08:00
justme 535bb3517d feat: node updates by restart, auto by default; Podman Quadlet install from the panel alongside compose [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
2026-10-09 01:33:22 +08:00
justme 88b1a0a9bb feat: probe host stats, node cards with a separate lines panel grouped by carrier, Windows host sampling, manual carrier override [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
2026-10-08 21:56:19 +08:00
justme bac1c27e72 fix: compose passes .env through env_file; clearer name and channel messages [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
podman-compose does not substitute defaults inside "environment:" (a value
like ${MOSHIRO_NAME:-} reached the node as the literal text), so the node was
enrolled with an invalid name and reported the wrong network scope. The three
compose files now read .env with env_file and only interpolate image and
network_mode; the Backend .env keys are the container's own names
(MoShiro__PublicUrl, MoShiro__ClaimCode).

- The generated .env no longer carries a comment line for MOSHIRO_NAME; the
  dialog says how to choose a name instead.
- The container entrypoint prints the name it enrolls with and defaults
  MOSHIRO_NETWORK_SCOPE to host (Docker Desktop sets container in .env).
- The overview and line pages say "measurement channel not established"
  instead of "not connected": the agent can be reachable and pingable while
  the measurement TCP/UDP path is not.
2026-10-08 20:47:38 +08:00
justme 12be44645f feat: install nodes and the backend from a static compose file; remove dead code [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
Agent, Probe and Backend now start from one static compose.yaml plus a local
.env: the panel (or the release assets, for the very first Backend) provides
the file, the panel's command writes the public values into .env, checks the
file's SHA-256 and runs compose up. Updating is compose pull && compose up -d.
The image is chosen by the CI channel (:latest, or MOSHIRO_VERSION in .env),
not by the panel; the previous one-line script stays as a fallback.

- packaging/compose/{agent,probe,backend}/compose.yaml are the single source,
  embedded in the Backend and served at /install/compose/{file}; the root
  compose.yaml moved there. Releases also carry them and list them in the
  signed SHA256SUMS.
- The container entrypoint defaults the node name to the host name.
- Join token creation returns a compose block (.env, url, hash, per-platform
  commands); the admin dialog leads with it.
- Remove code with no references (HotLayer members, LiveHub.SubscriberCount,
  QueryService.ParseParams, ReleaseService.Forget, a few constants) and reduce
  the install-code lookup to the role it is for.
2026-10-08 20:24:23 +08:00
justme e014a19554 docs: reorganize into design, security, api, ops and dev; drop stale designs; refresh the diagrams [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
- Group docs by category with an index (docs/README.md); fix every link,
  code comment and the protocol conformance test that read docs/protocol.md.
- access.md and security.md rewritten to what is implemented: removed the
  unadopted member-proof chain, roles and recovery key that do not exist,
  the old implementation order and the budgets; gaps now carry a status.
- enrollment, deployment, api: the install flow is the static, hash-checked
  script with environment variables; no default admin.
- README and architecture: new topology and update-flow diagrams (operator
  passkey, CI signing, registry feed, auth and trust modules).
2026-10-08 19:37:56 +08:00
justme 636a784043 fix: the panel polls the registry for its own and the nodes' releases [CI]
ci/woodpecker/push/main-amd64-build Pipeline is running
Registering a manifest with the panel is best effort in CI (an unreachable
panel only warns, the pipeline stays green). The Backend update notice then
never appeared, because the host updater only reads the mailbox the panel
writes. A background poller now fetches the signed manifests of the agent, the
probe and the backend itself from the registry every five minutes, verifies the
release signature and registers them, writing the backend one into the host
updater's mailbox.
2026-10-08 19:09:21 +08:00
justme 0f8f3f9a54 feat: trusted release keys setting; drop the duplicate release registration route [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
The panel now lets the deployer decide which release keys it trusts. The
official CI key is built in and always trusted; an own P-256 public key can be
added (off by default, the private key is rejected) so manifests signed with it
are accepted too. Removing it drops the manifests only it signed. Changes are
audited and need update.confirm.

Admin UI: System > Trust > "Trusted release keys" with a collapsible guide on
generating a key pair and signing a manifest with openssl.

Registration now has one path, POST /api/release/offer (no token, signature
verified). The admin POST /releases route, the release.publish capability and
the ci-release preset are removed.
2026-10-08 18:50:16 +08:00
justme 904ea2b0ae refactor: remove the runtime binary binding; every release registration path verifies the CI signature [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
The hash a node reports about itself cannot be trusted (a swapped binary can
copy the old hash), so the runtime binding only caught files that were replaced
by something that did not lie. Provenance stays where it can be verified: the
signed release manifest (digest) and the signed SHA256SUMS.

- Remove BinaryBindingService, the node_bindings table (schema v15 drops it),
  BinaryPolicy, the handshake hash/manifest fields and the admin binary column.
- Drop the per-platform "binaries" list from the release manifest, the hash
  scripts and the updater's installed.manifest copy.
- ReleaseService.PublishAsync now verifies the release signature, so the admin
  POST /releases route is no longer an unsigned path; tests sign with a test key.
- Docs: section 16 explains why the runtime check is gone.
2026-10-08 18:30:05 +08:00
justme a4f3d1892a ci: all-platform node binaries in the signed release manifest; signed SHA256SUMS for the archives; web build once before the parallel backend builds; release 0.3.3 [CI SKIP]
ci/woodpecker/tag/windows-release Pipeline was successful
ci/woodpecker/tag/linux-release Pipeline was successful
ci/woodpecker/tag/gitea-release Pipeline was successful
2026-10-08 16:26:29 +08:00
261 changed files with 9125 additions and 3802 deletions

No files matched your search

@@ -7,12 +7,19 @@ clone:
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:
@@ -28,7 +35,7 @@ steps:
RELEASE_SIGNING_KEY:
from_secret: release_signing_key
commands:
- bash ci/build-main-amd64.sh backend
- bash ci/build-dev-image.sh backend
depends_on: []
- name: build-linux-amd64-agent
@@ -43,7 +50,7 @@ steps:
RELEASE_SIGNING_KEY:
from_secret: release_signing_key
commands:
- bash ci/build-main-amd64.sh agent
- bash ci/build-dev-image.sh agent
depends_on: []
- name: build-linux-amd64-probe
@@ -58,5 +65,20 @@ steps:
RELEASE_SIGNING_KEY:
from_secret: release_signing_key
commands:
- bash ci/build-main-amd64.sh probe
- bash ci/build-dev-image.sh probe
depends_on: []
# 慢的端到端测试:等三个镜像都构建、推送完再跑,不和 NativeAOT 编译抢内存(同时跑过一次,Backend 的 AOT 编译被 OOM 杀掉了)。
# 它失败不影响已经推出去的镜像,只让流水线变红。没带 [CI] 的推送由 e2e.yml 跑。
- name: backend-e2e-tests
image: bash
when:
- evaluate: 'not (CI_COMMIT_MESSAGE contains "[SKIP TESTS]")'
environment:
NUGET_PACKAGES: /home/justme/.nuget/packages
commands:
- bash ci/verify-backend.sh slow
depends_on:
- build-linux-amd64-backend
- build-linux-amd64-agent
- build-linux-amd64-probe
+32
View File
@@ -0,0 +1,32 @@
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/.nuget/packages
commands:
# 标了 Category=Slow 的端到端测试,见 ci/verify-backend.sh
- bash ci/verify-backend.sh slow
+2
View File
@@ -40,6 +40,8 @@ steps:
GITEA_BASE: http://127.0.0.1:3100
GITEA_TOKEN:
from_secret: gitea_token
RELEASE_SIGNING_KEY:
from_secret: release_signing_key
commands:
- bash ci/publish-gitea-release.sh
depends_on:
+16 -3
View File
@@ -15,6 +15,19 @@ when:
concurrency: 1
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
image: bash
environment:
@@ -25,7 +38,8 @@ steps:
from_secret: zot_secret
commands:
- bash ci/release-linux.sh amd64 backend
depends_on: []
depends_on:
- web-build
- name: linux-arm64-backend
image: bash
@@ -36,10 +50,9 @@ steps:
ZOT_SECRET:
from_secret: zot_secret
commands:
# 两个架构共用一个工作目录:amd64 先构建并生成前端产物,arm64 在它之后(并行会同时 npm ci,互相删 node_modules)
- bash ci/release-linux.sh arm64 backend
depends_on:
- linux-amd64-backend
- web-build
- name: backend-multiarch-index
image: bash
+3 -2
View File
@@ -11,7 +11,7 @@ clone:
when:
- event: pull_request
- event: push
branch: dev
branch: main
# On Gitea PR events, CI_COMMIT_MESSAGE is the PR title.
steps:
@@ -23,4 +23,5 @@ steps:
environment:
NUGET_PACKAGES: /home/justme/.nuget/packages
commands:
- cd backend && dotnet test --solution MoShiro.slnx -c Release
# 增量编译 + 快的那批测试(约 20 秒);慢的端到端测试在 e2e workflow 里和出镜像并行跑。见 ci/verify-backend.sh
- bash ci/verify-backend.sh fast
+4 -7
View File
@@ -11,17 +11,14 @@ clone:
when:
- event: pull_request
- event: push
branch: dev
depends_on:
- verify
branch: main
# 和 verify 并行:前端构建不依赖后端测试,两个同时跑
steps:
- 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
# node_modules 按锁文件哈希缓存,展示端和管理端并行构建,再检查 CSS,见 ci/web-build.sh
- bash ci/web-build.sh
+5 -4
View File
@@ -6,8 +6,9 @@ MoShiro is a self-hosted network path quality monitor. Backend coordinates Agent
## Branch and workspace safety
- Treat `main` as the stable release branch and `dev` as the integration branch. Keep ordinary implementation work on `dev` or a feature branch; integrate into `main` through a pull request.
- A push to `main` builds 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. When squashing or pushing to `main`, add `[CI]` unless a build is explicitly not wanted.
- `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`.
@@ -38,6 +39,6 @@ Follow the user's requested validation scope. Do not start a full tag/release bu
## CI and releases
CI triggers and image tags can change; inspect `.woodpecker/*.yml` and `docs/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.
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. Development image builds and releases must remain isolated in their respective Zot repository paths.
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.
+42 -36
View File
@@ -12,45 +12,54 @@ MoShiro 是一个自托管的**网络线路质量观测工具**。它在若干 P
**它不做什么**
- **节点是纯采集器**:Agent 和 Probe 没有终端、命令执行、文件读写和自更新能力,也不加载外部代码。更新由节点宿主机上的更新器完成,节点进程只放面板转来的发布清单。
- **不是主机监控**:Agent 只上报 CPU、内存、磁盘、网卡流量和运行时长这类只读的汇总数字,不采集型号、主机名、进程和任何 IP;Probe 不上报主机资源。
- **节点是纯采集器**:Agent 和 Probe 没有终端、命令执行、文件读写和自更新能力,也不加载外部代码。更新由节点外面的服务管理器完成:Podman Quadlet 单元每次启动都先拉最新镜像,管理端点「更新」时节点只是有序退出(没有 Quadlet 的环境用宿主机更新器),节点自己不下载、不替换任何东西。
- **不是主机监控**:Agent 和 Probe 只上报 CPU、内存、磁盘、网卡流量和运行时长这类只读的汇总数字(Probe 可以在本地配置里关掉),不采集型号、主机名、进程和任何 IP。
- **不暴露地址**:公开接口里没有 IP、城市、ASN;国家和运营商只由 Backend 查询(节点自己不访问任何外部服务)。
- 暂不包含告警(见文末待办)。
## 信任关系
```text
管理员 ── 账号密码(Cookie 会话 + CSRF)或 API 令牌(按能力授权)──▶ Backend
管理员 ── 密码或 passkey(Cookie 会话 + CSRF),或 API 令牌(按能力授权)──▶ Backend
访客 ── 匿名只读,站点设为公开时才可用(没有 IP、城市、ASN)──────────────▶ Backend 公开接口
操作员 ── passkey 给「操作员集合」签名 ──▶ Backend 只转发 ──▶ 节点自己验链
(创世指纹和属主在安装时固定在节点上;集合里还登记了 Backend 的服务器密钥,节点据此认 Backend、加密链路)
节点 ── 加入令牌一次性注册,之后用自己的 ECDSA P-256 密钥签名认证 ───────▶ Backend(只存公钥)
Probe ── 用同一把密钥签名建立测量会话 ───────────────────────────────────▶ Agent(只放行面板名单里的 Probe 公钥)
CI ── 发布私钥对「镜像 digest 清单」签名 ──▶ Backend 原样转发 ──▶ 节点宿主机的更新器用本机公钥验签
Probe ── 先敲门,再用自己的密钥签名建立测量会话 ───────────────────────────▶ Agent(只放行面板名单里的 Probe 公钥)
CI ── 发布私钥对「镜像 digest 清单」签名 ──▶ 镜像仓库 / Backend ──▶ 节点宿主机的更新器用本机公钥验签
```
- **Backend 不信任节点上报的内容**:数据按线路隔离存放;节点的身份靠签名,数据库泄露也无法冒充节点。
- **节点不信任 Backend 的下发**:任务超出节点本机的硬上限(目标网段、码率、包速率、监听端口)就被拒绝;节点不执行任何「执行类」指令。
- **更新不依赖面板的诚实**:清单必须由发布私钥签名;节点宿主机的更新器用本机公钥验签、只认本机配置的镜像仓库、按 digest 拉取、拒绝旧的 `seq`。面板被攻破也推不出恶意镜像。
- **节点不信任 Backend 的下发**:任务超出节点本机的硬上限(目标网段、码率、包速率、监听端口)就被拒绝;节点不执行任何「执行类」指令;改信任(操作员集合)要操作员 passkey 签名,Backend 伪造不了。
- **更新不依赖面板的诚实**:清单必须由发布私钥签名(面板登记时验一次,节点宿主机的更新器用本机公钥再验一次)、只认本机配置的镜像仓库、按 digest 拉取、拒绝旧的 `seq`。面板被攻破也推不出恶意镜像。可信的发布密钥由部署的人决定:官方 CI 密钥始终信任,也可以另外信任自己的公钥。
- **没有默认账户**:第一次打开管理端进认领向导,用后端日志里的认领码创建第一个管理员、注册 passkey、建立信任根。每个节点都必须有属主(passkey),由节点自己强制。
- 运行中的二进制不做校验(节点自报的哈希不可信);「资产是我们的」靠安装时的签名清单和签名的 `SHA256SUMS`。详见 [`docs/security/`](docs/security/security.md)。
## 架构
```text
Web(展示端 · 管理端)
▲ REST + SSE
┌──────────────────────────────────────────────────────────┐
│ Backend(面板) │
│ Api · Registry · Planner · Links │
│ Releases · Analysis · LiveHub │
│ Store(段文件 + SQLite) │
└──────────────────────────────────────────────────────────┘
操作员 passkey ──签名「操作员集合」──┐ CI 发布私钥 ──签名「镜像 digest 清单」──▶ 镜像仓库(清单 + 镜像)
▼ ▲
Web(展示端 · 管理端) │ 定时取清单
▲ REST + SSE │
┌──────────────────────────────────────────────────────────────┐
│ Backend(面板) │──────────────────────────────┘
│ Api · Auth(会话 · passkey · API 令牌 · 审计) │
│ Trust(操作员集合 · 服务器身份)· Registry · Planner · Links │
│ Releases(发布清单 · 可信发布密钥) │
│ Ingest · Analysis · LiveHub │
│ Store(段文件 + SQLite) │
└──────────────────────────────────────────────────────────────┘
▲ ▲
│ wss 长连接(节点发起) │ wss 长连接(节点发起)
│ wss 长连接(节点发起,应用层加密) │ wss 长连接(节点发起,应用层加密)
│ 任务、数据、状态、更新通知 │ 身份、Agent 列表、状态、更新通知
┌────────────────────┐ ┌──────────────────────┐
│ Agent × 10+ │ ◀── 测量会话 ── │ Probe × N │
│ 有公网 IP,监听 │ │ 家宽等出口,NAT 后 │
└────────────────────┘ └──────────────────────┘
测量会话:TLS 控制通道 + UDP/TCP 数据通道,由 Probe 主动拨出
┌────────────────────┐ ┌──────────────────────┐
│ Agent × 10+ │ ◀── 测量会话 ── │ Probe × N │
│ 有公网 IP,监听 │ 先敲门再 TLS │ 家宽等出口,NAT 后 │
└────────────────────┘ └──────────────────────┘
测量会话:TLS 控制通道 + QUIC Datagram/TCP 数据通道,由 Probe 主动拨出
宿主机更新器(容器节点):用本机公钥验签 → 按 digest 拉镜像 → 重启,失败回滚
```
两种连接:**Probe 和 Agent 都用长连接连 Backend**(身份、任务、状态、更新通知),**测量只走 Probe → Agent**(Probe 主动拨出,Agent 不需要连 Probe,家宽也不需要入站端口)。哪些 Probe 连哪些 Agent 由 Backend 决定:默认自动全连,也可以在管理端手动选择。
@@ -60,16 +69,16 @@ MoShiro 是一个自托管的**网络线路质量观测工具**。它在若干 P
Probe / Agent 积木 ──▶ 原始列块(10 秒一块)──▶ Backend 段文件 ──▶ 指标与服务模型 ──▶ REST / SSE ──▶ Web
```
Backend 用段文件保存原始数据,用 SQLite 保存索引、指标与配置。**完整的架构、数据链路、安全边界和设计决策见 [`docs/architecture.md`](docs/architecture.md)。**
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 控制通道、UDP 和 TCP 数据通道(默认 `7443/tcp`、`7443/udp`、`7444/tcp`);会话用 `session_id` 和 MAC 认证、不绑定五元组,NAT 映射变化后自动跟随。
- **测量数据面**: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/protocol.md`](docs/protocol.md)、[`backend/fleet/README.md`](backend/fleet/README.md)、[`docs/api.md`](docs/api.md)、[`docs/enrollment.md`](docs/enrollment.md)。
细节见 [`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)。
## 如何构建
@@ -80,7 +89,7 @@ dotnet build MoShiro.slnx -c Release # Backend、Agent、Probe、Fleet
npm ci --prefix web && npm run build --prefix web # 展示端和管理端,产物在 web/display/dist、web/admin/dist
```
本地运行:先在 `backend/` 里 `dotnet run --project src/MoShiro.Backend -- --urls http://localhost:5000`,再 `npm run dev --prefix web`,展示端是 <http://localhost:5173/>,管理端是 <http://localhost:5173/admin/>。
本地运行:先在 `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` 指定)。
测试和检查:
@@ -90,22 +99,19 @@ npm run lint:css --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/deployment.md`](docs/deployment.md);前端见 [`web/README.md`](web/README.md),后端见 [`backend/README.md`](backend/README.md)。
Backend、Agent、Probe 以 OCI 镜像发布(Linux amd64、arm64),Agent 和 Probe 另有 Linux glibc/musl、macOS、Windows 的二进制包。NativeAOT 的构建细节、CI 触发规则(`[CI]`、`[SKIP TESTS]`、版本 tag)、镜像路径、容器节点的运行和更新见 [`docs/ops/deployment.md`](docs/ops/deployment.md);前端见 [`web/README.md`](web/README.md),后端见 [`backend/README.md`](backend/README.md)。
## 文档
| 文档 | 内容 |
| --- | --- |
| [`docs/architecture.md`](docs/architecture.md) | 架构、数据链路、安全边界、设计决策 |
| [`docs/security.md`](docs/security.md) | 零信任设计稿(部分已实现):威胁模型、入网信任链、监听隐身、extension 授权 |
| [`docs/access.md`](docs/access.md) | 访问控制与入网信任链设计(尚未实现):账户、角色、能力、API key、提权窗口、passkey 到节点的签名链 |
| [`docs/protocol.md`](docs/protocol.md)、[`backend/fleet/README.md`](backend/fleet/README.md) | 线协议、Fleet 节点框架(身份、注册、连接) |
| [`docs/enrollment.md`](docs/enrollment.md) | 注册、身份、Probe 与 Agent 的接入 |
| [`docs/measurement.md`](docs/measurement.md)、[`docs/analysis.md`](docs/analysis.md) | 采集引擎与任务模型、分析层与指标 |
| [`docs/storage.md`](docs/storage.md)、[`docs/query.md`](docs/query.md) | 存储、展示数据查询 |
| [`docs/api.md`](docs/api.md)、[`docs/api-guidelines.md`](docs/api-guidelines.md) | API 规范与约束 |
| [`docs/deployment.md`](docs/deployment.md) | 构建、发布、CI、部署与更新 |
| [`docs/project-layout.md`](docs/project-layout.md)、[`docs/coding-style.md`](docs/coding-style.md) | 仓库结构与依赖规则、代码风格 |
| [`docs/README.md`](docs/README.md) | 文档索引,按类别组织 |
| [`docs/design/`](docs/design/architecture.md) | 设计:架构与决策、线协议、存储与查询、采集与分析 |
| [`docs/security/`](docs/security/security.md) | 安全:零信任与威胁模型、访问控制与入网信任链、注册与身份 |
| [`docs/api/`](docs/api/api.md) | 接口:端点说明、API 规范 |
| [`docs/ops/deployment.md`](docs/ops/deployment.md) | 运维:构建、发布、CI、部署与更新 |
| [`docs/dev/`](docs/dev/project-layout.md) | 开发:仓库结构与依赖规则、代码风格 |
| [`backend/fleet/README.md`](backend/fleet/README.md) | Fleet 节点框架(身份、注册、连接) |
代码目录:`backend/`(Backend、`fleet/` 节点框架、`shared/` 测量引擎和协议、`agent/`、`probe/`、`tests/`)、`web/`(`display` 展示端、`admin` 管理端、`shared`)、`packaging/` 和 `scripts/`(部署与维护)、`ci/` 和 `.woodpecker/`(流水线)。
+4 -8
View File
@@ -5,19 +5,15 @@
## 测量
- 路由跳数、路径漂移和 trace
- QUIC 握手耗时:TCP 建连和 TLS 握手已经有了(`connect` 任务)。.NET 的 QUIC 依赖系统里的 msquic 原生库,和节点「只依赖 OpenSSL、NativeAOT 单文件」冲突,要做只能自己写最小的 QUIC Initial 握手探测
- 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.md`](docs/security.md) 第 9.2 节
- 原生 webhook 投递;告警(基于 webhook)。实现之前先定约束(只允许 HTTPS、禁止私网和回环、不跟随重定向、限制超时和响应大小、解析后再检查地址),见 [`docs/security/security.md`](docs/security/security.md) 第 9.2 节
## 部署
- 后端自动下载前端资源:用 Podman、Docker 部署时,默认拉取最新的前端资源(现在前端要自己构建,镜像里没有)。
[security.md](docs/security.md)
## 访客页面
- 人机验证:访客页面要通过验证才能拉数据,前端访问时静默触发。候选 tiago2/cap 和 Cloudflare Turnstile。
@@ -34,11 +30,11 @@
- 流程:下载 → 校验 → 解包到 `versions/<v>/` → 原子切换 `current`(Linux 符号链接,Windows 目录联结)→ 重启服务 → 等健康信标确认 → 失败切回旧版本并把该版本标记为坏版本。
- 权限分离:更新进程只能写安装目录和重启被管理的服务,读不到节点身份私钥。
- OpenWrt:`keep_versions = 1`,下载前检查闪存剩余空间。
- 容器节点已经实现的那一套(清单签名、`seq` 防重放、按 digest 拉取、回滚)见 [`docs/deployment.md`](docs/deployment.md)。
- 容器节点已经实现的那一套(清单签名、`seq` 防重放、按 digest 拉取、回滚)见 [`docs/ops/deployment.md`](docs/ops/deployment.md)。
## 安全
- **零信任**:设计稿在 [`docs/security.md`](docs/security.md),按其中第 12 节的顺序实现(阶段零「协议方向声明表」已完成):Probe 自己生成密钥、Agent 预握手敲门、操作员凭证和名册、测量数据的签名和归属校验、入库配额和管理端 CSP、Backend 应用层身份、extension 框架(敏感能力都放进可选安装的独立 extension,核心保持 NativeAOT);可选的 UDP 单包授权、UDP 会话迁移的路径验证。
- **零信任**:设计和各缺口的状态在 [`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 做模糊测试;伪造签名和重放的专项测试。
+1 -1
View File
@@ -13,7 +13,7 @@
<UseArtifactsOutput>true</UseArtifactsOutput>
<ArtifactsPath>$(MSBuildThisFileDirectory)artifacts</ArtifactsPath>
<Deterministic>true</Deterministic>
<Version>0.3.2</Version>
<Version>0.0.0-dev</Version>
<NuGetAudit>false</NuGetAudit>
</PropertyGroup>
<ItemGroup>
+3 -3
View File
@@ -36,7 +36,7 @@ dotnet test --solution MoShiro.slnx
dotnet publish src/MoShiro.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/deployment.md`](../docs/deployment.md)。
例如 `<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)。
正式发布通过 Woodpecker tag 流水线生成 Linux/amd64 与 Linux/arm64 OCI 镜像,容器内监听 `8080`,数据写入挂载的 `/data` 卷。Agent 与 Probe 的多平台二进制包单独发布,不包含在 Backend 镜像中。
@@ -49,6 +49,6 @@ cd backend
dotnet run --project src/MoShiro.Backend -- --urls http://localhost:5000
```
没有默认管理员:第一次打开面板时按页面引导自己创建管理员、注册 passkey、建立信任根,页面要的认领码写在后端日志里(或用 `MoShiro__ClaimCode` 指定)。`MoShiro__InitialAdminUser` / `MoShiro__InitialAdminPassword` 只给自动化用。数据目录、公开地址、Cookie 和 CORS 等设置通过 `MoShiro` 配置节传入;完整配置说明见 [`../docs/deployment.md`](../docs/deployment.md)。站点默认需要登录,可在管理端修改公开设置。
没有默认管理员:第一次打开面板时按页面引导自己创建管理员、注册 passkey、建立信任根,页面要的认领码写在后端日志里(或用 `MoShiro__ClaimCode` 指定)。`MoShiro__InitialAdminUser` / `MoShiro__InitialAdminPassword` 只给自动化用。数据目录、公开地址、Cookie 和 CORS 等设置通过 `MoShiro` 配置节传入;完整配置说明见 [`../docs/ops/deployment.md`](../docs/ops/deployment.md)。站点默认需要登录,可在管理端修改公开设置。
OpenAPI 文档由构建生成到 `openapi/v1.json`。API 设计和端点说明见 [`../docs/api.md`](../docs/api.md);项目总览见仓库根目录的 [`README.md`](../README.md)。
OpenAPI 文档由构建生成到 `openapi/v1.json`。API 设计和端点说明见 [`../docs/api/api.md`](../docs/api/api.md);项目总览见仓库根目录的 [`README.md`](../README.md)。
+5 -5
View File
@@ -7,7 +7,7 @@ Agent 仅开放配置的控制和数据端口。它不提供远程终端、命
## 工作流程
```text
Probe ──TLS 控制 / UDP、TCP 测量──> Agent ──长连接与数据上报──> Backend
Probe ──TLS 控制 / QUIC Datagram、TCP 测量──> Agent ──长连接与数据上报──> Backend
▲
fleet-enroll 注册
```
@@ -29,7 +29,7 @@ dotnet publish agent/src/MoShiro.Agent -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/deployment.md`](../../docs/deployment.md)。
也可在仓库根目录运行 `bash scripts/publish.sh agent <RID>` 和 `bash scripts/publish.sh fleet-enroll <RID>`。部署包和平台说明见 [`../../docs/ops/deployment.md`](../../docs/ops/deployment.md)。
## 注册与运行
@@ -47,7 +47,7 @@ fleet-enroll --server https://<backend> --join <token> --name <agent-name> --ide
moshiro-agent --config /etc/moshiro/agent.json
```
容器节点的运行方式(podman + systemd)和宿主机更新器见 [`../../docs/deployment.md`](../../docs/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 由引擎按镜像目录初始化权限。
容器节点的运行方式(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 由引擎按镜像目录初始化权限。
不传 `--config` 时默认读取 `/etc/moshiro/agent.json`。Agent 默认监听控制端口 `7443/tcp`,数据端口 `7443/udp` 和 `7444/tcp`;防火墙和面板中的节点端口配置需与本地配置一致。`reporting.host_stats` 可关闭主机状态上报;启用时只上报资源使用等汇总数据,不包含主机名、进程或 IP。
@@ -58,6 +58,6 @@ IPv6 线路需要 Agent 实际绑定 IPv6。新配置的默认 `listen.bind` 为
- 每个 Probe 使用独立的 TLS 会话和数据密钥;Probe 断开时对应的会话资源会释放。
- 数据面会校验会话认证、重放窗口和速率限制;无效数据包会静默丢弃。
- 测量引擎和协议由 `backend/shared/` 提供;节点身份、注册和连接由 `backend/fleet/` 提供。
- 详细安全边界与拓扑见 [`../../docs/architecture.md`](../../docs/architecture.md),协议说明见 [`../../docs/protocol.md`](../../docs/protocol.md),部署见 [`../../docs/deployment.md`](../../docs/deployment.md)。
- 详细安全边界与拓扑见 [`../../docs/design/architecture.md`](../../docs/design/architecture.md),协议说明见 [`../../docs/design/protocol.md`](../../docs/design/protocol.md),部署见 [`../../docs/ops/deployment.md`](../../docs/ops/deployment.md)。
Docker 与 rootless/rootful Podman 共用 `packaging/compose/agent/compose.yaml`,配置、身份和缓存放在命名 volume 中,无需宿主配置目录、keep-id 或手动 chown。首次导入和自动更新见部署文档。
Podman 系用 Quadlet(`packaging/quadlet/moshiro-agent.container`,默认自动更新),Docker 用 `packaging/compose/agent/compose.yaml`(手动更新);配置、身份和缓存放在命名 volume 中,无需宿主配置目录、keep-id 或手动 chown。首次导入和自动更新见部署文档。
+22 -22
View File
@@ -31,9 +31,9 @@ public sealed class AgentApp : IDisposable
private readonly TaskCompletionSource _ready = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly AgentAdmission _admission;
private readonly Outbox _outbox = new(64 << 20);
private readonly UpdateBridge _update;
private readonly RestartUpdater? _update;
private readonly BackendUplink _uplink;
private readonly UdpMux _udp;
private readonly QuicDatagramTransport _datagrams;
private readonly TcpAcceptor _tcp;
private readonly PeerListener _peers;
private readonly PeerHub _hub;
@@ -46,8 +46,7 @@ public sealed class AgentApp : IDisposable
{
_ownedRestart = restart is null ? new RestartController() : null;
_restart = restart ?? _ownedRestart!;
_update = new UpdateBridge(dataDirectory, new ConfigStore(dataDirectory).Write, log);
NodeBinary.Prime(); // 正在运行的二进制的哈希:先在后台算好,握手时用
_update = RestartUpdater.FromEnvironment("agent", _restart.RequestNow, time, log);
_config = config;
_identity = identity;
_time = time;
@@ -63,7 +62,7 @@ public sealed class AgentApp : IDisposable
_ipv6Supported = bind.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6;
_admission = new AgentAdmission(_streams);
_udp = new UdpMux(_sessions, _admission, _clock, _counters.Data, log, bind, config.Listen.UdpPorts);
_datagrams = new QuicDatagramTransport(_sessions, _admission, _clock, _counters.Data, log, bind, config.Listen.EffectiveQuicPorts, identity.Certificate);
// 预握手敲门:控制端口和数据端口共用一个关卡(限速和去重一起算);名册就是期望状态里的 peers
_trust = TrustAnchor.Load(config.Fleet.IdentityDir, log);
_knock = new KnockGate(identity.PeerIdentity, RosterKeys, time);
@@ -75,7 +74,7 @@ public sealed class AgentApp : IDisposable
Clock = _clock,
Log = log,
Session = () => null, // 每个 Probe 会话各用自己绑定的环境(BlockEnv.WithSession)
Udp = _udp,
Datagrams = _datagrams,
TcpListen = _tcp,
Admission = _admission,
Counters = _counters.Data,
@@ -84,14 +83,14 @@ public sealed class AgentApp : IDisposable
};
var chunks = new ChunkAssembler(_streams, _clock, _counters, _outbox, identity.NodeId, time, log);
var targets = new TargetRunners(chunks, identity.NodeId, _clock, time, log);
_tasks = new TaskManager(CreateGuard, _streams, env, targets, () => _udp.BoundPorts, () => _tcp.BoundPorts, _trust);
_tasks = new TaskManager(CreateGuard, _streams, env, targets, () => _datagrams.BoundPorts, () => _tcp.BoundPorts, _trust);
_hub = new PeerHub(_sessions, _clock, chunks, _tasks, _uplink, time, log);
_tests = new TestRunner(_tasks, _sessions, _streams, env, _uplink, config.Limits.MaxConcurrentTests, transport => transport == "udp" ? _udp.BoundPorts : _tcp.BoundPorts, time, log);
_tests = new TestRunner(_tasks, _sessions, _streams, env, _uplink, config.Limits.MaxConcurrentTests, transport => transport == "quic_datagram" ? _datagrams.BoundPorts : _tcp.BoundPorts, time, log);
_tasks.OnLineTasksChanged = (probe, set) => _ = _hub.PushToAsync(probe, set).AsTask();
_tasks.OnPeersChanged = _hub.CloseNotIn;
_hub.OnProbeLost = probe => _tests.ProbeLost(probe);
_admission.OnEndpointChanged = probe => _ = _uplink.EmitEventAsync(AgentEventKinds.EndpointChanged, probe.UdpEndpoint?.ToString()).AsTask();
_admission.OnEndpointChanged = probe => _ = _uplink.EmitEventAsync(AgentEventKinds.EndpointChanged, probe.DatagramRemoteEndPoint?.ToString()).AsTask();
_peers = new PeerListener(
new PeerListenerOptions
@@ -114,13 +113,13 @@ public sealed class AgentApp : IDisposable
public void Dispose()
{
_udp.Dispose();
_datagrams.Dispose();
_ownedRestart?.Dispose();
}
internal StreamRegistry Streams => _streams;
public IReadOnlyList<int> UdpPorts => _udp.BoundPorts;
public IReadOnlyList<int> QuicPorts => _datagrams.BoundPorts;
public IReadOnlyList<int> TcpPorts => _tcp.BoundPorts;
@@ -144,25 +143,26 @@ public sealed class AgentApp : IDisposable
public async Task RunAsync(CancellationToken ct)
{
_log.Info("moshiro agent starting", MoShiro.Protocol.BuildInfo.Fields);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct);
// 数据面和控制端口先开始监听,再连接 Backend:hello 里上报的是实际端口
_udp.Bind();
_datagrams.Bind();
_tcp.Bind();
Task udp = _udp.RunAsync(linked.Token);
Task quic = _datagrams.RunAsync(linked.Token);
Task tcp = _tcp.RunAsync(linked.Token);
Task peers = _peers.RunAsync(linked.Token);
await Task.WhenAny(_peers.Started.Task, peers);
if (peers.IsFaulted)
{
await linked.CancelAsync();
await Task.WhenAll(udp, tcp, peers); // 抛出监听失败的原因(端口被占用等)
await Task.WhenAll(quic, tcp, peers); // 抛出监听失败的原因(端口被占用等)
}
_ready.TrySetResult();
_log.Info("agent listening", ("control", ControlPort), ("udp", string.Join(',', UdpPorts)), ("tcp", string.Join(',', TcpPorts)));
_log.Info("agent listening", ("control", ControlPort), ("quic", string.Join(',', QuicPorts)), ("tcp", string.Join(',', TcpPorts)));
try
{
await Task.WhenAll(udp, tcp, peers, _link.RunAsync(linked.Token), ReportHostAsync(linked.Token));
await Task.WhenAll(quic, tcp, peers, _link.RunAsync(linked.Token), ReportHostAsync(linked.Token));
}
finally
{
@@ -203,7 +203,7 @@ public sealed class AgentApp : IDisposable
MaxTotalPps = _config.Limits.MaxTotalPps,
AllowedTargetCidrs = _config.Limits.AllowedTargetCidrs,
MaxConcurrentTests = _config.Limits.MaxConcurrentTests,
AllowedUdpPorts = _udp.BoundPorts,
AllowedQuicPorts = _datagrams.BoundPorts,
AllowedTcpPorts = _tcp.BoundPorts,
},
BlockCatalog.AgentKinds);
@@ -236,7 +236,7 @@ public sealed class AgentApp : IDisposable
{
router.OnNotify(MoShiroMethods.UpdateOffer, MoShiroJsonContext.Default.UpdateOfferParams, (_, offer, _) =>
{
_update.WriteOffer(offer);
_update?.Offer(offer);
return ValueTask.CompletedTask;
});
}
@@ -258,7 +258,7 @@ public sealed class AgentApp : IDisposable
{
router.OnNotify(MoShiroMethods.UpdateApply, MoShiroJsonContext.Default.UpdateOfferParams, (_, _, _) =>
{
_update.RequestApply();
_update?.RequestApply();
return ValueTask.CompletedTask;
});
}
@@ -277,7 +277,6 @@ public sealed class AgentApp : IDisposable
Server = new Uri(_config.Fleet.Server),
Identity = _identity.Identity,
ServerVerifier = _trust,
Attestation = _update.Attest,
NodeId = _identity.NodeId,
NodeVersion = Version,
Capabilities = [MoShiroCapabilities.Base, MoShiroCapabilities.KindsPrefix + string.Join(',', BlockCatalog.AgentKinds)],
@@ -311,7 +310,8 @@ public sealed class AgentApp : IDisposable
Platform = MoShiro.Measurement.PlatformDetector.Detect(),
TlsPublicKey = Convert.ToBase64String(_identity.Certificate.PublicKey.ExportSubjectPublicKeyInfo()),
ProbeTcpPort = ControlPort,
ProbeUdpPorts = [.. UdpPorts],
ProbeQuicPorts = [.. QuicPorts],
LegacyProbeUdpPorts = [.. QuicPorts],
LocalLimits = new LocalLimits
{
MaxTotalMbps = _config.Limits.MaxTotalMbps,
@@ -343,7 +343,7 @@ public sealed class AgentApp : IDisposable
DroppedInvalid = _counters.DroppedInvalid,
DroppedRateLimited = _counters.DroppedRateLimited,
QueueBytes = _outbox.PendingBytes,
Update = _update.ReadStatus(),
Update = _update?.Status(),
};
_lastIn = packetsIn;
_lastOut = packetsOut;
+9 -11
View File
@@ -1,4 +1,5 @@
using System.Text.Json.Serialization;
using MoShiro.Measurement;
namespace MoShiro.Agent;
@@ -28,16 +29,6 @@ public sealed class AgentConfig
public string? RestartAt { get; set; }
}
public sealed class ReportingSection
{
/// <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
{
[JsonPropertyName("server")]
@@ -60,8 +51,15 @@ public sealed class ListenSection
[JsonPropertyName("control_port")]
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")]
public List<int> UdpPorts { get; set; } = [7443];
public List<int>? LegacyUdpPorts { get; set; }
[JsonIgnore]
public IReadOnlyList<int> EffectiveQuicPorts => LegacyUdpPorts ?? QuicPorts;
[JsonPropertyName("tcp_ports")]
public List<int> TcpPorts { get; set; } = [7444];
@@ -44,7 +44,7 @@ public sealed class ProbeSession : DataSession
{
private readonly ConcurrentDictionary<uint, ReplayWindow> _windows = new();
private readonly ConcurrentDictionary<ulong, byte> _nonces = new();
private SocketAddress? _udpSource;
private SocketAddress? _datagramSource;
public ProbeSession(PeerSessionInfo info)
: base(info.Credentials.SessionIdValue, info.Credentials.Key)
@@ -71,11 +71,11 @@ public sealed class ProbeSession : DataSession
public RpcSession<PeerContext>? Rpc { get; set; }
/// <summary>最近一次认证通过的 UDP 包的源地址(Agent 把单向数据发往这里)。</summary>
public override IPEndPoint? UdpEndpoint
public override IPEndPoint? DatagramRemoteEndPoint
{
get
{
SocketAddress? source = Volatile.Read(ref _udpSource);
SocketAddress? source = Volatile.Read(ref _datagramSource);
return source is null ? null : (IPEndPoint)new IPEndPoint(source.Family == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any, 0).Create(source);
}
}
@@ -83,7 +83,7 @@ public sealed class ProbeSession : DataSession
/// <summary>记录 UDP 源地址;NAT 映射变化后地址变了返回 true(上报 endpoint_changed)。第一次看到地址不算变化。</summary>
public bool UpdateSource(SocketAddress source)
{
SocketAddress? previous = Volatile.Read(ref _udpSource);
SocketAddress? previous = Volatile.Read(ref _datagramSource);
if (previous is not null && previous.Equals(source))
{
return false;
@@ -96,7 +96,7 @@ public sealed class ProbeSession : DataSession
copy[i] = source[i];
}
Volatile.Write(ref _udpSource, copy);
Volatile.Write(ref _datagramSource, copy);
return previous is not null;
}
@@ -312,7 +312,7 @@ public sealed class AgentAdmission : IAdmission
/// <summary>NAT 映射变化,源地址变了:上报 <c>endpoint_changed</c>。</summary>
public Action<ProbeSession>? OnEndpointChanged { get; set; }
public Admission AdmitPacket(DataSession session, in UdpHeader header, SocketAddress source, long nowUs)
public Admission AdmitDatagram(DataSession session, in MeasurementDatagramHeader 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))
{
@@ -78,8 +78,9 @@ public sealed class PeerHub
{
private const int DedupeWindow = 1024;
/// <summary>超过这么久没有收到 Probe 的任何帧就认为会话已死(Probe 每 30 秒至少发一次时钟同步)。</summary>
private static readonly TimeSpan IdleLimit = TimeSpan.FromSeconds(75);
/// <summary>超过这么久没有收到 Probe 的任何帧就认为会话已死(Probe 每 30 秒至少发一次时钟同步)。
/// 要比线路可能的中断长得多:差的线路上几十秒甚至一两分钟的黑洞时有发生(TCP RTT 能到 30 秒),75 秒就关会话会把一次中断变成几分钟的数据空白(要重新握手,而握手在差线路上也慢)。</summary>
private static readonly TimeSpan IdleLimit = TimeSpan.FromSeconds(180);
private readonly RpcRouter<PeerContext> _router;
private readonly SessionRegistry _sessions;
@@ -213,8 +214,6 @@ public sealed class PeerHub
private static ConnectedProbe Describe(ProbeSession p) => new() { Id = new ProbeId(p.ProbeId), Version = p.Version, Remote = p.RemoteAddress, SinceUtcMs = p.ConnectedAtUtcMs, IpVersion = p.IpVersion };
public IReadOnlyList<string> ConnectedProbeIds() => [.. _sessions.All.Select(p => p.ProbeId)];
/// <summary>向一个 Probe 下发它的线路任务集(Probe 连上时由 <see cref="OnSessionAsync"/> 先发一份)。</summary>
public async ValueTask PushToAsync(ProbeSession probe, LineTaskSet set) => await PushAsync(probe, set, CancellationToken.None);
@@ -24,7 +24,7 @@ public sealed class TaskManager
private readonly StreamRegistry _streams;
private readonly BlockEnv _env;
private readonly TargetRunners _targets;
private readonly Func<IReadOnlyList<int>> _udpPorts;
private readonly Func<IReadOnlyList<int>> _quicPorts;
private readonly Func<IReadOnlyList<int>> _tcpPorts;
private readonly TrustAnchor _trust;
private readonly Dictionary<ProbeSession, Dictionary<string, LineEntry>> _sessionLines = [];
@@ -33,14 +33,14 @@ public sealed class TaskManager
private long _version;
private BudgetGuard? _guard;
public TaskManager(Func<BudgetGuard> guardFactory, StreamRegistry streams, BlockEnv env, TargetRunners targets, Func<IReadOnlyList<int>> udpPorts, Func<IReadOnlyList<int>> tcpPorts, TrustAnchor? trust = null)
public TaskManager(Func<BudgetGuard> guardFactory, StreamRegistry streams, BlockEnv env, TargetRunners targets, Func<IReadOnlyList<int>> quicPorts, Func<IReadOnlyList<int>> tcpPorts, TrustAnchor? trust = null)
{
_trust = trust ?? TrustAnchor.None;
_guardFactory = guardFactory;
_streams = streams;
_env = env;
_targets = targets;
_udpPorts = udpPorts;
_quicPorts = quicPorts;
_tcpPorts = tcpPorts;
}
@@ -229,7 +229,8 @@ public sealed class TaskManager
{
Version = _version,
Tasks = [.. entries.Values.Select(e => e.Task)],
UdpPorts = [.. _udpPorts()],
QuicPorts = [.. _quicPorts()],
LegacyUdpPorts = [.. _quicPorts()],
TcpPorts = [.. _tcpPorts()],
};
@@ -107,7 +107,7 @@ public sealed class TestRunner
// 按需测试的数据端口由 Agent 决定:Backend 给的端口如果不是本机监听的端口(模板里是出厂默认值,每个 Agent 可以改,
// 而 Backend 并不知道 TCP 数据端口),就用本机监听的第一个端口。块头里记录的是实际用的端口。
IReadOnlyList<int> ports = _ports(kind.Blocks.Transport == "udp" ? "udp" : "tcp");
IReadOnlyList<int> ports = _ports(kind.Blocks.Transport == "quic_datagram" ? "quic_datagram" : "tcp");
bool listening = phase.Params.TryGetValue("port", out System.Text.Json.JsonElement requested) && requested.TryGetInt32(out int port) && ports.Contains(port);
if (!listening)
{
+2 -2
View File
@@ -23,7 +23,7 @@
2. **安全默认值**:Fleet 永远不提供 exec、shell、文件读写、远程下发代码这类方法;节点只响应本地注册过的方法(本地策略 `allow_methods` 还能收窄);调用对方不支持的方法在本地直接返回 `fleet_unsupported`。
3. **权限分离**:注册(`fleet-enroll`,一次性)和常驻业务进程是两个进程;常驻进程不含注册代码,也不使用 `HttpClient`。
4. **NativeAOT**:节点侧只依赖 BCL,泛型扩展点通过产品传入的 `JsonTypeInfo<T>` 序列化,不使用反射。
5. **更新不在 Fleet 里**:节点进程没有自更新能力。容器节点的签名更新由宿主机上的更新器完成,见 [`docs/deployment.md`](../../docs/deployment.md);纯二进制节点的更新是待办,见 [`TODO.md`](../../TODO.md)。
5. **更新不在 Fleet 里**:节点进程没有自更新能力。Quadlet 装的节点由退出后 systemd 重新拉镜像、Backend 和脚本装的节点由宿主机上的更新器验签更新,见 [`docs/ops/deployment.md`](../../docs/ops/deployment.md);纯二进制节点的更新是待办,见 [`TODO.md`](../../TODO.md)。
## 身份
@@ -56,7 +56,7 @@
- JSON-RPC 2.0;方法名 `领域.动作`,`fleet.*` 保留给框架,产品用自己的前缀(MoShiro 是 `moshiro.*`)。
- 二进制帧 `u8 channel | 正文`:`0`–`15` 框架保留,产品从 `16` 开始。
- 错误:`error.data.code` 为 snake_case,框架的以 `fleet_` 开头,产品可以扩展。
- **方法必须声明**:每个方法在声明表(`MethodTable`)里写明发起方、请求还是通知、哪些角色可以参与;不存在双向方法,同一个方法名在一个平面里只有一个发起方。`RpcRouter` 在注册、分发和发送三处核对,违规回 `fleet_protocol_violation`,超过上限断开连接。详见 [`docs/security.md`](../../docs/security.md) 第 8 节。
- **方法必须声明**:每个方法在声明表(`MethodTable`)里写明发起方、请求还是通知、哪些角色可以参与;不存在双向方法,同一个方法名在一个平面里只有一个发起方。`RpcRouter` 在注册、分发和发送三处核对,违规回 `fleet_protocol_violation`,超过上限断开连接。详见 [`docs/security/security.md`](../../docs/security/security.md) 第 8 节。
- 与传输无关:同一套路由跑在 wss 上,也跑在 TLS 帧(`u32 长度 | u8 类型 | 正文`)上。
| 保留方法 | 方向 | 说明 |
@@ -1,43 +0,0 @@
using System.Security.Cryptography;
namespace Fleet.Core;
/// <summary>
/// 节点握手时自述的「我跑的是哪个二进制」:正在运行的可执行文件的 SHA-256,以及(容器节点)宿主机更新器验过的 CI 签名清单和签名。
/// 服务端在节点第一次握手成功的那一刻把哈希绑定到这个节点,之后哈希对不上就不让它继续运行。
/// </summary>
public sealed record BinaryAttestation(string Sha256, string? Manifest, string? Signature);
/// <summary>
/// 正在运行的二进制的哈希。Linux 上读 <c>/proc/self/exe</c>:它指向进程启动时映射的那个映像,磁盘上的文件之后被换掉也不影响,
/// 所以「运行期间被替换、重启后换了别的」的情况下一次握手就会对不上。算一次,之后缓存。
/// </summary>
public static class NodeBinary
{
private static readonly Lazy<string?> s_hash = new(Compute);
/// <summary>小写十六进制;读不到可执行文件时为 null(这样的节点不做绑定)。</summary>
public static string? Sha256Hex => s_hash.Value;
/// <summary>在后台先算好,免得第一次握手等它。</summary>
public static void Prime() => _ = Task.Run(() => s_hash.Value);
private static string? Compute()
{
try
{
string? path = OperatingSystem.IsLinux() && File.Exists("/proc/self/exe") ? "/proc/self/exe" : Environment.ProcessPath;
if (path is null)
{
return null;
}
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, 1 << 16);
return Convert.ToHexStringLower(SHA256.HashData(stream));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return null;
}
}
}
@@ -27,6 +27,4 @@ public static class FleetErrors
public static readonly ErrorCode JoinTokenExpired = new("fleet_join_token_expired");
public static readonly ErrorCode ProofInvalid = new("fleet_proof_invalid");
public static readonly ErrorCode FrameTooLarge = new("fleet_frame_too_large");
/// <summary>节点现在跑的二进制和它第一次握手时绑定的不一致,也不在 CI 签名的更新清单里。</summary>
public static readonly ErrorCode BinaryMismatch = new("fleet_binary_mismatch");
}
-12
View File
@@ -51,16 +51,4 @@ public static class VarInt
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;
}
}
+8 -1
View File
@@ -181,7 +181,7 @@ public static class ServiceShell
// 先取令牌、再取设置:中间被改动的话令牌会被取消,下一轮重新排
CancellationToken changed = restart.ChangeToken;
RestartSchedule? schedule = restart.Current;
using CancellationTokenSource wait = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, changed);
using CancellationTokenSource wait = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, changed, restart.NowToken);
try
{
if (schedule is null)
@@ -196,6 +196,13 @@ public static class ServiceShell
}
catch (OperationCanceledException)
{
if (restart.NowReason is { } reason)
{
log.Info("stopping", ("result", reason));
await cts.CancelAsync();
return;
}
continue; // 进程在退出,或者设置被改了:循环条件和下一轮会处理
}
@@ -8,7 +8,9 @@ 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)
{
@@ -41,6 +43,32 @@ public sealed class RestartController : IDisposable
}
}
/// <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)
{
@@ -68,6 +96,7 @@ public sealed class RestartController : IDisposable
lock (_gate)
{
_changed.Dispose();
_now.Dispose();
}
}
}
@@ -25,7 +25,7 @@ public sealed partial class TrustFileContext : JsonSerializerContext;
/// <summary>
/// 节点的信任根:安装命令里带来的创世指纹和属主,在注册时固定到本地,之后 Backend 改不了。
/// 操作员集合的新版本随期望状态下发,节点只接受从固定的创世出发、签名完整、版本更新的链。
/// 属主只是被固定和保存:节点现在没有 extension,等做 extension 时由节点本地强制(见 docs/access.md 第 8 节)。
/// 属主只是被固定和保存:节点现在没有 extension,等做 extension 时由节点本地强制(见 docs/security/access.md 第 9 节)。
/// </summary>
public sealed class TrustAnchor : IServerVerifier
{
@@ -20,7 +20,7 @@ public sealed record OperatorKey(string CredentialId, byte[] PublicKeySpki);
public sealed record OperatorSet(int Version, string Previous, string RpId, IReadOnlyList<string> Origins, IReadOnlyList<OperatorKey> Keys, IReadOnlyList<byte[]>? Servers = null);
/// <summary>
/// 信任根:操作员(passkey)→ 操作员集合 → 节点本地固定的属主。设计见 docs/access.md 第 7 节。
/// 信任根:操作员(passkey)→ 操作员集合 → 节点本地固定的属主。设计见 docs/security/access.md 第 8 节。
/// 安装命令里带着创世指纹和属主(passkey 的凭证 id):节点把它们固定在本地,之后只认从创世出发、由操作员 passkey 签过名的集合版本,
/// 属主必须是集合里的凭证。Backend 能转发集合,但签不出新的版本,也改不了节点已经固定的东西。
/// 全是纯函数,只用 BCL,节点和 Backend 共用。语句是 <c>key=value</c> 的文本,第一行是带版本的标题;签名覆盖整段文本。
@@ -7,7 +7,7 @@ namespace Fleet.Identity;
/// <summary>
/// 验证一次 WebAuthn 断言(<c>navigator.credentials.get</c> 的结果)。只支持 ES256(P-256 的 ECDSA),纯函数,只用 BCL:
/// 面板用它验证 passkey 登录,节点以后用它验证 passkey 签出的委托(docs/access.md 第 7 节)。
/// 面板用它验证 passkey 登录,节点验证操作员集合的签名也用它(docs/security/access.md 第 8 节)。
/// 不解析 CBOR——注册时的 attestation 解析只在 Backend 里。
/// </summary>
public static class WebAuthnAssertion
-15
View File
@@ -72,9 +72,6 @@ public sealed class FleetLinkOptions
/// 服务器必须在挑战里证明自己,之后这条链路用应用层加密;否则按旧的方式(没有固定信任根的节点)。
/// </summary>
public IServerVerifier? ServerVerifier { get; init; }
/// <summary>握手时自述正在运行的二进制(哈希和 CI 签名的清单);没有就不上报(旧行为)。</summary>
public Func<BinaryAttestation?>? Attestation { get; init; }
}
/// <summary>
@@ -226,16 +223,6 @@ public sealed class FleetLink
}
/// <summary>挑战 → 签名 → 认证结果。失败返回原因;服务端禁用该节点时不再重试。</summary>
private void Attest(AuthParams auth)
{
if (_options.Attestation?.Invoke() is { } attestation)
{
auth.Binary = attestation.Sha256;
auth.Release = attestation.Manifest;
auth.ReleaseSignature = attestation.Signature;
}
}
private async Task<string?> AuthenticateAsync(SecureTransport transport, CancellationToken ct)
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15), _time);
@@ -270,7 +257,6 @@ public sealed class FleetLink
Signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.AuthSecure, _options.NodeId.Value, nonce.Nonce, serverEphemeralText, nodeEphemeralText)),
NodeEphemeral = nodeEphemeralText,
};
Attest(authParams);
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));
@@ -278,7 +264,6 @@ public sealed class FleetLink
else
{
authParams = new AuthParams { NodeId = _options.NodeId.Value, Signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.Auth, _options.NodeId.Value, nonce.Nonce)) };
Attest(authParams);
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);
}
+15 -6
View File
@@ -29,7 +29,8 @@ public sealed class PeerListenerOptions
public int MaxSessions { get; init; } = 8;
public TimeSpan HandshakeTimeout { get; init; } = TimeSpan.FromSeconds(10);
/// <summary>敲门、TLS、挑战应答整个握手的上限。线路差的时候(RTT 好几秒、还丢包)握手本来就要很久,10 秒做不完就会一直连不上;敲门已经挡住了不认识的连接,放宽不会放大被占连接的风险。</summary>
public TimeSpan HandshakeTimeout { get; init; } = PeerTimings.Handshake;
}
/// <summary>
@@ -209,7 +210,8 @@ public sealed class PeerListener
}
/// <summary>发起点对点会话(Probe 一侧):按 SPKI 指纹固定对端证书,用身份私钥签名,派生同样的数据面凭据。</summary>
/// <summary>点对点会话的套接字:开启 TCP keepalive(空闲 15 秒后每 5 秒探测,连续 3 次无响应就断开,约 30 秒),主机丢失或路径被切断时连接不会一直挂着。</summary>
/// <summary>点对点会话的套接字:开启 TCP keepalive(空闲 30 秒后每 10 秒探测,连续 6 次无响应才断开,约 90 秒),主机真的没了的时候连接不会一直挂着;
/// 但线路短暂中断(几十秒的黑洞)不能把会话杀掉——线路中断本来就是要测的东西,会话得扛过去,恢复之后接着测。</summary>
internal static class PeerSockets
{
public static void EnableKeepAlive(Socket socket)
@@ -217,9 +219,9 @@ internal static class PeerSockets
try
{
socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true);
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, 15);
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, 5);
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, 3);
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, 30);
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, 10);
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, 6);
}
catch (Exception ex) when (ex is SocketException or ArgumentException or PlatformNotSupportedException)
{
@@ -228,6 +230,13 @@ 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 async Task<(PeerSessionInfo Info, TlsFrameTransport Transport)> ConnectAsync(
@@ -240,7 +249,7 @@ public static class PeerConnector
PeerSockets.EnableKeepAlive(client.Client);
try
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10), time);
using var timeout = new CancellationTokenSource(PeerTimings.Handshake, time);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
await client.ConnectAsync(host, port, linked.Token);
Stream stream = client.GetStream();
@@ -40,12 +40,6 @@ public static class FleetMethodTable
]);
}
/// <summary>二进制通道:0–15 保留给框架,产品从 16 开始。</summary>
public static class FleetChannels
{
public const byte FirstProductChannel = 16;
}
public sealed class ChallengeParams
{
[JsonPropertyName("nonce")]
@@ -75,17 +69,6 @@ public sealed class AuthParams
/// <summary>节点的临时 ECDH 公钥(SPKI,base64):有它就是安全链路,签名覆盖双方的临时公钥,认证结果起所有帧都是加密的。</summary>
[JsonPropertyName("node_eph")]
public string? NodeEphemeral { get; set; }
/// <summary>正在运行的二进制的 SHA-256(小写十六进制):服务端在第一次握手时绑定,之后核对。旧节点没有这个字段。</summary>
[JsonPropertyName("binary")]
public string? Binary { get; set; }
/// <summary>安装这个二进制时宿主机更新器验过的 CI 签名清单(原文)和签名(base64):服务端用 CI 公钥验过,才相信它列出的官方哈希。</summary>
[JsonPropertyName("release")]
public string? Release { get; set; }
[JsonPropertyName("release_sig")]
public string? ReleaseSignature { get; set; }
}
public sealed class AuthResult
@@ -19,21 +19,6 @@ public interface IServerIdentity
}
/// <summary>操作员集合的链(passkey 签的,创世在前)。没有初始化信任根时是空的。节点注册时用它确认「这个面板确实是我的操作员的」。</summary>
/// <summary>节点握手时自述的二进制(旧节点没有)。</summary>
public sealed record BinaryEvidence(string? Sha256, string? Manifest, string? Signature);
/// <summary>是否让这个节点继续:不放行时 <c>Reason</c> 会原样告诉节点(这是它自己的信息,不是秘密)。</summary>
public sealed record BinaryDecision(bool Accept, string Reason = "");
/// <summary>
/// 服务端对节点二进制的把关:节点第一次握手成功的那一刻绑定它的二进制哈希,之后哈希对不上、又不在 CI 签名的更新清单里,就不让它继续运行。
/// 没有注册实现时不做任何检查。
/// </summary>
public interface IBinaryAttestor
{
ValueTask<BinaryDecision> CheckAsync(NodeRecord node, BinaryEvidence evidence, CancellationToken ct);
}
public interface IOperatorTrustSource
{
ValueTask<IReadOnlyList<SignedStatementDto>> ChainAsync(CancellationToken ct);
@@ -24,11 +24,9 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier
IEnumerable<IInbox> inboxes,
IEnumerable<INodeEventSink> sinks,
FleetRouter router,
IServerIdentity? serverIdentity = null,
IBinaryAttestor? binaryAttestor = null)
IServerIdentity? serverIdentity = null)
{
ServerIdentity = serverIdentity;
BinaryAttestor = binaryAttestor;
Store = store;
Options = options;
Time = time;
@@ -45,9 +43,6 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier
/// <summary>服务器的身份密钥;没有时节点连接用旧的明文认证。</summary>
internal IServerIdentity? ServerIdentity { get; }
/// <summary>节点二进制的把关;没有时不检查。</summary>
internal IBinaryAttestor? BinaryAttestor { get; }
internal FleetServerOptions Options { get; }
internal TimeProvider Time { get; }
@@ -110,8 +110,7 @@ public static class FleetServerExtensions
provider.GetServices<IInbox>(),
provider.GetServices<INodeEventSink>(),
provider.GetRequiredService<FleetRouter>(),
provider.GetService<IServerIdentity>(),
provider.GetService<IBinaryAttestor>()));
provider.GetService<IServerIdentity>()));
services.AddSingleton<IFleetConnections>(provider => provider.GetRequiredService<FleetLinkHost>());
services.AddSingleton<IStateNotifier>(provider => provider.GetRequiredService<FleetLinkHost>());
return builder;
@@ -88,18 +88,6 @@ internal sealed partial class NodeConnection
return false;
}
// 二进制:第一次握手成功的这一刻绑定,之后哈希对不上又不在 CI 签名的更新清单里,就不让它继续运行
if (_host.BinaryAttestor is { } attestor)
{
BinaryDecision decision = await attestor.CheckAsync(node, new BinaryEvidence(auth!.Binary, auth.Release, auth.ReleaseSignature), ct);
if (!decision.Accept)
{
await SendAsync(RpcMessage.Failure(message.Id, new FleetError(FleetErrors.BinaryMismatch, decision.Reason)), ct);
await RejectAsync(null, "binary mismatch: " + decision.Reason, 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),
+576 -223
View File
@@ -815,7 +815,7 @@
"tags": [
"public"
],
"summary": "Latest host resource usage of every visible agent (no hardware model, no addresses)",
"summary": "Latest host resource usage of every visible agent and probe (no hardware model, no addresses)",
"operationId": "hosts_list",
"responses": {
"200": {
@@ -841,16 +841,16 @@
}
}
},
"/api/public/agents/{agent_id}/host": {
"/api/public/hosts/{node_id}": {
"get": {
"tags": [
"public"
],
"summary": "Host resource usage history of one agent (columnar, averaged per step)",
"summary": "Host resource usage history of one agent or probe (columnar, averaged per step)",
"operationId": "hosts_history",
"parameters": [
{
"name": "agent_id",
"name": "node_id",
"in": "path",
"required": true,
"schema": {
@@ -1369,118 +1369,6 @@
}
}
},
"/api/admin/releases": {
"post": {
"tags": [
"admin-build"
],
"summary": "Register a CI-signed release manifest; online nodes get it right away (their updaters verify the signature). Admin or release tokens",
"operationId": "releases_publish",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PublishReleaseRequest"
}
}
},
"required": true
},
"responses": {
"201": {
"description": "Created",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReleaseDto"
}
}
}
},
"400": {
"description": "Bad Request",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
},
"409": {
"description": "Conflict",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
}
},
"x-moshiro-capability": "release.publish"
},
"get": {
"tags": [
"admin-build"
],
"summary": "The latest signed release manifest of each component",
"operationId": "releases_list",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PageDtoOfReleaseDto"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
}
},
"x-moshiro-capability": "update.read"
}
},
"/api/admin/auth/logout": {
"post": {
"tags": [
@@ -4412,6 +4300,48 @@
"x-moshiro-capability": "update.read"
}
},
"/api/admin/releases": {
"get": {
"tags": [
"admin-build"
],
"summary": "The latest signed release manifest of each component",
"operationId": "releases_list",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PageDtoOfReleaseDto"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
}
},
"x-moshiro-capability": "update.read"
}
},
"/api/admin/nodes/{id}/update": {
"post": {
"tags": [
@@ -4472,56 +4402,6 @@
"x-moshiro-capability": "update.confirm"
}
},
"/api/admin/nodes/{id}/binary": {
"delete": {
"tags": [
"admin-build"
],
"summary": "Accept the node's current binary: clear its binding so the next handshake binds it again",
"operationId": "nodes_binary_clear",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"204": {
"description": "No Content",
"content": {
"application/json": {
"schema": { }
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
}
},
"x-moshiro-capability": "fleet.write"
}
},
"/api/admin/backend/update": {
"get": {
"tags": [
@@ -4667,6 +4547,148 @@
"x-moshiro-capability": "update.read"
}
},
"/api/admin/release-key": {
"get": {
"tags": [
"admin-release-key"
],
"summary": "The release keys this panel trusts: the built-in official key and the optional own key",
"operationId": "release_key_get",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReleaseKeysDto"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
}
},
"x-moshiro-capability": "update.read"
},
"put": {
"tags": [
"admin-release-key"
],
"summary": "Trust your own release public key (P-256 PEM; never the private key) in addition to the official one",
"operationId": "release_key_set",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SetReleaseKeyRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReleaseKeysDto"
}
}
}
},
"400": {
"description": "Bad Request",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
}
},
"x-moshiro-capability": "update.confirm"
},
"delete": {
"tags": [
"admin-release-key"
],
"summary": "Stop trusting your own release key; manifests only it signed are dropped",
"operationId": "release_key_clear",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReleaseKeysDto"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
}
},
"x-moshiro-capability": "update.confirm"
}
},
"/api/admin/links": {
"get": {
"tags": [
@@ -4944,6 +4966,74 @@
}
}
},
"/install/compose/{file}": {
"get": {
"tags": [
"ops"
],
"summary": "The static compose file of agent, probe or backend (agent.yaml, probe.yaml, backend.yaml; same bytes for everyone)",
"operationId": "install_compose",
"parameters": [
{
"name": "file",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "OK"
},
"404": {
"description": "Not Found",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
}
}
}
},
"/install/quadlet/{file}": {
"get": {
"tags": [
"ops"
],
"summary": "The static Podman Quadlet unit of agent or probe (agent.container, probe.container; same bytes for everyone)",
"operationId": "install_quadlet",
"parameters": [
{
"name": "file",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "OK"
},
"404": {
"description": "Not Found",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
}
}
}
},
"/install.ps1": {
"get": {
"tags": [
@@ -5084,6 +5174,7 @@
"public_host",
"probe_tcp_port",
"probe_udp_ports",
"probe_quic_ports",
"labels",
"public_visible",
"kinds",
@@ -5152,6 +5243,17 @@
"format": "int32"
}
},
"probe_quic_ports": {
"type": "array",
"items": {
"pattern": "^-?(?:0|[1-9]\\d*)$",
"type": [
"integer",
"string"
],
"format": "int32"
}
},
"labels": {
"type": "array",
"items": {
@@ -5774,6 +5876,37 @@
}
}
},
"ComposeInstallDto": {
"required": [
"env",
"url",
"sha256",
"linux",
"macos",
"windows"
],
"type": "object",
"properties": {
"env": {
"type": "string"
},
"url": {
"type": "string"
},
"sha256": {
"type": "string"
},
"linux": {
"type": "string"
},
"macos": {
"type": "string"
},
"windows": {
"type": "string"
}
}
},
"ConfirmBackendUpdateRequest": {
"required": [
"seq"
@@ -6007,7 +6140,9 @@
"required": [
"token",
"plaintext",
"install_commands"
"install_commands",
"compose",
"quadlet"
],
"type": "object",
"properties": {
@@ -6019,6 +6154,12 @@
},
"install_commands": {
"$ref": "#/components/schemas/InstallCommandsDto"
},
"compose": {
"$ref": "#/components/schemas/ComposeInstallDto"
},
"quadlet": {
"$ref": "#/components/schemas/QuadletInstallDto"
}
}
},
@@ -6493,7 +6634,8 @@
},
"HostItemDto": {
"required": [
"agent_id",
"id",
"kind",
"name",
"online",
"stale",
@@ -6502,7 +6644,10 @@
],
"type": "object",
"properties": {
"agent_id": {
"id": {
"type": "string"
},
"kind": {
"type": "string"
},
"name": {
@@ -6531,7 +6676,7 @@
},
"HostSeriesDto": {
"required": [
"agent_id",
"id",
"from",
"to",
"step",
@@ -6540,7 +6685,7 @@
],
"type": "object",
"properties": {
"agent_id": {
"id": {
"type": "string"
},
"from": {
@@ -6841,6 +6986,16 @@
"ipv6_available": {
"type": "boolean",
"default": false
},
"quic_health": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/QuicLineHealthDto"
}
]
}
}
},
@@ -7139,45 +7294,6 @@
}
}
},
"NodeBinaryDto": {
"required": [
"sha256",
"signed",
"seq",
"bound_at",
"violation",
"violation_at"
],
"type": "object",
"properties": {
"sha256": {
"type": "string"
},
"signed": {
"type": "boolean"
},
"seq": {
"pattern": "^-?(?:0|[1-9]\\d*)$",
"type": [
"integer",
"string"
],
"format": "int64"
},
"bound_at": {
"format": "date-time"
},
"violation": {
"type": [
"null",
"string"
]
},
"violation_at": {
"format": "date-time"
}
}
},
"NodeBuildDto": {
"required": [
"id",
@@ -7250,16 +7366,6 @@
}
]
},
"binary": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/NodeBinaryDto"
}
]
},
"owners": {
"type": [
"null",
@@ -7337,6 +7443,10 @@
"null",
"string"
]
},
"isp_manual": {
"type": "boolean",
"default": false
}
}
},
@@ -7513,6 +7623,16 @@
"ip_version": {
"type": "string",
"default": "ipv4"
},
"quic_health": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/QuicLineHealthDto"
}
]
}
}
},
@@ -8164,6 +8284,12 @@
"null",
"string"
]
},
"isp": {
"type": [
"null",
"string"
]
}
}
},
@@ -8464,6 +8590,179 @@
}
}
},
"QuadletInstallDto": {
"required": [
"env",
"url",
"sha256",
"command"
],
"type": "object",
"properties": {
"env": {
"type": "string"
},
"url": {
"type": "string"
},
"sha256": {
"type": "string"
},
"command": {
"type": "string"
}
}
},
"QuicLineHealthDto": {
"required": [
"connected",
"handshake_attempts",
"handshake_successes",
"handshake_failures",
"handshake_timeouts",
"handshake_success_rate",
"handshake_failure_rate",
"handshake_timeout_rate",
"handshake_p95_ms",
"handshake_sample_count",
"transport_packet_loss_rate",
"transport_packets_sent",
"transport_congestion_events",
"transport_ecn_congestion_events",
"path_mtu",
"at"
],
"type": "object",
"properties": {
"connected": {
"type": "boolean"
},
"handshake_attempts": {
"pattern": "^-?(?:0|[1-9]\\d*)$",
"type": [
"integer",
"string"
],
"format": "int64"
},
"handshake_successes": {
"pattern": "^-?(?:0|[1-9]\\d*)$",
"type": [
"integer",
"string"
],
"format": "int64"
},
"handshake_failures": {
"pattern": "^-?(?:0|[1-9]\\d*)$",
"type": [
"integer",
"string"
],
"format": "int64"
},
"handshake_timeouts": {
"pattern": "^-?(?:0|[1-9]\\d*)$",
"type": [
"integer",
"string"
],
"format": "int64"
},
"handshake_success_rate": {
"pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$",
"type": [
"null",
"number",
"string"
],
"format": "double"
},
"handshake_failure_rate": {
"pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$",
"type": [
"null",
"number",
"string"
],
"format": "double"
},
"handshake_timeout_rate": {
"pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$",
"type": [
"null",
"number",
"string"
],
"format": "double"
},
"handshake_p95_ms": {
"pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$",
"type": [
"null",
"number",
"string"
],
"format": "double"
},
"handshake_sample_count": {
"pattern": "^-?(?:0|[1-9]\\d*)$",
"type": [
"integer",
"string"
],
"format": "int64"
},
"transport_packet_loss_rate": {
"pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$",
"type": [
"null",
"number",
"string"
],
"format": "double"
},
"transport_packets_sent": {
"pattern": "^-?(?:0|[1-9]\\d*)$",
"type": [
"null",
"integer",
"string"
],
"format": "uint64"
},
"transport_congestion_events": {
"pattern": "^-?(?:0|[1-9]\\d*)$",
"type": [
"null",
"integer",
"string"
],
"format": "uint32"
},
"transport_ecn_congestion_events": {
"pattern": "^-?(?:0|[1-9]\\d*)$",
"type": [
"null",
"integer",
"string"
],
"format": "uint32"
},
"path_mtu": {
"pattern": "^-?(?:0|[1-9]\\d*)$",
"type": [
"null",
"integer",
"string"
],
"format": "uint16"
},
"at": {
"format": "date-time"
}
}
},
"RawChunkMetaDto": {
"required": [
"chunk_id",
@@ -8674,6 +8973,43 @@
}
}
},
"ReleaseKeyDto": {
"required": [
"fingerprint",
"public_key"
],
"type": "object",
"properties": {
"fingerprint": {
"type": "string"
},
"public_key": {
"type": "string"
}
}
},
"ReleaseKeysDto": {
"required": [
"official",
"custom"
],
"type": "object",
"properties": {
"official": {
"$ref": "#/components/schemas/ReleaseKeyDto"
},
"custom": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/ReleaseKeyDto"
}
]
}
}
},
"ScoreDto": {
"required": [
"score",
@@ -8836,6 +9172,20 @@
}
}
},
"SetReleaseKeyRequest": {
"required": [
"public_key"
],
"type": "object",
"properties": {
"public_key": {
"type": [
"null",
"string"
]
}
}
},
"SettingDto": {
"required": [
"key",
@@ -9442,9 +9792,6 @@
{
"name": "admin-auth"
},
{
"name": "admin-build"
},
{
"name": "admin-agents"
},
@@ -9478,6 +9825,12 @@
{
"name": "admin-users"
},
{
"name": "admin-build"
},
{
"name": "admin-release-key"
},
{
"name": "admin-links"
},
+4 -4
View File
@@ -13,7 +13,7 @@ Backend ──任务──> Agent <──测量会话──> Probe
Probe 用 `probe.json` 读取面板地址(`fleet.server`)、身份目录(`fleet.identity_dir`,由 `fleet-enroll` 注册时生成)和本地测试限制。连上面板之后,面板下发要连接的 Agent 列表;Probe 将列表缓存到同目录的 `agents.json`,面板暂时连不上时靠它继续测量。身份目录里有私钥,请限制访问权限,不要提交到版本控制。
容器节点的运行方式(podman + systemd)和宿主机更新器见 [`../../docs/deployment.md`](../../docs/deployment.md);Probe 也可以用 OCI 镜像 `registry.oeo.one/moshiro/moshiro-probe:<version>`,容器默认读取 `/etc/moshiro/probe.json`,命名 volume 保存身份配置和缓存,镜像以 UID/GID 1654 运行。
容器节点的运行方式(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 运行。
## 构建
@@ -30,7 +30,7 @@ dotnet build probe/src/MoShiro.Probe -c Release
dotnet publish probe/src/MoShiro.Probe -c Release -r <RID> -p:PublishAot=true
```
也可在仓库根目录运行 `bash scripts/publish.sh probe <RID>`,再使用 `scripts/package.sh probe <RID>` 生成部署包。支持平台和安装流程见 [`../../docs/deployment.md`](../../docs/deployment.md)。
也可在仓库根目录运行 `bash scripts/publish.sh probe <RID>`,再使用 `scripts/package.sh probe <RID>` 生成部署包。支持平台和安装流程见 [`../../docs/ops/deployment.md`](../../docs/ops/deployment.md)。
## 配置与运行
@@ -54,6 +54,6 @@ moshiro-probe --config /etc/moshiro/probe.json
- Probe 只执行编译进程序的测量模块。它不落盘测量数据,也不能执行命令或自行更新(更新由宿主机上的更新器完成,Probe 只写清单和请求文件)。
- 测量模块由 `backend/shared/MoShiro.Measurement/` 与 Agent 共用;Probe 负责发起连接、调度任务和记录本端数据。
- 测量模型和协议细节见 [`../../docs/measurement.md`](../../docs/measurement.md) 与 [`../../docs/protocol.md`](../../docs/protocol.md);整体架构见 [`../../docs/architecture.md`](../../docs/architecture.md)。
- 测量模型和协议细节见 [`../../docs/design/measurement.md`](../../docs/design/measurement.md) 与 [`../../docs/design/protocol.md`](../../docs/design/protocol.md);整体架构见 [`../../docs/design/architecture.md`](../../docs/design/architecture.md)。
Docker 与 rootless/rootful Podman 共用 `packaging/compose/probe/compose.yaml`,配置、身份和缓存放在命名 volume 中,无需宿主配置目录、keep-id 或手动 chown。首次导入和自动更新见部署文档。
Podman 系用 Quadlet(`packaging/quadlet/moshiro-probe.container`,默认自动更新),Docker 用 `packaging/compose/probe/compose.yaml`(手动更新);配置、身份和缓存放在命名 volume 中,无需宿主配置目录、keep-id 或手动 chown。首次导入和自动更新见部署文档。
@@ -30,6 +30,14 @@ public sealed class AgentConnection
private readonly ConnectionEnv _env;
private volatile ConnectionSession? _session;
private int _previousQuicSourcePort;
private QuicDatagramTransport? _activeDatagrams;
private readonly object _handshakeGate = new();
private readonly Queue<double> _handshakeSamplesMs = new();
private long _handshakeAttempts;
private long _handshakeSuccesses;
private long _handshakeFailures;
private long _handshakeTimeouts;
public AgentConnection(KnownAgent endpoint, ConnectionEnv env)
{
@@ -45,9 +53,42 @@ public sealed class AgentConnection
public long ClockOffsetUs => _session?.Clock.OffsetUs ?? 0;
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,
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,
};
}
}
public async Task RunAsync(CancellationToken ct)
{
var backoff = new Backoff(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(20));
// 重连的间隔别拉太长:每次尝试本来就要等好几秒(握手上限 30 秒),线路一恢复要尽快接上;带抖动,不会和别的节点撞在一起
var backoff = new Backoff(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(8));
while (!ct.IsCancellationRequested)
{
try
@@ -92,10 +133,22 @@ public sealed class AgentConnection
byte[] agentKey = Convert.FromBase64String(Endpoint.PublicKey);
(PeerSessionInfo info, TlsFrameTransport transport) = await PeerConnector.ConnectAsync(
address.ToString(), Endpoint.TcpPort, agentKey, _env.Identity, _env.ProbeId, ProbeApp.Version, 8 << 20, _env.Time, ct);
var remote = (IPEndPoint)info.Remote!;
var data = new DataSession(info.Credentials.SessionIdValue, info.Credentials.Key);
using var udp = new UdpMux(new SingleSession(data), null, _env.Clock, new DataPlaneCounters(), _env.Log, remote.AddressFamily == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any, []);
udp.Bind();
var remote = (IPEndPoint)info.Remote!;
IReadOnlyList<int> quicPorts = Endpoint.EffectiveQuicPorts;
int quicPort = quicPorts.Count == 0 ? 7443 : quicPorts[0];
using var datagrams = new QuicDatagramTransport(
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 clock = new ClockEstimator();
var shipper = new FragmentShipper(outbox, new NodeId(Endpoint.AgentId), new ProbeId(_env.ProbeId), clock, _env.Time);
@@ -104,7 +157,7 @@ public sealed class AgentConnection
Clock = _env.Clock,
Log = _env.Log,
Session = () => data,
Udp = udp,
Datagrams = datagrams,
PeerAddress = remote.Address,
PeerSpkiSha256 = NodeIdentity.Fingerprint(agentKey),
KnockKey = Knock.DeriveKey(_env.Identity, agentKey),
@@ -116,14 +169,52 @@ public sealed class AgentConnection
var rpc = new RpcSession<ConnectionSession>(transport, s_router, session, _env.Time);
rpc.OnBinary = (_, _) => ValueTask.CompletedTask;
using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
Task run = rpc.RunAsync(cts.Token);
Task receive = udp.RunAsync(cts.Token);
Task receive = datagrams.RunAsync(cts.Token);
Task pump = PumpAsync(rpc, outbox, cts.Token);
try
{
// 先同步时钟(块头里要带偏移);Agent 随后推送线路任务集
await SyncClockAsync(rpc, clock, rounds: 5, cts.Token);
lock (_handshakeGate)
{
_handshakeAttempts++;
}
try
{
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();
_session = session;
_env.Log.Info("agent session up", ("agent", Endpoint.AgentId), ("offset_us", clock.OffsetUs));
@@ -136,6 +227,7 @@ public sealed class AgentConnection
finally
{
_session = null;
Volatile.Write(ref _activeDatagrams, null);
await cts.CancelAsync();
await session.StopAllAsync();
await Quiet(run);
@@ -73,14 +73,15 @@ public sealed class AgentDirectory
_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, PublicKey = agent.PublicKey });
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, PublicKey = agent.PublicKey, IpVersion = IpVersions.V6 });
next.Add(new KnownAgent { AgentId = agent.AgentId.Value, Host = agent.Ipv6Host, TcpPort = agent.TcpPort, QuicPorts = [.. ports], PublicKey = agent.PublicKey, IpVersion = IpVersions.V6 });
}
}
lock (_gate)
@@ -111,5 +112,5 @@ public sealed class AgentDirectory
a.Count == b.Count && a.Zip(b).All(p => Equal(p.First, p.Second));
public static bool Equal(KnownAgent a, KnownAgent b) =>
a.AgentId == b.AgentId && a.IpVersion == b.IpVersion && a.Host == b.Host && a.TcpPort == b.TcpPort && a.PublicKey == b.PublicKey;
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;
}
+53 -7
View File
@@ -22,28 +22,30 @@ public sealed class ProbeApp : IDisposable
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();
var update = new UpdateBridge(configDirectory, new ConfigStore(configDirectory).Write, log);
NodeBinary.Prime(); // 正在运行的二进制的哈希:先在后台算好,握手时用
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,
Attestation = update.Attest,
NodeId = identity.NodeId,
NodeVersion = Version,
Capabilities = [MoShiroCapabilities.Base],
HeartbeatExtra = () => JsonSerializer.SerializeToElement(new ProbeHeartbeatExtra { Update = update.ReadStatus(), Ipv6 = Ipv6Diagnostics.Sample(null, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")) }, MoShiroJsonContext.Default.ProbeHeartbeatExtra),
HeartbeatExtra = () => JsonSerializer.SerializeToElement(new ProbeHeartbeatExtra { Update = update?.Status(), 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>(
@@ -57,12 +59,12 @@ public sealed class ProbeApp : IDisposable
var router = new RpcRouter<LinkContext>(RpcSide.Node, MoShiroMethodTable.Table, NodeRoles.Probe)
.OnNotify(MoShiroMethods.UpdateOffer, MoShiroJsonContext.Default.UpdateOfferParams, (_, offer, _) =>
{
update.WriteOffer(offer);
update?.Offer(offer);
return ValueTask.CompletedTask;
})
.OnNotify(MoShiroMethods.UpdateApply, MoShiroJsonContext.Default.UpdateOfferParams, (_, _, _) =>
{
update.RequestApply();
update?.RequestApply();
return ValueTask.CompletedTask;
})
.OnNotify(MoShiroMethods.Schedule, MoShiroJsonContext.Default.ScheduleParams, (_, schedule, _) =>
@@ -99,14 +101,24 @@ public sealed class ProbeApp : IDisposable
}
}
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)
@@ -122,7 +134,41 @@ public sealed class ProbeApp : IDisposable
finally
{
await ReconcileAsync([], CancellationToken.None);
await Task.WhenAny(link, Task.Delay(TimeSpan.FromSeconds(5), 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)
{
// 停止
}
}
@@ -1,4 +1,5 @@
using System.Text.Json.Serialization;
using MoShiro.Measurement;
using MoShiro.Protocol;
namespace MoShiro.Probe;
@@ -18,6 +19,10 @@ public sealed class ProbeFile
[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
@@ -52,6 +57,15 @@ public sealed class KnownAgent
[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; } = "";
@@ -26,7 +26,7 @@ public static class BlockCatalog
{
public static readonly IReadOnlyList<BlockPair> All =
[
new("udp_echo", (e, s) => new UdpEchoDialer(e, s), (e, s) => new UdpEchoListener(e, s)),
new("udp_echo", (e, s) => new DatagramEchoDialer(e, s), (e, s) => new DatagramEchoListener(e, s)),
new("tcp_echo", (e, s) => new TcpEchoDialer(e, s), (e, s) => new TcpEchoListener(e, s)),
new("udp_train", (e, s) => new TrainDialer(e, s), (e, s) => new TrainListener(e, s), OnDemandOnly: true, Directions: ["rev"]),
new("tcp_bulk", (e, s) => new TcpBulkDialer(e, s), (e, s) => new TcpBulkListener(e, s), OnDemandOnly: true, Directions: ["fwd", "rev"]),
@@ -11,7 +11,7 @@ namespace MoShiro.Measurement;
internal sealed class EchoRows
{
/// <summary>UDP:没有在这段时间内回来的包就是丢了。</summary>
public const long UdpGraceUs = 2_000_000;
public const long DatagramGraceUs = 2_000_000;
/// <summary>
/// TCP 不会丢包,只会延迟(重传、队头阻塞):一次几秒的中断之后,积压的回显会陆续回来,RTT 就是那几秒。
@@ -65,26 +65,27 @@ internal sealed class EchoRows
/// <summary>
/// UDP 回显,拨号方(Probe):按声明的速率发包,t1 在发出时记录,回复到达时回填 t4。回复丢失的行 t4 缺失(Agent 一侧再补 t2/t3)。
/// </summary>
public sealed class UdpEchoDialer : BlockBase
public sealed class DatagramEchoDialer : BlockBase
{
private readonly EchoRows _rows;
public UdpEchoDialer(BlockEnv env, BlockSpec spec)
public DatagramEchoDialer(BlockEnv env, BlockSpec spec)
: base(env, spec)
{
_rows = new EchoRows(spec.Kind, 16384, EchoRows.UdpGraceUs, settleOnReply: false);
_rows = new EchoRows(spec.Kind, 16384, EchoRows.DatagramGraceUs, settleOnReply: false);
}
public override long Produced => _rows.Count;
public override async Task RunAsync(CancellationToken ct)
{
UdpMux udp = Env.Udp!;
QuicDatagramTransport datagrams = Env.Datagrams!;
DataSession session = Env.Session() ?? throw new BlockFaultException("no data session");
await datagrams.WaitUntilConnectedAsync(ct);
IPEndPoint target = PeerEndpoint(Spec.Port);
int size = (int)Spec.Int("size");
byte[] packet = new byte[size];
udp.Register(Spec.StreamId, OnPacket);
datagrams.Register(Spec.StreamId, OnPacket);
try
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1.0 / Spec.Int("rate_hz")), Env.Time);
@@ -92,8 +93,8 @@ public sealed class UdpEchoDialer : BlockBase
{
long t1 = Env.Clock.NowUs();
uint seq = _rows.Add(size, t1);
UdpPacket.Write(packet, new UdpHeader(UdpPacketType.EchoRequest, session.SessionId, Spec.StreamId, seq, t1), session.Key);
udp.Send(target, packet);
MeasurementDatagram.Write(packet, new MeasurementDatagramHeader(MeasurementDatagramType.EchoRequest, session.SessionId, Spec.StreamId, seq, t1), session.Key);
datagrams.Send(session, target, packet);
}
}
catch (OperationCanceledException)
@@ -102,7 +103,7 @@ public sealed class UdpEchoDialer : BlockBase
}
finally
{
udp.Unregister(Spec.StreamId);
datagrams.Unregister(Spec.StreamId);
}
}
@@ -118,9 +119,9 @@ public sealed class UdpEchoDialer : BlockBase
return 1;
}
private void OnPacket(in UdpDelivery delivery)
private void OnPacket(in QuicDatagramDelivery delivery)
{
if (delivery.Header.Type == UdpPacketType.EchoReply)
if (delivery.Header.Type == MeasurementDatagramType.EchoReply)
{
_rows.Reply(delivery.Header.Seq, delivery.ReceiveUs);
}
@@ -128,11 +129,11 @@ public sealed class UdpEchoDialer : BlockBase
}
/// <summary>UDP 回显,监听方(Agent):校验后立即回复一个等长的包,记录 t2(到达)和 t3(回复发出),按序号留给合并。</summary>
public sealed class UdpEchoListener : BlockBase
public sealed class DatagramEchoListener : BlockBase
{
private readonly RowStore _records;
public UdpEchoListener(BlockEnv env, BlockSpec spec)
public DatagramEchoListener(BlockEnv env, BlockSpec spec)
: base(env, spec)
{
_records = new RowStore(spec.Kind.FindStream("msg")!, Side.Agent, 32768);
@@ -142,26 +143,26 @@ public sealed class UdpEchoListener : BlockBase
public override async Task RunAsync(CancellationToken ct)
{
Env.Udp!.Register(Spec.StreamId, OnPacket);
Env.Datagrams!.Register(Spec.StreamId, OnPacket);
try
{
await WaitUntilCancelledAsync(ct);
}
finally
{
Env.Udp.Unregister(Spec.StreamId);
Env.Datagrams.Unregister(Spec.StreamId);
}
}
private void OnPacket(in UdpDelivery delivery)
private void OnPacket(in QuicDatagramDelivery delivery)
{
if (delivery.Header.Type != UdpPacketType.EchoRequest)
if (delivery.Header.Type != MeasurementDatagramType.EchoRequest)
{
return; // Data:探活包,只用来保持 NAT 映射和让准入策略学到源地址
}
long t2 = delivery.ReceiveUs;
delivery.ReplyAs(UdpPacketType.EchoReply);
delivery.ReplyAs(MeasurementDatagramType.EchoReply);
Span<long> row = [t2, Env.Clock.NowUs()];
_records.SetRow(delivery.Header.Seq, row);
}
@@ -30,11 +30,11 @@ public sealed class TrainListener : BlockBase
public override async Task RunAsync(CancellationToken ct)
{
// Probe 开始前发来的探活包(Data)要被接受,准入策略才能据此学到它当前的源地址(NAT 映射);它们本身没有内容
Env.Udp!.Register(Spec.StreamId, static (in UdpDelivery _) => { });
Env.Datagrams!.Register(Spec.StreamId, static (in QuicDatagramDelivery _) => { });
try
{
DataSession session = await WaitForPeerAsync(ct);
IPEndPoint target = session.UdpEndpoint!;
IPEndPoint target = session.DatagramRemoteEndPoint!;
TimeSpan duration = TimeSpan.FromSeconds(Spec.Int("duration_s"));
var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var thread = new Thread(() =>
@@ -58,7 +58,7 @@ public sealed class TrainListener : BlockBase
}
finally
{
Env.Udp.Unregister(Spec.StreamId);
Env.Datagrams.Unregister(Spec.StreamId);
}
}
@@ -68,7 +68,7 @@ public sealed class TrainListener : BlockBase
while (true)
{
DataSession? session = Env.Session();
if (session?.UdpEndpoint is not null)
if (session?.DatagramRemoteEndPoint is not null)
{
return session;
}
@@ -120,8 +120,8 @@ public sealed class TrainListener : BlockBase
for (; seq < due; seq++)
{
long sendUs = Env.Clock.NowUs();
UdpPacket.Write(packet, new UdpHeader(UdpPacketType.Data, session.SessionId, Spec.StreamId, seq, sendUs), session.Key);
Env.Udp!.Send(target, packet);
MeasurementDatagram.Write(packet, new MeasurementDatagramHeader(MeasurementDatagramType.Data, session.SessionId, Spec.StreamId, seq, sendUs), session.Key);
Env.Datagrams!.Send(session, target, packet);
Span<long> row = [size, sendUs];
_records.SetRow(seq, row);
Interlocked.Exchange(ref _sent, seq + 1L);
@@ -159,18 +159,19 @@ public sealed class TrainDialer : BlockBase
public override async Task RunAsync(CancellationToken ct)
{
UdpMux udp = Env.Udp!;
QuicDatagramTransport datagrams = Env.Datagrams!;
DataSession session = Env.Session() ?? throw new BlockFaultException("no data session");
await datagrams.WaitUntilConnectedAsync(ct);
_startUs = Env.Clock.NowUs();
udp.Register(Spec.StreamId, OnPacket);
datagrams.Register(Spec.StreamId, OnPacket);
try
{
byte[] hello = new byte[UdpPacket.MinBytes + 20];
byte[] hello = new byte[MeasurementDatagram.MinBytes + 20];
IPEndPoint target = PeerEndpoint(Spec.Port);
for (uint seq = 0; seq < 3; seq++)
{
UdpPacket.Write(hello, new UdpHeader(UdpPacketType.Data, session.SessionId, Spec.StreamId, seq, Env.Clock.NowUs()), session.Key);
udp.Send(target, hello);
MeasurementDatagram.Write(hello, new MeasurementDatagramHeader(MeasurementDatagramType.Data, session.SessionId, Spec.StreamId, seq, Env.Clock.NowUs()), session.Key);
datagrams.Send(session, target, hello);
}
await WaitUntilCancelledAsync(ct);
@@ -178,7 +179,7 @@ public sealed class TrainDialer : BlockBase
finally
{
_endUs = Env.Clock.NowUs();
udp.Unregister(Spec.StreamId);
datagrams.Unregister(Spec.StreamId);
}
}
@@ -228,10 +229,10 @@ public sealed class TrainDialer : BlockBase
private static System.Text.Json.JsonElement Number(long value) =>
System.Text.Json.JsonDocument.Parse(value.ToString(System.Globalization.CultureInfo.InvariantCulture)).RootElement.Clone();
private void OnPacket(in UdpDelivery delivery)
private void OnPacket(in QuicDatagramDelivery delivery)
{
long seq = delivery.Header.Seq;
if (delivery.Header.Type == UdpPacketType.Data && seq < _maxSeq && !_received.IsSet(seq, _tRecv))
if (delivery.Header.Type == MeasurementDatagramType.Data && seq < _maxSeq && !_received.IsSet(seq, _tRecv))
{
_received.Set(seq, _tRecv, delivery.ReceiveUs); // 只记第一次到达(重复的包不覆盖)
}
@@ -11,6 +11,8 @@ namespace MoShiro.Measurement;
/// </summary>
public class DataSession
{
private IDatagramConnection? _datagramConnection;
public DataSession(long sessionId, byte[] key)
{
SessionId = sessionId;
@@ -21,13 +23,26 @@ public class DataSession
public byte[] Key { get; }
public IDatagramConnection? DatagramConnection => Volatile.Read(ref _datagramConnection);
public void BindDatagramConnection(IDatagramConnection connection) => Volatile.Write(ref _datagramConnection, connection);
/// <summary>对端最近一次通过认证的 UDP 源地址(监听方把单向数据发往这里;拨号方不需要)。</summary>
public virtual IPEndPoint? UdpEndpoint => null;
public virtual IPEndPoint? DatagramRemoteEndPoint => null;
/// <summary>TCP 首帧的随机数只能用一次。拨号方不接受连接,所以默认放行。</summary>
public virtual bool UseNonce(ReadOnlySpan<byte> nonce) => true;
}
/// <summary>Unreliable QUIC datagram sender bound to one authenticated measurement session.</summary>
public interface IDatagramConnection
{
bool Send(ReadOnlySpan<byte> datagram);
}
/// <summary>Transport-layer QUIC counters. These are separate from application Datagram delivery/loss.</summary>
public sealed record QuicTransportSnapshot(double? PacketLossRate, ulong PacketsSent, uint CongestionEvents, uint EcnCongestionEvents, ushort PathMtu);
public interface IDataSessions
{
DataSession? Find(long sessionId);
@@ -60,7 +75,7 @@ public enum Admission
public interface IAdmission
{
/// <summary>UDP 包:<paramref name="source"/> 是认证通过的源地址(实现可以据此学习 NAT 重绑定)。</summary>
Admission AdmitPacket(DataSession session, in UdpHeader header, SocketAddress source, long nowUs);
Admission AdmitDatagram(DataSession session, in MeasurementDatagramHeader header, SocketAddress source, long nowUs);
/// <summary>TCP 回显的一条消息(只限速)。</summary>
bool AdmitMessage(DataSession session, uint streamId, long nowUs);
@@ -201,7 +216,7 @@ public sealed class BlockEnv
/// <summary>当前的数据面会话(拨号方固定;监听方是 Agent 当前的 Probe 会话,可能没有)。</summary>
public required Func<DataSession?> Session { get; init; }
public UdpMux? Udp { get; init; }
public QuicDatagramTransport? Datagrams { get; init; }
/// <summary>监听方的准入策略(限速等);拨号方为 null。</summary>
public IAdmission? Admission { get; init; }
@@ -238,7 +253,7 @@ public sealed class BlockEnv
Clock = Clock,
Log = Log,
Session = () => session,
Udp = Udp,
Datagrams = Datagrams,
Admission = Admission,
Counters = Counters,
TcpListen = TcpListen,
@@ -17,7 +17,7 @@ public sealed class NodeLimits
public int MaxConcurrentTests { get; init; } = 1;
/// <summary>任务参数里的 <c>port</c> 只能取这些端口(本机配置的 listen 列表)。</summary>
public IReadOnlyList<int> AllowedUdpPorts { get; init; } = [7443];
public IReadOnlyList<int> AllowedQuicPorts { get; init; } = [7443];
public IReadOnlyList<int> AllowedTcpPorts { get; init; } = [7444];
}
@@ -143,7 +143,7 @@ public sealed class BudgetGuard
}
int port = (int)values.GetInt("port");
return kind.Blocks.Transport == "udp" ? _limits.AllowedUdpPorts.Contains(port) : _limits.AllowedTcpPorts.Contains(port);
return kind.Blocks.Transport == "quic_datagram" ? _limits.AllowedQuicPorts.Contains(port) : _limits.AllowedTcpPorts.Contains(port);
}
private static long EstimatePps(string kindId, ParamValues values) => kindId switch
@@ -1,6 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsAotCompatible>true</IsAotCompatible>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="MoShiro.Measurement.Tests" />
@@ -0,0 +1,110 @@
using System.Collections.Concurrent;
using System.Net;
using System.Net.Sockets;
using MoShiro.Protocol;
namespace MoShiro.Measurement;
/// <summary>An authenticated, per-stream QUIC Datagram delivered to a measurement block.</summary>
public readonly ref struct QuicDatagramDelivery
{
private readonly Span<byte> _payload;
private readonly MsQuicConnection _connection;
private readonly DataPlaneCounters _counters;
internal QuicDatagramDelivery(MeasurementDatagramHeader header, Span<byte> payload, long receiveUs, DataSession session, MsQuicConnection connection, DataPlaneCounters counters)
{
Header = header;
_payload = payload;
ReceiveUs = receiveUs;
Session = session;
_connection = connection;
_counters = counters;
}
public MeasurementDatagramHeader Header { get; }
public ReadOnlySpan<byte> Payload => _payload;
public long ReceiveUs { get; }
public DataSession Session { get; }
public void ReplyAs(MeasurementDatagramType type)
{
_payload[3] = (byte)type;
MeasurementDatagram.Seal(_payload, Session.Key);
if (_connection.Send(_payload))
{
_counters.PacketOut();
}
}
}
public delegate void QuicDatagramHandler(in QuicDatagramDelivery delivery);
/// <summary>Authenticates datagrams, applies Agent admission policy, and dispatches by stream.</summary>
internal sealed class MeasurementDatagramRouter
{
private readonly IDataSessions _sessions;
private readonly IAdmission? _admission;
private readonly MonoClock _clock;
private readonly ConcurrentDictionary<uint, QuicDatagramHandler> _handlers = new();
public MeasurementDatagramRouter(IDataSessions sessions, IAdmission? admission, MonoClock clock, DataPlaneCounters counters)
{
_sessions = sessions;
_admission = admission;
_clock = clock;
Counters = counters;
}
public DataPlaneCounters Counters { get; }
public void Register(uint streamId, QuicDatagramHandler handler) => _handlers[streamId] = handler;
public void Unregister(uint streamId) => _handlers.TryRemove(streamId, out _);
public void Dispatch(MsQuicConnection connection, ReadOnlySpan<byte> datagram)
{
Counters.PacketIn();
Span<byte> payload = stackalloc byte[MeasurementDatagram.MaxBytes];
if (datagram.Length > payload.Length)
{
Counters.DropInvalid();
return;
}
datagram.CopyTo(payload);
payload = payload[..datagram.Length];
if (!MeasurementDatagram.TryParseHeader(payload, out MeasurementDatagramHeader header)
|| !_handlers.TryGetValue(header.StreamId, out QuicDatagramHandler? handler)
|| _sessions.Find(header.SessionId) is not { } session
|| !MeasurementDatagram.VerifyMac(payload, session.Key))
{
Counters.DropInvalid();
return;
}
IPEndPoint? remote = connection.RefreshRemoteEndPoint();
if (remote is null)
{
Counters.DropInvalid();
return;
}
long receiveUs = _clock.NowUs();
if (_admission is not null)
{
switch (_admission.AdmitDatagram(session, header, remote.Serialize(), receiveUs))
{
case Admission.Invalid:
Counters.DropInvalid();
return;
case Admission.RateLimited:
Counters.DropRateLimited();
return;
}
}
session.BindDatagramConnection(connection);
handler(new QuicDatagramDelivery(header, payload, receiveUs, session, connection, Counters));
}
}
@@ -0,0 +1,341 @@
using System.Runtime.InteropServices;
namespace MoShiro.Measurement;
/// <summary>Small, versioned view of the MsQuic v2 C API table used by this transport.</summary>
internal static unsafe class MsQuicApi
{
private const int SetContextIndex = 0;
private const int SetCallbackHandlerIndex = 2;
private const int SetParamIndex = 3;
private const int GetParamIndex = 4;
private const int RegistrationOpenIndex = 5;
private const int RegistrationCloseIndex = 6;
private const int ConfigurationOpenIndex = 8;
private const int ConfigurationCloseIndex = 9;
private const int ConfigurationLoadCredentialIndex = 10;
private const int ListenerOpenIndex = 11;
private const int ListenerCloseIndex = 12;
private const int ListenerStartIndex = 13;
private const int ListenerStopIndex = 14;
private const int ConnectionOpenIndex = 15;
private const int ConnectionCloseIndex = 16;
private const int ConnectionShutdownIndex = 17;
private const int ConnectionStartIndex = 18;
private const int ConnectionSetConfigurationIndex = 19;
private const int DatagramSendIndex = 28;
private const uint ConnectionStatisticsV2Parameter = 0x05000016;
private static readonly SetContextDelegate s_setContext = MsQuicRuntime.Function<SetContextDelegate>(SetContextIndex);
private static readonly SetCallbackHandlerDelegate s_setCallbackHandler = MsQuicRuntime.Function<SetCallbackHandlerDelegate>(SetCallbackHandlerIndex);
private static readonly SetParamDelegate s_setParam = MsQuicRuntime.Function<SetParamDelegate>(SetParamIndex);
private static readonly GetParamDelegate s_getParam = MsQuicRuntime.Function<GetParamDelegate>(GetParamIndex);
private static readonly RegistrationOpenDelegate s_registrationOpen = MsQuicRuntime.Function<RegistrationOpenDelegate>(RegistrationOpenIndex);
private static readonly HandleCloseDelegate s_registrationClose = MsQuicRuntime.Function<HandleCloseDelegate>(RegistrationCloseIndex);
private static readonly ConfigurationOpenDelegate s_configurationOpen = MsQuicRuntime.Function<ConfigurationOpenDelegate>(ConfigurationOpenIndex);
private static readonly HandleCloseDelegate s_configurationClose = MsQuicRuntime.Function<HandleCloseDelegate>(ConfigurationCloseIndex);
private static readonly ConfigurationLoadCredentialDelegate s_loadCredential = MsQuicRuntime.Function<ConfigurationLoadCredentialDelegate>(ConfigurationLoadCredentialIndex);
private static readonly ListenerOpenDelegate s_listenerOpen = MsQuicRuntime.Function<ListenerOpenDelegate>(ListenerOpenIndex);
private static readonly HandleCloseDelegate s_listenerClose = MsQuicRuntime.Function<HandleCloseDelegate>(ListenerCloseIndex);
private static readonly ListenerStartDelegate s_listenerStart = MsQuicRuntime.Function<ListenerStartDelegate>(ListenerStartIndex);
private static readonly ListenerStopDelegate s_listenerStop = MsQuicRuntime.Function<ListenerStopDelegate>(ListenerStopIndex);
private static readonly ConnectionOpenDelegate s_connectionOpen = MsQuicRuntime.Function<ConnectionOpenDelegate>(ConnectionOpenIndex);
private static readonly HandleCloseDelegate s_connectionClose = MsQuicRuntime.Function<HandleCloseDelegate>(ConnectionCloseIndex);
private static readonly ConnectionShutdownDelegate s_connectionShutdown = MsQuicRuntime.Function<ConnectionShutdownDelegate>(ConnectionShutdownIndex);
private static readonly ConnectionStartDelegate s_connectionStart = MsQuicRuntime.Function<ConnectionStartDelegate>(ConnectionStartIndex);
private static readonly ConnectionSetConfigurationDelegate s_connectionSetConfiguration = MsQuicRuntime.Function<ConnectionSetConfigurationDelegate>(ConnectionSetConfigurationIndex);
private static readonly DatagramSendDelegate s_datagramSend = MsQuicRuntime.Function<DatagramSendDelegate>(DatagramSendIndex);
public static void SetContext(nint handle, nint context) => s_setContext(handle, context);
public static void SetCallbackHandler<T>(nint handle, T callback, nint context)
where T : Delegate => s_setCallbackHandler(handle, Marshal.GetFunctionPointerForDelegate(callback), context);
public static void SetParam(nint handle, uint parameter, byte value)
{
unsafe
{
uint status;
status = s_setParam(handle, parameter, sizeof(byte), &value);
Check(status, "SetParam");
}
}
public static byte[] GetAddress(nint handle, uint parameter)
{
byte[] address = new byte[128];
unsafe
{
fixed (byte* buffer = address)
{
uint length = (uint)address.Length;
uint status = s_getParam(handle, parameter, &length, buffer);
Check(status, "GetParam(address)");
Array.Resize(ref address, checked((int)length));
}
}
return address;
}
public static QuicStatisticsV2 GetStatisticsV2(nint connection)
{
QuicStatisticsV2 statistics = default;
unsafe
{
uint length = (uint)sizeof(QuicStatisticsV2);
uint status = s_getParam(connection, ConnectionStatisticsV2Parameter, &length, &statistics);
Check(status, "GetParam(connection statistics v2)");
}
return statistics;
}
public static nint RegistrationOpen(string appName)
{
nint utf8 = Marshal.StringToCoTaskMemUTF8(appName);
try
{
var config = new RegistrationConfig { AppName = utf8, ExecutionProfile = 0 };
uint status = s_registrationOpen(ref config, out nint registration);
Check(status, "RegistrationOpen");
return registration;
}
finally
{
Marshal.FreeCoTaskMem(utf8);
}
}
public static void RegistrationClose(nint handle) => s_registrationClose(handle);
public static nint ConfigurationOpen(nint registration, byte[] alpn)
{
unsafe
{
fixed (byte* alpnBytes = alpn)
{
var buffer = new QuicBuffer { Length = (uint)alpn.Length, Buffer = (nint)alpnBytes };
uint status = s_configurationOpen(registration, ref buffer, 1, 0, 0, 0, out nint configuration);
Check(status, "ConfigurationOpen");
return configuration;
}
}
}
public static void ConfigurationClose(nint handle) => s_configurationClose(handle);
public static void ConfigurationLoadCredential(nint configuration, ref CredentialConfig credential)
{
uint status = s_loadCredential(configuration, ref credential);
Check(status, "ConfigurationLoadCredential");
}
public static nint ListenerOpen<T>(nint registration, T callback, nint context)
where T : Delegate
{
uint status = s_listenerOpen(registration, Marshal.GetFunctionPointerForDelegate(callback), context, out nint listener);
Check(status, "ListenerOpen");
return listener;
}
public static void ListenerClose(nint handle) => s_listenerClose(handle);
public static void ListenerStart(nint listener, byte[] alpn, byte[] address)
{
unsafe
{
fixed (byte* alpnBytes = alpn)
fixed (byte* addressBytes = address)
{
var buffer = new QuicBuffer { Length = (uint)alpn.Length, Buffer = (nint)alpnBytes };
uint status = s_listenerStart(listener, ref buffer, 1, (nint)addressBytes);
Check(status, "ListenerStart");
}
}
}
public static void ListenerStop(nint handle) => s_listenerStop(handle);
public static nint ConnectionOpen<T>(nint registration, T callback, nint context)
where T : Delegate
{
uint status = s_connectionOpen(registration, Marshal.GetFunctionPointerForDelegate(callback), context, out nint connection);
Check(status, "ConnectionOpen");
return connection;
}
public static void ConnectionClose(nint handle) => s_connectionClose(handle);
public static void ConnectionShutdown(nint handle, ulong errorCode = 0) => s_connectionShutdown(handle, 0x00000001, errorCode);
public static void ConnectionStart(nint handle, nint configuration, ushort addressFamily, nint serverName, ushort port)
{
uint status = s_connectionStart(handle, configuration, addressFamily, serverName, port);
Check(status, "ConnectionStart");
}
public static void ConnectionBind(nint handle, byte[] address)
{
unsafe
{
fixed (byte* addressBytes = address)
{
uint status = s_setParam(handle, 0x05000001, (uint)address.Length, addressBytes);
Check(status, "SetParam(local address)");
}
}
}
public static void ConnectionSetConfiguration(nint handle, nint configuration)
{
uint status = s_connectionSetConfiguration(handle, configuration);
Check(status, "ConnectionSetConfiguration");
}
public static uint DatagramSend(nint connection, nint buffers, nint clientContext) => s_datagramSend(connection, buffers, 1, 0, clientContext);
public static void Check(uint status, string operation)
{
if (!Succeeded(status))
{
throw new InvalidOperationException($"MsQuic {operation} failed with status 0x{status:X8}.");
}
}
// QUIC_STATUS_PENDING (0xFFFFFFFE) is a successful asynchronous result. Other
// negative HRESULT/NTSTATUS values are failures on both Windows and POSIX.
public static bool Succeeded(uint status) => status == 0 || status == 0xFFFFFFFE;
[StructLayout(LayoutKind.Sequential)]
internal struct QuicBuffer
{
public uint Length;
public nint Buffer;
}
// MsQuic QUIC_STATISTICS_V2 through v2.5. All fields are fixed-width on
// the supported 64-bit RIDs; keeping the field order aligned with msquic.h.
[StructLayout(LayoutKind.Sequential)]
internal struct QuicStatisticsV2
{
public ulong CorrelationId;
public uint Flags;
public uint RttUs;
public uint MinRttUs;
public uint MaxRttUs;
public ulong TimingStart;
public ulong TimingInitialFlightEnd;
public ulong TimingHandshakeFlightEnd;
public uint HandshakeClientFlight1Bytes;
public uint HandshakeServerFlight1Bytes;
public uint HandshakeClientFlight2Bytes;
public ushort SendPathMtu;
public ulong SendTotalPackets;
public ulong SendRetransmittablePackets;
public ulong SendSuspectedLostPackets;
public ulong SendSpuriousLostPackets;
public ulong SendTotalBytes;
public ulong SendTotalStreamBytes;
public uint SendCongestionCount;
public uint SendPersistentCongestionCount;
public ulong RecvTotalPackets;
public ulong RecvReorderedPackets;
public ulong RecvDroppedPackets;
public ulong RecvDuplicatePackets;
public ulong RecvTotalBytes;
public ulong RecvTotalStreamBytes;
public ulong RecvDecryptionFailures;
public ulong RecvValidAckFrames;
public uint KeyUpdateCount;
public uint SendCongestionWindow;
public uint DestCidUpdateCount;
public uint SendEcnCongestionCount;
public byte HandshakeHopLimitTtl;
public uint RttVarianceUs;
}
[StructLayout(LayoutKind.Sequential)]
internal struct RegistrationConfig
{
public nint AppName;
public int ExecutionProfile;
}
[StructLayout(LayoutKind.Sequential)]
internal struct CredentialConfig
{
public uint Type;
public uint Flags;
public nint Certificate;
public nint Principal;
public nint Reserved;
public nint AsyncHandler;
public uint AllowedCipherSuites;
public nint CaCertificateFile;
}
[StructLayout(LayoutKind.Sequential)]
internal struct Pkcs12Certificate
{
public nint Asn1Blob;
public uint Asn1BlobLength;
public nint PrivateKeyPassword;
}
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
internal delegate uint ListenerCallback(nint listener, nint context, nint @event);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
internal delegate uint ConnectionCallback(nint connection, nint context, nint @event);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void SetContextDelegate(nint handle, nint context);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void SetCallbackHandlerDelegate(nint handle, nint handler, nint context);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate uint SetParamDelegate(nint handle, uint parameter, uint bufferLength, void* buffer);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate uint GetParamDelegate(nint handle, uint parameter, uint* bufferLength, void* buffer);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate uint RegistrationOpenDelegate(ref RegistrationConfig config, out nint registration);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void HandleCloseDelegate(nint handle);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate uint ConfigurationOpenDelegate(nint registration, ref QuicBuffer alpn, uint alpnCount, nint settings, uint settingsLength, nint context, out nint configuration);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate uint ConfigurationLoadCredentialDelegate(nint configuration, ref CredentialConfig credential);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate uint ListenerOpenDelegate(nint registration, nint callback, nint context, out nint listener);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate uint ListenerStartDelegate(nint listener, ref QuicBuffer alpn, uint alpnCount, nint address);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void ListenerStopDelegate(nint listener);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate uint ConnectionOpenDelegate(nint registration, nint callback, nint context, out nint connection);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void ConnectionShutdownDelegate(nint connection, uint flags, ulong errorCode);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate uint ConnectionStartDelegate(nint connection, nint configuration, ushort family, nint serverName, ushort serverPort);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate uint ConnectionSetConfigurationDelegate(nint connection, nint configuration);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate uint DatagramSendDelegate(nint connection, nint buffers, uint bufferCount, uint flags, nint clientContext);
}
@@ -0,0 +1,610 @@
using System.Buffers;
using System.Net;
using System.Net.Sockets;
using System.Runtime.InteropServices;
using System.Diagnostics;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Fleet.Core;
namespace MoShiro.Measurement;
internal delegate void MsQuicDatagramCallback(MsQuicConnection connection, ReadOnlySpan<byte> datagram);
/// <summary>A single MsQuic connection with connection-bound datagram delivery.</summary>
internal sealed class MsQuicConnection : IAsyncDisposable, IDatagramConnection
{
private const uint DatagramReceiveEnabled = 0x0500000D;
private const uint ClientCredentialFlags = 0x00000001 | 0x00000004 | 0x00000010 | 0x00004000;
private const uint CertificateCredentialNone = 0;
private const uint CertificateCredentialContext = 3;
private const uint CertificateCredentialPkcs12 = 6;
private const uint ConnectionEventConnected = 0;
private const uint ConnectionEventShutdownByTransport = 1;
private const uint ConnectionEventShutdownByPeer = 2;
private const uint ConnectionEventShutdownComplete = 3;
private const uint ConnectionEventPeerAddressChanged = 5;
private const uint ConnectionEventDatagramStateChanged = 10;
private const uint ConnectionEventDatagramReceived = 11;
private const uint ConnectionEventDatagramSendStateChanged = 12;
private const uint ConnectionEventPeerCertificateReceived = 15;
private const int DatagramStateFinal = 3;
private const int QuicAddressBytes = 28;
private static readonly MsQuicApi.ConnectionCallback s_callback = OnEvent;
private readonly object _sendGate = new();
private readonly TaskCompletionSource _connected = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource _shutdown = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly GCHandle _self;
private readonly byte[]? _expectedPeerSpki;
private readonly Action<MsQuicConnection>? _closed;
private readonly Action<IPEndPoint>? _peerAddressChanged;
private readonly INodeLog _log;
private readonly Dictionary<nint, SendState> _sends = [];
private nint _registration;
private nint _configuration;
private nint _handle;
private int _maxDatagramBytes;
private int _sendEnabled;
private int _disposeStarted;
private double? _handshakeDurationMs;
private MsQuicConnection(
nint handle,
nint registration,
nint configuration,
byte[]? expectedPeerSpki,
MsQuicDatagramCallback? datagramHandler,
INodeLog log,
Action<MsQuicConnection>? closed,
Action<IPEndPoint>? peerAddressChanged)
{
_handle = handle;
_registration = registration;
_configuration = configuration;
_expectedPeerSpki = expectedPeerSpki;
DatagramHandler = datagramHandler;
_log = log;
_closed = closed;
_peerAddressChanged = peerAddressChanged;
_self = GCHandle.Alloc(this);
}
public MsQuicDatagramCallback? DatagramHandler { get; set; }
public IPEndPoint? RemoteEndPoint { get; private set; }
public IPEndPoint? LocalEndPoint => Volatile.Read(ref _handle) is nint handle && handle != 0
? ReadEndpoint(MsQuicApi.GetAddress(handle, 0x05000001))
: null;
public nint Handle => Volatile.Read(ref _handle);
public IPEndPoint? RefreshRemoteEndPoint()
{
if (RemoteEndPoint is not null || Handle == 0)
{
return RemoteEndPoint;
}
try
{
RemoteEndPoint = ReadEndpoint(MsQuicApi.GetAddress(Handle, 0x05000002));
}
catch (InvalidOperationException)
{
// Address isn't available until the handshake finishes.
}
return RemoteEndPoint;
}
public bool IsReady => _connected.Task.IsCompletedSuccessfully && Volatile.Read(ref _sendEnabled) != 0;
public int MaxDatagramBytes => Volatile.Read(ref _maxDatagramBytes);
public double? HandshakeDurationMs => _handshakeDurationMs;
public bool TryGetStatistics(out MsQuicApi.QuicStatisticsV2 statistics)
{
nint handle = Volatile.Read(ref _handle);
if (handle == 0)
{
statistics = default;
return false;
}
try
{
statistics = MsQuicApi.GetStatisticsV2(handle);
return true;
}
catch (InvalidOperationException)
{
statistics = default;
return false;
}
}
public static async Task<MsQuicConnection> ConnectAsync(
IPAddress address,
int port,
int localPort,
byte[] expectedPeerSpki,
MsQuicDatagramCallback datagramHandler,
INodeLog log,
CancellationToken ct)
{
nint registration = 0;
nint configuration = 0;
MsQuicConnection? connection = null;
long handshakeStarted = 0;
try
{
log.Info("opening MsQuic client registration");
registration = MsQuicApi.RegistrationOpen("moshiro-probe");
log.Info("opening MsQuic client configuration");
configuration = MsQuicApi.ConfigurationOpen(registration, QuicAlpn.Value);
log.Info("loading MsQuic client credentials");
LoadClientCredentials(configuration);
var endpoint = new IPEndPoint(address.AddressFamily == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any, localPort);
SocketAddress serialized = endpoint.Serialize();
byte[] localAddress = new byte[QuicAddressBytes];
for (int i = 0; i < serialized.Size; i++)
{
localAddress[i] = serialized[i];
}
ushort nativeFamily = ReadAddressFamily(localAddress);
nint name = Marshal.StringToCoTaskMemUTF8(address.ToString());
try
{
nint handle = MsQuicApi.ConnectionOpen(registration, s_callback, 0);
log.Info("opened MsQuic client connection");
connection = new MsQuicConnection(handle, registration, configuration, expectedPeerSpki, datagramHandler, log, null, null);
registration = 0;
configuration = 0;
MsQuicApi.SetContext(handle, GCHandle.ToIntPtr(connection._self));
log.Info("enabling MsQuic datagram receive");
MsQuicApi.SetParam(handle, DatagramReceiveEnabled, 1);
log.Info("binding MsQuic local address", ("local_port", localPort));
MsQuicApi.ConnectionBind(handle, localAddress);
log.Info("starting MsQuic QUIC handshake", ("remote", address), ("port", port), ("local_port", localPort));
handshakeStarted = Stopwatch.GetTimestamp();
MsQuicApi.ConnectionStart(handle, connection._configuration, nativeFamily, name, checked((ushort)port));
log.Info("MsQuic connection start returned");
}
finally
{
Marshal.FreeCoTaskMem(name);
}
await connection._connected.Task.WaitAsync(TimeSpan.FromSeconds(10), ct);
connection._handshakeDurationMs = Stopwatch.GetElapsedTime(handshakeStarted).TotalMilliseconds;
return connection;
}
catch
{
if (connection is not null)
{
await connection.DisposeAsync();
}
if (configuration != 0)
{
MsQuicApi.ConfigurationClose(configuration);
}
if (registration != 0)
{
MsQuicApi.RegistrationClose(registration);
}
throw;
}
}
public static MsQuicConnection Accept(
nint handle,
nint configuration,
MsQuicDatagramCallback datagramHandler,
INodeLog log,
Action<MsQuicConnection> closed,
Action<IPEndPoint>? peerAddressChanged)
{
var connection = new MsQuicConnection(handle, 0, 0, null, datagramHandler, log, closed, peerAddressChanged);
MsQuicApi.SetContext(handle, GCHandle.ToIntPtr(connection._self));
MsQuicApi.SetCallbackHandler(handle, s_callback, GCHandle.ToIntPtr(connection._self));
MsQuicApi.ConnectionSetConfiguration(handle, configuration);
MsQuicApi.SetParam(handle, DatagramReceiveEnabled, 1);
return connection;
}
public bool Send(ReadOnlySpan<byte> datagram)
{
nint handle = Volatile.Read(ref _handle);
if (handle == 0 || !IsReady || datagram.Length > MaxDatagramBytes)
{
return false;
}
byte[] buffer = ArrayPool<byte>.Shared.Rent(datagram.Length);
datagram.CopyTo(buffer);
GCHandle pinned = GCHandle.Alloc(buffer, GCHandleType.Pinned);
nint nativeBuffer = Marshal.AllocHGlobal(Marshal.SizeOf<MsQuicApi.QuicBuffer>());
var state = new SendState(buffer, pinned, nativeBuffer);
GCHandle stateHandle = GCHandle.Alloc(state);
nint context = GCHandle.ToIntPtr(stateHandle);
lock (_sendGate)
{
if (_handle == 0)
{
stateHandle.Free();
state.Release();
return false;
}
_sends.Add(context, state);
}
var nativeBufferValue = new MsQuicApi.QuicBuffer
{
Length = (uint)datagram.Length,
Buffer = pinned.AddrOfPinnedObject(),
};
Marshal.StructureToPtr(nativeBufferValue, nativeBuffer, false);
uint status = MsQuicApi.DatagramSend(handle, nativeBuffer, context);
if (MsQuicApi.Succeeded(status))
{
return true;
}
ReleaseSend(context);
return false;
}
public async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref _disposeStarted, 1) == 0 && Volatile.Read(ref _handle) is nint handle && handle != 0)
{
MsQuicApi.ConnectionShutdown(handle);
}
if (Volatile.Read(ref _handle) != 0)
{
await _shutdown.Task.ConfigureAwait(false);
}
}
public Task WaitForShutdownAsync() => _shutdown.Task;
private static void LoadClientCredentials(nint configuration)
{
var credential = new MsQuicApi.CredentialConfig { Type = CertificateCredentialNone, Flags = ClientCredentialFlags };
if (OperatingSystem.IsWindows())
{
credential.Flags &= ~0x00004000u;
}
MsQuicApi.ConfigurationLoadCredential(configuration, ref credential);
}
internal static void LoadServerCredentials(nint configuration, X509Certificate2 certificate)
{
var credential = new MsQuicApi.CredentialConfig();
GCHandle pfxPin = default;
nint pkcs12 = 0;
try
{
if (OperatingSystem.IsWindows())
{
credential.Type = CertificateCredentialContext;
credential.Certificate = certificate.Handle;
}
else
{
byte[] pfxBytes = certificate.Export(X509ContentType.Pkcs12);
pfxPin = GCHandle.Alloc(pfxBytes, GCHandleType.Pinned);
var pfxCertificate = new MsQuicApi.Pkcs12Certificate
{
Asn1Blob = pfxPin.AddrOfPinnedObject(),
Asn1BlobLength = (uint)pfxBytes.Length,
};
pkcs12 = Marshal.AllocHGlobal(Marshal.SizeOf<MsQuicApi.Pkcs12Certificate>());
Marshal.StructureToPtr(pfxCertificate, pkcs12, false);
credential.Type = CertificateCredentialPkcs12;
credential.Flags = 0x00004000;
credential.Certificate = pkcs12;
}
MsQuicApi.ConfigurationLoadCredential(configuration, ref credential);
}
finally
{
if (pkcs12 != 0)
{
Marshal.FreeHGlobal(pkcs12);
}
if (pfxPin.IsAllocated)
{
pfxPin.Free();
}
}
}
private static uint OnEvent(nint handle, nint context, nint eventPointer)
{
try
{
var connection = (MsQuicConnection)GCHandle.FromIntPtr(context).Target!;
uint eventType = unchecked((uint)Marshal.ReadInt32(eventPointer));
switch (eventType)
{
case ConnectionEventConnected:
connection._log.Info("MsQuic QUIC handshake completed");
connection.RefreshRemoteEndPoint();
connection._connected.TrySetResult();
break;
case ConnectionEventShutdownByTransport:
case ConnectionEventShutdownByPeer:
connection._connected.TrySetException(new IOException("QUIC connection closed during handshake."));
break;
case ConnectionEventShutdownComplete:
connection.OnShutdownComplete(handle);
break;
case ConnectionEventPeerAddressChanged:
{
nint address = Marshal.ReadIntPtr(eventPointer, IntPtr.Size);
IPEndPoint? endpoint = ReadEndpoint(address);
if (endpoint is not null)
{
connection.RemoteEndPoint = endpoint;
connection._peerAddressChanged?.Invoke(endpoint);
}
break;
}
case ConnectionEventDatagramStateChanged:
connection.OnDatagramStateChanged(eventPointer);
break;
case ConnectionEventDatagramReceived:
connection.OnDatagramReceived(eventPointer);
break;
case ConnectionEventDatagramSendStateChanged:
connection.OnDatagramSendStateChanged(eventPointer);
break;
case ConnectionEventPeerCertificateReceived:
return connection.ValidatePeerCertificate(eventPointer);
}
return 0;
}
catch (Exception ex)
{
try
{
if (context != 0 && GCHandle.FromIntPtr(context).Target is MsQuicConnection connection)
{
connection._log.Error("MsQuic connection callback failed", ("err", ex.Message));
}
}
catch
{
// Keep exceptions inside the native callback boundary.
}
return OperatingSystem.IsWindows() ? 0x80004005u : 1u;
}
}
private void OnDatagramStateChanged(nint eventPointer)
{
byte sendEnabled = Marshal.ReadByte(eventPointer, IntPtr.Size);
int maxSendLength = (ushort)Marshal.ReadInt16(eventPointer, IntPtr.Size + 1);
Volatile.Write(ref _maxDatagramBytes, maxSendLength);
Volatile.Write(ref _sendEnabled, sendEnabled == 0 ? 0 : 1);
}
private void OnDatagramReceived(nint eventPointer)
{
nint bufferPointer = Marshal.ReadIntPtr(eventPointer, IntPtr.Size);
if (bufferPointer == 0)
{
return;
}
var buffer = Marshal.PtrToStructure<MsQuicApi.QuicBuffer>(bufferPointer);
if (buffer.Buffer != 0 && buffer.Length > 0 && buffer.Length <= 65535)
{
unsafe
{
DatagramHandler?.Invoke(this, new ReadOnlySpan<byte>((void*)buffer.Buffer, checked((int)buffer.Length)));
}
}
}
private void OnDatagramSendStateChanged(nint eventPointer)
{
nint context = Marshal.ReadIntPtr(eventPointer, IntPtr.Size);
int state = Marshal.ReadInt32(eventPointer, IntPtr.Size * 2);
if (state >= DatagramStateFinal)
{
ReleaseSend(context);
}
}
private uint ValidatePeerCertificate(nint eventPointer)
{
nint certificate = Marshal.ReadIntPtr(eventPointer, IntPtr.Size);
if (certificate == 0 || _expectedPeerSpki is null)
{
return 0;
}
byte[] der;
if (OperatingSystem.IsWindows())
{
nint encoded = Marshal.ReadIntPtr(certificate, IntPtr.Size);
int length = Marshal.ReadInt32(certificate, IntPtr.Size * 2);
if (encoded == 0 || length <= 0 || length > 64 * 1024)
{
return 0x80004005u;
}
der = new byte[length];
Marshal.Copy(encoded, der, 0, length);
}
else
{
var nativeBuffer = Marshal.PtrToStructure<MsQuicApi.QuicBuffer>(certificate);
if (nativeBuffer.Buffer == 0 || nativeBuffer.Length == 0 || nativeBuffer.Length > 64 * 1024)
{
return 1;
}
der = new byte[checked((int)nativeBuffer.Length)];
Marshal.Copy(nativeBuffer.Buffer, der, 0, der.Length);
}
using X509Certificate2 peerCertificate = X509CertificateLoader.LoadCertificate(der);
bool valid = ExportSubjectPublicKeyInfo(peerCertificate).AsSpan().SequenceEqual(_expectedPeerSpki);
return valid ? 0 : OperatingSystem.IsWindows() ? 0x80004005u : 1u;
}
private static byte[] ExportSubjectPublicKeyInfo(X509Certificate2 certificate)
{
using ECDsa? ecdsa = certificate.GetECDsaPublicKey();
if (ecdsa is not null)
{
return ecdsa.ExportSubjectPublicKeyInfo();
}
using RSA? rsa = certificate.GetRSAPublicKey();
if (rsa is not null)
{
return rsa.ExportSubjectPublicKeyInfo();
}
using DSA? dsa = certificate.GetDSAPublicKey();
if (dsa is not null)
{
return dsa.ExportSubjectPublicKeyInfo();
}
return [];
}
private void ReleaseSend(nint context)
{
SendState? state;
lock (_sendGate)
{
if (!_sends.Remove(context, out state))
{
return;
}
}
if (GCHandle.FromIntPtr(context).IsAllocated)
{
GCHandle.FromIntPtr(context).Free();
}
state.Release();
}
private void OnShutdownComplete(nint handle)
{
if (Interlocked.Exchange(ref _handle, 0) == 0)
{
return;
}
MsQuicApi.ConnectionClose(handle);
lock (_sendGate)
{
foreach ((nint context, SendState state) in _sends)
{
GCHandle.FromIntPtr(context).Free();
state.Release();
}
_sends.Clear();
}
if (_configuration != 0)
{
MsQuicApi.ConfigurationClose(_configuration);
}
if (_registration != 0)
{
MsQuicApi.RegistrationClose(_registration);
}
_self.Free();
_shutdown.TrySetResult();
_closed?.Invoke(this);
}
private static IPEndPoint? ReadEndpoint(nint address)
{
if (address == 0)
{
return null;
}
ushort family = unchecked((ushort)Marshal.ReadInt16(address));
if (family == 2)
{
byte[] bytes = new byte[4];
Marshal.Copy(address + 4, bytes, 0, bytes.Length);
int port = (Marshal.ReadByte(address, 2) << 8) | Marshal.ReadByte(address, 3);
return new IPEndPoint(new IPAddress(bytes), port);
}
ushort ipv6Family = OperatingSystem.IsWindows() ? (ushort)23 : OperatingSystem.IsMacOS() ? (ushort)30 : (ushort)10;
if (family == ipv6Family)
{
byte[] bytes = new byte[16];
Marshal.Copy(address + 8, bytes, 0, bytes.Length);
int port = (Marshal.ReadByte(address, 2) << 8) | Marshal.ReadByte(address, 3);
long scope = unchecked((uint)Marshal.ReadInt32(address, 24));
return new IPEndPoint(new IPAddress(bytes, scope), port);
}
return null;
}
private static IPEndPoint? ReadEndpoint(byte[] address)
{
unsafe
{
fixed (byte* addressBytes = address)
{
return ReadEndpoint((nint)addressBytes);
}
}
}
private static ushort ReadAddressFamily(byte[] address) => (ushort)(address[0] | (address[1] << 8));
private sealed class SendState(byte[] buffer, GCHandle pin, nint nativeBuffer)
{
public void Release()
{
if (pin.IsAllocated)
{
pin.Free();
}
if (nativeBuffer != 0)
{
Marshal.FreeHGlobal(nativeBuffer);
}
ArrayPool<byte>.Shared.Return(buffer);
}
}
}
@@ -0,0 +1,195 @@
using System.Collections.Concurrent;
using System.Net;
using System.Net.Sockets;
using System.Runtime.InteropServices;
using System.Security.Cryptography.X509Certificates;
using Fleet.Core;
namespace MoShiro.Measurement;
/// <summary>Agent-side MsQuic listener. Measurement frames arrive only as QUIC DATAGRAM frames.</summary>
internal sealed class MsQuicListener : IAsyncDisposable
{
private const int QuicAddressBytes = 28;
private const uint ListenerEventNewConnection = 0;
private const uint ListenerLocalAddress = 0x04000000;
private static readonly MsQuicApi.ListenerCallback s_callback = OnListenerEvent;
private readonly IPAddress _bindAddress;
private readonly IReadOnlyList<int> _ports;
private readonly MsQuicDatagramCallback _datagramHandler;
private readonly INodeLog _log;
private readonly Action<IPEndPoint>? _peerAddressChanged;
private readonly X509Certificate2 _certificate;
private readonly GCHandle _self;
private readonly ConcurrentDictionary<nint, MsQuicConnection> _connections = new();
private readonly List<nint> _listeners = [];
private nint _registration;
private nint _configuration;
private int _disposed;
public MsQuicListener(
IPAddress bindAddress,
IReadOnlyList<int> ports,
X509Certificate2 certificate,
MsQuicDatagramCallback datagramHandler,
INodeLog log,
Action<IPEndPoint>? peerAddressChanged)
{
_bindAddress = bindAddress;
_ports = ports.Count == 0 ? [7443] : ports;
_certificate = certificate;
_datagramHandler = datagramHandler;
_log = log;
_peerAddressChanged = peerAddressChanged;
_self = GCHandle.Alloc(this);
}
public IReadOnlyList<int> BoundPorts { get; private set; } = [];
public void Start()
{
if (_registration != 0)
{
throw new InvalidOperationException("MsQuic listener is already started.");
}
_registration = MsQuicApi.RegistrationOpen("moshiro-agent");
try
{
_configuration = MsQuicApi.ConfigurationOpen(_registration, QuicAlpn.Value);
MsQuicConnection.LoadServerCredentials(_configuration, _certificate);
var bound = new List<int>(_ports.Count);
foreach (int port in _ports)
{
nint listener = MsQuicApi.ListenerOpen(_registration, s_callback, GCHandle.ToIntPtr(_self));
_listeners.Add(listener);
byte[] address = Serialize(new IPEndPoint(_bindAddress, port));
MsQuicApi.ListenerStart(listener, QuicAlpn.Value, address);
bound.Add(ReadPort(MsQuicApi.GetAddress(listener, ListenerLocalAddress)));
}
BoundPorts = bound;
}
catch
{
foreach (nint listener in _listeners)
{
MsQuicApi.ListenerStop(listener);
MsQuicApi.ListenerClose(listener);
}
_listeners.Clear();
DisposeHandles();
throw;
}
}
public async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
foreach (nint listener in _listeners)
{
MsQuicApi.ListenerStop(listener);
}
foreach (nint listener in _listeners)
{
MsQuicApi.ListenerClose(listener);
}
_listeners.Clear();
MsQuicConnection[] connections = [.. _connections.Values];
await Task.WhenAll(connections.Select(c => c.DisposeAsync().AsTask())).ConfigureAwait(false);
DisposeHandles();
_self.Free();
}
private static uint OnListenerEvent(nint listener, nint context, nint @event)
{
try
{
var owner = (MsQuicListener)GCHandle.FromIntPtr(context).Target!;
uint eventType = unchecked((uint)Marshal.ReadInt32(@event));
if (eventType != ListenerEventNewConnection || Volatile.Read(ref owner._disposed) != 0)
{
return 1;
}
nint connectionHandle = Marshal.ReadIntPtr(@event, IntPtr.Size * 2);
owner._log.Info("accepted MsQuic QUIC connection");
MsQuicConnection? connection = null;
connection = MsQuicConnection.Accept(
connectionHandle,
owner._configuration,
owner._datagramHandler,
owner._log,
closed => owner._connections.TryRemove(connectionHandle, out _),
owner._peerAddressChanged);
if (!owner._connections.TryAdd(connectionHandle, connection))
{
_ = connection.DisposeAsync().AsTask();
return 1;
}
return 0;
}
catch (Exception ex)
{
try
{
if (context != 0 && GCHandle.FromIntPtr(context).Target is MsQuicListener owner)
{
owner._log.Error("MsQuic listener callback failed", ("err", ex.Message));
}
}
catch
{
// Keep exceptions inside the native callback boundary.
}
return 1;
}
}
private void DisposeHandles()
{
if (_configuration != 0)
{
MsQuicApi.ConfigurationClose(_configuration);
_configuration = 0;
}
if (_registration != 0)
{
MsQuicApi.RegistrationClose(_registration);
_registration = 0;
}
}
private static byte[] Serialize(IPEndPoint endpoint)
{
SocketAddress source = endpoint.Serialize();
byte[] address = new byte[QuicAddressBytes];
for (int i = 0; i < source.Size; i++)
{
address[i] = source[i];
}
return address;
}
private static int ReadPort(byte[] address)
{
if (address.Length < 4)
{
throw new InvalidOperationException("MsQuic returned a truncated bound address.");
}
return (address[2] << 8) | address[3];
}
}
@@ -0,0 +1,118 @@
using System.Runtime.InteropServices;
namespace MoShiro.Measurement;
/// <summary>Loads the MsQuic v2 ABI used by the QUIC datagram transport.</summary>
internal static class MsQuicRuntime
{
private const uint ApiVersion = 2;
private static readonly object s_gate = new();
private static nint s_library;
private static nint s_api;
private static OpenVersionDelegate? s_open;
private static CloseDelegate? s_close;
private static readonly List<nint> s_supportLibraries = [];
public static nint Api
{
get
{
lock (s_gate)
{
if (s_api != 0)
{
return s_api;
}
string[] candidates = OperatingSystem.IsWindows()
? ["msquic.dll"]
: OperatingSystem.IsMacOS()
? ["libmsquic.dylib"]
: ["libmsquic.so.2", "libmsquic.so"];
LoadBundledSupportLibraries();
string appDirectory = AppContext.BaseDirectory;
foreach (string candidate in candidates)
{
if (NativeLibrary.TryLoad(Path.Combine(appDirectory, candidate), out s_library)
|| NativeLibrary.TryLoad(candidate, out s_library))
{
break;
}
}
if (s_library == 0)
{
throw new DllNotFoundException($"MsQuic runtime was not found. Expected one of: {string.Join(", ", candidates)}.");
}
s_open = Marshal.GetDelegateForFunctionPointer<OpenVersionDelegate>(NativeLibrary.GetExport(s_library, "MsQuicOpenVersion"));
s_close = Marshal.GetDelegateForFunctionPointer<CloseDelegate>(NativeLibrary.GetExport(s_library, "MsQuicClose"));
int status = s_open(ApiVersion, out s_api);
if (status != 0 || s_api == 0)
{
NativeLibrary.Free(s_library);
s_library = 0;
s_api = 0;
throw new InvalidOperationException($"MsQuic API v{ApiVersion} could not be opened (status {status}).");
}
return s_api;
}
}
}
public static void Close()
{
lock (s_gate)
{
if (s_api != 0)
{
s_close!(s_api);
s_api = 0;
}
if (s_library != 0)
{
NativeLibrary.Free(s_library);
s_library = 0;
}
for (int i = s_supportLibraries.Count - 1; i >= 0; i--)
{
NativeLibrary.Free(s_supportLibraries[i]);
}
s_supportLibraries.Clear();
}
}
private static void LoadBundledSupportLibraries()
{
if (OperatingSystem.IsLinux())
{
TryLoadSupport("libnuma.so.1");
}
else if (OperatingSystem.IsMacOS())
{
TryLoadSupport("libcrypto.3.dylib");
TryLoadSupport("libssl.3.dylib");
}
}
private static void TryLoadSupport(string library)
{
if (NativeLibrary.TryLoad(Path.Combine(AppContext.BaseDirectory, library), out nint handle))
{
s_supportLibraries.Add(handle);
}
}
public static T Function<T>(int index)
where T : Delegate => Marshal.GetDelegateForFunctionPointer<T>(Marshal.ReadIntPtr(Api, checked(index * IntPtr.Size)));
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate int OpenVersionDelegate(uint version, out nint api);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void CloseDelegate(nint api);
}
@@ -0,0 +1,7 @@
namespace MoShiro.Measurement;
/// <summary>The application protocol negotiated by the Agent and Probe QUIC endpoints.</summary>
internal static class QuicAlpn
{
internal static readonly byte[] Value = "h3"u8.ToArray();
}
@@ -0,0 +1,205 @@
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography.X509Certificates;
using Fleet.Core;
namespace MoShiro.Measurement;
/// <summary>
/// Measurement data transport. Agent instances listen on configured QUIC UDP ports; Probe instances
/// open one QUIC connection from a newly selected ephemeral source port. Measurement frames are sent
/// only with MsQuic DATAGRAM calls; the existing TLS peer connection continues to carry session control.
/// </summary>
public sealed class QuicDatagramTransport : IDisposable
{
private readonly MeasurementDatagramRouter _router;
private readonly INodeLog _log;
private readonly MsQuicDatagramCallback _onDatagram;
private readonly IPAddress _bind;
private readonly IReadOnlyList<int> _ports;
private readonly X509Certificate2? _serverCertificate;
private readonly DataSession? _clientSession;
private readonly IPEndPoint? _remote;
private readonly byte[]? _peerSpki;
private readonly int _previousSourcePort;
private readonly TaskCompletionSource _clientReady = new(TaskCreationOptions.RunContinuationsAsynchronously);
private MsQuicListener? _listener;
private MsQuicConnection? _client;
private int _disposed;
/// <summary>Constructs an Agent-side QUIC listener.</summary>
public QuicDatagramTransport(IDataSessions sessions, IAdmission? admission, MonoClock clock, DataPlaneCounters counters, INodeLog log, IPAddress bind, IReadOnlyList<int> ports, X509Certificate2 serverCertificate)
{
_router = new MeasurementDatagramRouter(sessions, admission, clock, counters);
_log = log;
_onDatagram = _router.Dispatch;
_bind = bind;
_ports = ports;
_serverCertificate = serverCertificate;
}
/// <summary>Constructs a Probe-side QUIC dialer for one Agent session.</summary>
public QuicDatagramTransport(DataSession session, MonoClock clock, DataPlaneCounters counters, INodeLog log, IPEndPoint remote, byte[] peerSpki, int previousSourcePort)
{
_router = new MeasurementDatagramRouter(new SingleSession(session), null, clock, counters);
_log = log;
_onDatagram = _router.Dispatch;
_bind = remote.AddressFamily == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any;
_ports = [];
_clientSession = session;
_remote = remote;
_peerSpki = peerSpki;
_previousSourcePort = previousSourcePort;
}
public IReadOnlyList<int> BoundPorts { get; private set; } = [];
public DataPlaneCounters Counters => _router.Counters;
public bool IsConnected => _client?.IsReady == true;
public double? HandshakeDurationMs => _client?.HandshakeDurationMs;
public QuicTransportSnapshot? GetTransportSnapshot()
{
if (_client is null || !_client.TryGetStatistics(out MsQuicApi.QuicStatisticsV2 stats))
{
return null;
}
ulong lost = stats.SendSuspectedLostPackets >= stats.SendSpuriousLostPackets
? stats.SendSuspectedLostPackets - stats.SendSpuriousLostPackets
: 0;
double? packetLossRate = stats.SendTotalPackets > 0 ? lost * 100.0 / stats.SendTotalPackets : null;
return new QuicTransportSnapshot(packetLossRate, stats.SendTotalPackets, stats.SendCongestionCount, stats.SendEcnCongestionCount, stats.SendPathMtu);
}
public void Bind()
{
if (_remote is not null)
{
return;
}
if (_listener is not null)
{
throw new InvalidOperationException("QUIC listener is already bound.");
}
_listener = new MsQuicListener(_bind, _ports, _serverCertificate!, _onDatagram, _log, null);
_listener.Start();
BoundPorts = _listener.BoundPorts;
}
public async Task ConnectAsync(CancellationToken ct)
{
if (_remote is null || _clientSession is null || _peerSpki is null)
{
throw new InvalidOperationException("Only Probe-side QUIC transports can connect.");
}
try
{
int sourcePort = SelectSourcePort(_remote.AddressFamily, _previousSourcePort);
_client = await MsQuicConnection.ConnectAsync(
_remote.Address,
_remote.Port,
sourcePort,
_peerSpki,
_onDatagram,
_log,
ct);
_clientSession.BindDatagramConnection(_client);
BoundPorts = _client.LocalEndPoint is { } local ? [local.Port] : [sourcePort];
_clientReady.TrySetResult();
}
catch (Exception ex)
{
_log.Error("QUIC client connection failed", ("err", ex.Message));
_clientReady.TrySetException(ex);
throw;
}
}
public Task WaitUntilConnectedAsync(CancellationToken ct) => _remote is null ? Task.CompletedTask : _clientReady.Task.WaitAsync(ct);
public void Register(uint streamId, QuicDatagramHandler handler) => _router.Register(streamId, handler);
public void Unregister(uint streamId) => _router.Unregister(streamId);
/// <summary>Sends one measurement datagram on the connection for the supplied session.</summary>
public void Send(DataSession session, EndPoint target, ReadOnlySpan<byte> packet)
{
_ = target; // A QUIC connection owns its path; the Agent learns it from authenticated datagrams.
if (session.DatagramConnection?.Send(packet) == true)
{
_router.Counters.PacketOut();
}
}
public async Task RunAsync(CancellationToken ct)
{
if (_remote is not null)
{
await _clientReady.Task.WaitAsync(ct);
await _client!.WaitForShutdownAsync().WaitAsync(ct);
return;
}
try
{
await Task.Delay(Timeout.InfiniteTimeSpan, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// Listener lifetime is controlled by AgentApp.
}
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
_ = DisposeNativeResourcesAsync();
}
private async Task DisposeNativeResourcesAsync()
{
try
{
if (_client is not null)
{
await _client.DisposeAsync().ConfigureAwait(false);
}
if (_listener is not null)
{
await _listener.DisposeAsync().ConfigureAwait(false);
}
}
catch (Exception ex) when (ex is IOException or InvalidOperationException or ObjectDisposedException)
{
_log.Error("QUIC datagram transport shutdown failed", ("err", ex.Message));
}
}
private static int SelectSourcePort(AddressFamily family, int previousPort)
{
IPAddress local = family == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any;
for (int attempt = 0; attempt < 8; attempt++)
{
using var socket = new Socket(family, SocketType.Dgram, ProtocolType.Udp);
socket.Bind(new IPEndPoint(local, 0));
int port = ((IPEndPoint)socket.LocalEndPoint!).Port;
if (port != previousPort)
{
return port;
}
}
throw new SocketException((int)SocketError.AddressAlreadyInUse);
}
}
@@ -1,216 +0,0 @@
using System.Collections.Concurrent;
using System.Net;
using System.Net.Sockets;
using Fleet.Core;
using MoShiro.Protocol;
namespace MoShiro.Measurement;
/// <summary>一个通过校验的 UDP 包,交给按 <c>stream_id</c> 登记的处理函数。处理函数在接收循环里同步执行,不能阻塞。</summary>
public readonly ref struct UdpDelivery
{
private readonly Span<byte> _packet;
private readonly Socket _socket;
private readonly SocketAddress _source;
private readonly DataPlaneCounters _counters;
internal UdpDelivery(UdpHeader header, Span<byte> packet, long receiveUs, DataSession session, Socket socket, SocketAddress source, DataPlaneCounters counters)
{
Header = header;
_packet = packet;
ReceiveUs = receiveUs;
Session = session;
_socket = socket;
_source = source;
_counters = counters;
}
public UdpHeader Header { get; }
public ReadOnlySpan<byte> Packet => _packet;
/// <summary>包到达的时刻(本节点的单调时钟,微秒):从接收返回的那一刻取,不含校验的耗时。</summary>
public long ReceiveUs { get; }
public DataSession Session { get; }
/// <summary>
/// 原地回复:同样的长度,只把 type 改成 <paramref name="type"/> 并重新计算 MAC,发回请求的实际源地址。
/// 回复与请求等长,所以监听方不能被当成放大器。发送失败(源地址已经不可达)不抛出。
/// </summary>
public void ReplyAs(UdpPacketType type)
{
_packet[3] = (byte)type;
UdpPacket.Seal(_packet, Session.Key);
try
{
_socket.SendTo(_packet, SocketFlags.None, _source);
_counters.PacketOut();
}
catch (SocketException)
{
// 回复丢失由对端的缺失体现
}
}
}
public delegate void UdpHandler(in UdpDelivery delivery);
/// <summary>
/// UDP 数据面:一个或多个套接字,按 <c>stream_id</c> 把包分发给积木。校验顺序:长度、magic、version、type →
/// 流和会话都存在 → MAC → (监听方)<see cref="IAdmission"/>(重放窗口、限速)。任何一步失败都静默丢弃并计数,不回复。
/// 接收循环只做校验和分发,不分配对象。两端共用这一份代码:Agent 监听多个端口并带准入策略,Probe 每个 Agent 一个临时端口。
/// </summary>
public sealed class UdpMux : IDisposable
{
private const int WindowsUdpConnReset = -1744830452; // SIO_UDP_CONNRESET
private readonly IDataSessions _sessions;
private readonly IAdmission? _admission;
private readonly MonoClock _clock;
private readonly DataPlaneCounters _counters;
private readonly INodeLog _log;
private readonly IPAddress _bind;
private readonly IReadOnlyList<int> _ports;
private readonly List<Socket> _sockets = [];
private readonly ConcurrentDictionary<uint, UdpHandler> _handlers = new();
/// <param name="ports">要监听的端口(0 表示由系统分配);空表示一个临时端口。</param>
public UdpMux(IDataSessions sessions, IAdmission? admission, MonoClock clock, DataPlaneCounters counters, INodeLog log, IPAddress bind, IReadOnlyList<int> ports)
{
_sessions = sessions;
_admission = admission;
_clock = clock;
_counters = counters;
_log = log;
_bind = bind;
_ports = ports.Count == 0 ? [0] : ports;
}
public IReadOnlyList<int> BoundPorts { get; private set; } = [];
public DataPlaneCounters Counters => _counters;
/// <summary>绑定套接字(端口被占用等错误在这里抛出)。</summary>
public void Bind()
{
var bound = new List<int>();
foreach (int port in _ports)
{
var socket = new Socket(_bind.AddressFamily, SocketType.Dgram, ProtocolType.Udp) { ReceiveBufferSize = 4 << 20, SendBufferSize = 4 << 20 };
if (_bind.AddressFamily == AddressFamily.InterNetworkV6)
{
socket.DualMode = true;
}
if (OperatingSystem.IsWindows())
{
// Windows 上对一个已关闭的端口发送之后,下一次接收会抛 ConnectionReset:关掉这个行为
socket.IOControl(WindowsUdpConnReset, [0], null);
}
socket.Bind(new IPEndPoint(_bind, port));
bound.Add(((IPEndPoint)socket.LocalEndPoint!).Port);
_sockets.Add(socket);
}
BoundPorts = bound;
}
public void Register(uint streamId, UdpHandler handler) => _handlers[streamId] = handler;
public void Unregister(uint streamId) => _handlers.TryRemove(streamId, out _);
/// <summary>从第一个套接字发出一个已经封好的包。发送失败(没有路由、缓冲区满)不抛出:丢包本身就是测量结果。</summary>
public void Send(EndPoint to, ReadOnlySpan<byte> packet)
{
try
{
_sockets[0].SendTo(packet, SocketFlags.None, to);
_counters.PacketOut();
}
catch (SocketException)
{
// 记为丢失
}
}
public async Task RunAsync(CancellationToken ct)
{
try
{
await Task.WhenAll(_sockets.Select(s => ReceiveLoopAsync(s, ct)));
}
catch (ObjectDisposedException)
{
// 套接字已释放:停止
}
}
public void Dispose()
{
foreach (Socket socket in _sockets)
{
socket.Dispose();
}
}
private async Task ReceiveLoopAsync(Socket socket, CancellationToken ct)
{
byte[] buffer = new byte[UdpPacket.MaxBytes + 64];
var source = new SocketAddress(socket.AddressFamily);
while (!ct.IsCancellationRequested)
{
int length;
try
{
length = await socket.ReceiveFromAsync(buffer.AsMemory(), SocketFlags.None, source, ct);
}
catch (OperationCanceledException)
{
return;
}
catch (ObjectDisposedException)
{
return;
}
catch (SocketException ex)
{
// 个别错误(例如对端的 ICMP 不可达)不能让接收循环退出
_log.Warn("udp receive failed", ("err", ex.SocketErrorCode));
continue;
}
Dispatch(socket, buffer, length, source, _clock.NowUs());
}
}
private void Dispatch(Socket socket, byte[] buffer, int length, SocketAddress source, long receiveUs)
{
_counters.PacketIn();
Span<byte> packet = buffer.AsSpan(0, length);
if (!UdpPacket.TryParseHeader(packet, out UdpHeader header)
|| !_handlers.TryGetValue(header.StreamId, out UdpHandler? handler)
|| _sessions.Find(header.SessionId) is not { } session
|| !UdpPacket.VerifyMac(packet, session.Key))
{
_counters.DropInvalid();
return;
}
if (_admission is not null)
{
switch (_admission.AdmitPacket(session, header, source, receiveUs))
{
case Admission.Invalid:
_counters.DropInvalid();
return;
case Admission.RateLimited:
_counters.DropRateLimited();
return;
}
}
handler(new UdpDelivery(header, packet, receiveUs, session, socket, source, _counters));
}
}
@@ -1,13 +1,13 @@
using System.Globalization;
using MoShiro.Protocol;
namespace MoShiro.Agent;
namespace MoShiro.Measurement;
/// <summary>
/// 主机资源占用的只读快照:CPU 占用、内存、根分区、网卡累计流量与瞬时网速、系统运行时长。
/// Agent 和 Probe 共用。主机资源占用的只读快照:CPU 占用、内存、根分区、网卡累计流量与瞬时网速、系统运行时长。
/// 只有汇总的数字:<b>不采集 CPU 型号、主机名、网卡名和任何 IP 地址</b>,所以面板和展示页想泄漏也泄漏不出去。
/// Linux 读 <c>/proc</c> 和 <c>/sys</c>;容器通过 HOST_PROC、HOST_SYS、HOST_ROOT 指定宿主路径。
/// 读不到的项是 null(例如 Windows 上的 CPU)。不执行任何命令,不写文件。
/// Windows 用 GetSystemTimes、GlobalMemoryStatusEx 和 NetworkInterface(见 WindowsHost);macOS 等其他系统只有磁盘和运行时长。读不到的项是 null。不执行任何命令,不写文件。
/// </summary>
public sealed class HostSampler
{
@@ -18,6 +18,7 @@ public sealed class HostSampler
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 At, long Rx, long Tx)? _previousNet;
@@ -25,6 +26,7 @@ public sealed class HostSampler
string? procRoot = null, string? sysRoot = null, string? diskRoot = null)
{
_time = time;
_native = read is null && OperatingSystem.IsWindows(); // 只有默认读法才走 Windows 的系统调用;测试注入 read 时仍按 Linux 的文本解析
_read = read ?? ReadProc;
_procRoot = Root(procRoot, "HOST_PROC", "/proc");
_sysRoot = Root(sysRoot, "HOST_SYS", "/sys");
@@ -37,7 +39,8 @@ public sealed class HostSampler
long now = _time.GetTimestamp();
var snapshot = new HostSnapshot { AtUtcMs = _time.GetUtcNow().ToUnixTimeMilliseconds() };
if (_read(Path.Combine(_procRoot, "stat")) is { } stat && ParseCpu(stat) is { } cpu)
(long Total, long Idle)? cpuNow = _native ? WindowsCpu() : _read(Path.Combine(_procRoot, "stat")) is { } stat ? ParseCpu(stat) : null;
if (cpuNow is { } cpu)
{
if (_previousCpu is { } before && cpu.Total > before.Total)
{
@@ -48,7 +51,8 @@ public sealed class HostSampler
_previousCpu = cpu;
}
if (_read(Path.Combine(_procRoot, "meminfo")) is { } meminfo && ParseMemory(meminfo) is { } memory)
(long Total, long Used)? memoryNow = _native ? WindowsMemory() : _read(Path.Combine(_procRoot, "meminfo")) is { } meminfo ? ParseMemory(meminfo) : null;
if (memoryNow is { } memory)
{
snapshot.MemTotal = memory.Total;
snapshot.MemUsed = memory.Used;
@@ -60,11 +64,21 @@ public sealed class HostSampler
snapshot.DiskUsed = disk.Used;
}
if (_read(Path.Combine(_procRoot, "net/dev")) is { } dev && ParseNetwork(dev) is { } net)
(long Rx, long Tx, long? LinkMbps)? netNow = null;
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.NetTxTotal = net.Tx;
snapshot.NetLinkMbps = ReadLinkSpeed(dev);
snapshot.NetLinkMbps = net.LinkMbps;
if (_previousNet is { } before && net.Rx >= before.Rx && net.Tx >= before.Tx)
{
double seconds = Math.Max(0.001, _time.GetElapsedTime(before.At, now).TotalSeconds);
@@ -83,6 +97,12 @@ public sealed class HostSampler
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>
public static (long Total, long Idle)? ParseCpu(string stat)
{
@@ -0,0 +1,14 @@
using System.Text.Json.Serialization;
namespace MoShiro.Measurement;
/// <summary>节点本地配置里的 <c>reporting</c> 段(面板无法修改):Agent 和 Probe 共用。</summary>
public sealed class ReportingSection
{
/// <summary>上报主机资源占用(CPU、内存、磁盘、流量、运行时长,只有汇总数字,没有型号和 IP)。设为 false 就一个字也不上报。</summary>
[JsonPropertyName("host_stats")]
public bool HostStats { get; set; } = true;
[JsonPropertyName("host_interval_s")]
public int HostIntervalSeconds { get; set; } = 2;
}
@@ -0,0 +1,125 @@
using System.Net.NetworkInformation;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
namespace MoShiro.Measurement;
/// <summary>
/// Windows 上读主机资源占用(<see cref="HostSampler"/> 在 Linux 之外的实现):CPU 时间和物理内存用两个只读的系统调用
/// (<c>GetSystemTimes</c>、<c>GlobalMemoryStatusEx</c>),网卡流量和链路速率用 .NET 自带的 <see cref="NetworkInterface"/>。
/// 和 Linux 一样只有汇总的数字:不读网卡名、MAC、IP,也不启动任何进程。
/// </summary>
[SupportedOSPlatform("windows")]
internal static partial class WindowsHost
{
private static readonly string[] s_virtualWords = ["Virtual", "vEthernet", "Hyper-V", "VMware", "VirtualBox", "TAP-", "Wintun", "WireGuard", "Npcap", "Bluetooth", "Loopback", "isatap", "Teredo"];
[StructLayout(LayoutKind.Sequential)]
private struct FileTime
{
public uint Low;
public uint High;
public readonly long Value => (long)(((ulong)High << 32) | Low);
}
[StructLayout(LayoutKind.Sequential)]
private struct MemoryStatus
{
public uint Length;
public uint MemoryLoad;
public ulong TotalPhys;
public ulong AvailPhys;
public ulong TotalPageFile;
public ulong AvailPageFile;
public ulong TotalVirtual;
public ulong AvailVirtual;
public ulong AvailExtendedVirtual;
}
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool GetSystemTimes(out FileTime idle, out FileTime kernel, out FileTime user);
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool GlobalMemoryStatusEx(ref MemoryStatus status);
/// <summary>系统启动以来的 CPU 总时间和空闲时间(和 Linux 的 <c>/proc/stat</c> 第一行同一个意思:两次采样相减得到占用)。</summary>
public static (long Total, long Idle)? Cpu()
{
try
{
if (!GetSystemTimes(out FileTime idle, out FileTime kernel, out FileTime user))
{
return null;
}
// 内核时间里已经包含了空闲时间
return (kernel.Value + user.Value, idle.Value);
}
catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException)
{
return null;
}
}
/// <summary>物理内存的总量和已用(总量减去可用)。</summary>
public static (long Total, long Used)? Memory()
{
try
{
var status = new MemoryStatus { Length = (uint)Marshal.SizeOf<MemoryStatus>() };
if (!GlobalMemoryStatusEx(ref status) || status.TotalPhys == 0)
{
return null;
}
return ((long)status.TotalPhys, (long)(status.TotalPhys - Math.Min(status.AvailPhys, status.TotalPhys)));
}
catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException)
{
return null;
}
}
/// <summary>所有物理网卡(在用的,排除回环、隧道、虚拟网卡)的累计收发字节,以及链路速率之和(Mbit/s;有一块读不到速率就是 null)。</summary>
public static (long Rx, long Tx, long? LinkMbps)? Network()
{
try
{
long rx = 0;
long tx = 0;
long mbps = 0;
bool speedKnown = true;
bool any = false;
foreach (NetworkInterface nic in NetworkInterface.GetAllNetworkInterfaces())
{
if (nic.OperationalStatus != OperationalStatus.Up || nic.NetworkInterfaceType is NetworkInterfaceType.Loopback or NetworkInterfaceType.Tunnel
|| s_virtualWords.Any(w => nic.Description.Contains(w, StringComparison.OrdinalIgnoreCase) || nic.Name.Contains(w, StringComparison.OrdinalIgnoreCase)))
{
continue;
}
IPInterfaceStatistics stats = nic.GetIPStatistics();
rx += stats.BytesReceived;
tx += stats.BytesSent;
any = true;
if (nic.Speed > 0)
{
mbps += nic.Speed / 1_000_000;
}
else
{
speedKnown = false;
}
}
return any ? (rx, tx, speedKnown && mbps > 0 ? mbps : null) : null;
}
catch (NetworkInformationException)
{
return null;
}
}
}
@@ -0,0 +1,153 @@
using System.Globalization;
using System.Text.Json;
using Fleet.Core;
using MoShiro.Protocol;
namespace MoShiro.Measurement;
/// <summary>
/// 节点的「更新」:面板建议、请求,节点<b>只负责有序退出</b>,换新版本由外面的服务管理器完成——
/// 例如 Podman 的 Quadlet 单元写了 <c>Pull=always</c>,每次启动都先从镜像仓库拉最新的镜像,所以退出再被拉起就是更新。
/// 节点自己不下载、不安装、不执行任何东西,所以不需要验证什么:最多让面板(管理员)请求一次重启,和定时重启是同一种能力。
/// <list type="bullet">
/// <item>只有设了环境变量 <c>MOSHIRO_UPDATE=restart</c> 才启用(Quadlet 模板里写好了):别的部署方式退出之后不会重新拉取,开了也不会更新,所以没有启用就不报告更新状态。</item>
/// <item>策略由环境变量 <c>MOSHIRO_UPDATE_POLICY</c> 决定:<c>auto</c>(默认)发现新版本就自己退出重启,<c>manual</c> 等管理端请求。自动更新有保护:
/// 进程至少运行 <see cref="MinimumUptime"/> 才会自动退出,新镜像没拉到(标签没变、仓库还没更新)最多每个这么久重启一次,不会陷入重启循环。</item>
/// <item>面板转来的发布清单(面板登记时已验过 CI 签名)只用来显示「有没有新版本」:组件对、比这个程序新(seq 晚于构建时间)、tag 和提交不同。</item>
/// </list>
/// </summary>
public sealed class RestartUpdater
{
public const string EnvironmentVariable = "MOSHIRO_UPDATE";
public const string PolicyVariable = "MOSHIRO_UPDATE_POLICY";
/// <summary>自动更新之前,进程至少要运行这么久。</summary>
public static readonly TimeSpan MinimumUptime = TimeSpan.FromMinutes(10);
private const int MaxManifestBytes = 4096;
private readonly string _component;
private readonly Action<string> _requestRestart;
private readonly TimeProvider _time;
private readonly INodeLog _log;
private readonly string _tag;
private readonly string _commit;
private readonly long _builtAt;
private readonly object _gate = new();
private readonly bool _auto;
private int _autoScheduled;
private readonly long _startedAt;
private UpdateStatus _status;
/// <param name="tag">这个程序的构建标签、提交和构建时间(秒):默认取 <see cref="BuildInfo"/>,测试可以传入。</param>
public RestartUpdater(string component, Action<string> requestRestart, TimeProvider time, INodeLog log, string? tag = null, string? commit = null, long? builtAtEpoch = null, bool auto = false)
{
_auto = auto;
_startedAt = time.GetTimestamp();
_component = component;
_requestRestart = requestRestart;
_time = time;
_log = log;
_tag = tag ?? BuildInfo.Tag;
_commit = commit ?? BuildInfo.Commit;
_builtAt = builtAtEpoch ?? (DateTimeOffset.TryParse(BuildInfo.BuiltAt, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out DateTimeOffset built) ? built.ToUnixTimeSeconds() : 0);
_status = new UpdateStatus { State = "up_to_date", At = Now() };
}
/// <summary>环境变量 <c>MOSHIRO_UPDATE=restart</c> 才启用;否则返回 null,节点不报告更新状态。</summary>
public static RestartUpdater? FromEnvironment(string component, Action<string> requestRestart, TimeProvider time, INodeLog log) =>
string.Equals(Environment.GetEnvironmentVariable(EnvironmentVariable), "restart", StringComparison.Ordinal)
? new RestartUpdater(component, requestRestart, time, log, auto: !string.Equals(Environment.GetEnvironmentVariable(PolicyVariable), "manual", StringComparison.OrdinalIgnoreCase))
: null;
/// <summary>面板转来的发布清单:只用来判断有没有新版本。</summary>
public void Offer(UpdateOfferParams offer)
{
if (offer.Manifest.Length is 0 or > MaxManifestBytes)
{
_log.Warn("ignoring an update offer of an unexpected size");
return;
}
try
{
using JsonDocument document = JsonDocument.Parse(offer.Manifest);
JsonElement root = document.RootElement;
if (root.ValueKind != JsonValueKind.Object || Text(root, "component") != _component || !root.TryGetProperty("seq", out JsonElement seqValue) || !seqValue.TryGetInt64(out long seq))
{
return;
}
string tag = Text(root, "tag");
string commit = Text(root, "commit");
bool same = (_tag.Length > 0 && tag == _tag) || (_commit.Length > 0 && commit.Length > 0 && commit == _commit);
bool available;
lock (_gate)
{
_status = same || seq <= _builtAt
? new UpdateStatus { State = "up_to_date", At = Now() }
: new UpdateStatus
{
State = "available",
Available = new UpdateAvailable { Tag = tag, Version = Text(root, "version"), Digest = Text(root, "digest"), Seq = seq },
At = Now(),
};
available = _status.State == "available";
}
if (available && _auto && Interlocked.Exchange(ref _autoScheduled, 1) == 0)
{
_ = ApplyAfterMinimumUptimeAsync();
}
}
catch (JsonException)
{
_log.Warn("ignoring an update offer that is not valid JSON");
}
}
/// <summary>自动更新:等进程运行够久再退出(刚启动就发现新版本,通常是重启之后标签还没变,立刻再退出会变成重启循环)。</summary>
private async Task ApplyAfterMinimumUptimeAsync()
{
TimeSpan wait = MinimumUptime - _time.GetElapsedTime(_startedAt);
if (wait > TimeSpan.Zero)
{
_log.Info("update available, restarting automatically later", ("in", wait.ToString("c", CultureInfo.InvariantCulture)));
await Task.Delay(wait, _time);
}
RequestApply();
}
/// <summary>面板请求更新:有新版本才退出(由外面的服务管理器拉起并拉取最新镜像)。</summary>
public bool RequestApply()
{
lock (_gate)
{
if (_status.State != "available")
{
return false;
}
_status = new UpdateStatus { State = "applying", Available = _status.Available, At = Now() };
}
_log.Info("update requested, restarting so the supervisor pulls the latest image");
_requestRestart("update");
return true;
}
/// <summary>随心跳上报的状态。</summary>
public UpdateStatus Status()
{
lock (_gate)
{
return new UpdateStatus { State = _status.State, Current = _status.Current, Available = _status.Available, Error = _status.Error, At = _status.At };
}
}
private long Now() => _time.GetUtcNow().ToUnixTimeSeconds();
private static string Text(JsonElement root, string name) =>
root.TryGetProperty(name, out JsonElement value) && value.ValueKind == JsonValueKind.String ? value.GetString() ?? "" : "";
}
@@ -1,129 +0,0 @@
using System.Text;
using System.Text.Json;
using Fleet.Core;
using MoShiro.Protocol;
namespace MoShiro.Measurement;
/// <summary>
/// 节点进程和宿主机更新器之间的文件交换(容器里 <c>/etc/moshiro</c>,也就是数据目录):
/// 节点把面板发来的清单和签名写进 <c>update/</c>,把「更新」请求写成 <c>update/apply.request</c>;更新器验签并更新,状态由宿主更新器写在只读挂载的 <c>update-status/status.json</c>。
/// 节点进程不执行任何命令,也不信任清单的内容(验签在宿主机上)。没有安装更新器时目录写不了或没人读,调用只是返回 false。
/// </summary>
public sealed class UpdateBridge
{
private const int MaxManifestBytes = 4096;
private const int MaxSignatureBytes = 512;
private const int MaxStatusBytes = 4096;
private readonly string _updateDirectory;
private readonly string _statusFile;
private readonly Action<string, byte[]> _write;
private readonly INodeLog _log;
/// <param name="write">写数据目录里的文件(相对路径,原子替换):由节点传入 <c>ConfigStore.Write</c>——常驻进程里只有它可以写文件。</param>
public UpdateBridge(string dataDirectory, Action<string, byte[]> write, INodeLog log)
{
_updateDirectory = Path.Combine(dataDirectory, "update");
_statusFile = Path.Combine(dataDirectory, "update-status", "status.json");
_write = write;
_log = log;
}
/// <summary>放下面板发来的清单和签名(和已有的一样就不动,免得每次重连都惊动更新器)。先写清单、后写签名:更新器监视的是签名文件。</summary>
public bool WriteOffer(UpdateOfferParams offer)
{
byte[] manifest = Encoding.UTF8.GetBytes(offer.Manifest);
byte[] signature = Encoding.UTF8.GetBytes(offer.Signature);
if (manifest.Length is 0 or > MaxManifestBytes || signature.Length is 0 or > MaxSignatureBytes)
{
_log.Warn("ignoring an update offer of an unexpected size");
return false;
}
try
{
string manifestPath = Path.Combine(_updateDirectory, "offer.manifest");
string signaturePath = Path.Combine(_updateDirectory, "offer.sig");
if (Same(manifestPath, manifest) && Same(signaturePath, signature))
{
return true;
}
_write("update/offer.manifest", manifest);
_write("update/offer.sig", signature);
_log.Info("update offer received");
return true;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_log.Warn("could not store the update offer (is the updater installed?)", ("err", ex.Message));
return false;
}
}
/// <summary>请求更新:写一个空的 <c>apply.request</c>(更新器验签通过、清单更新才会真的动手)。</summary>
public bool RequestApply()
{
try
{
_write("update/apply.request", []);
_log.Info("update requested");
return true;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_log.Warn("could not request the update (is the updater installed?)", ("err", ex.Message));
return false;
}
}
/// <summary>更新器的状态;没有安装更新器、文件不存在或内容不对时返回 null。</summary>
public UpdateStatus? ReadStatus()
{
try
{
if (!File.Exists(_statusFile) || new FileInfo(_statusFile).Length > MaxStatusBytes)
{
return null;
}
return JsonSerializer.Deserialize(File.ReadAllBytes(_statusFile), MoShiroJsonContext.Default.UpdateStatus);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
{
return null;
}
}
/// <summary>
/// 握手时自述的二进制:正在运行的可执行文件的哈希,加上宿主机更新器验过的、安装这个版本时用的 CI 签名清单(更新器写在只读的 <c>update-status/</c>)。
/// 容器节点以外(没有更新器)没有清单,只有哈希;读不到可执行文件时返回 null(不做绑定)。
/// </summary>
public BinaryAttestation? Attest()
{
if (NodeBinary.Sha256Hex is not { } hash)
{
return null;
}
string? manifest = ReadSmall("installed.manifest", MaxManifestBytes);
string? signature = ReadSmall("installed.sig", MaxSignatureBytes);
return manifest is not null && signature is not null ? new BinaryAttestation(hash, manifest, signature) : new BinaryAttestation(hash, null, null);
}
private string? ReadSmall(string name, int maxBytes)
{
try
{
string path = Path.Combine(Path.GetDirectoryName(_statusFile)!, name);
return File.Exists(path) && new FileInfo(path).Length <= maxBytes ? File.ReadAllText(path, Encoding.UTF8) : null;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return null;
}
}
private static bool Same(string path, byte[] content) => File.Exists(path) && File.ReadAllBytes(path).AsSpan().SequenceEqual(content);
}
@@ -0,0 +1,19 @@
namespace MoShiro.Protocol;
/// <summary>构建信息的人能读的形式(启动时打进日志);常量部分由 MoShiro.Protocol.csproj 的 WriteBuildInfo 在构建时生成。</summary>
public static partial class BuildInfo
{
/// <summary>版本、tag、提交、构建时间的字段,没有的(本地构建不设环境变量)写 unknown。</summary>
public static (string Key, object? Value)[] Fields { get; } =
[
("version", Or(Version)),
("tag", Or(Tag)),
("commit", Or(Commit)),
("built_at", Or(BuiltAt)),
];
/// <summary>一行摘要,例如:0.4.1-dev.2-a3338e1 (tag v0.4.1-dev.2-a3338e1, commit a3338e1b2c, built 2026-10-09T03:00:00Z)。</summary>
public static string Summary => $"{Or(Version)} (tag {Or(Tag)}, commit {Or(Commit)}, built {Or(BuiltAt)})";
private static string Or(string value) => string.IsNullOrWhiteSpace(value) ? "unknown" : value;
}
@@ -27,10 +27,10 @@ public static class Kinds
public static readonly KindSpec UdpEcho = new(
"udp_echo",
"UDP echo",
"Fixed-rate UDP echo with four timestamps per packet.",
"QUIC Datagram echo",
"Fixed-rate QUIC Datagram echo with four timestamps per packet.",
Scope.Line,
new BlockSet("udp", "fixed", "fixed", "echo", []),
new BlockSet("quic_datagram", "fixed", "fixed", "echo", []),
[
P.Int("rate_hz", 10, 1, 200, "Hz"),
P.Int("size", 64, 44, 1400, "B"),
@@ -41,10 +41,10 @@ public static class Kinds
public static readonly KindSpec UdpTrain = new(
"udp_train",
"UDP train",
"Fixed-rate or laddered one-way UDP stream (Agent to Probe) for loss and jitter under load.",
"QUIC Datagram train",
"Fixed-rate or laddered one-way QUIC Datagram stream (Agent to Probe) for loss and delay variation under load.",
Scope.Line,
new BlockSet("udp", "ladder", "fixed", "sink", []),
new BlockSet("quic_datagram", "ladder", "fixed", "sink", []),
[
P.Number("mbps", 10, 0.1, 1000, "Mbit/s"),
P.Int("size", 1250, 200, 1400, "B"),
@@ -18,7 +18,7 @@
<ItemGroup>
<BuildInfoLine Include="namespace MoShiro.Protocol%3B" />
<BuildInfoLine Include="/// &lt;summary&gt;这个程序是什么时候、从哪个版本构建出来的(构建时生成)。&lt;/summary&gt;" />
<BuildInfoLine Include="public static class BuildInfo" />
<BuildInfoLine Include="public static partial class BuildInfo" />
<BuildInfoLine Include="{" />
<BuildInfoLine Include=" public const string Version = &quot;$(Version)&quot;%3B" />
<BuildInfoLine Include=" public const string Tag = &quot;$(MOSHIRO_TAG)&quot;%3B" />
@@ -3,7 +3,6 @@ namespace MoShiro.Protocol;
public static class ProtocolVersion
{
public const int Container = 1;
public const int Wire = 1;
}
public static class ProtocolLimits
@@ -39,5 +38,4 @@ public static class MoShiroCapabilities
{
public const string Base = "moshiro.v1";
public const string KindsPrefix = "moshiro.kinds:";
public const string BlocksPrefix = "moshiro.blocks:";
}
@@ -34,10 +34,10 @@ public static class MoShiroMethodTable
MethodSpec.Notification(MoShiroMethods.UpdateApply, RpcSide.Server),
MethodSpec.Notification(MoShiroMethods.Schedule, RpcSide.Server),
// 节点面:Agent → Backend
// 节点面:节点 → Backend
MethodSpec.Notification(MoShiroMethods.TestStatus, RpcSide.Node, NodeRoles.Agent),
MethodSpec.Notification(MoShiroMethods.Event, RpcSide.Node, NodeRoles.Agent),
MethodSpec.Notification(MoShiroMethods.Host, RpcSide.Node, NodeRoles.Agent),
MethodSpec.Notification(MoShiroMethods.Host, RpcSide.Node), // Agent 和 Probe 都上报主机资源占用的汇总
// 对等面:Probe(拨号方)→ Agent(监听方)
MethodSpec.Request(MoShiroMethods.Clock, RpcSide.Dialer),
@@ -146,8 +146,11 @@ public sealed class AgentEndpoint
[JsonPropertyName("tcp_port")]
public int TcpPort { get; set; }
[JsonPropertyName("quic_ports")]
public List<int> QuicPorts { get; set; } = [];
[JsonPropertyName("udp_ports")]
public List<int> UdpPorts { get; set; } = [];
public List<int> LegacyUdpPorts { get; set; } = [];
/// <summary>Agent 面向 Probe 的 TLS 证书公钥(SPKI,base64):证书按它的 SHA-256 指纹固定,敲门密钥由它和 Probe 的身份密钥派生。</summary>
[JsonPropertyName("public_key")]
@@ -175,8 +178,11 @@ public sealed class AgentEnrollMeta
[JsonPropertyName("probe_tcp_port")]
public int ProbeTcpPort { get; set; } = 7443;
[JsonPropertyName("probe_quic_ports")]
public List<int> ProbeQuicPorts { get; set; } = [];
[JsonPropertyName("probe_udp_ports")]
public List<int> ProbeUdpPorts { get; set; } = [7443];
public List<int>? LegacyProbeUdpPorts { get; set; }
[JsonPropertyName("public_host")]
public string? PublicHost { get; set; }
@@ -296,6 +302,9 @@ public sealed class UpdateStatus
/// <summary>Probe 的 <c>fleet.heartbeat</c> 附加字段:更新器的状态。</summary>
public sealed class ProbeHeartbeatExtra
{
[JsonPropertyName("quic_links")]
public List<ProbeQuicLinkHealth>? QuicLinks { get; set; }
[JsonPropertyName("ipv6")]
public Ipv6Diagnostic? Ipv6 { get; set; }
@@ -303,6 +312,53 @@ public sealed class ProbeHeartbeatExtra
public UpdateStatus? Update { get; set; }
}
/// <summary>Probe's rolling QUIC connection health snapshot for one Agent/IP path.</summary>
public sealed class ProbeQuicLinkHealth
{
[JsonPropertyName("agent_id")]
public string AgentId { get; set; } = "";
[JsonPropertyName("ip_version")]
public string IpVersion { get; set; } = IpVersions.V4;
[JsonPropertyName("connected")]
public bool Connected { get; set; }
[JsonPropertyName("handshake_attempts")]
public long HandshakeAttempts { get; set; }
[JsonPropertyName("handshake_successes")]
public long HandshakeSuccesses { get; set; }
[JsonPropertyName("handshake_failures")]
public long HandshakeFailures { get; set; }
[JsonPropertyName("handshake_timeouts")]
public long HandshakeTimeouts { get; set; }
[JsonPropertyName("handshake_p95_ms")]
public double? HandshakeP95Ms { get; set; }
[JsonPropertyName("handshake_sample_count")]
public long HandshakeSampleCount { get; set; }
/// <summary>QUIC packet-loss estimate from MsQuic's non-spurious loss counters; not Datagram application loss or retransmission.</summary>
[JsonPropertyName("transport_packet_loss_rate")]
public double? TransportPacketLossRate { get; set; }
[JsonPropertyName("transport_packets_sent")]
public ulong? TransportPacketsSent { get; set; }
[JsonPropertyName("transport_congestion_events")]
public uint? TransportCongestionEvents { get; set; }
[JsonPropertyName("transport_ecn_congestion_events")]
public uint? TransportEcnCongestionEvents { get; set; }
[JsonPropertyName("path_mtu")]
public ushort? PathMtu { get; set; }
}
/// <summary>Probe 的 <c>fleet.hello</c> 附加字段:系统、架构和构建信息(只用于展示和更新)。</summary>
public sealed class ProbeHelloMeta
{
@@ -345,8 +401,11 @@ public sealed class AgentHelloMeta
[JsonPropertyName("probe_tcp_port")]
public int? ProbeTcpPort { get; set; }
[JsonPropertyName("probe_quic_ports")]
public List<int>? ProbeQuicPorts { get; set; }
[JsonPropertyName("probe_udp_ports")]
public List<int>? ProbeUdpPorts { get; set; }
public List<int>? LegacyProbeUdpPorts { get; set; }
[JsonPropertyName("local_limits")]
public LocalLimits? LocalLimits { get; set; }
@@ -482,8 +541,11 @@ public sealed class LineTaskSet
public List<LineTask> Tasks { get; set; } = [];
/// <summary>Agent 的数据面端口(Probe 把包发到这里)。</summary>
[JsonPropertyName("quic_ports")]
public List<int> QuicPorts { get; set; } = [];
[JsonPropertyName("udp_ports")]
public List<int> UdpPorts { get; set; } = [];
public List<int> LegacyUdpPorts { get; set; } = [];
[JsonPropertyName("tcp_ports")]
public List<int> TcpPorts { get; set; } = [];
@@ -3,7 +3,7 @@ using System.Security.Cryptography;
namespace MoShiro.Protocol;
public enum UdpPacketType : byte
public enum MeasurementDatagramType : byte
{
EchoRequest = 1,
EchoReply = 2,
@@ -12,7 +12,7 @@ public enum UdpPacketType : byte
Data = 3,
}
public readonly record struct UdpHeader(UdpPacketType Type, long SessionId, uint StreamId, uint Seq, long SendTsUs);
public readonly record struct MeasurementDatagramHeader(MeasurementDatagramType Type, long SessionId, uint StreamId, uint Seq, long SendTsUs);
/// <summary>
/// 数据面的 UDP 包(Fleet 之外的线格式):
@@ -20,7 +20,7 @@ public readonly record struct UdpHeader(UdpPacketType Type, long SessionId, uint
/// mac = HMAC-SHA256(session_key, 前面全部字节) 截断到 16 字节。所有整数小端。
/// 接收方对不合法的包静默丢弃并计数,不回复(防放大)。
/// </summary>
public static class UdpPacket
public static class MeasurementDatagram
{
public const ushort Magic = 0x4D4E; // "MN"
public const byte Version = 1;
@@ -30,19 +30,19 @@ public static class UdpPacket
public const int MaxBytes = 1472;
/// <summary>检查长度、magic、version、type 并解析头部(不校验 MAC)。</summary>
public static bool TryParseHeader(ReadOnlySpan<byte> packet, out UdpHeader header)
public static bool TryParseHeader(ReadOnlySpan<byte> packet, out MeasurementDatagramHeader header)
{
header = default;
if (packet.Length is < MinBytes or > MaxBytes
|| BinaryPrimitives.ReadUInt16LittleEndian(packet) != Magic
|| packet[2] != Version
|| packet[3] is < (byte)UdpPacketType.EchoRequest or > (byte)UdpPacketType.Data)
|| packet[3] is < (byte)MeasurementDatagramType.EchoRequest or > (byte)MeasurementDatagramType.Data)
{
return false;
}
header = new UdpHeader(
(UdpPacketType)packet[3],
header = new MeasurementDatagramHeader(
(MeasurementDatagramType)packet[3],
BinaryPrimitives.ReadInt64LittleEndian(packet[4..]),
BinaryPrimitives.ReadUInt32LittleEndian(packet[12..]),
BinaryPrimitives.ReadUInt32LittleEndian(packet[16..]),
@@ -51,7 +51,7 @@ public static class UdpPacket
}
/// <summary>写入整个包(长度就是 <paramref name="destination"/> 的长度),填充为零,末尾是 MAC。</summary>
public static void Write(Span<byte> destination, in UdpHeader header, ReadOnlySpan<byte> key)
public static void Write(Span<byte> destination, in MeasurementDatagramHeader header, ReadOnlySpan<byte> key)
{
if (destination.Length is < MinBytes or > MaxBytes)
{
@@ -19,8 +19,8 @@ public static class TcpWire
public static void WriteAuth(Span<byte> destination, long sessionId, uint streamId, ReadOnlySpan<byte> nonce, ReadOnlySpan<byte> key)
{
BinaryPrimitives.WriteUInt16LittleEndian(destination, UdpPacket.Magic);
destination[2] = UdpPacket.Version;
BinaryPrimitives.WriteUInt16LittleEndian(destination, MeasurementDatagram.Magic);
destination[2] = MeasurementDatagram.Version;
BinaryPrimitives.WriteInt64LittleEndian(destination[3..], sessionId);
BinaryPrimitives.WriteUInt32LittleEndian(destination[11..], streamId);
nonce[..NonceBytes].CopyTo(destination[15..]);
@@ -35,7 +35,7 @@ public static class TcpWire
sessionId = 0;
streamId = 0;
nonce = default;
if (frame.Length != AuthBytes || BinaryPrimitives.ReadUInt16LittleEndian(frame) != UdpPacket.Magic || frame[2] != UdpPacket.Version)
if (frame.Length != AuthBytes || BinaryPrimitives.ReadUInt16LittleEndian(frame) != MeasurementDatagram.Magic || frame[2] != MeasurementDatagram.Version)
{
return false;
}
@@ -16,7 +16,7 @@ public sealed class MeasurementTests
Params = parameters.ToDictionary(p => p.Key, p => Json(p.Raw)),
};
private static BudgetGuard Guard(NodeLimits? limits = null) => new(limits ?? new NodeLimits { AllowedUdpPorts = [7443], AllowedTcpPorts = [7444] }, BlockCatalog.AgentKinds);
private static BudgetGuard Guard(NodeLimits? limits = null) => new(limits ?? new NodeLimits { AllowedQuicPorts = [7443], AllowedTcpPorts = [7444] }, BlockCatalog.AgentKinds);
[Fact]
public void BudgetGuard_AcceptsDefaults_RejectsUnknownKindsBadParamsAndForeignPorts()
@@ -34,7 +34,7 @@ public sealed class MeasurementTests
[Fact]
public void BudgetGuard_AccumulatesTheBudget_AndRejectsWhatDoesNotFit()
{
BudgetGuard guard = Guard(new NodeLimits { MaxTotalMbps = 15, AllowedUdpPorts = [7443] });
BudgetGuard guard = Guard(new NodeLimits { MaxTotalMbps = 15, AllowedQuicPorts = [7443] });
double mbps = 0;
long pps = 0;
Assert.True(guard.CheckPhase("udp_train", new Dictionary<string, JsonElement> { ["mbps"] = Json("10") }).Accepted);
@@ -298,7 +298,7 @@ public sealed class MeasurementTests
[Fact]
public void EchoRows_Udp_GivesUpOnAMissingReplyAfterTwoSeconds()
{
var rows = new EchoRows(Kinds.UdpEcho, 64, EchoRows.UdpGraceUs, settleOnReply: false);
var rows = new EchoRows(Kinds.UdpEcho, 64, EchoRows.DatagramGraceUs, settleOnReply: false);
rows.Add(64, 0);
rows.Add(64, 1_000_000);
RawStream cut = rows.Take(2_500_000, all: false)!;
@@ -5,6 +5,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
<ProjectReference Include="..\..\MoShiro.Measurement\MoShiro.Measurement.csproj" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,113 @@
using Fleet.Core;
using Microsoft.Extensions.Time.Testing;
using MoShiro.Protocol;
namespace MoShiro.Measurement.Tests;
public sealed class RestartUpdaterTests
{
private readonly List<string> _restarts = [];
private RestartUpdater Create(string tag = "dev-aaa", string commit = "aaa", long builtAt = 1_000) =>
new("agent", _restarts.Add, TimeProvider.System, NullNodeLog.Instance, tag, commit, builtAt);
private static UpdateOfferParams Offer(string tag, long seq, string component = "agent", string commit = "bbb") =>
new() { Manifest = $"{{\"component\":\"{component}\",\"tag\":\"{tag}\",\"version\":\"0.4.0\",\"commit\":\"{commit}\",\"digest\":\"sha256:{new string('a', 64)}\",\"seq\":{seq}}}", Signature = "ignored" };
[Fact]
public void ANewerRelease_IsAvailable_AndOnlyARequestRestartsTheNode()
{
RestartUpdater updater = Create();
updater.Offer(Offer("dev-bbb", 2_000));
UpdateStatus status = updater.Status();
Assert.Equal("available", status.State);
Assert.Equal("dev-bbb", status.Available!.Tag);
Assert.Equal(2_000, status.Available.Seq);
Assert.Empty(_restarts);
Assert.True(updater.RequestApply());
Assert.Equal(["update"], _restarts);
Assert.Equal("applying", updater.Status().State);
}
[Fact]
public void TheReleaseThatIsAlreadyRunning_IsUpToDate_ByTagOrByCommit()
{
RestartUpdater updater = Create();
updater.Offer(Offer("dev-aaa", 2_000));
Assert.Equal("up_to_date", updater.Status().State);
updater.Offer(Offer("dev-other-tag", 2_000, commit: "aaa"));
Assert.Equal("up_to_date", updater.Status().State);
Assert.False(updater.RequestApply());
Assert.Empty(_restarts);
}
[Fact]
public void AnOfferOlderThanThisBuild_OrForAnotherComponent_IsNotAnUpdate()
{
RestartUpdater updater = Create(builtAt: 5_000);
updater.Offer(Offer("dev-bbb", 2_000));
Assert.Equal("up_to_date", updater.Status().State);
updater.Offer(Offer("dev-ccc", 9_000, component: "probe"));
Assert.Equal("up_to_date", updater.Status().State);
Assert.False(updater.RequestApply());
}
[Fact]
public void GarbageOffers_AreIgnored()
{
RestartUpdater updater = Create();
updater.Offer(new UpdateOfferParams { Manifest = "not json", Signature = "x" });
updater.Offer(new UpdateOfferParams { Manifest = new string('x', 5000), Signature = "x" });
updater.Offer(new UpdateOfferParams { Manifest = "{\"component\":\"agent\"}", Signature = "x" });
Assert.Equal("up_to_date", updater.Status().State);
Assert.False(updater.RequestApply());
}
[Fact]
public async Task TheAutoPolicy_RestartsByItself_ButNotBeforeTheMinimumUptime()
{
var time = new FakeTimeProvider(DateTimeOffset.FromUnixTimeSeconds(2_000_000_000));
var updater = new RestartUpdater("agent", _restarts.Add, time, NullNodeLog.Instance, "dev-aaa", "aaa", 1_000, auto: true);
updater.Offer(Offer("dev-bbb", 2_000));
updater.Offer(Offer("dev-bbb", 2_000)); // 重连时面板会再发一次:不能排两个
Assert.Empty(_restarts);
time.Advance(TimeSpan.FromMinutes(9));
await Task.Delay(50);
Assert.Empty(_restarts);
time.Advance(TimeSpan.FromMinutes(2));
await WaitAsync(() => _restarts.Count == 1);
Assert.Equal("applying", updater.Status().State);
await Task.Delay(50);
Assert.Single(_restarts);
}
[Fact]
public async Task TheManualPolicy_NeverRestartsByItself()
{
var time = new FakeTimeProvider(DateTimeOffset.FromUnixTimeSeconds(2_000_000_000));
var updater = new RestartUpdater("agent", _restarts.Add, time, NullNodeLog.Instance, "dev-aaa", "aaa", 1_000, auto: false);
updater.Offer(Offer("dev-bbb", 2_000));
time.Advance(TimeSpan.FromHours(1));
await Task.Delay(50);
Assert.Empty(_restarts);
Assert.Equal("available", updater.Status().State);
}
private static async Task WaitAsync(Func<bool> condition)
{
for (int i = 0; i < 100 && !condition(); i++)
{
await Task.Delay(20);
}
Assert.True(condition());
}
[Fact]
public void WithoutTheEnvironmentVariable_ThereIsNoUpdater()
{
Assert.Null(Environment.GetEnvironmentVariable(RestartUpdater.EnvironmentVariable));
Assert.Null(RestartUpdater.FromEnvironment("agent", _restarts.Add, TimeProvider.System, NullNodeLog.Instance));
}
}
@@ -26,10 +26,18 @@ public static class LinkMetrics
"jitter.down", s_udpMsg, Ops.OwdVar(Dir.Down), "Jitter (down)", "One-way delay variation, offset and drift removed.", Unit.Ms, [Stat.P95], good: 3, poor: 20);
public static readonly MetricDef LossRun = Metric.RunLength(
"loss.run", s_udpMsg, Ops.LossRuns, "Loss runs", "Lengths of consecutive lost packets.",
"loss.run", s_udpMsg, Ops.LossRuns(Dir.Down), "Loss runs (legacy downstream alias)", "Legacy downstream-only consecutive loss metric.",
new MetricExport("loss.run_max", Stat.Max, Unit.Packets, 2, 10),
new MetricExport("loss.run_mean", Stat.Mean, Unit.Packets));
public static readonly MetricDef LossRunUp = Metric.Max(
"loss.run.up", s_udpMsg, Ops.LossRunMax(Dir.Up), "Upstream longest loss run", "Longest consecutive Probe-to-Agent Datagram loss run in each chunk; zero means no loss.",
Unit.Packets, "loss.run.up_max", good: 0, poor: 10);
public static readonly MetricDef LossRunDown = Metric.Max(
"loss.run.down", s_udpMsg, Ops.LossRunMax(Dir.Down), "Downstream longest loss run", "Longest consecutive Agent-to-Probe Datagram loss run in each chunk, conditioned on the request reaching the Agent; zero means no loss.",
Unit.Packets, "loss.run.down_max", good: 0, poor: 10);
public static readonly MetricDef Reorder = Metric.Counter(
"reorder", s_udpMsg, Ops.Reorder, "Reordered", "Replies that arrived after a higher sequence number.", new MetricExport("reorder", Stat.Count, Unit.Count));
@@ -11,7 +11,7 @@ public static class MetricRegistry
public static readonly IReadOnlyList<MetricDef> All =
[
LinkMetrics.Rtt, LinkMetrics.LossUp, LinkMetrics.LossDown, LinkMetrics.LossTotal,
LinkMetrics.JitterUp, LinkMetrics.JitterDown, LinkMetrics.LossRun, LinkMetrics.Reorder, LinkMetrics.Dup, LinkMetrics.SamplesUdp,
LinkMetrics.JitterUp, LinkMetrics.JitterDown, LinkMetrics.LossRun, LinkMetrics.LossRunUp, LinkMetrics.LossRunDown, LinkMetrics.Reorder, LinkMetrics.Dup, LinkMetrics.SamplesUdp,
TcpMetrics.Rtt, TcpMetrics.Slow, TcpMetrics.Stall, TcpMetrics.StallMax, TcpMetrics.Retrans, TcpMetrics.Srtt, TcpMetrics.BulkRate, TcpMetrics.BulkPeak, TcpMetrics.SamplesTcp,
SessionMetrics.ConnectSuccess, SessionMetrics.ConnectTcp, SessionMetrics.ConnectTls,
SessionMetrics.FailTimeout, SessionMetrics.FailReset, SessionMetrics.FailTls,
@@ -22,7 +22,8 @@ public static class MetricRegistry
/// <summary>指标分组:前端按分组展示,新增指标只需要把它加进分组。</summary>
public static readonly IReadOnlyList<PresetDef> Presets =
[
new("udp", "UDP", ["loss.up", "loss.down", "loss.total", "rtt.p50", "rtt.p95", "rtt.p99", "jitter.up.p95", "jitter.down.p95", "loss.run_max", "reorder", "dup"]),
new("quic", "QUIC Datagram", ["loss.up", "loss.down", "loss.total", "rtt.p50", "rtt.p95", "rtt.p99", "jitter.up.p95", "jitter.down.p95", "loss.run.up_max", "loss.run.down_max", "reorder", "dup"]),
new("udp", "QUIC Datagram (legacy alias)", ["loss.up", "loss.down", "loss.total", "rtt.p50", "rtt.p95", "rtt.p99", "jitter.up.p95", "jitter.down.p95", "loss.run_max", "reorder", "dup"]),
new("tcp", "TCP", ["tcp.rtt.p50", "tcp.rtt.p95", "tcp.rtt.p99", "tcp.rtt.p999", "tcp.rtt.max", "tcp.rtt.tail", "tcp.slow", "tcp.stall.per_min", "tcp.stall_max", "tcp.retrans", "tcp.srtt.p95"]),
new("connect", "Connect", ["connect.success", "connect.tcp_ms.p95", "connect.tls_ms.p95", "connect.fail.timeout", "connect.fail.rst", "connect.fail.tls"]),
new("session", "Session", ["hold.resets.per_h", "hold.lifetime_max"]),
@@ -1,6 +1,6 @@
namespace MoShiro.Backend.Analysis;
/// <summary>连接建立、长连接、UDP 压测(udp_train)的指标。</summary>
/// <summary>TCP 建连、长连接、QUIC Datagram 压测(legacy kind udp_train)的指标。</summary>
public static class SessionMetrics
{
private static readonly string[] s_conn = ["connect.conn"];
@@ -33,13 +33,13 @@ public static class SessionMetrics
"hold.lifetime", s_hold, Ops.HoldLifetime, "Longest connection lifetime", "Longest observed lifetime of the held connection (ms).", Unit.Ms, "hold.lifetime_max", Better.Higher);
public static readonly MetricDef TrainLoss = Metric.Ratio(
"train.loss", s_train, Ops.TrainLoss, "Train loss", "Loss of a fixed-rate UDP stream.", good: 1, poor: 10);
"train.loss", s_train, Ops.TrainLoss, "Datagram train loss", "Loss of a fixed-rate QUIC Datagram stream.", good: 1, poor: 10);
public static readonly MetricDef TrainJitter = Metric.Histogram(
"train.jitter", s_train, Ops.TrainJitter, "Train jitter", "One-way delay variation of a fixed-rate UDP stream.", Unit.Ms, [Stat.P95], good: 3, poor: 20);
"train.jitter", s_train, Ops.TrainJitter, "Datagram train delay variation", "One-way delay variation of a fixed-rate QUIC Datagram stream.", Unit.Ms, [Stat.P95], good: 3, poor: 20);
public static readonly MetricDef TrainRate = Metric.Histogram(
"train.rate", s_train, Ops.TrainRate, "Train rate", "Delivered rate of a fixed-rate UDP stream per second.", Unit.Mbps, [Stat.P50, Stat.Mean], Better.Higher);
"train.rate", s_train, Ops.TrainRate, "Datagram train rate", "Delivered rate of a fixed-rate QUIC Datagram stream per second.", Unit.Mbps, [Stat.P50, Stat.Mean], Better.Higher);
public static readonly MetricDef SamplesConnect = Metric.Counter(
"n.connect", s_conn, Ops.RowCount, "connect samples", "Number of connection attempts.", new MetricExport("n.connect", Stat.Count, Unit.Count), Better.None);
@@ -12,9 +12,9 @@ public sealed class BaseModel : ServiceModelBase
public override string Title => "Line quality";
public override int Version => 1;
public override int Version => 2;
public override IReadOnlyList<string> Needs { get; } = ["rtt.p95", "loss.total", "jitter.down.p95", "jitter.up.p95", "loss.run_max"];
public override IReadOnlyList<string> Needs { get; } = ["rtt.p95", "loss.total", "jitter.down.p95", "jitter.up.p95", "loss.run.up_max", "loss.run.down_max"];
public override ModelScore Score(MetricView view) =>
Combine(
@@ -24,7 +24,8 @@ public sealed class BaseModel : ServiceModelBase
Dim(view, "loss.total", "Packet loss", 4, s_loss),
Dim(view, "jitter.down.p95", "Jitter (down)", 1.5, s_jitter),
Dim(view, "jitter.up.p95", "Jitter (up)", 1.5, s_jitter),
Dim(view, "loss.run_max", "Longest loss run", 1, s_run),
Dim(view, "loss.run.up_max", "Longest upstream loss run", 0.5, s_run),
Dim(view, "loss.run.down_max", "Longest downstream loss run", 0.5, s_run),
],
estimated: false);
}
@@ -273,7 +273,14 @@ internal static class JitterEmitter
/// <summary>连续丢失段的长度(按发送顺序)。</summary>
internal sealed class LossRunsOp : MetricOp
{
public override string Signature => "loss_runs/1";
private readonly Dir _direction;
public LossRunsOp(Dir direction)
{
_direction = direction;
}
public override string Signature => $"loss_runs/{_direction}/2";
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
{
@@ -292,7 +299,13 @@ internal sealed class LossRunsOp : MetricOp
continue;
}
if (c.IsReplyLost(i))
bool lost = _direction switch
{
Dir.Up => c.IsUpLost(i),
Dir.Down => !c.IsUpLost(i) && c.IsReplyLost(i),
_ => c.IsReplyLost(i),
};
if (lost)
{
if (run == 0)
{
@@ -315,6 +328,59 @@ internal sealed class LossRunsOp : MetricOp
}
}
/// <summary>每个块一个最长连续丢包值;无丢包的有效块明确输出 0。</summary>
internal sealed class LossRunMaxOp(Dir direction) : MetricOp
{
public override string Signature => $"loss_run_max/{direction}/1";
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
{
var c = new EchoColumns(stream);
if (!c.HasBasics || (direction != Dir.Total && c.T2 is null))
{
return;
}
int run = 0;
int maximum = 0;
bool hasSample = false;
long sampleAt = 0;
for (int i = 0; i < c.Count; i++)
{
if (!c.T1!.IsValid(i))
{
continue;
}
bool lost = direction switch
{
Dir.Up => c.IsUpLost(i),
Dir.Down => !c.IsUpLost(i) && c.IsReplyLost(i),
_ => c.IsReplyLost(i),
};
if (direction == Dir.Down && c.IsUpLost(i))
{
// A request lost before reaching the Agent is not a downstream sample.
run = 0;
continue;
}
if (!hasSample)
{
sampleAt = c.SentUtcMs(view, i);
hasSample = true;
}
run = lost ? run + 1 : 0;
maximum = Math.Max(maximum, run);
}
if (hasSample)
{
sink.Emit(sampleAt, maximum);
}
}
}
/// <summary>乱序:按到达顺序,序号比之前见过的最大序号还小。重复的序号不算乱序。</summary>
internal sealed class ReorderOp : MetricOp
{
@@ -11,7 +11,9 @@ public static class Ops
public static MetricOp OwdVar(Dir dir) => new OwdVarOp(dir);
public static MetricOp LossRuns { get; } = new LossRunsOp();
public static MetricOp LossRuns(Dir direction) => new LossRunsOp(direction);
public static MetricOp LossRunMax(Dir direction) => new LossRunMaxOp(direction);
public static MetricOp Reorder { get; } = new ReorderOp();
@@ -143,7 +143,7 @@ public static class AgentEndpoints
return Problems.From(http, ownerError);
}
var plan = new InstallPlan(genesis, controllers, InstallScripts.Sha256(InstallScripts.Sh), InstallScripts.Sha256(InstallScripts.Ps1));
var plan = new InstallPlan(genesis, controllers, InstallScripts.Sha256(InstallScripts.Sh), InstallScripts.Sha256(InstallScripts.Ps1), InstallScripts.ComposeSha256(request.Role ?? JoinTokenService.AgentRole), InstallScripts.QuadletSha256(request.Role ?? JoinTokenService.AgentRole));
http.SetDetail($"{request.Role ?? JoinTokenService.AgentRole}: {request.Note}");
Result<CreatedJoinToken> result = await tokens.CreateAsync(
new Application.CreateJoinTokenRequest(request.Note, request.Labels, request.AutoApprove ?? true, request.MaxUses ?? 1, request.ExpiresInHours ?? 24, request.Role ?? JoinTokenService.AgentRole, http.GetPrincipal().Label), baseUrl, plan, http.RequestAborted);
@@ -154,7 +154,7 @@ public static class AgentEndpoints
http.Response.Headers.CacheControl = "no-store";
http.Response.Headers.Location = "/api/admin/join-tokens";
return TypedResults.Json(new CreatedJoinTokenDto(Mappers.ToDto(created.Info), created.Plaintext, new InstallCommandsDto(created.InstallCommands.Linux, created.InstallCommands.Macos, created.InstallCommands.Windows)), ApiJsonContext.Default.CreatedJoinTokenDto, statusCode: StatusCodes.Status201Created);
return TypedResults.Json(new CreatedJoinTokenDto(Mappers.ToDto(created.Info), created.Plaintext, new InstallCommandsDto(created.InstallCommands.Linux, created.InstallCommands.Macos, created.InstallCommands.Windows), new ComposeInstallDto(created.Compose.Env, created.Compose.Url, created.Compose.Sha256, created.Compose.Linux, created.Compose.Macos, created.Compose.Windows), new QuadletInstallDto(created.Quadlet.Env, created.Quadlet.Url, created.Quadlet.Sha256, created.Quadlet.Command)), ApiJsonContext.Default.CreatedJoinTokenDto, statusCode: StatusCodes.Status201Created);
}
private static async Task<IResult> RevokeTokenAsync(JoinTokenService tokens, string id, bool? purge, CancellationToken ct)
@@ -179,15 +179,32 @@ public static class AgentEndpoints
? TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.ProbeDto)
: Problems.Create(http, MoShiroErrors.ProbeNotFound, "probe not found");
private static async Task<IResult> PatchProbeAsync(HttpContext http, ProbeService probes, PatchProbeRequest request, string id, CancellationToken ct)
private static async Task<IResult> PatchProbeAsync(HttpContext http, ProbeService probes, NodeInfoService nodes, PatchProbeRequest request, string id, CancellationToken ct)
{
if (request.Name is null)
if (request.Name is null && request.Isp is null)
{
return Problems.From(http, ValidationError.Single("name", "is required"));
return Problems.From(http, ValidationError.Single("name", "name or isp is required"));
}
Result<ProbeView> result = await probes.RenameAsync(id, request.Name, ct);
return result.TryGet(out ProbeView? view, out FleetError? error) ? TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.ProbeDto) : Problems.From(http, error);
if (request.Isp is not null && request.Isp != "auto" && IspClassifier.NameOf(request.Isp) is null)
{
return Problems.From(http, ValidationError.Single("isp", "must be telecom, unicom, mobile, broadnet, other or auto"));
}
Result<ProbeView> result = request.Name is null
? (await probes.GetAsync(id, ct) is { } existing ? Result<ProbeView>.Ok(existing) : Result<ProbeView>.Fail(MoShiroErrors.ProbeNotFound, "probe not found"))
: await probes.RenameAsync(id, request.Name, ct);
if (!result.TryGet(out ProbeView? view, out FleetError? error))
{
return Problems.From(http, error);
}
if (request.Isp is not null)
{
await nodes.SetIspAsync(id, NodeKinds.Probe, request.Isp == "auto" ? null : request.Isp, ct);
}
return TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.ProbeDto);
}
private static async Task<IResult> RevokeProbeAsync(HttpContext http, ProbeService probes, string id, CancellationToken ct)
@@ -25,9 +25,6 @@ public static class BuildEndpoints
group.MapPost("/nodes/{id}/update", RequestUpdateAsync)
.Api("nodes_update", "Ask an online agent or probe to install the release it has been offered", Tag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 409)
.Requires(Capability.UpdateConfirm);
group.MapDelete("/nodes/{id}/binary", ClearBinaryAsync)
.Api("nodes_binary_clear", "Accept the node's current binary: clear its binding so the next handshake binds it again", Tag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403)
.Requires(Capability.FleetWrite);
group.MapGet("/backend/update", BackendUpdateAsync)
.Api("backend_update_get", "Current channel and host-verified backend update", Tag).Returns<BackendUpdateDto>().ProducesProblems(401, 403)
.Requires(Capability.UpdateRead);
@@ -39,15 +36,6 @@ public static class BuildEndpoints
.Requires(Capability.UpdateRead);
}
/// <summary>登记发布清单:挂在 <see cref="ReleaseGuard"/> 下,除了管理员,CI 用的 <c>release</c> 令牌也能调用(它只能做这一件事)。</summary>
public static void MapPublish(RouteGroupBuilder group)
{
group.MapPost("/releases", PublishReleaseAsync)
.Api("releases_publish", "Register a CI-signed release manifest; online nodes get it right away (their updaters verify the signature). Admin or release tokens", Tag)
.Returns<ReleaseDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409)
.Requires(Capability.ReleasePublish);
}
/// <summary>
/// CI 登记清单的入口,<b>不需要令牌</b>:请求里的清单必须带有效的 CI 发布签名(用 Backend 内嵌的发布公钥验)才收下。
/// 这样登记不依赖面板里的任何凭证(清库、重装、还没有 passkey 的时候照常工作),也没有一把能被偷的长期令牌;
@@ -60,12 +48,12 @@ public static class BuildEndpoints
.Returns<ReleaseDto>(StatusCodes.Status201Created).ProducesProblems(400, 403, 409);
}
private static async Task<IResult> OfferReleaseAsync(HttpContext http, ReleaseService releases, PublishReleaseRequest request)
private static async Task<IResult> OfferReleaseAsync(HttpContext http, ReleaseService releases, ReleaseKeyService keys, PublishReleaseRequest request)
{
string manifest = request.Manifest ?? "";
string signature = request.Signature ?? "";
if (manifest.Length is 0 or > ReleaseService.MaxManifestBytes || signature.Length is 0 or > ReleaseService.MaxSignatureBytes
|| !ReleaseSignature.Verify(InstallScripts.ReleaseKey(), manifest, signature))
|| !await keys.VerifyAsync(manifest, signature, http.RequestAborted))
{
return Problems.Create(http, MoShiroErrors.Forbidden, "the manifest does not carry a valid release signature");
}
@@ -73,14 +61,13 @@ public static class BuildEndpoints
return await PublishReleaseAsync(http, releases, request);
}
private static async Task<IResult> GetAsync(HttpContext http, NodeInfoService nodes, ReleaseService releases, BackendUpdates backendUpdates, BinaryBindingService bindings, NodeOwnerService owners, CancellationToken ct)
private static async Task<IResult> GetAsync(HttpContext http, NodeInfoService nodes, ReleaseService releases, BackendUpdates backendUpdates, NodeOwnerService owners, CancellationToken ct)
{
IReadOnlyDictionary<string, IReadOnlyList<NodeOwner>> ownerOf = await owners.ListAsync(ct);
Dictionary<string, NodeBinding> bound = (await bindings.ListAsync(ct)).ToDictionary(b => b.NodeId, StringComparer.Ordinal);
var items = (await nodes.ListAsync(ct))
.OrderBy(n => n.Kind, StringComparer.Ordinal)
.ThenBy(n => n.NodeId, StringComparer.Ordinal)
.Select(n => new NodeBuildDto(n.NodeId, n.Kind, n.Os, n.OsName, n.Arch, n.Version, n.Tag, n.Commit, n.BuiltAt, ToDto(releases.StatusOf(new NodeId(n.NodeId))), bound.TryGetValue(n.NodeId, out NodeBinding? binding) ? ToDto(binding) : null, ownerOf.TryGetValue(n.NodeId, out IReadOnlyList<NodeOwner>? list) ? [.. list.Select(o => new NodeOwnerDto(o.CredentialId, o.PasskeyName, o.Username))] : null))
.Select(n => new NodeBuildDto(n.NodeId, n.Kind, n.Os, n.OsName, n.Arch, n.Version, n.Tag, n.Commit, n.BuiltAt, ToDto(releases.StatusOf(new NodeId(n.NodeId))), ownerOf.TryGetValue(n.NodeId, out IReadOnlyList<NodeOwner>? list) ? [.. list.Select(o => new NodeOwnerDto(o.CredentialId, o.PasskeyName, o.Username))] : null))
.ToList();
http.Response.Headers.CacheControl = "no-store";
return TypedResults.Json(new BuildInfoDto(NodeEndpoints.Backend(withCommit: true), items, ToDto(await backendUpdates.GetAsync(ct))), ApiJsonContext.Default.BuildInfoDto);
@@ -138,17 +125,6 @@ public static class BuildEndpoints
}
}
private static NodeBinaryDto ToDto(NodeBinding b) =>
new(b.Sha256, b.Signed, b.Seq, DateTimeOffset.FromUnixTimeMilliseconds(b.BoundAtMs), b.Violation, b.ViolationAtMs is { } at ? DateTimeOffset.FromUnixTimeMilliseconds(at) : null);
/// <summary>管理员确认新的二进制是自己要的:清除绑定,节点下一次握手重新绑定(没有绑定可清也返回 204,幂等)。</summary>
private static async Task<IResult> ClearBinaryAsync(HttpContext http, BinaryBindingService bindings, string id)
{
http.SetDetail("binary binding cleared");
await bindings.ClearAsync(id, http.RequestAborted);
return TypedResults.NoContent();
}
private static NodeUpdateDto? ToDto(UpdateStatus? status) =>
status is null ? null : new NodeUpdateDto(status.State, status.Current, status.Available?.Tag, status.Available?.Version, status.Error, status.At > 0 ? DateTimeOffset.FromUnixTimeSeconds(status.At) : null);
}
@@ -0,0 +1,54 @@
using Fleet.Core;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using MoShiro.Backend.Application;
namespace MoShiro.Backend.Api;
/// <summary>可信资产来源:官方发布密钥(只读)和可选的自有发布公钥(默认没有)。改动写审计。</summary>
public static class ReleaseKeyEndpoints
{
private const string Tag = "admin-release-key";
public static void Map(RouteGroupBuilder group)
{
group.MapGet("/release-key", GetAsync)
.Api("release_key_get", "The release keys this panel trusts: the built-in official key and the optional own key", Tag)
.Returns<ReleaseKeysDto>().ProducesProblems(401, 403)
.Requires(Capability.UpdateRead);
group.MapPut("/release-key", SetAsync)
.Api("release_key_set", "Trust your own release public key (P-256 PEM; never the private key) in addition to the official one", Tag)
.Returns<ReleaseKeysDto>().ProducesProblems(400, 401, 403)
.Requires(Capability.UpdateConfirm);
group.MapDelete("/release-key", ClearAsync)
.Api("release_key_clear", "Stop trusting your own release key; manifests only it signed are dropped", Tag)
.Returns<ReleaseKeysDto>().ProducesProblems(401, 403)
.Requires(Capability.UpdateConfirm);
}
private static async Task<IResult> GetAsync(ReleaseKeyService keys, CancellationToken ct) => Json(await keys.GetAsync(ct));
private static async Task<IResult> SetAsync(HttpContext http, ReleaseKeyService keys, SetReleaseKeyRequest request)
{
Result<ReleaseKeys> result = await keys.SetCustomAsync(request.PublicKey, http.RequestAborted);
if (!result.TryGet(out ReleaseKeys? view, out FleetError? error))
{
return Problems.From(http, error);
}
http.SetDetail("own release key " + view.Custom!.Fingerprint);
return Json(view);
}
private static async Task<IResult> ClearAsync(HttpContext http, ReleaseKeyService keys, ReleaseService releases)
{
await keys.ClearCustomAsync(http.RequestAborted);
await releases.PurgeUnverifiedAsync(http.RequestAborted);
http.SetDetail("own release key removed");
return Json(await keys.GetAsync(http.RequestAborted));
}
private static Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult<ReleaseKeysDto> Json(ReleaseKeys keys) =>
TypedResults.Json(new ReleaseKeysDto(new ReleaseKeyDto(keys.Official.Fingerprint, keys.Official.PublicKey), keys.Custom is null ? null : new ReleaseKeyDto(keys.Custom.Fingerprint, keys.Custom.PublicKey)), ApiJsonContext.Default.ReleaseKeysDto);
}
@@ -8,7 +8,7 @@ using MoShiro.Protocol;
namespace MoShiro.Backend.Api;
/// <summary>
/// 信任根(docs/access.md 第 7 节):操作员集合由 passkey 签名,节点从安装命令里固定的创世指纹出发验证它;
/// 信任根(docs/security/access.md 第 8 节):操作员集合由 passkey 签名,节点从安装命令里固定的创世指纹出发验证它;
/// 带操作员委托的加入码也在这里创建——委托是 passkey 对「这个码」签的,面板自己签不出来。
/// </summary>
public static class TrustEndpoints
+1 -2
View File
@@ -105,8 +105,6 @@ public static class ApiModule
RouteGroupBuilder admin = app.MapGroup("/api/admin");
AuthEndpoints.MapLogin(admin);
BuildEndpoints.MapPublish(admin.MapGroup(string.Empty).AddEndpointFilter(CapabilityGuard.InvokeAsync));
RouteGroupBuilder guarded = admin.MapGroup(string.Empty).AddEndpointFilter(CapabilityGuard.InvokeAsync);
AuthEndpoints.MapSession(guarded);
AgentEndpoints.Map(guarded);
@@ -116,6 +114,7 @@ public static class ApiModule
TrustEndpoints.Map(guarded);
SystemEndpoints.Map(guarded);
BuildEndpoints.Map(guarded);
ReleaseKeyEndpoints.Map(guarded);
LinkEndpoints.Map(guarded);
OpsEndpoints.Map(app);
@@ -31,7 +31,23 @@ public static class Mappers
public static OverviewLineDto ToDto(OverviewLine line) =>
new(
line.Line.Key.Id, line.Line.Agent.Agent.Id.Value, line.Line.Probe.Id, line.Line.Probe.Name, line.Line.Agent.Agent.Name, line.Line.Agent.Agent.Labels,
line.Line.Agent.Online, line.Line.Connected, line.Line.Agent.EndpointChanges, ToDto(line.Summary), Summary(line.LastTest), line.Line.IpVersion);
line.Line.Agent.Online, line.Line.Connected, line.Line.Agent.EndpointChanges, ToDto(line.Summary), Summary(line.LastTest), line.Line.IpVersion, ToDto(line.Line.QuicHealth));
public static QuicLineHealthDto? ToDto(ProbeQuicLinkSnapshot? snapshot)
{
if (snapshot is null)
{
return null;
}
ProbeQuicLinkHealth health = snapshot.Health;
double? Rate(long count) => health.HandshakeAttempts > 0 ? count * 100.0 / health.HandshakeAttempts : null;
return new QuicLineHealthDto(
health.Connected, health.HandshakeAttempts, health.HandshakeSuccesses, health.HandshakeFailures, health.HandshakeTimeouts,
Rate(health.HandshakeSuccesses), Rate(health.HandshakeFailures), Rate(health.HandshakeTimeouts), health.HandshakeP95Ms, health.HandshakeSampleCount,
health.TransportPacketLossRate, health.TransportPacketsSent, health.TransportCongestionEvents,
health.TransportEcnCongestionEvents, health.PathMtu, Time(snapshot.AtMs));
}
public static HostDto ToDto(HostSnapshot h) =>
new(h.CpuPercent, h.MemUsed, h.MemTotal, h.DiskUsed, h.DiskTotal, h.NetRxTotal, h.NetTxTotal, h.NetRxBps, h.NetTxBps, h.NetLinkMbps, h.UptimeSeconds, Time(h.AtUtcMs));
@@ -102,7 +118,7 @@ public static class Mappers
? new HeartbeatDto(h.Sessions, h.PpsIn, h.PpsOut, h.DroppedInvalid, h.DroppedRateLimited, h.QueueBytes, Time(view.HeartbeatAtMs ?? 0))
: null;
return new AgentDto(
a.Id.Value, a.Name, StatusName(a.Status), view.Online, view.ProbeConnected, [.. view.Probes.Select(ToDto)], view.Host is null ? null : ToDto(view.Host), a.PublicHost, a.ProbeTcpPort, a.ProbeUdpPorts, a.Labels, a.PublicVisible,
a.Id.Value, a.Name, StatusName(a.Status), view.Online, view.ProbeConnected, [.. view.Probes.Select(ToDto)], view.Host is null ? null : ToDto(view.Host), a.PublicHost, a.ProbeTcpPort, a.ProbeUdpPorts, a.ProbeUdpPorts, a.Labels, a.PublicVisible,
a.Kinds, a.AgentVersion, limits, a.TaskVersion, a.AppliedTaskVersion, rejected, heartbeat, view.EndpointChanges, Time(a.CreatedAtMs), Time(a.LastSeenAtMs), a.Ipv6Host, a.Ipv6Supported, Ipv6Of(view), !a.PublicHostManual, !a.Ipv6HostManual, a.AddedBy);
}
@@ -42,6 +42,7 @@ public sealed record AgentDto(
string PublicHost,
int ProbeTcpPort,
IReadOnlyList<int> ProbeUdpPorts,
IReadOnlyList<int> ProbeQuicPorts,
IReadOnlyList<string> Labels,
bool PublicVisible,
IReadOnlyList<string> Kinds,
@@ -83,6 +84,20 @@ public sealed record SetupRequest(string? ClaimCode, string? Username, string? P
public sealed record InstallCommandsDto(string Linux, string Macos, string Windows);
/// <param name="Env">要存成 <c>.env</c> 的内容(只有公开的值)。</param>
/// <param name="Url">compose 文件的下载地址。</param>
/// <param name="Sha256">compose 文件的 SHA-256:命令下载之后先核对它。</param>
/// <param name="Linux">Linux(Podman):建目录、写 .env、下载、核对哈希、启动,合在一起的一段命令。</param>
/// <param name="Macos">macOS(Docker Desktop);Agent 需要 Linux 宿主网络,所以是空的。</param>
/// <param name="Windows">Windows(Docker Desktop,PowerShell);Agent 同样是空的。</param>
public sealed record ComposeInstallDto(string Env, string Url, string Sha256, string Linux, string Macos, string Windows);
/// <param name="Env">要存成 <c>~/.config/moshiro/&lt;角色&gt;.env</c> 的内容(只有公开的值)。</param>
/// <param name="Url">Quadlet 单元的下载地址。</param>
/// <param name="Sha256">Quadlet 单元的 SHA-256:命令下载之后先核对它。</param>
/// <param name="Command">Linux + Podman(rootless):写 env、下载并核对单元、指定镜像仓库、启动,合在一起的一条命令。</param>
public sealed record QuadletInstallDto(string Env, string Url, string Sha256, string Command);
/// <summary>下一版里新增的 passkey。</summary>
public sealed record ProposedCredentialDto(string Id, string Name, DateTimeOffset CreatedAt);
@@ -100,7 +115,7 @@ public sealed record TrustProposalDto(string Statement, int Version, string Chal
/// <param name="InstallMatchesRelease">和 CI 签名的发布清单里记的哈希是否一致;没有清单或清单里没记时为空。不一致说明这个面板提供的脚本不是 CI 构建出来的那份。</param>
public sealed record TrustStateDto(bool Initialized, string? Genesis, int Version, bool InSync, TrustProposalDto? Proposal, string InstallUrl = "", IReadOnlyList<string>? Operators = null, string InstallShSha256 = "", string InstallPs1Sha256 = "", bool? InstallMatchesRelease = null, string ServerKey = "", bool ServerKeyListed = false, IReadOnlyList<NodeOwnerDto>? OperatorDetails = null);
public sealed record CreatedJoinTokenDto(JoinTokenDto Token, string Plaintext, InstallCommandsDto InstallCommands);
public sealed record CreatedJoinTokenDto(JoinTokenDto Token, string Plaintext, InstallCommandsDto InstallCommands, ComposeInstallDto Compose, QuadletInstallDto Quadlet);
/// <summary>Agent 眼中连着的一个 Probe(Agent 上报)。</summary>
public sealed record ConnectedProbeDto(string ProbeId, string? Version, string? Remote, DateTimeOffset ConnectedSince, string IpVersion = "ipv4");
@@ -117,7 +132,8 @@ public sealed record LinkMatrixDto(bool AutoLink, IReadOnlyList<LinkDto> Items);
public sealed record SetLinkRequest(bool? Enabled);
public sealed record PatchProbeRequest(string? Name);
/// <summary>Isp:telecom / unicom / mobile / broadnet / other 是手动指定,auto 是回到自动识别;不传就不改。</summary>
public sealed record PatchProbeRequest(string? Name, string? Isp = null);
public sealed record TaskDto(
string Id,
@@ -185,13 +201,10 @@ public sealed record ChangePasswordRequest(string Password);
public sealed record RecomputeDto(string State, IReadOnlyList<string> Metrics, long WindowsDone, long WindowsTotal, DateTimeOffset? From, DateTimeOffset? To, int QueueLength);
/// <summary>管理端看的构建信息:每个节点自报的版本、tag、提交和构建时间,以及系统。</summary>
/// <summary>节点二进制的绑定:第一次握手成功时绑定的 SHA-256。<c>Signed</c> 表示它出现在一份 CI 签名的清单里(否则只是首次握手时记下的);<c>Violation</c> 是最近一次不符的原因,下一次握手符合了就清掉。</summary>
public sealed record NodeBinaryDto(string Sha256, bool Signed, long Seq, DateTimeOffset BoundAt, string? Violation, DateTimeOffset? ViolationAt);
/// <summary>节点的一个属主:节点报上来的(安装时固定在节点本地的)passkey 凭证 id,以及它在面板上对应的 passkey 名称和账户(对不上任何已注册的 passkey 时为空)。</summary>
public sealed record NodeOwnerDto(string CredentialId, string? Passkey, string? Account);
public sealed record NodeBuildDto(string Id, string Kind, string? Os, string? OsName, string? Arch, string? Version, string? Tag, string? Commit, string? BuiltAt, NodeUpdateDto? Update = null, NodeBinaryDto? Binary = null, IReadOnlyList<NodeOwnerDto>? Owners = null);
public sealed record NodeBuildDto(string Id, string Kind, string? Os, string? OsName, string? Arch, string? Version, string? Tag, string? Commit, string? BuiltAt, NodeUpdateDto? Update = null, IReadOnlyList<NodeOwnerDto>? Owners = null);
/// <summary>节点宿主机上的更新器报告的状态(<c>up_to_date</c>、<c>available</c>、<c>applying</c>、<c>failed</c>、<c>rolled_back</c>、<c>invalid</c>);没装更新器的节点没有这个字段。</summary>
public sealed record NodeUpdateDto(string State, string? CurrentDigest, string? AvailableTag, string? AvailableVersion, string? Error, DateTimeOffset? At);
@@ -201,6 +214,14 @@ public sealed record ReleaseDto(string Component, long Seq, string Tag, string V
public sealed record PublishReleaseRequest(string? Manifest, string? Signature);
/// <summary>一把发布公钥:指纹是 SPKI 的 SHA-256(小写十六进制)。</summary>
public sealed record ReleaseKeyDto(string Fingerprint, string PublicKey);
/// <summary>可信资产来源:官方发布密钥(始终信任)和可选的自有发布公钥(默认没有)。</summary>
public sealed record ReleaseKeysDto(ReleaseKeyDto Official, ReleaseKeyDto? Custom);
public sealed record SetReleaseKeyRequest(string? PublicKey);
public sealed record BackendUpdateDto(string Channel, bool Enabled, string State, string? CurrentTag, string? CurrentDigest, ReleaseDto? Available, string? Error);
public sealed record ConfirmBackendUpdateRequest(long Seq);
@@ -22,6 +22,7 @@ namespace MoShiro.Backend.Api;
[JsonSerializable(typeof(DistributionDto))]
[JsonSerializable(typeof(HeatmapDto))]
[JsonSerializable(typeof(LineDto))]
[JsonSerializable(typeof(QuicLineHealthDto))]
[JsonSerializable(typeof(TestDto))]
[JsonSerializable(typeof(PageDto<TestDto>))]
[JsonSerializable(typeof(PageDto<KindDto>))]
@@ -45,9 +46,10 @@ namespace MoShiro.Backend.Api;
[JsonSerializable(typeof(ReleaseDto))]
[JsonSerializable(typeof(PageDto<ReleaseDto>))]
[JsonSerializable(typeof(PublishReleaseRequest))]
[JsonSerializable(typeof(ReleaseKeysDto))]
[JsonSerializable(typeof(SetReleaseKeyRequest))]
[JsonSerializable(typeof(AboutDto))]
[JsonSerializable(typeof(NodeBuildDto))]
[JsonSerializable(typeof(NodeBinaryDto))]
[JsonSerializable(typeof(NodeOwnerDto))]
[JsonSerializable(typeof(BuildInfoDto))]
[JsonSerializable(typeof(BackendUpdateDto))]
@@ -85,6 +87,8 @@ namespace MoShiro.Backend.Api;
[JsonSerializable(typeof(TrustStateDto))]
[JsonSerializable(typeof(SetupStatusDto))]
[JsonSerializable(typeof(SetupRequest))]
[JsonSerializable(typeof(ComposeInstallDto))]
[JsonSerializable(typeof(QuadletInstallDto))]
[JsonSerializable(typeof(ProposedCredentialDto))]
[JsonSerializable(typeof(PasskeyDto))]
[JsonSerializable(typeof(PageDto<PasskeyDto>))]
@@ -28,7 +28,27 @@ public sealed record OverviewLineDto(
int EndpointChanges,
SummaryDto Summary,
TestSummaryDto? LastTest,
string IpVersion = "ipv4");
string IpVersion = "ipv4",
QuicLineHealthDto? QuicHealth = null);
/// <summary>Latest QUIC session health reported by the Probe; cumulative counters reset when the Probe restarts.</summary>
public sealed record QuicLineHealthDto(
bool Connected,
long HandshakeAttempts,
long HandshakeSuccesses,
long HandshakeFailures,
long HandshakeTimeouts,
double? HandshakeSuccessRate,
double? HandshakeFailureRate,
double? HandshakeTimeoutRate,
double? HandshakeP95Ms,
long HandshakeSampleCount,
double? TransportPacketLossRate,
ulong? TransportPacketsSent,
uint? TransportCongestionEvents,
uint? TransportEcnCongestionEvents,
ushort? PathMtu,
DateTimeOffset At);
public sealed record OverviewDto(string Range, DateTimeOffset From, DateTimeOffset To, string Step, string Source, bool Degraded, IReadOnlyList<OverviewLineDto> Lines);
@@ -90,7 +110,8 @@ public sealed record LineDto(
IReadOnlyList<string> Kinds,
TestSummaryDto? LastTest,
string IpVersion = "ipv4",
bool Ipv6Available = false);
bool Ipv6Available = false,
QuicLineHealthDto? QuicHealth = null);
public sealed record DimensionDto(string Id, string Label, double Weight, double? Value, double? Score);
@@ -177,9 +198,9 @@ public sealed record HostDto(
long? UptimeSeconds,
DateTimeOffset At);
/// <summary>一个 Agent 的最新主机状态;<c>stale</c> 表示超过 15 秒没有更新(离线,或者 Agent 关闭了上报)。</summary>
/// <summary>一个节点(Agent 或 Probe)的最新主机状态;<c>stale</c> 表示超过 15 秒没有更新(离线,或者节点关闭了上报)。</summary>
/// <summary>节点的展示信息:只有系统、架构、国家代码、运营商,没有任何地址;站点打开「显示构建信息」之后还有版本和 tag。</summary>
public sealed record NodeInfoDto(string Id, string Kind, string? Os, string? OsName, string? Arch, string? Country, string? IspKey, string? IspName, string? Version = null, string? Tag = null);
public sealed record NodeInfoDto(string Id, string Kind, string? Os, string? OsName, string? Arch, string? Country, string? IspKey, string? IspName, string? Version = null, string? Tag = null, bool IspManual = false);
/// <summary>一个程序的构建信息:版本、tag、提交(短)和构建时间(UTC,ISO 8601)。没设置的是 <c>null</c>。</summary>
public sealed record BuildDto(string Version, string? Tag, string? Commit, string? BuiltAt);
@@ -187,9 +208,10 @@ public sealed record BuildDto(string Version, string? Tag, string? Commit, strin
/// <summary>公开的「关于」:站点没有打开「显示构建信息」时只有 <c>show_build: false</c>。</summary>
public sealed record AboutDto(bool ShowBuild, BuildDto? Backend);
public sealed record HostItemDto(string AgentId, string Name, bool Online, bool Stale, DateTimeOffset? LastSeenAt, HostDto? Host);
/// <param name="Kind"><c>agent</c> 或 <c>probe</c>。</param>
public sealed record HostItemDto(string Id, string Kind, string Name, bool Online, bool Stale, DateTimeOffset? LastSeenAt, HostDto? Host);
public sealed record LiveHostDto(string AgentId, bool Online, HostDto Host);
public sealed record LiveHostDto(string Id, string Kind, bool Online, HostDto Host);
/// <summary>主机资源占用的历史(列式,按步长取平均)。</summary>
public sealed record HostSeriesDto(string AgentId, DateTimeOffset From, DateTimeOffset To, string Step, IReadOnlyList<long> T, IReadOnlyDictionary<string, double?[]> Values);
public sealed record HostSeriesDto(string Id, DateTimeOffset From, DateTimeOffset To, string Step, IReadOnlyList<long> T, IReadOnlyDictionary<string, double?[]> Values);
@@ -6,7 +6,7 @@ namespace MoShiro.Backend.Api;
/// <summary>
/// 一行安装脚本:<c>packaging/install/</c> 下的 <c>install.sh</c> 和 <c>install.ps1</c> 作为嵌入资源打进 Backend,<b>原样返回,不替换任何值</b>。
/// 静态,所以它的 SHA-256 在一个版本内固定:CI 构建时算出来写进签名的发布清单,面板给的命令先核对哈希再执行(docs/access.md 第 15 节)。
/// 静态,所以它的 SHA-256 在一个版本内固定:CI 构建时算出来写进签名的发布清单,面板给的命令先核对哈希再执行(docs/security/access.md 第 10 节)。
/// 角色、面板地址、代码都走命令里的环境变量。
/// </summary>
public static class InstallScripts
@@ -19,6 +19,18 @@ public static class InstallScripts
public static string Script(string resource) => Read(resource);
/// <summary>compose 文件的资源名(<c>agent.yaml</c>、<c>probe.yaml</c>、<c>backend.yaml</c> 对应的);不是这三个就是 null。</summary>
public static string? ComposeResource(string file) => file is "agent.yaml" or "probe.yaml" or "backend.yaml" ? "compose-" + file : null;
/// <summary>某个角色(agent、probe)的 compose 文件的 SHA-256;其他角色返回空。</summary>
public static string ComposeSha256(string? role) => role is "agent" or "probe" ? Sha256("compose-" + role + ".yaml") : string.Empty;
/// <summary>Quadlet 单元的资源名(<c>agent.container</c>、<c>probe.container</c>);不是这两个就是 null。</summary>
public static string? QuadletResource(string file) => file is "agent.container" or "probe.container" ? "quadlet-" + file : null;
/// <summary>某个角色(agent、probe)的 Quadlet 单元的 SHA-256;其他角色返回空。</summary>
public static string QuadletSha256(string? role) => role is "agent" or "probe" ? Sha256("quadlet-" + role + ".container") : string.Empty;
/// <summary>脚本内容的 SHA-256(小写十六进制,按 UTF-8 字节)。</summary>
public static string Sha256(string resource) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(Read(resource))));
@@ -10,6 +10,11 @@
<EmbeddedResource Include="..\..\..\packaging\install\install.sh" LogicalName="install.sh" />
<EmbeddedResource Include="..\..\..\packaging\install\install.ps1" LogicalName="install.ps1" />
<EmbeddedResource Include="..\..\..\packaging\update\release-key.pub.pem" LogicalName="release-key.pub.pem" />
<EmbeddedResource Include="..\..\..\packaging\compose\agent\compose.yaml" LogicalName="compose-agent.yaml" />
<EmbeddedResource Include="..\..\..\packaging\compose\probe\compose.yaml" LogicalName="compose-probe.yaml" />
<EmbeddedResource Include="..\..\..\packaging\compose\backend\compose.yaml" LogicalName="compose-backend.yaml" />
<EmbeddedResource Include="..\..\..\packaging\quadlet\moshiro-agent.container" LogicalName="quadlet-agent.container" />
<EmbeddedResource Include="..\..\..\packaging\quadlet\moshiro-probe.container" LogicalName="quadlet-probe.container" />
</ItemGroup>
<!-- 前端构建产物(web 目录下 npm run build)内嵌进 Backend;没构建时不嵌入,Backend 只提供 API -->
<ItemGroup Condition="Exists('..\..\..\web\display\dist\index.html')">
@@ -33,6 +33,15 @@ public static class OpsEndpoints
app.MapGet("/install/manifest.sig", (HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code) => ManifestAsync(http, tokens, releases, options, code, signature: true))
.Api("install_manifest_signature", "Base64 DER ECDSA P-256 signature of the manifest", Tag)
.Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(404);
// compose 文件:Agent、Probe、Backend 各一份,静态(镜像用 :latest 或 .env 里的 MOSHIRO_VERSION,不含任何一次性的值),
// 面板的命令把参数放在本地的 .env 里,下载后先核对 SHA-256。
app.MapGet("/install/compose/{file}", (HttpContext http, string file) => InstallScripts.ComposeResource(file) is { } resource ? Static(http, resource, "text/yaml") : Problems.Create(http, MoShiroErrors.NotFound, "unknown compose file"))
.Api("install_compose", "The static compose file of agent, probe or backend (agent.yaml, probe.yaml, backend.yaml; same bytes for everyone)", Tag)
.Produces(StatusCodes.Status200OK, contentType: "text/yaml").ProducesProblems(404);
// Podman Quadlet 单元(Linux 上推荐,节点的更新靠 Pull=always):Agent、Probe 各一份,静态,命令里先核对 SHA-256。
app.MapGet("/install/quadlet/{file}", (HttpContext http, string file) => InstallScripts.QuadletResource(file) is { } resource ? Static(http, resource, "text/plain") : Problems.Create(http, MoShiroErrors.NotFound, "unknown quadlet file"))
.Api("install_quadlet", "The static Podman Quadlet unit of agent or probe (agent.container, probe.container; same bytes for everyone)", Tag)
.Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(404);
app.MapGet("/install.ps1", (HttpContext http) => Static(http, InstallScripts.Ps1, "text/plain"))
.Api("install_ps1", "The static one-line installer for Windows nodes (same bytes for everyone; its SHA-256 is published with each release)", Tag)
.Produces(StatusCodes.Status200OK, contentType: "text/plain");
@@ -40,9 +49,8 @@ public static class OpsEndpoints
private static async Task<IResult> ManifestAsync(HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code, bool signature)
{
string baseUrl = options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}";
InstallTarget? target = string.IsNullOrEmpty(code) ? null : await tokens.ResolveInstallAsync(code, baseUrl, http.RequestAborted);
ReleaseOffer? offer = target is null ? null : await releases.GetOrFetchAsync(target.Role, http.RequestAborted);
string? role = string.IsNullOrEmpty(code) ? null : await tokens.RoleOfCodeAsync(code, http.RequestAborted);
ReleaseOffer? offer = role is null ? null : await releases.GetOrFetchAsync(role, http.RequestAborted);
if (offer is null)
{
return Problems.Create(http, MoShiroErrors.NotFound, "no signed release is registered for this install code");
@@ -7,22 +7,27 @@ using MoShiro.Protocol;
namespace MoShiro.Backend.Api;
/// <summary>Agent 的主机资源占用:最新值(实时的部分走 SSE 的 <c>hosts</c> 主题)和历史曲线。只有汇总的数字,没有型号和 IP。</summary>
/// <summary>
/// Agent 和 Probe 的主机资源占用:最新值(实时的部分走 SSE 的 <c>hosts</c> 主题)和历史曲线。只有汇总的数字,没有型号和 IP。
/// 和别的公开接口一样:Agent 看得见才给(隐藏的只有登录后才看得见),Probe 没被吊销就给。
/// </summary>
public static class HostEndpoints
{
private const string Tag = "public";
private const string AgentKind = "agent";
private const string ProbeKind = "probe";
public static void Map(RouteGroupBuilder group)
{
group.MapGet("/hosts", ListAsync)
.Api("hosts_list", "Latest host resource usage of every visible agent (no hardware model, no addresses)", Tag)
.Api("hosts_list", "Latest host resource usage of every visible agent and probe (no hardware model, no addresses)", Tag)
.Returns<PageDto<HostItemDto>>().ProducesProblems(403);
group.MapGet("/agents/{agent_id}/host", HistoryAsync)
.Api("hosts_history", "Host resource usage history of one agent (columnar, averaged per step)", Tag)
group.MapGet("/hosts/{node_id}", HistoryAsync)
.Api("hosts_history", "Host resource usage history of one agent or probe (columnar, averaged per step)", Tag)
.Returns<HostSeriesDto>().ProducesProblems(403, 404, 422);
}
private static async Task<IResult> ListAsync(HttpContext http, AgentService agents, HostStatusService hosts, CancellationToken ct)
private static async Task<IResult> ListAsync(HttpContext http, AgentService agents, ProbeService probes, HostStatusService hosts, CancellationToken ct)
{
var items = new List<HostItemDto>();
foreach (AgentView view in await agents.ListAsync(ct))
@@ -33,18 +38,39 @@ public static class HostEndpoints
}
(HostSnapshot? host, bool stale) = hosts.Latest(view.Agent.Id);
items.Add(new HostItemDto(view.Agent.Id.Value, view.Agent.Name, view.Online, stale, Mappers.Time(view.Agent.LastSeenAtMs), host is null ? null : Mappers.ToDto(host)));
items.Add(new HostItemDto(view.Agent.Id.Value, AgentKind, view.Agent.Name, view.Online, stale, Mappers.Time(view.Agent.LastSeenAtMs), host is null ? null : Mappers.ToDto(host)));
}
foreach (ProbeView view in await probes.ListAsync(ct))
{
if (view.Revoked)
{
continue;
}
(HostSnapshot? host, bool stale) = hosts.Latest(new NodeId(view.Probe.Id));
items.Add(new HostItemDto(view.Probe.Id, ProbeKind, view.Probe.Name, view.Online, stale, host is null ? null : Mappers.Time(host.AtUtcMs), host is null ? null : Mappers.ToDto(host)));
}
return TypedResults.Json(new PageDto<HostItemDto>(items, null), ApiJsonContext.Default.PageDtoHostItemDto);
}
private static async Task<IResult> HistoryAsync(HttpContext http, AgentService agents, HostStatusService hosts, TimeProvider time, string agent_id, string? range, CancellationToken ct)
private static async Task<IResult> HistoryAsync(HttpContext http, AgentService agents, ProbeService probes, HostStatusService hosts, TimeProvider time, string node_id, string? range, CancellationToken ct)
{
AgentView? view = await agents.GetAsync(new NodeId(agent_id), ct);
if (view is not { Agent.Status: Fleet.Server.NodeStatus.Active } || !(view.Agent.PublicVisible || PublicGuard.CanSeeHidden(http)))
bool visible;
if (node_id.StartsWith(AgentIds.Prefix, StringComparison.Ordinal))
{
return Problems.Create(http, MoShiroErrors.AgentNotFound, "agent not found");
AgentView? view = await agents.GetAsync(new NodeId(node_id), ct);
visible = view is { Agent.Status: Fleet.Server.NodeStatus.Active } && (view.Agent.PublicVisible || PublicGuard.CanSeeHidden(http));
}
else
{
visible = node_id.StartsWith(ProbeId.Prefix, StringComparison.Ordinal) && await probes.GetAsync(node_id, ct) is { Revoked: false };
}
if (!visible)
{
return Problems.Create(http, MoShiroErrors.AgentNotFound, "node not found");
}
if (!RangeResolver.TryParseSpan(range ?? "1h", out long span) || span < Steps.Hour || span > 30 * Steps.Day)
@@ -54,7 +80,7 @@ public static class HostEndpoints
long now = time.GetUtcNow().ToUnixTimeMilliseconds();
long from = now - span;
IReadOnlyList<HostSample> samples = await hosts.HistoryAsync(view.Agent.Id, from, now + 60_000, ct);
IReadOnlyList<HostSample> samples = await hosts.HistoryAsync(new NodeId(node_id), from, now + 60_000, ct);
// 最多约 300 个点:按分钟的整数倍取步长,每个步长内取平均
long stepMs = Math.Max(60_000, (long)Math.Ceiling(span / 300.0 / 60_000) * 60_000);
@@ -92,6 +118,6 @@ public static class HostEndpoints
["rx_bps"] = Average(s => s.NetRxBps),
["tx_bps"] = Average(s => s.NetTxBps),
};
return TypedResults.Json(new HostSeriesDto(view.Agent.Id.Value, Mappers.Time(first), Mappers.Time(now), Steps.Format(stepMs), t, values), ApiJsonContext.Default.HostSeriesDto);
return TypedResults.Json(new HostSeriesDto(node_id, Mappers.Time(first), Mappers.Time(now), Steps.Format(stepMs), t, values), ApiJsonContext.Default.HostSeriesDto);
}
}
@@ -32,7 +32,7 @@ public static class LiveEndpoints
}
private static async Task<IResult> StreamAsync(
HttpContext http, AgentService agents, LineService lineService, TestService tests, QueryService query, LiveHub hub, SseLimiter limiter,
HttpContext http, AgentService agents, ProbeService probes, HostStatusService hosts, LineService lineService, TestService tests, QueryService query, LiveHub hub, SseLimiter limiter,
BackendOptions options, BackendCounters counters, TimeProvider time, IHostApplicationLifetime lifetime, string? ip_version)
{
string ipVersion = ip_version ?? IpVersions.V4;
@@ -84,7 +84,7 @@ public static class LiveEndpoints
http.Response.Headers["X-Accel-Buffering"] = "no";
await http.Response.Body.FlushAsync(http.RequestAborted);
var session = new LiveSession(http, agents, lineService, tests, query, hub, options, time, topics, lineKeys, ipVersion);
var session = new LiveSession(http, agents, probes, hosts, lineService, tests, query, hub, options, time, topics, lineKeys, ipVersion);
using var linked = CancellationTokenSource.CreateLinkedTokenSource(http.RequestAborted, lifetime.ApplicationStopping);
await session.RunAsync(linked.Token);
}
@@ -105,6 +105,8 @@ public static class LiveEndpoints
{
private readonly HttpContext _http;
private readonly AgentService _agents;
private readonly ProbeService _probes;
private readonly HostStatusService _hosts;
private readonly LineService _lines;
private readonly IReadOnlyDictionary<string, LineKey> _lineKeys;
private readonly string _ipVersion;
@@ -124,10 +126,12 @@ public static class LiveEndpoints
private long _lastWriteMs;
private long _lastPeriodicMs;
public LiveSession(HttpContext http, AgentService agents, LineService lines, TestService tests, QueryService query, LiveHub hub, BackendOptions options, TimeProvider time, IReadOnlyList<LiveTopic> topics, IReadOnlyDictionary<string, LineKey> lineKeys, string ipVersion)
public LiveSession(HttpContext http, AgentService agents, ProbeService probes, HostStatusService hosts, LineService lines, TestService tests, QueryService query, LiveHub hub, BackendOptions options, TimeProvider time, IReadOnlyList<LiveTopic> topics, IReadOnlyDictionary<string, LineKey> lineKeys, string ipVersion)
{
_http = http;
_agents = agents;
_probes = probes;
_hosts = hosts;
_lines = lines;
_lineKeys = lineKeys;
_ipVersion = ipVersion;
@@ -171,7 +175,7 @@ public static class LiveEndpoints
_nodeSignals.Enqueue(node);
break;
case HostSignal host when _topics.Any(t => t.Kind == LiveTopicKind.Hosts):
_hostSignals[host.AgentId] = host;
_hostSignals[host.Node] = host;
break;
case ChunkSignal chunk:
_dirtyLines.Add((chunk.AgentId, chunk.Series));
@@ -216,10 +220,20 @@ public static class LiveEndpoints
foreach (HostSignal host in hostSignals)
{
AgentView? view = await _agents.GetAsync(host.AgentId, ct);
if (host.Node.Value.StartsWith(ProbeId.Prefix, StringComparison.Ordinal))
{
if (await _probes.GetAsync(host.Node.Value, ct) is { Revoked: false } probe)
{
await WriteAsync("host", new LiveHostDto(host.Node.Value, "probe", probe.Online, Mappers.ToDto(host.Host)), ApiJsonContext.Default.LiveHostDto, ct);
}
continue;
}
AgentView? view = await _agents.GetAsync(host.Node, ct);
if (view is not null && IsVisible(view) && view.Agent.Status == Fleet.Server.NodeStatus.Active)
{
await WriteAsync("host", new LiveHostDto(host.AgentId.Value, view.Online, Mappers.ToDto(host.Host)), ApiJsonContext.Default.LiveHostDto, ct);
await WriteAsync("host", new LiveHostDto(host.Node.Value, "agent", view.Online, Mappers.ToDto(host.Host)), ApiJsonContext.Default.LiveHostDto, ct);
}
}
@@ -284,7 +298,15 @@ public static class LiveEndpoints
case LiveTopicKind.Hosts:
foreach (AgentView view in (await _agents.ListAsync(ct)).Where(v => v.Agent.Status == Fleet.Server.NodeStatus.Active && IsVisible(v) && v.Host is not null))
{
await WriteAsync("host", new LiveHostDto(view.Agent.Id.Value, view.Online, Mappers.ToDto(view.Host!)), ApiJsonContext.Default.LiveHostDto, ct);
await WriteAsync("host", new LiveHostDto(view.Agent.Id.Value, "agent", view.Online, Mappers.ToDto(view.Host!)), ApiJsonContext.Default.LiveHostDto, ct);
}
foreach (ProbeView view in await _probes.ListAsync(ct))
{
if (!view.Revoked && _hosts.Latest(new NodeId(view.Probe.Id)).Host is { } host)
{
await WriteAsync("host", new LiveHostDto(view.Probe.Id, "probe", view.Online, Mappers.ToDto(host)), ApiJsonContext.Default.LiveHostDto, ct);
}
}
break;
@@ -61,7 +61,7 @@ public static class NodeEndpoints
var items = (await nodes.ListAsync(ct))
.Where(n => visible.Contains(n.NodeId))
.OrderBy(n => n.NodeId, StringComparer.Ordinal)
.Select(n => new NodeInfoDto(n.NodeId, n.Kind, n.Os, n.OsName, n.Arch, n.Country, n.IspKey, n.IspName, showBuild ? n.Version : null, showBuild ? n.Tag : null))
.Select(n => new NodeInfoDto(n.NodeId, n.Kind, n.Os, n.OsName, n.Arch, n.Country, n.IspKey, n.IspName, showBuild ? n.Version : null, showBuild ? n.Tag : null, n.IspManual))
.ToList();
http.Response.Headers.CacheControl = "no-store";
return TypedResults.Json(new PageDto<NodeInfoDto>(items, null), ApiJsonContext.Default.PageDtoNodeInfoDto);
@@ -13,8 +13,8 @@ namespace MoShiro.Backend.Api;
public static class QueryEndpoints
{
private const string Tag = "public";
private const string DefaultOverviewPreset = "udp,tcp,scores";
private const string DefaultSeriesPreset = "udp,tcp";
private const string DefaultOverviewPreset = "quic,tcp,scores";
private const string DefaultSeriesPreset = "quic,tcp";
public static void Map(RouteGroupBuilder group)
{
@@ -102,7 +102,7 @@ public static class QueryEndpoints
return TypedResults.Json(
new LineDto(
line.Key.Id, view.Agent.Id.Value, line.Probe.Id, line.Probe.Name, view.Agent.Name, view.Agent.Labels, view.Online, line.Connected,
Mappers.Time(await query.EarliestAsync(line.Key, ct)), view.Agent.Kinds, Mappers.Summary(last), line.IpVersion, view.Agent.Ipv6Host.Length > 0),
Mappers.Time(await query.EarliestAsync(line.Key, ct)), view.Agent.Kinds, Mappers.Summary(last), line.IpVersion, view.Agent.Ipv6Host.Length > 0, Mappers.ToDto(line.QuicHealth)),
ApiJsonContext.Default.LineDto);
}
@@ -14,12 +14,23 @@ public sealed record InstallCommands(string Linux, string Macos, string Windows)
/// 安装命令里除了一次性的码,还带的公开值:信任根的创世指纹(节点把它固定下来)、属主(允许授权这个节点 extension 的 passkey 凭证 id)、
/// 和面板提供的静态安装脚本的 SHA-256(命令先核对哈希再执行)。没有建立信任根时 <c>Genesis</c> 为空,节点不固定信任根。
/// </summary>
public sealed record InstallPlan(string? Genesis, IReadOnlyList<string> Controllers, string ScriptShSha256, string ScriptPs1Sha256);
/// <param name="ComposeSha256">面板提供的这个角色的 compose 文件的 SHA-256(命令下载之后先核对)。</param>
/// <param name="QuadletSha256">面板提供的这个角色的 Podman Quadlet 单元的 SHA-256。</param>
public sealed record InstallPlan(string? Genesis, IReadOnlyList<string> Controllers, string ScriptShSha256, string ScriptPs1Sha256, string ComposeSha256 = "", string QuadletSha256 = "");
public sealed record CreatedJoinToken(JoinTokenInfo Info, string Plaintext, InstallCommands InstallCommands);
/// <summary>
/// compose 安装:<c>Env</c> 是要存成 <c>.env</c> 的内容(只有公开的值),<c>Url</c> 和 <c>Sha256</c> 是 compose 文件的下载地址和哈希,
/// 三条命令(Linux 用 Podman,macOS、Windows 用 Docker Desktop)把 .env、下载、核对哈希和启动合在一起;Agent 的 compose 需要 Linux 宿主网络,所以它的 macOS、Windows 命令是空的。
/// </summary>
public sealed record ComposeInstall(string Env, string Url, string Sha256, string Linux, string Macos, string Windows);
/// <summary>安装脚本里要填的四个值。</summary>
public sealed record InstallTarget(string Role, string Server, string Code, string Image);
/// <summary>
/// Podman Quadlet 安装(Linux 上推荐):<c>Env</c> 存成 <c>~/.config/moshiro/&lt;角色&gt;.env</c>,<c>Url</c> 和 <c>Sha256</c> 是 Quadlet 单元的下载地址和哈希,
/// <c>Command</c> 把 env、单元、镜像仓库(drop-in)、核对哈希和启动合在一起。单元写了 <c>Pull=always</c>,节点退出再被拉起就是更新,默认自动更新。
/// </summary>
public sealed record QuadletInstall(string Env, string Url, string Sha256, string Command);
public sealed record CreatedJoinToken(JoinTokenInfo Info, string Plaintext, InstallCommands InstallCommands, ComposeInstall Compose, QuadletInstall Quadlet);
/// <summary>加入令牌:明文只在创建时返回一次,服务端只保存 SHA-256。</summary>
public sealed class JoinTokenService
@@ -82,7 +93,7 @@ public sealed class JoinTokenService
Ulid.NewPrefixed("jtk", _time), TokenHash.Sha256(plaintext), request.Note, request.Labels ?? [], request.AutoApprove, KindOf(request.Role),
request.MaxUses, now + (request.ExpiresInHours * 3_600_000L), now, request.CreatedBy, ct);
return Result<CreatedJoinToken>.Ok(new CreatedJoinToken(info, plaintext, Commands(BaseUrl(requestBaseUrl), request.Role, plaintext, plan)));
return Result<CreatedJoinToken>.Ok(new CreatedJoinToken(info, plaintext, Commands(BaseUrl(requestBaseUrl), request.Role, plaintext, plan), Compose(BaseUrl(requestBaseUrl), request.Role, plaintext, plan), Quadlet(BaseUrl(requestBaseUrl), request.Role, plaintext, plan)));
}
/// <summary>
@@ -101,13 +112,95 @@ public sealed class JoinTokenService
+ $"$env:MOSHIRO_SERVER='{baseUrl}'; $env:MOSHIRO_ROLE='{role}'; $env:MOSHIRO_CODE='{code}'; {psPin}iex (Get-Content -Raw $f)");
}
/// <summary>
/// 把一次性码和公开的值写进本地的 <c>.env</c>,下载静态的 compose 文件、核对哈希、<c>compose up -d</c>:以后更新就是
/// <c>compose pull &amp;&amp; compose up -d</c>。镜像来自 CI 渠道(<c>:latest</c>,也可以在 .env 里固定版本),不由面板决定。
/// </summary>
public ComposeInstall Compose(string baseUrl, string role, string code, InstallPlan plan)
{
string url = $"{baseUrl}/install/compose/{role}.yaml";
string dir = $"moshiro-{role}";
string Env(bool desktop)
{
var lines = new List<string> { $"MOSHIRO_SERVER={baseUrl}", $"MOSHIRO_JOIN={code}" };
if (plan.Genesis is null)
{
lines.Add("MOSHIRO_ALLOW_NO_OWNER=1");
}
else
{
lines.Add($"MOSHIRO_GENESIS={plan.Genesis}");
lines.Add($"MOSHIRO_CONTROLLERS={string.Join(',', plan.Controllers)}");
}
lines.Add($"MOSHIRO_IMAGE={ImageRepository(role)}");
if (desktop)
{
// Docker Desktop 没有宿主网络
lines.Add("MOSHIRO_NETWORK_MODE=bridge");
lines.Add("MOSHIRO_NETWORK_SCOPE=container");
}
return string.Join('\n', lines);
}
string linux = $"mkdir -p {dir} && cd {dir} && cat > .env <<'EOF'\n{Env(false)}\nEOF\n"
+ $"chmod 600 .env && curl -fsSL '{url}' -o compose.yaml && echo \"{plan.ComposeSha256} compose.yaml\" | sha256sum -c - && podman compose up -d";
string macos = role == ProbeRole
? $"mkdir -p {dir} && cd {dir} && cat > .env <<'EOF'\n{Env(true)}\nEOF\n"
+ $"chmod 600 .env && curl -fsSL '{url}' -o compose.yaml && echo \"{plan.ComposeSha256} compose.yaml\" | shasum -a 256 -c - && docker compose up -d"
: "";
string windows = role == ProbeRole
? $"New-Item -ItemType Directory -Force {dir} | Out-Null; Set-Location {dir}\n@'\n{Env(true)}\n'@ | Set-Content -Encoding ascii .env\n"
+ $"irm '{url}' -OutFile compose.yaml; if ((Get-FileHash compose.yaml -Algorithm SHA256).Hash -ne '{plan.ComposeSha256}') {{ throw 'compose.yaml does not match its published hash' }}\ndocker compose up -d"
: "";
return new ComposeInstall(Env(false), url, plan.ComposeSha256, linux, macos, windows);
}
/// <summary>
/// Podman Quadlet 安装:env 文件、单元文件(静态,核对哈希)、一个把镜像仓库指向面板配置的 drop-in,然后启动用户服务。
/// 自动更新默认开(<c>MOSHIRO_UPDATE_POLICY</c> 缺省是 auto):env 里留了一行注释,去掉 # 就改成在管理端手动点更新。
/// </summary>
public QuadletInstall Quadlet(string baseUrl, string role, string code, InstallPlan plan)
{
string url = $"{baseUrl}/install/quadlet/{role}.container";
var lines = new List<string> { $"MOSHIRO_SERVER={baseUrl}", $"MOSHIRO_JOIN={code}" };
if (plan.Genesis is null)
{
lines.Add("MOSHIRO_ALLOW_NO_OWNER=1");
}
else
{
lines.Add($"MOSHIRO_GENESIS={plan.Genesis}");
lines.Add($"MOSHIRO_CONTROLLERS={string.Join(',', plan.Controllers)}");
}
lines.Add("# 自动更新默认开:面板发现新版本后,节点运行满 10 分钟就自动重启,Podman 启动时拉最新镜像。想改成在管理端手动点「更新」,去掉下一行的 #");
lines.Add("# MOSHIRO_UPDATE_POLICY=manual");
string env = string.Join('\n', lines);
string unit = $"$HOME/.config/containers/systemd/moshiro-{role}.container";
string command = $"mkdir -p ~/.config/moshiro ~/.config/containers/systemd/moshiro-{role}.container.d && cat > ~/.config/moshiro/{role}.env <<'EOF'\n{env}\nEOF\n"
+ $"chmod 600 ~/.config/moshiro/{role}.env && curl -fsSL '{url}' -o \"{unit}\" && echo \"{plan.QuadletSha256} {unit}\" | sha256sum -c - "
+ $"&& printf '[Container]\\nImage={ImageRepository(role)}:latest\\n' > ~/.config/containers/systemd/moshiro-{role}.container.d/10-image.conf "
+ $"&& systemctl --user daemon-reload && systemctl --user start moshiro-{role}";
return new QuadletInstall(env, url, plan.QuadletSha256, command);
}
/// <summary>这个角色的镜像仓库地址(不带 tag):节点镜像仓库加上命名空间。</summary>
private string ImageRepository(string role)
{
string registry = (_options.NodeImageRegistry ?? "").Trim('/');
string ns = _options.ImageNamespace.Trim('/');
return $"{(registry.Length > 0 ? registry + "/" : "")}{(ns.Length > 0 ? ns : "moshiro")}/moshiro-{role}";
}
private string BaseUrl(string? requestBaseUrl) => (_options.PublicUrl ?? requestBaseUrl ?? "https://panel.example.com").TrimEnd('/');
/// <summary>
/// 安装脚本要填的值:由代码找到令牌(不消耗),角色、网关、镜像都在这里决定,Agent 和 Probe 共用同一份脚本。
/// 代码不存在、已吊销、已过期或用完了返回 null。网关和镜像要是含有会破坏脚本引号的字符也返回 null。
/// 这个一次性码是给哪个角色(agent 或 probe)的:安装脚本据此向面板取该角色已签名的发布清单。
/// 代码不存在、已吊销、已过期或用完了返回 null(不消耗它)。
/// </summary>
public async ValueTask<InstallTarget?> ResolveInstallAsync(string code, string? requestBaseUrl, CancellationToken ct)
public async ValueTask<string?> RoleOfCodeAsync(string code, CancellationToken ct)
{
if (code.Length is 0 or > 200)
{
@@ -115,22 +208,11 @@ public sealed class JoinTokenService
}
JoinTokenInfo? token = await _store.FindAsync(TokenHash.Sha256(code), ct);
if (token is null || token.RevokedAtMs is not null || token.ExpiresAtMs <= _time.GetUtcNow().ToUnixTimeMilliseconds() || token.Uses >= token.MaxUses)
{
return null;
}
string role = RoleOf(token.Kind);
string registry = (_options.NodeImageRegistry ?? "").Trim('/');
string ns = _options.ImageNamespace.Trim('/');
string image = $"{(registry.Length > 0 ? registry + "/" : "")}{(ns.Length > 0 ? ns : "moshiro")}/moshiro-{role}:latest";
string server = BaseUrl(requestBaseUrl);
return IsSafe(server) && IsSafe(image) && IsSafe(code) ? new InstallTarget(role, server, code, image) : null;
return token is null || token.RevokedAtMs is not null || token.ExpiresAtMs <= _time.GetUtcNow().ToUnixTimeMilliseconds() || token.Uses >= token.MaxUses
? null
: RoleOf(token.Kind);
}
/// <summary>值会被放进脚本的单引号里:只允许 URL 和镜像名里正常出现的字符。</summary>
private static bool IsSafe(string value) => value.All(c => char.IsAsciiLetterOrDigit(c) || c is '.' or '-' or '_' or ':' or '/' or '[' or ']' or '~' or '%');
public ValueTask<IReadOnlyList<JoinTokenInfo>> ListAsync(CancellationToken ct) => _store.ListAsync(ct);
public ValueTask<bool> RevokeAsync(string id, CancellationToken ct) => _store.RevokeAsync(id, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct);
@@ -26,12 +26,10 @@ public sealed partial class AgentService
private readonly LinkService _links;
private readonly TrustService _trust;
private readonly BinaryBindingService _bindings;
private readonly NodeOwnerService _owners;
public AgentService(BinaryBindingService bindings, NodeOwnerService owners, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TaskPlanner planner, ISettingsStore settings, NodeInfoService nodes, LinkService links, TrustService trust)
public AgentService(NodeOwnerService owners, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TaskPlanner planner, ISettingsStore settings, NodeInfoService nodes, LinkService links, TrustService trust)
{
_bindings = bindings;
_owners = owners;
_trust = trust;
_links = links;
@@ -152,7 +150,6 @@ public sealed partial class AgentService
await _gateway.DisconnectAsync(id, "deleted", ct);
await _agents.DeleteAsync(id, ct);
await _nodes.ForgetAsync(id.Value, ct);
await _bindings.ForgetAsync(id.Value, ct);
_owners.Forget(id.Value);
await _links.ForgetNodeAsync(id.Value, ct);
_runtime.Remove(id);
@@ -176,7 +173,8 @@ public sealed partial class AgentService
Host = agent.PublicHost,
Ipv6Host = agent.Ipv6Supported && agent.Ipv6Host.Length > 0 ? agent.Ipv6Host : null,
TcpPort = agent.ProbeTcpPort,
UdpPorts = [.. agent.ProbeUdpPorts],
QuicPorts = [.. agent.ProbeUdpPorts],
LegacyUdpPorts = [.. agent.ProbeUdpPorts],
PublicKey = Convert.ToBase64String(agent.TlsPublicKey!),
});
}
@@ -1,175 +0,0 @@
using System.Text.Json;
using Fleet.Core;
using Fleet.Server;
namespace MoShiro.Backend.Application;
/// <summary>CI 发布公钥(PEM):验节点握手时带来的签名清单。由 Api 层提供(它就是内嵌在程序里的那一把)。</summary>
public interface IReleaseKey
{
string Pem { get; }
}
/// <summary>
/// 节点二进制的绑定:节点第一次握手成功的那一刻,把它正在运行的二进制的 SHA-256 和这个节点绑在一起;之后哈希对不上,就不让它继续运行。
/// <para>
/// 合法的升级:新哈希出现在一份 <b>CI 签名的清单</b>里(节点握手时带着宿主机更新器验过的清单,这里用内嵌的 CI 公钥再验一遍,
/// 所以不依赖面板有没有收到过这次发布),并且清单的 seq 不低于绑定时的——不许回退到更旧的版本。其余情况(文件被换掉、降级、没人签过的二进制、
/// 手工替换了二进制的非容器节点)都算不符;管理员确认过之后可以清除绑定,下一次握手重新绑定。
/// </para>
/// <para>
/// 边界:哈希是节点自己报的。一个被换进来、故意撒谎的恶意二进制可以照抄原来的哈希;这一层抓的是文件被换、版本被改、降级和起了不对的二进制。
/// 容器节点要堵住撒谎,需要宿主机更新器背书(容器之外),见 docs/access.md。
/// </para>
/// </summary>
public sealed class BinaryBindingService : IBinaryAttestor
{
public const string Enforce = "enforce";
public const string Warn = "warn";
public const string Off = "off";
private readonly INodeBindingStore _store;
private readonly ISettingsStore _settings;
private readonly IReleaseKey _key;
private readonly BackendOptions _options;
private readonly TimeProvider _time;
public BinaryBindingService(INodeBindingStore store, ISettingsStore settings, IReleaseKey key, BackendOptions options, TimeProvider time)
{
_settings = settings;
_store = store;
_key = key;
_options = options;
_time = time;
}
public ValueTask<IReadOnlyList<NodeBinding>> ListAsync(CancellationToken ct) => _store.ListAsync(ct);
/// <summary>管理员确认新的二进制是自己要的(比如手工升级了非容器节点):清除绑定,下一次握手重新绑定。</summary>
public ValueTask<bool> ClearAsync(string nodeId, CancellationToken ct) => _store.DeleteAsync(nodeId, ct);
/// <summary>节点删除之后顺手清掉它的绑定。</summary>
public async ValueTask ForgetAsync(string nodeId, CancellationToken ct) => await _store.DeleteAsync(nodeId, ct);
public async ValueTask<BinaryDecision> CheckAsync(NodeRecord node, BinaryEvidence evidence, CancellationToken ct)
{
string policy = _options.BinaryPolicy;
if (policy == Off || ReleaseService.ComponentOf(node.Id) is not { } component)
{
return new BinaryDecision(true);
}
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
NodeBinding? binding = await _store.GetAsync(node.Id.Value, ct);
if (evidence.Sha256 is null)
{
// 旧节点不上报:还没绑定过就放行(未绑定);绑定过的不许退回去,否则换个旧版本就绕过了
return binding is null ? new BinaryDecision(true) : await ViolationAsync(binding, "the node stopped reporting its binary hash", policy, now, ct);
}
string hash = evidence.Sha256;
if (!IsHash(hash))
{
return new BinaryDecision(policy != Enforce, "the reported binary hash is malformed");
}
// 节点自己带来的清单(宿主机更新器验过的那份);没有更新器的节点(一键容器安装、二进制安装)没有,就用面板自己手里的:
// 面板登记它们时已经验过 CI 签名,这里再验一遍。只有节点自带的清单才能因为「不在清单里」而被拒绝,面板手里的只用来把绑定标成已签名。
Official? official = VerifyManifest(evidence.Manifest, evidence.Signature, component);
bool carried = official is not null;
official ??= await StoredOfficialAsync(component, ct);
bool listed = official is not null && official.Binaries.Contains(hash);
if (binding is null)
{
if (carried && !listed)
{
// 带着一份有效的签名清单,自己的哈希却不在里面:它不是这份清单对应的官方二进制
return new BinaryDecision(policy != Enforce, "this binary is not in the CI-signed manifest it was installed from");
}
await _store.UpsertAsync(new NodeBinding(node.Id.Value, hash, listed ? official!.Seq : 0, listed, now, null, null), ct);
return new BinaryDecision(true);
}
if (binding.Sha256 == hash)
{
if (listed && (official!.Seq > binding.Seq || !binding.Signed || binding.Violation is not null))
{
await _store.UpsertAsync(binding with { Seq = Math.Max(binding.Seq, official!.Seq), Signed = true, Violation = null, ViolationAtMs = null }, ct);
}
else if (binding.Violation is not null)
{
await _store.UpsertAsync(binding with { Violation = null, ViolationAtMs = null }, ct);
}
return new BinaryDecision(true);
}
// 哈希变了:必须是一份不低于绑定时 seq 的 CI 签名清单里的官方二进制(合法升级),否则不符
if (listed && official!.Seq >= binding.Seq)
{
await _store.UpsertAsync(new NodeBinding(node.Id.Value, hash, official.Seq, true, now, null, null), ct);
return new BinaryDecision(true);
}
return await ViolationAsync(binding, $"the running binary changed (bound {Short(binding.Sha256)}, now {Short(hash)}) and is not in a newer CI-signed manifest", policy, now, ct);
}
private async ValueTask<BinaryDecision> ViolationAsync(NodeBinding binding, string reason, string policy, long now, CancellationToken ct)
{
await _store.UpsertAsync(binding with { Violation = reason, ViolationAtMs = now }, ct);
return new BinaryDecision(policy != Enforce, reason);
}
private sealed record Official(long Seq, HashSet<string> Binaries);
/// <summary>用内嵌的 CI 公钥验清单;验过且是这个组件的才返回它列出的官方二进制哈希。</summary>
private async ValueTask<Official?> StoredOfficialAsync(string component, CancellationToken ct) =>
VerifyManifest(await _settings.GetAsync(ReleaseService.Key(component, "manifest"), ct), await _settings.GetAsync(ReleaseService.Key(component, "signature"), ct), component);
private Official? VerifyManifest(string? manifestText, string? signatureText, string component)
{
if (manifestText is not { Length: > 0 and <= ReleaseService.MaxManifestBytes } manifest
|| signatureText is not { Length: > 0 and <= ReleaseService.MaxSignatureBytes } signature
|| !ReleaseSignature.Verify(_key.Pem, manifest, signature))
{
return null;
}
try
{
using JsonDocument document = JsonDocument.Parse(manifest);
JsonElement root = document.RootElement;
if (root.ValueKind != JsonValueKind.Object
|| !root.TryGetProperty("component", out JsonElement c) || c.GetString() != component
|| !root.TryGetProperty("seq", out JsonElement s) || !s.TryGetInt64(out long seq) || seq <= 0)
{
return null;
}
var binaries = new HashSet<string>(StringComparer.Ordinal);
if (root.TryGetProperty("binaries", out JsonElement list) && list.ValueKind == JsonValueKind.String)
{
foreach (string item in (list.GetString() ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (IsHash(item))
{
binaries.Add(item);
}
}
}
return new Official(seq, binaries);
}
catch (JsonException)
{
return null;
}
}
private static bool IsHash(string value) => value.Length == 64 && value.All(ch => ch is (>= '0' and <= '9') or (>= 'a' and <= 'f'));
private static string Short(string hash) => hash.Length > 12 ? hash[..12] : hash;
}
@@ -4,8 +4,8 @@ using MoShiro.Protocol;
namespace MoShiro.Backend.Application;
/// <summary>
/// <c>moshiro.host</c>:Agent 每隔几秒上报的主机资源占用快照。最新值放在内存里并实时推给订阅者;每个 Agent 每分钟存一个样本做历史。
/// 快照里没有 CPU 型号、主机名、网卡名和 IP(Agent 根本不采集它们)。
/// <c>moshiro.host</c>:Agent 和 Probe 每隔几秒上报的主机资源占用快照。最新值放在内存里并实时推给订阅者;每个节点每分钟存一个样本做历史。
/// 快照里没有 CPU 型号、主机名、网卡名和 IP(节点根本不采集它们)。
/// </summary>
public sealed class HostStatusService
{
@@ -43,7 +43,7 @@ public sealed class MoShiroEnrollmentHook : IEnrollmentHook
// 注册请求带了 public_host 就用它;否则来源地址是公网地址时用来源地址(私网或隧道出口不用,等 hello 里的本机地址)
string host = !string.IsNullOrWhiteSpace(meta.PublicHost) ? meta.PublicHost
: System.Net.IPAddress.TryParse(context.RemoteIp, out System.Net.IPAddress? remote) && PublicAddress.IsPublicIpv4(remote) ? remote.ToString() : "";
await _agents.ApplyEnrollmentAsync(node.Id, new AgentEnrollment(host, meta.ProbeTcpPort, meta.ProbeUdpPorts, meta.Kinds), ct);
await _agents.ApplyEnrollmentAsync(node.Id, new AgentEnrollment(host, meta.ProbeTcpPort, meta.ProbeQuicPorts.Count > 0 ? meta.ProbeQuicPorts : meta.LegacyProbeUdpPorts ?? [7443], meta.Kinds), ct);
if (await _agents.GetAsync(node.Id, ct) is { } enrolled)
{
(_, string? observedV6) = AgentAddressPolicy.Choose(enrolled, [], context.RemoteIp);
@@ -70,10 +70,12 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
private readonly ScheduleService _schedule;
private readonly IProbeStore _probes;
private readonly NodeOwnerService _owners;
private readonly ProbeQuicRuntime _quicRuntime;
public MoShiroNodeEventSink(IAgentStore agents, AgentRuntime runtime, IEventStore events, LiveHub live, AgentService service, NodeInfoService nodes, ReleaseService releases, ScheduleService schedule, IProbeStore probes, NodeOwnerService owners)
public MoShiroNodeEventSink(IAgentStore agents, AgentRuntime runtime, IEventStore events, LiveHub live, AgentService service, NodeInfoService nodes, ReleaseService releases, ScheduleService schedule, IProbeStore probes, NodeOwnerService owners, ProbeQuicRuntime quicRuntime)
{
_owners = owners;
_quicRuntime = quicRuntime;
_releases = releases;
_schedule = schedule;
_probes = probes;
@@ -168,12 +170,17 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
case NodeEventKind.Offline:
_live.Publish(new NodeSignal("probe", id.Value, false, nodeEvent.AtMs));
_releases.RecordStatus(id, null);
_quicRuntime.MarkProbeOffline(id.Value, nodeEvent.AtMs);
break;
case NodeEventKind.Heartbeat when nodeEvent.Extra is { } extra:
try
{
ProbeHeartbeatExtra? heartbeat = extra.Deserialize(MoShiroJsonContext.Default.ProbeHeartbeatExtra);
_releases.RecordStatus(id, heartbeat?.Update);
if (heartbeat?.QuicLinks is { } quicLinks)
{
_quicRuntime.Replace(id.Value, nodeEvent.AtMs, quicLinks);
}
if (heartbeat?.Ipv6 is { } ipv6)
{
await _probes.SetIpv6DiagnosticsAsync(id.Value, JsonSerializer.Serialize(ipv6, MoShiroJsonContext.Default.Ipv6Diagnostic), ct);
@@ -236,7 +243,8 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
AgentRecord? before = await _agents.GetAsync(id, ct);
string? limitsJson = meta.LocalLimits is null ? null : JsonSerializer.Serialize(meta.LocalLimits, LocalLimitsJson.Default.LocalLimits);
string? ipv6Json = meta.Ipv6 is null ? null : JsonSerializer.Serialize(meta.Ipv6, MoShiroJsonContext.Default.Ipv6Diagnostic);
await _agents.ApplyHelloAsync(id, new AgentHello(kinds, hello.NodeVersion, limitsJson, spki is { Length: 32 } ? spki : null, meta.ProbeTcpPort, meta.ProbeUdpPorts, meta.Ipv6Supported, ipv6Json, meta.Addresses, tlsKey), ct);
IReadOnlyList<int>? quicPorts = meta.ProbeQuicPorts is { Count: > 0 } ? meta.ProbeQuicPorts : meta.LegacyProbeUdpPorts;
await _agents.ApplyHelloAsync(id, new AgentHello(kinds, hello.NodeVersion, limitsJson, spki is { Length: 32 } ? spki : null, meta.ProbeTcpPort, quicPorts, meta.Ipv6Supported, ipv6Json, meta.Addresses, tlsKey), ct);
// 自动识别对外地址:Agent 上报的公网地址;手填的不动
if (meta.Addresses is not null && await _agents.GetAsync(id, ct) is { } reported)
@@ -0,0 +1,56 @@
using System.Collections.Concurrent;
using MoShiro.Protocol;
namespace MoShiro.Backend.Application;
public sealed record ProbeQuicLinkSnapshot(ProbeQuicLinkHealth Health, long AtMs);
/// <summary>Latest per-line QUIC health reported by connected Probes. Volatile runtime state, not historical metrics.</summary>
public sealed class ProbeQuicRuntime
{
private readonly ConcurrentDictionary<(string Probe, string Agent, string IpVersion), ProbeQuicLinkSnapshot> _links = new();
public ProbeQuicLinkSnapshot? Get(string probeId, string agentId, string ipVersion) =>
_links.TryGetValue((probeId, agentId, ipVersion), out ProbeQuicLinkSnapshot? snapshot) ? snapshot : null;
public void Replace(string probeId, long atMs, IReadOnlyList<ProbeQuicLinkHealth> links)
{
var current = new HashSet<(string Probe, string Agent, string IpVersion)>();
foreach (ProbeQuicLinkHealth link in links)
{
var key = (probeId, link.AgentId, link.IpVersion);
current.Add(key);
_links[key] = new ProbeQuicLinkSnapshot(link, atMs);
}
foreach ((string Probe, string Agent, string IpVersion) key in _links.Keys.Where(k => k.Probe == probeId && !current.Contains(k)))
{
_links.TryRemove(key, out _);
}
}
public void MarkProbeOffline(string probeId, long atMs)
{
foreach (KeyValuePair<(string Probe, string Agent, string IpVersion), ProbeQuicLinkSnapshot> pair in _links.ToArray().Where(p => p.Key.Probe == probeId))
{
ProbeQuicLinkHealth old = pair.Value.Health;
_links[pair.Key] = new ProbeQuicLinkSnapshot(new ProbeQuicLinkHealth
{
AgentId = old.AgentId,
IpVersion = old.IpVersion,
Connected = false,
HandshakeAttempts = old.HandshakeAttempts,
HandshakeSuccesses = old.HandshakeSuccesses,
HandshakeFailures = old.HandshakeFailures,
HandshakeTimeouts = old.HandshakeTimeouts,
HandshakeP95Ms = old.HandshakeP95Ms,
HandshakeSampleCount = old.HandshakeSampleCount,
TransportPacketLossRate = old.TransportPacketLossRate,
TransportPacketsSent = old.TransportPacketsSent,
TransportCongestionEvents = old.TransportCongestionEvents,
TransportEcnCongestionEvents = old.TransportEcnCongestionEvents,
PathMtu = old.PathMtu,
}, atMs);
}
}
}
@@ -29,12 +29,10 @@ public sealed partial class ProbeService
private readonly NodeInfoService _nodes;
private readonly LinkService _links;
private readonly BinaryBindingService _bindings;
private readonly NodeOwnerService _owners;
public ProbeService(BinaryBindingService bindings, NodeOwnerService owners, IProbeStore probes, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TimeProvider time, NodeInfoService nodes, LinkService links)
public ProbeService(NodeOwnerService owners, IProbeStore probes, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TimeProvider time, NodeInfoService nodes, LinkService links)
{
_bindings = bindings;
_owners = owners;
_links = links;
_nodes = nodes;
@@ -99,7 +97,6 @@ public sealed partial class ProbeService
if (deleted)
{
await _nodes.ForgetAsync(id, ct);
await _bindings.ForgetAsync(id, ct);
_owners.Forget(id);
await _links.ForgetNodeAsync(id, ct);
_gateway.NotifyAllTaskSetsChanged();
@@ -16,6 +16,7 @@ public static class ApplicationServiceCollectionExtensions
services.AddHostedService<StartupTasks>();
services.AddSingleton<AgentRuntime>();
services.AddSingleton<ProbeQuicRuntime>();
services.AddSingleton<LiveHub>();
services.AddSingleton<HotLayer>();
services.AddSingleton<BackendCounters>();
@@ -40,9 +41,8 @@ public static class ApplicationServiceCollectionExtensions
services.AddSingleton<ServerIdentityService>();
services.AddSingleton<SetupService>();
services.AddSingleton<ReleaseFeed>();
services.AddSingleton<BinaryBindingService>();
services.AddSingleton<ReleaseKeyService>();
services.AddSingleton<NodeOwnerService>();
services.AddSingleton<Fleet.Server.IBinaryAttestor>(sp => sp.GetRequiredService<BinaryBindingService>());
services.AddSingleton<Fleet.Server.IServerIdentity>(sp => sp.GetRequiredService<ServerIdentityService>());
services.AddSingleton<TrustService>();
services.AddSingleton<Fleet.Server.IOperatorTrustSource>(sp => sp.GetRequiredService<TrustService>());
@@ -72,6 +72,7 @@ public static class ApplicationServiceCollectionExtensions
services.AddHostedService(provider => provider.GetRequiredService<TestFinalizer>());
services.AddHostedService<HotLayerBackfill>();
services.AddHostedService<RetentionService>();
services.AddHostedService<ReleasePoller>();
return services;
}
}
Loaded 100 of 261 files, more files were not shown because too many files have changed in this diff. Show more