92 Commits
Author SHA1 Message Date
justme 675a2bc5b4 Merge branch 'main' into dev
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/push/web-build Pipeline was successful
2026-10-08 16:26:33 +08:00
justme 76efc15e9a Merge branch 'main' into dev
ci/woodpecker/push/verify Pipeline failed
ci/woodpecker/push/web-build unknown status
2026-10-08 16:13:02 +08:00
justme 3ee8b6bb3d Merge branch 'main' into dev
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/push/web-build Pipeline was successful
2026-10-08 16:05:42 +08:00
justme 0fa2f2a26a Merge branch 'main' into dev
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/push/web-build Pipeline was successful
2026-10-08 15:59:32 +08:00
justme 0e7a8c14fb Merge branch 'main' into dev
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/push/web-build Pipeline was successful
2026-10-08 15:30:16 +08:00
justme 4a15dba8de Merge branch 'main' into dev
ci/woodpecker/push/verify Pipeline failed
ci/woodpecker/push/web-build unknown status
2026-10-08 06:47:15 +08:00
justme 8410735377 Merge branch 'main' into dev
ci/woodpecker/push/verify Pipeline failed
ci/woodpecker/push/web-build unknown status
2026-10-08 05:44:20 +08:00
justme 6310358490 Merge branch 'main' into dev
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/push/web-build Pipeline was successful
2026-10-08 05:41:02 +08:00
justme 0dc8c6fe34 Merge branch 'main' into dev
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/push/web-build Pipeline was successful
2026-10-08 04:23:16 +08:00
justme 1f70c8d31e Merge branch 'main' into dev
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/push/web-build Pipeline was successful
2026-10-08 04:10:29 +08:00
justme 0e0b925c8b Merge branch 'main' into dev
ci/woodpecker/push/verify Pipeline failed
ci/woodpecker/push/web-build unknown status
2026-10-08 03:22:20 +08:00
justme f01bb8fd63 ci: sync main into dev [CI SKIP] 2026-10-07 20:03:38 +08:00
justme 96990c6f4e ci: sync main into dev [CI SKIP] 2026-10-07 19:14:58 +08:00
justme bd35a1a966 ci: sync main into dev [CI SKIP] 2026-10-07 18:13:20 +08:00
justme b47e01d944 ci: sync main into dev [CI SKIP] 2026-10-07 17:21:25 +08:00
justme 7ee860cb27 ci: sync main into dev [CI SKIP] 2026-10-07 16:58:59 +08:00
justme 699ed8c411 ci: sync main into dev [CI SKIP] 2026-10-07 16:20:25 +08:00
justme 0012df7b91 ci: sync main into dev [CI SKIP] 2026-10-07 15:08:26 +08:00
justme d5967b6110 ci: sync main into dev [CI SKIP] 2026-10-07 14:57:08 +08:00
justme 450748f3bc ci: sync main into dev [CI SKIP] 2026-10-07 14:18:03 +08:00
justme 5bd53f8880 ci: sync main into dev [CI SKIP] 2026-10-07 03:36:55 +08:00
justme 643f9e1ea3 ci: sync main into dev [CI SKIP] 2026-10-07 03:30:19 +08:00
justme ac0bcd8e1f docs: merge the Fleet docs into backend/fleet/README.md, add TODO.md, fix stale references [CI SKIP] 2026-10-07 03:30:03 +08:00
justme f4aa3f2482 docs: audit the docs, merge the Fleet docs into backend/fleet/README.md and add TODO.md [CI SKIP]
Drop the unimplemented fleet updater design, the stale project-layout sections and broken references, fix the API table, and rename the four Monitor*.cs files left over from the rename.
2026-10-07 03:29:05 +08:00
justme 180e54cb3c ci: sync main into dev [CI SKIP] 2026-10-07 03:12:48 +08:00
justme a19517a57d docs: rewrite the README: scope, trust model, architecture, protocol and build [SKIP TESTS]
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/push/web-build Pipeline was successful
2026-10-07 01:51:54 +08:00
justme 83e5120377 refactor!: rename the project from Monitor to MoShiro
Namespaces, assemblies, projects and directories, the solution, binaries,
images, systemd units, config paths, environment variables, protocol method
names, the media type and the raw chunk magic all move to MoShiro/moshiro.
Existing data and deployments are not compatible and must be recreated.
Also fix the architecture tests after the solution layout change and make
the update tests independent of their order.
2026-10-07 01:49:04 +08:00
justme 17b35223aa ci: sync main into dev [CI SKIP] 2026-10-07 01:46:35 +08:00
justme 4d329f5e16 ci: sync main into dev [CI SKIP] 2026-10-07 01:30:46 +08:00
justme ad17170664 docs: redraw the architecture diagrams and update the README [SKIP TESTS]
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/push/web-build Pipeline was successful
2026-10-07 01:30:28 +08:00
justme 710aa9e99f ci: sync main into dev [CI SKIP] 2026-10-07 01:28:32 +08:00
justme 71d2866b9c docs: update the architecture [SKIP TESTS] 2026-10-07 01:21:49 +08:00
justme 48664c0ada docs: remove the roadmap and bring the docs in line with the current code [SKIP TESTS]
Probes link to the panel directly, container nodes update through signed
releases, the frontend exists and the ports changed. The roadmap is gone; its
open items live in the README.
2026-10-06 23:27:26 +08:00
justme 198ee556d6 ci: sync main into dev [CI SKIP] 2026-10-06 23:22:52 +08:00
justme da465866d5 fix(web): lift the hovered overview card above its neighbours so the popover is not hidden 2026-10-06 23:22:36 +08:00
justme 3a751a0f89 feat: choose which probes link to which agents from the admin
The panel decides the probe/agent pairs and probes always dial out. A new
fleet.auto_link setting connects every probe to every agent (default); with it
off only the pairs chosen by hand connect, and any pair can be forced on or
off. The Probes page has a link matrix with the switch and per-pair toggles.
2026-10-06 23:19:32 +08:00
justme d69581dcb5 docs: mount the host os-release and fix the agent public host after tunnel enrollment [SKIP TESTS] 2026-10-06 23:11:26 +08:00
justme 8f44e924a4 docs: describe container node deployment and the update flow [SKIP TESTS] 2026-10-06 23:02:16 +08:00
justme 0c39485a78 ci: sync main into dev [CI SKIP] 2026-10-06 22:57:11 +08:00
justme 7a71bf57f9 feat: update controls in the admin, release token scope, build info in CI images
The versions page shows each node's update state with update buttons, API
tokens get a release scope that can only register releases (used by CI), and
the CI image and release builds now pass the tag, commit and build time.
2026-10-06 22:56:56 +08:00
justme 22a98f2eb0 ci: sync main into dev [CI SKIP] 2026-10-06 22:42:20 +08:00
justme 0a66f5e07a feat: signed releases and a host-side updater for container nodes
CI signs the image digest of each agent and probe release; the panel only
stores and forwards it. Nodes write the manifest to their data directory and
a host updater verifies the signature with a local public key, pulls by
digest, restarts the unit and rolls back when it is unhealthy. The panel can
list releases, ask a node to update and show the reported update state.
2026-10-06 22:37:26 +08:00
justme 48072737c9 feat!: probes connect to the backend directly
A probe now links to the backend like an agent (probe.json carries the
server address). The backend sends the agent list as desired state and the
probe reports its platform and build in its own hello. The agent no longer
relays the list or the probe platform. Existing probes must be re-created.
2026-10-06 22:25:16 +08:00
justme 604cbe3123 fix(ci): restore with PublishAot so node images are native, add compose files
Agent and probe images were built with a restore step that lacked
PublishAot, so the publish silently produced a trimmed launcher that looks
for a .dll. Restore now passes PublishAot and the build fails if the binary
is smaller than 2 MB. Also add agent and probe compose files and document
the [CI] marker in AGENTS.md.
2026-10-06 21:43:46 +08:00
justme b242701e9e ci: sync main into dev [CI SKIP] 2026-10-06 21:24:47 +08:00
justme 83f54978d2 feat(web): acrylic theme, node flags and icons, build info
Acrylic cards and topbar over a blurred background, country flags, ISP and
OS icons, per-node version tags, a build info footer and an admin versions
tab. Overview cards drop the sparkline and widen the hover area.
2026-10-06 21:22:04 +08:00
justme 1e5c660d05 feat(backend): report node platform, geo and build info
Agents and probes report OS, arch and build (version, tag, commit, time).
The backend looks up country and ISP of public addresses only, stores them
in node_meta (schema v5) and exposes display info via /api/public/nodes,
/api/public/about and /api/admin/build-info behind site.show_build.
2026-10-06 21:22:04 +08:00
justme 2e470607fa docs: add project agent instructions [SKIP TESTS] 2026-10-06 17:11:07 +08:00
justme a70b89b95c feat(web): show UDP and TCP strips on overview cards
Add 3/6/12 hour ranges with status strips, latency sparklines and a side popover for the hovered interval.
2026-10-06 16:59:17 +08:00
justme a9538eae4a feat(web): add backend switcher and connection status
Split the login page into backend list and credentials, show connection status in the sidebar and top bar, and render dialogs in a portal.
2026-10-06 16:59:17 +08:00
justme 91c63d0cf2 feat(api): add return_session login for cross-origin clients
Return the session token in the login response instead of a cookie, and accept it as a Bearer token without CSRF.
2026-10-06 16:59:16 +08:00
justme 193fc3a95c feat: improve agent management and speed up tests 2026-10-06 15:48:46 +08:00
justme 134c06d605 merge: bring line detail timeline into dev [CI SKIP] 2026-10-06 14:52:48 +08:00
justme d1be70cda2 feat(web): 线路详情页的时间轴、EWMA 平滑和加载进度
- 先选日期,再在连续 48 小时(或 24 小时)的丢包缩略图上拖选区;选区可以跨零点,
  点轴或刻度时离得近的端点贴过去,悬停预览会动的端点;没有数据的时段画成斜纹
- 刻度下方的时长快捷档:30 分钟、1 小时、3 小时、6 小时、12 小时、整天
- 指标卡片平铺,丢包等标明 UDP/TCP;图表按 UDP、TCP、评分分组
- 图表数据刷新时原地更新(不再整张重建),毫秒到 1 秒以上显示成秒
- EWMA 平滑开关(关/轻/中/重),原始数据以淡线垫底
- 数据加载时卡片上显示进度条和占位;useQuery 区分定时刷新和用户触发的加载
- 前端可以在页面里指定后端地址和令牌(配合后端 Monitor__CorsOrigins)
2026-10-06 14:48:58 +08:00
justme 672f6e3dcb fix(ci): emit native apphosts in fastlane images [CI SKIP] 2026-10-05 10:42:34 -07:00
justme 9a375ffd9e fix(build): select AotAnywhere explicitly [CI SKIP] 2026-10-05 10:37:10 -07:00
justme 407c09f6a5 fix(ci): use host networking for native builds [CI SKIP] 2026-10-05 10:30:35 -07:00
justme fffee8937c ci: sync main into dev [CI SKIP] 2026-10-05 10:26:53 -07:00
justme 91485db500 fix(ci): fall back to git diff for empty path lists [CI SKIP] 2026-10-05 10:25:59 -07:00
justme fa34869f18 ci: sync main into dev [CI SKIP] 2026-10-05 10:21:22 -07:00
justme deb5a97802 ci: add dependency-aware main fastlane [CI SKIP] 2026-10-05 10:18:22 -07:00
justme 479f8c25b0 feat(ci): publish agent and probe OCI images [CI SKIP] 2026-10-05 09:33:22 -07:00
justme 2b80bb66d1 ci: use local Gitea endpoint for release uploads 2026-10-05 08:44:19 -07:00
justme 078eacf1cb ci: persist Buildah cache for Linux releases 2026-10-05 08:29:08 -07:00
justme 3db6d5b159 ci: authenticate Zot with API key for justme [CI SKIP] 2026-10-05 22:24:16 +08:00
justme a7a8212c08 ci: push images to public Zot over HTTPS [CI SKIP] 2026-10-05 16:28:15 +08:00
justme 68406843de ci: retry transient Zot registry errors [CI SKIP] 2026-10-04 13:26:09 -07:00
justme fae8c45c90 ci: use bounded runspaces for artifact uploads [CI SKIP] 2026-10-04 13:11:01 -07:00
justme 3bf12ec73d ci: upload release artifacts concurrently [CI SKIP] 2026-10-04 13:05:17 -07:00
justme a9ee4ec34c build: disable PDB debug symbols in releases [CI SKIP] 2026-10-04 12:54:39 -07:00
justme 6b12049df0 ci: drop 32-bit release targets [CI SKIP] 2026-10-04 12:44:32 -07:00
justme d4b4869501 fix(ci): package cross-platform tarballs on Windows [CI SKIP] 2026-10-04 12:38:13 -07:00
justme 92b72d4443 ci: release without blocking on test workflow [CI SKIP] 2026-10-04 12:31:27 -07:00
justme 67b428991f ci: parallelize NativeAOT target builds 2026-10-04 12:29:35 -07:00
justme 55756f7695 ci: cross-publish full NativeAOT matrix from Windows 2026-10-04 12:10:06 -07:00
justme 17a69c1c7a ci: parallelize release components 2026-10-04 11:44:41 -07:00
justme 50d8c07939 ci: gate parallel releases on verified commits 2026-10-05 02:39:21 +08:00
justme 2e549b230d build: use ASP.NET runtime for backend images 2026-10-05 02:24:15 +08:00
justme ace42e5da7 fix(ci): use POST for Gitea release assets 2026-10-05 02:16:14 +08:00
justme ad32afd038 ci: run release builds in parallel 2026-10-05 02:11:29 +08:00
justme 286c9ff7cd ci: publish release assets to Gitea 2026-10-05 02:06:36 +08:00
justme 8aaa574485 fix(ci): make release retryable 2026-10-05 01:21:37 +08:00
justme ce30828ed3 ci: use runner-local Gitea package endpoints 2026-10-05 01:10:17 +08:00
justme a46f45cce3 ci: order multiarch index after builds 2026-10-05 01:05:24 +08:00
justme ce3ba51c02 test: normalize project reference paths across platforms 2026-10-05 00:49:20 +08:00
justme faf1203f0a ci: disable git lfs in clone steps 2026-10-05 00:43:46 +08:00
justme ee422ca366 ci: run verification for push, pull requests and tags 2026-10-05 00:41:29 +08:00
justme c33003c279 ci: restore public Gitea package endpoint 2026-10-04 23:11:37 +08:00
justme 31af31b96f ci: use internal Gitea endpoint 2026-10-04 23:01:59 +08:00
justme 9b6c4e0fd8 ci: skip host OpenAPI generation for arm64 2026-10-04 22:38:26 +08:00
justme ceef543854 ci: verify development branch 2026-10-04 22:20:58 +08:00
justme 299f86dbee ci: configure Woodpecker release pipelines 2026-10-04 22:12:21 +08:00
326 changed files with 4577 additions and 11656 deletions

No files matched your search

-9
View File
@@ -1,4 +1,3 @@
/.gitattributes text eol=lf
ci/build-main-amd64.sh text eol=lf ci/build-main-amd64.sh text eol=lf
ci/index-backend-image.sh text eol=lf ci/index-backend-image.sh text eol=lf
ci/publish-release-offer.sh text eol=lf ci/publish-release-offer.sh text eol=lf
@@ -8,11 +7,3 @@ packaging/container/*.sh text eol=lf
ci/release-node-images.sh text eol=lf ci/release-node-images.sh text eol=lf
packaging/install/*.sh text eol=lf packaging/install/*.sh text eol=lf
packaging/install/*.ps1 text eol=lf packaging/install/*.ps1 text eol=lf
*.sh text eol=lf
*.init text eol=lf
*.service text eol=lf
*.container text eol=lf
*.Containerfile text eol=lf
*.ps1 text eol=lf
*.yml text eol=lf
*.md text eol=lf
-128
View File
@@ -1,128 +0,0 @@
labels:
ci: domain-linux-native
platform: linux/amd64
clone:
- name: git
image: plugin-git
settings:
lfs: false
# 要拿到 tag 才算得出开发版本号(ci/version.sh:自最近 tag 的提交数)
tags: true
when:
- event: push
branch: main
evaluate: 'CI_COMMIT_MESSAGE contains "[CI]"'
# 先等后端测试和前端构建都通过再出镜像(提交信息写 [SKIP TESTS] 只跳过后端测试)
depends_on:
- verify
- web-build
concurrency: 1
steps:
- name: build-linux-amd64-backend
image: bash
environment:
NUGET_PACKAGES: /home/justme/woodpecker/cache/nuget/packages
ZOT_REGISTRY: registry.oeo.one
ZOT_USERNAME: justme
ZOT_SECRET:
from_secret: zot_secret
RELEASE_API_URL: https://mo.oeo.one
RELEASE_SIGNING_KEY:
from_secret: release_signing_key
commands:
- bash ci/build-dev-image.sh backend
depends_on: []
- name: build-linux-amd64-agent
image: bash
environment:
ZOT_REGISTRY: registry.oeo.one
ZOT_USERNAME: justme
ZOT_SECRET:
from_secret: zot_secret
RELEASE_API_URL: https://mo.oeo.one
RELEASE_SIGNING_KEY:
from_secret: release_signing_key
commands:
- bash ci/build-dev-image.sh agent
depends_on: []
- name: build-linux-amd64-probe
image: bash
environment:
ZOT_REGISTRY: registry.oeo.one
ZOT_USERNAME: justme
ZOT_SECRET:
from_secret: zot_secret
RELEASE_API_URL: https://mo.oeo.one
RELEASE_SIGNING_KEY:
from_secret: release_signing_key
commands:
- bash ci/build-dev-image.sh probe
depends_on: []
# Build both node architectures in parallel; each multi-architecture index waits for both of its images.
- name: build-linux-arm64-agent
image: bash
environment:
ZOT_REGISTRY: registry.oeo.one
ZOT_USERNAME: justme
ZOT_SECRET:
from_secret: zot_secret
RELEASE_API_URL: https://mo.oeo.one
RELEASE_SIGNING_KEY:
from_secret: release_signing_key
commands:
- bash ci/build-dev-image.sh agent arm64
depends_on: []
- name: build-linux-arm64-probe
image: bash
environment:
ZOT_REGISTRY: registry.oeo.one
ZOT_USERNAME: justme
ZOT_SECRET:
from_secret: zot_secret
RELEASE_API_URL: https://mo.oeo.one
RELEASE_SIGNING_KEY:
from_secret: release_signing_key
commands:
- bash ci/build-dev-image.sh probe arm64
depends_on: []
- name: publish-linux-agent-multiarch
image: bash
environment:
ZOT_REGISTRY: registry.oeo.one
ZOT_USERNAME: justme
ZOT_SECRET:
from_secret: zot_secret
RELEASE_API_URL: https://mo.oeo.one
RELEASE_SIGNING_KEY:
from_secret: release_signing_key
commands:
- bash ci/publish-dev-node-index.sh agent
depends_on:
- build-linux-amd64-agent
- build-linux-arm64-agent
- name: publish-linux-probe-multiarch
image: bash
environment:
ZOT_REGISTRY: registry.oeo.one
ZOT_USERNAME: justme
ZOT_SECRET:
from_secret: zot_secret
RELEASE_API_URL: https://mo.oeo.one
RELEASE_SIGNING_KEY:
from_secret: release_signing_key
commands:
- bash ci/publish-dev-node-index.sh probe
depends_on:
- build-linux-amd64-probe
- build-linux-arm64-probe
-32
View File
@@ -1,32 +0,0 @@
labels:
ci: domain-linux-native
platform: linux/amd64
clone:
- name: git
image: plugin-git
settings:
lfs: false
# 带 [CI] 的推送会出镜像,慢测试放在 dev-image 里、等镜像构建完再跑(不和 AOT 编译抢内存);这个 workflow 管其余的:PR 和没带 [CI] 的推送
when:
- event: pull_request
- event: push
branch: main
evaluate: 'not (CI_COMMIT_MESSAGE contains "[CI]")'
# 等 verify 编译完、快的测试过了再跑(共用它的增量编译缓存,不重新编译)
depends_on:
- verify
steps:
- name: backend-e2e-tests
image: bash
# [SKIP TESTS] 和 verify 一样,跳过后端测试
when:
- evaluate: 'not (CI_COMMIT_MESSAGE contains "[SKIP TESTS]")'
environment:
NUGET_PACKAGES: /home/justme/woodpecker/cache/nuget/packages
commands:
# 标了 Category=Slow 的端到端测试,见 ci/verify-backend.sh
- bash ci/verify-backend.sh slow
+2 -4
View File
@@ -22,8 +22,6 @@ steps:
environment: environment:
NPM_CONFIG_CACHE: /home/justme/.npm NPM_CONFIG_CACHE: /home/justme/.npm
commands: commands:
# 版本取自 tag:先写进工作区(仓库里的版本号只是占位),前端才会带上对的版本
- bash ci/version.sh apply
- npm ci --prefix web --prefer-offline - npm ci --prefix web --prefer-offline
- timeout 600 npm run build --prefix web - timeout 600 npm run build --prefix web
depends_on: [] depends_on: []
@@ -31,7 +29,7 @@ steps:
- name: linux-amd64-backend - name: linux-amd64-backend
image: bash image: bash
environment: environment:
NUGET_PACKAGES: /home/justme/woodpecker/cache/nuget/packages NUGET_PACKAGES: /home/justme/.nuget/packages
ZOT_REGISTRY: registry.oeo.one ZOT_REGISTRY: registry.oeo.one
ZOT_USERNAME: justme ZOT_USERNAME: justme
ZOT_SECRET: ZOT_SECRET:
@@ -44,7 +42,7 @@ steps:
- name: linux-arm64-backend - name: linux-arm64-backend
image: bash image: bash
environment: environment:
NUGET_PACKAGES: /home/justme/woodpecker/cache/nuget/packages NUGET_PACKAGES: /home/justme/.nuget/packages
ZOT_REGISTRY: registry.oeo.one ZOT_REGISTRY: registry.oeo.one
ZOT_USERNAME: justme ZOT_USERNAME: justme
ZOT_SECRET: ZOT_SECRET:
+62
View File
@@ -0,0 +1,62 @@
labels:
ci: domain-linux-native
platform: linux/amd64
clone:
- name: git
image: plugin-git
settings:
lfs: false
when:
- event: push
branch: main
evaluate: 'CI_COMMIT_MESSAGE contains "[CI]"'
concurrency: 1
steps:
- name: build-linux-amd64-backend
image: bash
environment:
NUGET_PACKAGES: /home/justme/.nuget/packages
ZOT_REGISTRY: registry.oeo.one
ZOT_USERNAME: justme
ZOT_SECRET:
from_secret: zot_secret
RELEASE_API_URL: https://mo.oeo.one
RELEASE_SIGNING_KEY:
from_secret: release_signing_key
commands:
- bash ci/build-main-amd64.sh backend
depends_on: []
- name: build-linux-amd64-agent
image: bash
environment:
NUGET_PACKAGES: /home/justme/.nuget/packages
ZOT_REGISTRY: registry.oeo.one
ZOT_USERNAME: justme
ZOT_SECRET:
from_secret: zot_secret
RELEASE_API_URL: https://mo.oeo.one
RELEASE_SIGNING_KEY:
from_secret: release_signing_key
commands:
- bash ci/build-main-amd64.sh agent
depends_on: []
- name: build-linux-amd64-probe
image: bash
environment:
NUGET_PACKAGES: /home/justme/.nuget/packages
ZOT_REGISTRY: registry.oeo.one
ZOT_USERNAME: justme
ZOT_SECRET:
from_secret: zot_secret
RELEASE_API_URL: https://mo.oeo.one
RELEASE_SIGNING_KEY:
from_secret: release_signing_key
commands:
- bash ci/build-main-amd64.sh probe
depends_on: []
+3 -4
View File
@@ -11,7 +11,7 @@ clone:
when: when:
- event: pull_request - event: pull_request
- event: push - event: push
branch: main branch: dev
# On Gitea PR events, CI_COMMIT_MESSAGE is the PR title. # On Gitea PR events, CI_COMMIT_MESSAGE is the PR title.
steps: steps:
@@ -21,7 +21,6 @@ steps:
when: when:
- evaluate: 'not (CI_COMMIT_MESSAGE contains "[SKIP TESTS]")' - evaluate: 'not (CI_COMMIT_MESSAGE contains "[SKIP TESTS]")'
environment: environment:
NUGET_PACKAGES: /home/justme/woodpecker/cache/nuget/packages NUGET_PACKAGES: /home/justme/.nuget/packages
commands: commands:
# 增量编译 + 快的那批测试(约 20 秒);慢的端到端测试在 e2e workflow 里和出镜像并行跑。见 ci/verify-backend.sh - cd backend && dotnet test --solution MoShiro.slnx -c Release
- bash ci/verify-backend.sh fast
+7 -4
View File
@@ -11,14 +11,17 @@ clone:
when: when:
- event: pull_request - event: pull_request
- event: push - event: push
branch: main branch: dev
depends_on:
- verify
# 和 verify 并行:前端构建不依赖后端测试,两个同时跑
steps: steps:
- name: web-build - name: web-build
image: bash image: bash
environment: environment:
NPM_CONFIG_CACHE: /home/justme/.npm NPM_CONFIG_CACHE: /home/justme/.npm
commands: commands:
# node_modules 按锁文件哈希缓存,展示端和管理端并行构建,再检查 CSS,见 ci/web-build.sh - npm ci --prefix web --prefer-offline
- bash ci/web-build.sh - npm run build --prefix web
- npm run lint:css --prefix web
-1
View File
@@ -22,7 +22,6 @@ steps:
GITEA_TOKEN: GITEA_TOKEN:
from_secret: gitea_token from_secret: gitea_token
NUGET_PACKAGES: 'C:\Users\JustMe-NAS\.nuget\packages' NUGET_PACKAGES: 'C:\Users\JustMe-NAS\.nuget\packages'
MOSHIRO_QUIC_CACHE: 'C:\Users\JustMe-NAS\.nuget\moshiro-quic-runtime'
RID: linux-x64 RID: linux-x64
COMPONENT: agent COMPONENT: agent
commands: &build-commands commands: &build-commands
+4 -5
View File
@@ -6,9 +6,8 @@ MoShiro is a self-hosted network path quality monitor. Backend coordinates Agent
## Branch and workspace safety ## Branch and workspace safety
- `main` is the only working branch (the old `dev` branch is retired and no pipeline uses it). Commit and push to `main` directly; use a pull request when a review is wanted. - 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` 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. - 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.
- 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. - 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. - 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`. - 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`.
@@ -39,6 +38,6 @@ Follow the user's requested validation scope. Do not start a full tag/release bu
## CI and releases ## CI and releases
CI triggers and image tags can change; inspect `.woodpecker/*.yml` and `docs/ops/deployment.md` before changing or describing them. In particular, check the distinction between `[CI]` main builds, `[SKIP TESTS]`, and version-tag releases in the current branch. 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.
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. 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.
+36 -42
View File
@@ -12,54 +12,45 @@ MoShiro 是一个自托管的**网络线路质量观测工具**。它在若干 P
**它不做什么** **它不做什么**
- **节点是纯采集器**:Agent 和 Probe 没有终端、命令执行、文件读写和自更新能力,也不加载外部代码。更新由节点外面的服务管理器完成:Podman Quadlet 单元每次启动都先拉最新镜像,管理端点「更新」时节点只是有序退出(没有 Quadlet 的环境用宿主机更新器),节点自己不下载、不替换任何东西。 - **节点是纯采集器**:Agent 和 Probe 没有终端、命令执行、文件读写和自更新能力,也不加载外部代码。更新由节点宿主机上的更新器完成,节点进程只放面板转来的发布清单。
- **不是主机监控**:Agent 和 Probe 只上报 CPU、内存、磁盘、网卡流量和运行时长这类只读的汇总数字(Probe 可以在本地配置里关掉),不采集型号、主机名、进程和任何 IP。 - **不是主机监控**:Agent 只上报 CPU、内存、磁盘、网卡流量和运行时长这类只读的汇总数字,不采集型号、主机名、进程和任何 IP;Probe 不上报主机资源。
- **不暴露地址**:公开接口里没有 IP、城市、ASN;国家和运营商只由 Backend 查询(节点自己不访问任何外部服务)。 - **不暴露地址**:公开接口里没有 IP、城市、ASN;国家和运营商只由 Backend 查询(节点自己不访问任何外部服务)。
- 暂不包含告警(见文末待办)。 - 暂不包含告警(见文末待办)。
## 信任关系 ## 信任关系
```text ```text
管理员 ── 密码或 passkey(Cookie 会话 + CSRF),或 API 令牌(按能力授权)──▶ Backend 管理员 ── 账号密码(Cookie 会话 + CSRF)或 API 令牌(按能力授权)──▶ Backend
访客 ── 匿名只读,站点设为公开时才可用(没有 IP、城市、ASN)──────────────▶ Backend 公开接口 访客 ── 匿名只读,站点设为公开时才可用(没有 IP、城市、ASN)──────────────▶ Backend 公开接口
操作员 ── passkey 给「操作员集合」签名 ──▶ Backend 只转发 ──▶ 节点自己验链
(创世指纹和属主在安装时固定在节点上;集合里还登记了 Backend 的服务器密钥,节点据此认 Backend、加密链路)
节点 ── 加入令牌一次性注册,之后用自己的 ECDSA P-256 密钥签名认证 ───────▶ Backend(只存公钥) 节点 ── 加入令牌一次性注册,之后用自己的 ECDSA P-256 密钥签名认证 ───────▶ Backend(只存公钥)
Probe ── 先敲门,再用自己的密钥签名建立测量会话 ───────────────────────────▶ Agent(只放行面板名单里的 Probe 公钥) Probe ── 用同一把密钥签名建立测量会话 ───────────────────────────────────▶ Agent(只放行面板名单里的 Probe 公钥)
CI ── 发布私钥对「镜像 digest 清单」签名 ──▶ 镜像仓库 / Backend ──▶ 节点宿主机的更新器用本机公钥验签 CI ── 发布私钥对「镜像 digest 清单」签名 ──▶ Backend 原样转发 ──▶ 节点宿主机的更新器用本机公钥验签
``` ```
- **Backend 不信任节点上报的内容**:数据按线路隔离存放;节点的身份靠签名,数据库泄露也无法冒充节点。 - **Backend 不信任节点上报的内容**:数据按线路隔离存放;节点的身份靠签名,数据库泄露也无法冒充节点。
- **节点不信任 Backend 的下发**:任务超出节点本机的硬上限(目标网段、码率、包速率、监听端口)就被拒绝;节点不执行任何「执行类」指令;改信任(操作员集合)要操作员 passkey 签名,Backend 伪造不了。 - **节点不信任 Backend 的下发**:任务超出节点本机的硬上限(目标网段、码率、包速率、监听端口)就被拒绝;节点不执行任何「执行类」指令。
- **更新不依赖面板的诚实**:清单必须由发布私钥签名(面板登记时验一次,节点宿主机的更新器用本机公钥再验一次)、只认本机配置的镜像仓库、按 digest 拉取、拒绝旧的 `seq`。面板被攻破也推不出恶意镜像。可信的发布密钥由部署的人决定:官方 CI 密钥始终信任,也可以另外信任自己的公钥。 - **更新不依赖面板的诚实**:清单必须由发布私钥签名;节点宿主机的更新器用本机公钥验签、只认本机配置的镜像仓库、按 digest 拉取、拒绝旧的 `seq`。面板被攻破也推不出恶意镜像。
- **没有默认账户**:第一次打开管理端进认领向导,用后端日志里的认领码创建第一个管理员、注册 passkey、建立信任根。每个节点都必须有属主(passkey),由节点自己强制。
- 运行中的二进制不做校验(节点自报的哈希不可信);「资产是我们的」靠安装时的签名清单和签名的 `SHA256SUMS`。详见 [`docs/security/`](docs/security/security.md)。
## 架构 ## 架构
```text ```text
操作员 passkey ──签名「操作员集合」──┐ CI 发布私钥 ──签名「镜像 digest 清单」──▶ 镜像仓库(清单 + 镜像) Web(展示端 · 管理端)
▼ ▲ ▲ REST + SSE
Web(展示端 · 管理端) │ 定时取清单 ┌──────────────────────────────────────────────────────────┐
▲ REST + SSE │ │ Backend(面板) │
┌──────────────────────────────────────────────────────────────┐ │ Api · Registry · Planner · Links │
│ Backend(面板) │──────────────────────────────┘ │ Releases · Analysis · LiveHub │
│ Api · Auth(会话 · passkey · API 令牌 · 审计) │ │ Store(段文件 + SQLite) │
│ Trust(操作员集合 · 服务器身份)· Registry · Planner · Links │ └──────────────────────────────────────────────────────────┘
│ Releases(发布清单 · 可信发布密钥) │
│ Ingest · Analysis · LiveHub │
│ Store(段文件 + SQLite) │
└──────────────────────────────────────────────────────────────┘
▲ ▲ ▲ ▲
│ wss 长连接(节点发起,应用层加密) │ wss 长连接(节点发起,应用层加密) │ wss 长连接(节点发起) │ wss 长连接(节点发起)
│ 任务、数据、状态、更新通知 │ 身份、Agent 列表、状态、更新通知 │ 任务、数据、状态、更新通知 │ 身份、Agent 列表、状态、更新通知
┌────────────────────┐ ┌──────────────────────┐ ┌────────────────────┐ ┌──────────────────────┐
│ Agent × 10+ │ ◀── 测量会话 ── │ Probe × N │ │ Agent × 10+ │ ◀── 测量会话 ── │ Probe × N │
│ 有公网 IP,监听 │ 先敲门再 TLS │ 家宽等出口,NAT 后 │ │ 有公网 IP,监听 │ │ 家宽等出口,NAT 后 │
└────────────────────┘ └──────────────────────┘ └────────────────────┘ └──────────────────────┘
测量会话:TLS 控制通道 + QUIC Datagram/TCP 数据通道,由 Probe 主动拨出 测量会话:TLS 控制通道 + UDP/TCP 数据通道,由 Probe 主动拨出
宿主机更新器(容器节点):用本机公钥验签 → 按 digest 拉镜像 → 重启,失败回滚
``` ```
两种连接:**Probe 和 Agent 都用长连接连 Backend**(身份、任务、状态、更新通知),**测量只走 Probe → Agent**(Probe 主动拨出,Agent 不需要连 Probe,家宽也不需要入站端口)。哪些 Probe 连哪些 Agent 由 Backend 决定:默认自动全连,也可以在管理端手动选择。 两种连接:**Probe 和 Agent 都用长连接连 Backend**(身份、任务、状态、更新通知),**测量只走 Probe → Agent**(Probe 主动拨出,Agent 不需要连 Probe,家宽也不需要入站端口)。哪些 Probe 连哪些 Agent 由 Backend 决定:默认自动全连,也可以在管理端手动选择。
@@ -69,16 +60,16 @@ MoShiro 是一个自托管的**网络线路质量观测工具**。它在若干 P
Probe / Agent 积木 ──▶ 原始列块(10 秒一块)──▶ Backend 段文件 ──▶ 指标与服务模型 ──▶ REST / SSE ──▶ Web Probe / Agent 积木 ──▶ 原始列块(10 秒一块)──▶ Backend 段文件 ──▶ 指标与服务模型 ──▶ REST / SSE ──▶ Web
``` ```
Backend 用段文件保存原始数据,用 SQLite 保存索引、指标与配置。**完整的架构、数据链路、安全边界和设计决策见 [`docs/design/architecture.md`](docs/design/architecture.md)。** Backend 用段文件保存原始数据,用 SQLite 保存索引、指标与配置。**完整的架构、数据链路、安全边界和设计决策见 [`docs/architecture.md`](docs/architecture.md)。**
## 协议 ## 协议
- **控制链路(Fleet)**:节点到 Backend 的 `wss` 长连接(`/api/agent/v1/rpc`),JSON-RPC 2.0 信封加二进制通道;挑战加签名认证,`hello` 协商版本和能力,心跳;**期望状态同步**(Backend 下发完整快照,节点调和并回报已应用的版本和被拒绝的条目);**可靠发件箱**(原始块至少一次投递,按 id 去重)。Fleet 与业务无关,放在 `backend/fleet/`。 - **控制链路(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 列表)。 - **业务方法**:`moshiro.*`(按需测试、Probe 断开、事件、主机汇总、更新通知等),状态文档有 `task_set`(Agent 的任务集)和 `probe_agents`(Probe 的 Agent 列表)。
- **测量数据面**:Probe 到 Agent 的 TLS 控制通道、QUIC Datagram 和 TCP 数据通道(默认 `7443/tcp`、`7443/udp`、`7444/tcp`);测量包不走可靠重传,继续由会话 MAC、序号窗口和速率准入校验。 - **测量数据面**:Probe 到 Agent 的 TLS 控制通道、UDP 和 TCP 数据通道(默认 `7443/tcp`、`7443/udp`、`7444/tcp`);会话用 `session_id` 和 MAC 认证、不绑定五元组,NAT 映射变化后自动跟随。
- **REST + SSE**:`/api/public`(展示,可匿名)、`/api/admin`(管理);OpenAPI 在 `backend/openapi/v1.json`,前端据此生成类型。 - **REST + SSE**:`/api/public`(展示,可匿名)、`/api/admin`(管理);OpenAPI 在 `backend/openapi/v1.json`,前端据此生成类型。
细节见 [`docs/design/protocol.md`](docs/design/protocol.md)、[`backend/fleet/README.md`](backend/fleet/README.md)、[`docs/api/api.md`](docs/api/api.md)、[`docs/security/enrollment.md`](docs/security/enrollment.md)。 细节见 [`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)。
## 如何构建 ## 如何构建
@@ -89,7 +80,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 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/>。第一次打开管理端会进认领向导,认领码在后端日志里(或用 `MoShiro__ClaimCode` 指定)。 本地运行:先在 `backend/` 里 `dotnet run --project src/MoShiro.Backend -- --urls http://localhost:5000`,再 `npm run dev --prefix web`,展示端是 <http://localhost:5173/>,管理端是 <http://localhost:5173/admin/>。
测试和检查: 测试和检查:
@@ -99,19 +90,22 @@ npm run lint:css --prefix web # 前端样式(颜色只能写
npm run gen:api --prefix web # 后端改了接口之后重新生成前端类型 npm run gen:api --prefix web # 后端改了接口之后重新生成前端类型
``` ```
Backend、Agent、Probe 以 OCI 镜像发布(Linux amd64、arm64),Agent 和 Probe 另有 Linux glibc/musl、macOS、Windows 的二进制包。NativeAOT 的构建细节、CI 触发规则(`[CI]`、`[SKIP TESTS]`、版本 tag)、镜像路径、容器节点的运行和更新见 [`docs/ops/deployment.md`](docs/ops/deployment.md);前端见 [`web/README.md`](web/README.md),后端见 [`backend/README.md`](backend/README.md)。 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)。
## 文档 ## 文档
| 文档 | 内容 | | 文档 | 内容 |
| --- | --- | | --- | --- |
| [`docs/README.md`](docs/README.md) | 文档索引,按类别组织 | | [`docs/architecture.md`](docs/architecture.md) | 架构、数据链路、安全边界、设计决策 |
| [`docs/design/`](docs/design/architecture.md) | 设计:架构与决策、线协议、存储与查询、采集与分析 | | [`docs/security.md`](docs/security.md) | 零信任设计稿(部分已实现):威胁模型、入网信任链、监听隐身、extension 授权 |
| [`docs/security/`](docs/security/security.md) | 安全:零信任与威胁模型、访问控制与入网信任链、注册与身份 | | [`docs/access.md`](docs/access.md) | 访问控制与入网信任链设计(尚未实现):账户、角色、能力、API key、提权窗口、passkey 到节点的签名链 |
| [`docs/api/`](docs/api/api.md) | 接口:端点说明、API 规范 | | [`docs/protocol.md`](docs/protocol.md)、[`backend/fleet/README.md`](backend/fleet/README.md) | 线协议、Fleet 节点框架(身份、注册、连接) |
| [`docs/ops/deployment.md`](docs/ops/deployment.md) | 运维:构建、发布、CI、部署与更新 | | [`docs/enrollment.md`](docs/enrollment.md) | 注册、身份、Probe 与 Agent 的接入 |
| [`docs/dev/`](docs/dev/project-layout.md) | 开发:仓库结构与依赖规则、代码风格 | | [`docs/measurement.md`](docs/measurement.md)、[`docs/analysis.md`](docs/analysis.md) | 采集引擎与任务模型、分析层与指标 |
| [`backend/fleet/README.md`](backend/fleet/README.md) | Fleet 节点框架(身份、注册、连接) | | [`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) | 仓库结构与依赖规则、代码风格 |
代码目录:`backend/`(Backend、`fleet/` 节点框架、`shared/` 测量引擎和协议、`agent/`、`probe/`、`tests/`)、`web/`(`display` 展示端、`admin` 管理端、`shared`)、`packaging/` 和 `scripts/`(部署与维护)、`ci/` 和 `.woodpecker/`(流水线)。 代码目录:`backend/`(Backend、`fleet/` 节点框架、`shared/` 测量引擎和协议、`agent/`、`probe/`、`tests/`)、`web/`(`display` 展示端、`admin` 管理端、`shared`)、`packaging/` 和 `scripts/`(部署与维护)、`ci/` 和 `.woodpecker/`(流水线)。
+8 -4
View File
@@ -5,15 +5,19 @@
## 测量 ## 测量
- 路由跳数、路径漂移和 trace - 路由跳数、路径漂移和 trace
- QUIC 握手耗时:TCP 建连和 TLS 握手已经有了(`connect` 任务)。.NET 的 QUIC 依赖系统里的 msquic 原生库,和节点「只依赖 OpenSSL、NativeAOT 单文件」冲突,要做只能自己写最小的 QUIC Initial 握手探测;另外 .NET 还没有 QUIC datagram([dotnet/runtime#123418](https://github.com/dotnet/runtime/issues/123418) 只是提案),`System.Net.Quic` 只有可靠的流,重传会盖住丢包,测不了 UDP 丢包率 - QUIC 握手耗时:TCP 建连和 TLS 握手已经有了(`connect` 任务)。.NET 的 QUIC 依赖系统里的 msquic 原生库,和节点「只依赖 OpenSSL、NativeAOT 单文件」冲突,要做只能自己写最小的 QUIC Initial 握手探测
- 网页长截图 - 网页长截图
- pcap / eBPF 采集 - pcap / eBPF 采集
## 任务与面板 ## 任务与面板
- 单个 Agent 的任务(目前是线路模板加按 Agent 的覆盖,没有只属于某一个 Agent 的独立任务) - 单个 Agent 的任务(目前是线路模板加按 Agent 的覆盖,没有只属于某一个 Agent 的独立任务)
- 原生 webhook 投递;告警(基于 webhook)。实现之前先定约束(只允许 HTTPS、禁止私网和回环、不跟随重定向、限制超时和响应大小、解析后再检查地址),见 [`docs/security/security.md`](docs/security/security.md) 第 9.2 节 - 原生 webhook 投递;告警(基于 webhook)。实现之前先定约束(只允许 HTTPS、禁止私网和回环、不跟随重定向、限制超时和响应大小、解析后再检查地址),见 [`docs/security.md`](docs/security.md) 第 9.2 节
## 部署
- 后端自动下载前端资源:用 Podman、Docker 部署时,默认拉取最新的前端资源(现在前端要自己构建,镜像里没有)。
[security.md](docs/security.md)
## 访客页面 ## 访客页面
- 人机验证:访客页面要通过验证才能拉数据,前端访问时静默触发。候选 tiago2/cap 和 Cloudflare Turnstile。 - 人机验证:访客页面要通过验证才能拉数据,前端访问时静默触发。候选 tiago2/cap 和 Cloudflare Turnstile。
@@ -30,11 +34,11 @@
- 流程:下载 → 校验 → 解包到 `versions/<v>/` → 原子切换 `current`(Linux 符号链接,Windows 目录联结)→ 重启服务 → 等健康信标确认 → 失败切回旧版本并把该版本标记为坏版本。 - 流程:下载 → 校验 → 解包到 `versions/<v>/` → 原子切换 `current`(Linux 符号链接,Windows 目录联结)→ 重启服务 → 等健康信标确认 → 失败切回旧版本并把该版本标记为坏版本。
- 权限分离:更新进程只能写安装目录和重启被管理的服务,读不到节点身份私钥。 - 权限分离:更新进程只能写安装目录和重启被管理的服务,读不到节点身份私钥。
- OpenWrt:`keep_versions = 1`,下载前检查闪存剩余空间。 - OpenWrt:`keep_versions = 1`,下载前检查闪存剩余空间。
- 容器节点已经实现的那一套(清单签名、`seq` 防重放、按 digest 拉取、回滚)见 [`docs/ops/deployment.md`](docs/ops/deployment.md)。 - 容器节点已经实现的那一套(清单签名、`seq` 防重放、按 digest 拉取、回滚)见 [`docs/deployment.md`](docs/deployment.md)。
## 安全 ## 安全
- **零信任**:设计和各缺口的状态在 [`docs/security/security.md`](docs/security/security.md)。已完成:协议方向声明表、Probe 自己生成密钥、Agent 预握手敲门、操作员集合与创世固定、服务器身份和安全链路、属主固定。还没做:测量数据的产生者签名和归属校验、入库配额、Probe 只拨公网地址、管理端 CSP 之外的输入校验、extension 框架(敏感能力都放进可选安装的独立 extension,核心保持 NativeAOT)、吊销表;可选的 UDP 单包授权、UDP 会话迁移的路径验证。 - **零信任**:设计稿在 [`docs/security.md`](docs/security.md),按其中第 12 节的顺序实现(阶段零「协议方向声明表」已完成):Probe 自己生成密钥、Agent 预握手敲门、操作员凭证和名册、测量数据的签名和归属校验、入库配额和管理端 CSP、Backend 应用层身份、extension 框架(敏感能力都放进可选安装的独立 extension,核心保持 NativeAOT);可选的 UDP 单包授权、UDP 会话迁移的路径验证。
- **extension**(Web SSH、TTY、更新遥测,敏感 operator):设计时必须保住「节点是纯采集器」这条边界,见 [`README.md`](README.md) 的项目边界。 - **extension**(Web SSH、TTY、更新遥测,敏感 operator):设计时必须保住「节点是纯采集器」这条边界,见 [`README.md`](README.md) 的项目边界。
- 对 UDP 包、控制帧、RawChunk、JSON-RPC 做模糊测试;伪造签名和重放的专项测试。 - 对 UDP 包、控制帧、RawChunk、JSON-RPC 做模糊测试;伪造签名和重放的专项测试。
+1 -7
View File
@@ -13,17 +13,11 @@
<UseArtifactsOutput>true</UseArtifactsOutput> <UseArtifactsOutput>true</UseArtifactsOutput>
<ArtifactsPath>$(MSBuildThisFileDirectory)artifacts</ArtifactsPath> <ArtifactsPath>$(MSBuildThisFileDirectory)artifacts</ArtifactsPath>
<Deterministic>true</Deterministic> <Deterministic>true</Deterministic>
<Version>0.0.0-dev</Version> <Version>0.3.3</Version>
<NuGetAudit>false</NuGetAudit> <NuGetAudit>false</NuGetAudit>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" PrivateAssets="all" /> <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" PrivateAssets="all" />
<AdditionalFiles Include="$(MSBuildThisFileDirectory)BannedSymbols.txt" /> <AdditionalFiles Include="$(MSBuildThisFileDirectory)BannedSymbols.txt" />
</ItemGroup> </ItemGroup>
<ItemGroup Condition="'$(UseAotAnywhere)' == 'true' and $([MSBuild]::IsOSPlatform('Linux'))">
<PackageReference Include="Vezel.Zig.Toolsets.linux-x64"
VersionOverride="0.16.0.2"
PrivateAssets="all"
GeneratePathProperty="true" />
</ItemGroup>
</Project> </Project>
+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 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/ops/deployment.md`](../docs/ops/deployment.md)。 例如 `<RID>` 可为 `win-x64`、`linux-x64` 或 `linux-arm64`。NativeAOT 发布需要对应平台的 C/C++ 工具链。仓库根目录的 `scripts/publish.sh backend <RID>` 可构建单个平台的 Backend 可执行文件;容器发布由 Woodpecker tag 流水线完成,详见 [`../docs/deployment.md`](../docs/deployment.md)。
正式发布通过 Woodpecker tag 流水线生成 Linux/amd64 与 Linux/arm64 OCI 镜像,容器内监听 `8080`,数据写入挂载的 `/data` 卷。Agent 与 Probe 的多平台二进制包单独发布,不包含在 Backend 镜像中。 正式发布通过 Woodpecker tag 流水线生成 Linux/amd64 与 Linux/arm64 OCI 镜像,容器内监听 `8080`,数据写入挂载的 `/data` 卷。Agent 与 Probe 的多平台二进制包单独发布,不包含在 Backend 镜像中。
@@ -49,6 +49,6 @@ cd backend
dotnet run --project src/MoShiro.Backend -- --urls http://localhost:5000 dotnet run --project src/MoShiro.Backend -- --urls http://localhost:5000
``` ```
没有默认管理员:第一次打开面板时按页面引导自己创建管理员、注册 passkey、建立信任根,页面要的认领码写在后端日志里(或用 `MoShiro__ClaimCode` 指定)。`MoShiro__InitialAdminUser` / `MoShiro__InitialAdminPassword` 只给自动化用。数据目录、公开地址、Cookie 和 CORS 等设置通过 `MoShiro` 配置节传入;完整配置说明见 [`../docs/ops/deployment.md`](../docs/ops/deployment.md)。站点默认需要登录,可在管理端修改公开设置。 没有默认管理员:第一次打开面板时按页面引导自己创建管理员、注册 passkey、建立信任根,页面要的认领码写在后端日志里(或用 `MoShiro__ClaimCode` 指定)。`MoShiro__InitialAdminUser` / `MoShiro__InitialAdminPassword` 只给自动化用。数据目录、公开地址、Cookie 和 CORS 等设置通过 `MoShiro` 配置节传入;完整配置说明见 [`../docs/deployment.md`](../docs/deployment.md)。站点默认需要登录,可在管理端修改公开设置。
OpenAPI 文档由构建生成到 `openapi/v1.json`。API 设计和端点说明见 [`../docs/api/api.md`](../docs/api/api.md);项目总览见仓库根目录的 [`README.md`](../README.md)。 OpenAPI 文档由构建生成到 `openapi/v1.json`。API 设计和端点说明见 [`../docs/api.md`](../docs/api.md);项目总览见仓库根目录的 [`README.md`](../README.md)。
+5 -5
View File
@@ -7,7 +7,7 @@ Agent 仅开放配置的控制和数据端口。它不提供远程终端、命
## 工作流程 ## 工作流程
```text ```text
Probe ──TLS 控制 / QUIC Datagram、TCP 测量──> Agent ──长连接与数据上报──> Backend Probe ──TLS 控制 / UDP、TCP 测量──> Agent ──长连接与数据上报──> Backend
▲ ▲
fleet-enroll 注册 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 dotnet publish fleet/tools/fleet-enroll -c Release -r <RID> -p:PublishAot=true
``` ```
也可在仓库根目录运行 `bash scripts/publish.sh agent <RID>` 和 `bash scripts/publish.sh fleet-enroll <RID>`。部署包和平台说明见 [`../../docs/ops/deployment.md`](../../docs/ops/deployment.md)。 也可在仓库根目录运行 `bash scripts/publish.sh agent <RID>` 和 `bash scripts/publish.sh fleet-enroll <RID>`。部署包和平台说明见 [`../../docs/deployment.md`](../../docs/deployment.md)。
## 注册与运行 ## 注册与运行
@@ -47,7 +47,7 @@ fleet-enroll --server https://<backend> --join <token> --name <agent-name> --ide
moshiro-agent --config /etc/moshiro/agent.json moshiro-agent --config /etc/moshiro/agent.json
``` ```
容器节点的运行方式(podman + systemd)和宿主机更新器见 [`../../docs/ops/deployment.md`](../../docs/ops/deployment.md)。也可使用 OCI 镜像 `registry.oeo.one/moshiro/moshiro-agent:<version>`,自动选择 Linux amd64 或 arm64 镜像。镜像同时包含 `fleet-enroll`;可通过 `--entrypoint /usr/local/bin/fleet-enroll` 执行首次注册。容器默认读取命名 volume 中的 `/etc/moshiro/agent.json` 与身份文件;建议使用宿主网络,Linux ICMP 优先使用普通用户 ping socket,内核 ping_group_range 需允许映射后的 GID。镜像以 UID/GID 1654 运行,新命名 volume 由引擎按镜像目录初始化权限。 容器节点的运行方式(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 由引擎按镜像目录初始化权限。
不传 `--config` 时默认读取 `/etc/moshiro/agent.json`。Agent 默认监听控制端口 `7443/tcp`,数据端口 `7443/udp` 和 `7444/tcp`;防火墙和面板中的节点端口配置需与本地配置一致。`reporting.host_stats` 可关闭主机状态上报;启用时只上报资源使用等汇总数据,不包含主机名、进程或 IP。 不传 `--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 断开时对应的会话资源会释放。 - 每个 Probe 使用独立的 TLS 会话和数据密钥;Probe 断开时对应的会话资源会释放。
- 数据面会校验会话认证、重放窗口和速率限制;无效数据包会静默丢弃。 - 数据面会校验会话认证、重放窗口和速率限制;无效数据包会静默丢弃。
- 测量引擎和协议由 `backend/shared/` 提供;节点身份、注册和连接由 `backend/fleet/` 提供。 - 测量引擎和协议由 `backend/shared/` 提供;节点身份、注册和连接由 `backend/fleet/` 提供。
- 详细安全边界与拓扑见 [`../../docs/design/architecture.md`](../../docs/design/architecture.md),协议说明见 [`../../docs/design/protocol.md`](../../docs/design/protocol.md),部署见 [`../../docs/ops/deployment.md`](../../docs/ops/deployment.md)。 - 详细安全边界与拓扑见 [`../../docs/architecture.md`](../../docs/architecture.md),协议说明见 [`../../docs/protocol.md`](../../docs/protocol.md),部署见 [`../../docs/deployment.md`](../../docs/deployment.md)。
Podman 系用 Quadlet(`packaging/quadlet/moshiro-agent.container`,默认自动更新),Docker 用 `packaging/compose/agent/compose.yaml`(手动更新);配置、身份和缓存放在命名 volume 中,无需宿主配置目录、keep-id 或手动 chown。首次导入和自动更新见部署文档。 Docker 与 rootless/rootful Podman 共用 `packaging/compose/agent/compose.yaml`,配置、身份和缓存放在命名 volume 中,无需宿主配置目录、keep-id 或手动 chown。首次导入和自动更新见部署文档。
+23 -42
View File
@@ -11,7 +11,7 @@ using MoShiro.Protocol;
namespace MoShiro.Agent; namespace MoShiro.Agent;
/// <summary>Agent 的组装点:数据面、点对点监听、与 Backend 的链路都在这里接起来。没有全局状态,测试可以在同一进程里启动多个。</summary> /// <summary>Agent 的组装点:数据面、点对点监听、与 Backend 的链路都在这里接起来。没有全局状态,测试可以在同一进程里启动多个。</summary>
public sealed class AgentApp : IDisposable, IAsyncDisposable public sealed class AgentApp : IDisposable
{ {
public const string Version = MoShiro.Protocol.BuildInfo.Version; public const string Version = MoShiro.Protocol.BuildInfo.Version;
@@ -31,23 +31,23 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable
private readonly TaskCompletionSource _ready = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource _ready = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly AgentAdmission _admission; private readonly AgentAdmission _admission;
private readonly Outbox _outbox = new(64 << 20); private readonly Outbox _outbox = new(64 << 20);
private readonly RestartUpdater? _update; private readonly UpdateBridge _update;
private readonly BackendUplink _uplink; private readonly BackendUplink _uplink;
private readonly QuicDatagramTransport _datagrams; private readonly UdpMux _udp;
private readonly TcpAcceptor _tcp; private readonly TcpAcceptor _tcp;
private readonly PeerListener _peers; private readonly PeerListener _peers;
private readonly PeerHub _hub; private readonly PeerHub _hub;
private readonly TaskManager _tasks; private readonly TaskManager _tasks;
private readonly TestRunner _tests; private readonly TestRunner _tests;
private readonly FleetLink _link; private readonly FleetLink _link;
private int _disposed;
/// <param name="dataDirectory">配置目录(容器里的 <c>/etc/moshiro</c>):宿主机更新器和节点在这里交换清单、请求和状态。</param> /// <param name="dataDirectory">配置目录(容器里的 <c>/etc/moshiro</c>):宿主机更新器和节点在这里交换清单、请求和状态。</param>
public AgentApp(AgentConfig config, LoadedNodeIdentity identity, string dataDirectory, TimeProvider time, INodeLog log, RestartController? restart = null) public AgentApp(AgentConfig config, LoadedNodeIdentity identity, string dataDirectory, TimeProvider time, INodeLog log, RestartController? restart = null)
{ {
_ownedRestart = restart is null ? new RestartController() : null; _ownedRestart = restart is null ? new RestartController() : null;
_restart = restart ?? _ownedRestart!; _restart = restart ?? _ownedRestart!;
_update = RestartUpdater.FromEnvironment("agent", _restart.RequestNow, time, log); _update = new UpdateBridge(dataDirectory, new ConfigStore(dataDirectory).Write, log);
NodeBinary.Prime(); // 正在运行的二进制的哈希:先在后台算好,握手时用
_config = config; _config = config;
_identity = identity; _identity = identity;
_time = time; _time = time;
@@ -63,7 +63,7 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable
_ipv6Supported = bind.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6; _ipv6Supported = bind.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6;
_admission = new AgentAdmission(_streams); _admission = new AgentAdmission(_streams);
_datagrams = new QuicDatagramTransport(_sessions, _admission, _clock, _counters.Data, log, bind, config.Listen.EffectiveQuicPorts, identity.Certificate); _udp = new UdpMux(_sessions, _admission, _clock, _counters.Data, log, bind, config.Listen.UdpPorts);
// 预握手敲门:控制端口和数据端口共用一个关卡(限速和去重一起算);名册就是期望状态里的 peers // 预握手敲门:控制端口和数据端口共用一个关卡(限速和去重一起算);名册就是期望状态里的 peers
_trust = TrustAnchor.Load(config.Fleet.IdentityDir, log); _trust = TrustAnchor.Load(config.Fleet.IdentityDir, log);
_knock = new KnockGate(identity.PeerIdentity, RosterKeys, time); _knock = new KnockGate(identity.PeerIdentity, RosterKeys, time);
@@ -75,7 +75,7 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable
Clock = _clock, Clock = _clock,
Log = log, Log = log,
Session = () => null, // 每个 Probe 会话各用自己绑定的环境(BlockEnv.WithSession) Session = () => null, // 每个 Probe 会话各用自己绑定的环境(BlockEnv.WithSession)
Datagrams = _datagrams, Udp = _udp,
TcpListen = _tcp, TcpListen = _tcp,
Admission = _admission, Admission = _admission,
Counters = _counters.Data, Counters = _counters.Data,
@@ -84,14 +84,14 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable
}; };
var chunks = new ChunkAssembler(_streams, _clock, _counters, _outbox, identity.NodeId, time, log); var chunks = new ChunkAssembler(_streams, _clock, _counters, _outbox, identity.NodeId, time, log);
var targets = new TargetRunners(chunks, identity.NodeId, _clock, time, log); var targets = new TargetRunners(chunks, identity.NodeId, _clock, time, log);
_tasks = new TaskManager(CreateGuard, _streams, env, targets, () => _datagrams.BoundPorts, () => _tcp.BoundPorts, _trust); _tasks = new TaskManager(CreateGuard, _streams, env, targets, () => _udp.BoundPorts, () => _tcp.BoundPorts, _trust);
_hub = new PeerHub(_sessions, _clock, chunks, _tasks, _uplink, time, log); _hub = new PeerHub(_sessions, _clock, chunks, _tasks, _uplink, time, log);
_tests = new TestRunner(_tasks, _sessions, _streams, env, _uplink, config.Limits.MaxConcurrentTests, transport => transport == "quic_datagram" ? _datagrams.BoundPorts : _tcp.BoundPorts, time, log); _tests = new TestRunner(_tasks, _sessions, _streams, env, _uplink, config.Limits.MaxConcurrentTests, transport => transport == "udp" ? _udp.BoundPorts : _tcp.BoundPorts, time, log);
_tasks.OnLineTasksChanged = (probe, set) => _ = _hub.PushToAsync(probe, set).AsTask(); _tasks.OnLineTasksChanged = (probe, set) => _ = _hub.PushToAsync(probe, set).AsTask();
_tasks.OnPeersChanged = _hub.CloseNotIn; _tasks.OnPeersChanged = _hub.CloseNotIn;
_hub.OnProbeLost = probe => _tests.ProbeLost(probe); _hub.OnProbeLost = probe => _tests.ProbeLost(probe);
_admission.OnEndpointChanged = probe => _ = _uplink.EmitEventAsync(AgentEventKinds.EndpointChanged, probe.DatagramRemoteEndPoint?.ToString()).AsTask(); _admission.OnEndpointChanged = probe => _ = _uplink.EmitEventAsync(AgentEventKinds.EndpointChanged, probe.UdpEndpoint?.ToString()).AsTask();
_peers = new PeerListener( _peers = new PeerListener(
new PeerListenerOptions new PeerListenerOptions
@@ -114,29 +114,13 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable
public void Dispose() public void Dispose()
{ {
if (Interlocked.Exchange(ref _disposed, 1) != 0) _udp.Dispose();
{
return;
}
_datagrams.Dispose();
_ownedRestart?.Dispose();
}
public async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
await _datagrams.DisposeAsync().ConfigureAwait(false);
_ownedRestart?.Dispose(); _ownedRestart?.Dispose();
} }
internal StreamRegistry Streams => _streams; internal StreamRegistry Streams => _streams;
public IReadOnlyList<int> QuicPorts => _datagrams.BoundPorts; public IReadOnlyList<int> UdpPorts => _udp.BoundPorts;
public IReadOnlyList<int> TcpPorts => _tcp.BoundPorts; public IReadOnlyList<int> TcpPorts => _tcp.BoundPorts;
@@ -160,26 +144,25 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable
public async Task RunAsync(CancellationToken ct) public async Task RunAsync(CancellationToken ct)
{ {
_log.Info("moshiro agent starting", MoShiro.Protocol.BuildInfo.Fields);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct); using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct);
// 数据面和控制端口先开始监听,再连接 Backend:hello 里上报的是实际端口 // 数据面和控制端口先开始监听,再连接 Backend:hello 里上报的是实际端口
_datagrams.Bind(); _udp.Bind();
_tcp.Bind(); _tcp.Bind();
Task quic = _datagrams.RunAsync(linked.Token); Task udp = _udp.RunAsync(linked.Token);
Task tcp = _tcp.RunAsync(linked.Token); Task tcp = _tcp.RunAsync(linked.Token);
Task peers = _peers.RunAsync(linked.Token); Task peers = _peers.RunAsync(linked.Token);
await Task.WhenAny(_peers.Started.Task, peers); await Task.WhenAny(_peers.Started.Task, peers);
if (peers.IsFaulted) if (peers.IsFaulted)
{ {
await linked.CancelAsync(); await linked.CancelAsync();
await Task.WhenAll(quic, tcp, peers); // 抛出监听失败的原因(端口被占用等) await Task.WhenAll(udp, tcp, peers); // 抛出监听失败的原因(端口被占用等)
} }
_ready.TrySetResult(); _ready.TrySetResult();
_log.Info("agent listening", ("control", ControlPort), ("quic", string.Join(',', QuicPorts)), ("tcp", string.Join(',', TcpPorts))); _log.Info("agent listening", ("control", ControlPort), ("udp", string.Join(',', UdpPorts)), ("tcp", string.Join(',', TcpPorts)));
try try
{ {
await Task.WhenAll(quic, tcp, peers, _link.RunAsync(linked.Token), ReportHostAsync(linked.Token)); await Task.WhenAll(udp, tcp, peers, _link.RunAsync(linked.Token), ReportHostAsync(linked.Token));
} }
finally finally
{ {
@@ -220,7 +203,7 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable
MaxTotalPps = _config.Limits.MaxTotalPps, MaxTotalPps = _config.Limits.MaxTotalPps,
AllowedTargetCidrs = _config.Limits.AllowedTargetCidrs, AllowedTargetCidrs = _config.Limits.AllowedTargetCidrs,
MaxConcurrentTests = _config.Limits.MaxConcurrentTests, MaxConcurrentTests = _config.Limits.MaxConcurrentTests,
AllowedQuicPorts = _datagrams.BoundPorts, AllowedUdpPorts = _udp.BoundPorts,
AllowedTcpPorts = _tcp.BoundPorts, AllowedTcpPorts = _tcp.BoundPorts,
}, },
BlockCatalog.AgentKinds); BlockCatalog.AgentKinds);
@@ -253,7 +236,7 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable
{ {
router.OnNotify(MoShiroMethods.UpdateOffer, MoShiroJsonContext.Default.UpdateOfferParams, (_, offer, _) => router.OnNotify(MoShiroMethods.UpdateOffer, MoShiroJsonContext.Default.UpdateOfferParams, (_, offer, _) =>
{ {
_update?.Offer(offer); _update.WriteOffer(offer);
return ValueTask.CompletedTask; return ValueTask.CompletedTask;
}); });
} }
@@ -275,7 +258,7 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable
{ {
router.OnNotify(MoShiroMethods.UpdateApply, MoShiroJsonContext.Default.UpdateOfferParams, (_, _, _) => router.OnNotify(MoShiroMethods.UpdateApply, MoShiroJsonContext.Default.UpdateOfferParams, (_, _, _) =>
{ {
_update?.RequestApply(); _update.RequestApply();
return ValueTask.CompletedTask; return ValueTask.CompletedTask;
}); });
} }
@@ -294,12 +277,12 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable
Server = new Uri(_config.Fleet.Server), Server = new Uri(_config.Fleet.Server),
Identity = _identity.Identity, Identity = _identity.Identity,
ServerVerifier = _trust, ServerVerifier = _trust,
Attestation = _update.Attest,
NodeId = _identity.NodeId, NodeId = _identity.NodeId,
NodeVersion = Version, NodeVersion = Version,
Capabilities = [MoShiroCapabilities.Base, MoShiroCapabilities.KindsPrefix + string.Join(',', BlockCatalog.AgentKinds)], Capabilities = [MoShiroCapabilities.Base, MoShiroCapabilities.KindsPrefix + string.Join(',', BlockCatalog.AgentKinds)],
HelloMeta = HelloMeta, HelloMeta = HelloMeta,
HeartbeatExtra = HeartbeatExtra, HeartbeatExtra = HeartbeatExtra,
ShutdownReason = () => _restart.NowReason == "update" ? "updating" : "stopping",
}; };
var link = new FleetLink(options, router, [new StateHandler<TaskSet>(TaskSet.StateType, MoShiroJsonContext.Default.TaskSet, _tasks.ApplyAsync)], _outbox, _time, _log); var link = new FleetLink(options, router, [new StateHandler<TaskSet>(TaskSet.StateType, MoShiroJsonContext.Default.TaskSet, _tasks.ApplyAsync)], _outbox, _time, _log);
@@ -328,8 +311,7 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable
Platform = MoShiro.Measurement.PlatformDetector.Detect(), Platform = MoShiro.Measurement.PlatformDetector.Detect(),
TlsPublicKey = Convert.ToBase64String(_identity.Certificate.PublicKey.ExportSubjectPublicKeyInfo()), TlsPublicKey = Convert.ToBase64String(_identity.Certificate.PublicKey.ExportSubjectPublicKeyInfo()),
ProbeTcpPort = ControlPort, ProbeTcpPort = ControlPort,
ProbeQuicPorts = [.. QuicPorts], ProbeUdpPorts = [.. UdpPorts],
LegacyProbeUdpPorts = [.. QuicPorts],
LocalLimits = new LocalLimits LocalLimits = new LocalLimits
{ {
MaxTotalMbps = _config.Limits.MaxTotalMbps, MaxTotalMbps = _config.Limits.MaxTotalMbps,
@@ -353,7 +335,6 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable
long packetsOut = _counters.PacketsOut; long packetsOut = _counters.PacketsOut;
var extra = new AgentHeartbeatExtra var extra = new AgentHeartbeatExtra
{ {
RuntimeStatus = _update?.Status().State == "applying" ? NodeRuntimeStatus.Updating : NodeRuntimeStatus.Running,
Ipv6 = Ipv6Diagnostics.Sample(_ipv6Supported, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")), Ipv6 = Ipv6Diagnostics.Sample(_ipv6Supported, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")),
Probes = [.. _hub.ConnectedProbes()], Probes = [.. _hub.ConnectedProbes()],
Sessions = _sessions.All.Count, Sessions = _sessions.All.Count,
@@ -362,7 +343,7 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable
DroppedInvalid = _counters.DroppedInvalid, DroppedInvalid = _counters.DroppedInvalid,
DroppedRateLimited = _counters.DroppedRateLimited, DroppedRateLimited = _counters.DroppedRateLimited,
QueueBytes = _outbox.PendingBytes, QueueBytes = _outbox.PendingBytes,
Update = _update?.Status(), Update = _update.ReadStatus(),
}; };
_lastIn = packetsIn; _lastIn = packetsIn;
_lastOut = packetsOut; _lastOut = packetsOut;
+11 -9
View File
@@ -1,5 +1,4 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using MoShiro.Measurement;
namespace MoShiro.Agent; namespace MoShiro.Agent;
@@ -29,6 +28,16 @@ public sealed class AgentConfig
public string? RestartAt { get; set; } 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 public sealed class FleetSection
{ {
[JsonPropertyName("server")] [JsonPropertyName("server")]
@@ -51,15 +60,8 @@ public sealed class ListenSection
[JsonPropertyName("control_port")] [JsonPropertyName("control_port")]
public int ControlPort { get; set; } = 7443; public int ControlPort { get; set; } = 7443;
[JsonPropertyName("quic_ports")]
public List<int> QuicPorts { get; set; } = [7443];
/// <summary>Legacy config key accepted while deployments migrate to QUIC Datagram.</summary>
[JsonPropertyName("udp_ports")] [JsonPropertyName("udp_ports")]
public List<int>? LegacyUdpPorts { get; set; } public List<int> UdpPorts { get; set; } = [7443];
[JsonIgnore]
public IReadOnlyList<int> EffectiveQuicPorts => LegacyUdpPorts ?? QuicPorts;
[JsonPropertyName("tcp_ports")] [JsonPropertyName("tcp_ports")]
public List<int> TcpPorts { get; set; } = [7444]; public List<int> TcpPorts { get; set; } = [7444];
@@ -44,7 +44,7 @@ public sealed class ProbeSession : DataSession
{ {
private readonly ConcurrentDictionary<uint, ReplayWindow> _windows = new(); private readonly ConcurrentDictionary<uint, ReplayWindow> _windows = new();
private readonly ConcurrentDictionary<ulong, byte> _nonces = new(); private readonly ConcurrentDictionary<ulong, byte> _nonces = new();
private SocketAddress? _datagramSource; private SocketAddress? _udpSource;
public ProbeSession(PeerSessionInfo info) public ProbeSession(PeerSessionInfo info)
: base(info.Credentials.SessionIdValue, info.Credentials.Key) : base(info.Credentials.SessionIdValue, info.Credentials.Key)
@@ -71,11 +71,11 @@ public sealed class ProbeSession : DataSession
public RpcSession<PeerContext>? Rpc { get; set; } public RpcSession<PeerContext>? Rpc { get; set; }
/// <summary>最近一次认证通过的 UDP 包的源地址(Agent 把单向数据发往这里)。</summary> /// <summary>最近一次认证通过的 UDP 包的源地址(Agent 把单向数据发往这里)。</summary>
public override IPEndPoint? DatagramRemoteEndPoint public override IPEndPoint? UdpEndpoint
{ {
get get
{ {
SocketAddress? source = Volatile.Read(ref _datagramSource); SocketAddress? source = Volatile.Read(ref _udpSource);
return source is null ? null : (IPEndPoint)new IPEndPoint(source.Family == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any, 0).Create(source); return source is null ? null : (IPEndPoint)new IPEndPoint(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> /// <summary>记录 UDP 源地址;NAT 映射变化后地址变了返回 true(上报 endpoint_changed)。第一次看到地址不算变化。</summary>
public bool UpdateSource(SocketAddress source) public bool UpdateSource(SocketAddress source)
{ {
SocketAddress? previous = Volatile.Read(ref _datagramSource); SocketAddress? previous = Volatile.Read(ref _udpSource);
if (previous is not null && previous.Equals(source)) if (previous is not null && previous.Equals(source))
{ {
return false; return false;
@@ -96,7 +96,7 @@ public sealed class ProbeSession : DataSession
copy[i] = source[i]; copy[i] = source[i];
} }
Volatile.Write(ref _datagramSource, copy); Volatile.Write(ref _udpSource, copy);
return previous is not null; return previous is not null;
} }
@@ -312,7 +312,7 @@ public sealed class AgentAdmission : IAdmission
/// <summary>NAT 映射变化,源地址变了:上报 <c>endpoint_changed</c>。</summary> /// <summary>NAT 映射变化,源地址变了:上报 <c>endpoint_changed</c>。</summary>
public Action<ProbeSession>? OnEndpointChanged { get; set; } public Action<ProbeSession>? OnEndpointChanged { get; set; }
public Admission AdmitDatagram(DataSession session, in MeasurementDatagramHeader header, SocketAddress source, long nowUs) public Admission AdmitPacket(DataSession session, in UdpHeader header, SocketAddress source, long nowUs)
{ {
if (session is not ProbeSession probe || !_streams.TryGet(header.StreamId, out StreamInfo? stream) || !ReferenceEquals(stream.Owner, probe) || !probe.AcceptSequence(header.StreamId, header.Seq)) if (session is not ProbeSession probe || !_streams.TryGet(header.StreamId, out StreamInfo? stream) || !ReferenceEquals(stream.Owner, probe) || !probe.AcceptSequence(header.StreamId, header.Seq))
{ {
@@ -1,13 +1,13 @@
using System.Globalization; using System.Globalization;
using MoShiro.Protocol; using MoShiro.Protocol;
namespace MoShiro.Measurement; namespace MoShiro.Agent;
/// <summary> /// <summary>
/// Agent 和 Probe 共用。主机资源占用的只读快照:CPU 占用、内存、根分区、网卡累计流量与瞬时网速、系统运行时长。 /// 主机资源占用的只读快照:CPU 占用、内存、根分区、网卡累计流量与瞬时网速、系统运行时长。
/// 只有汇总的数字:<b>不采集 CPU 型号、主机名、网卡名和任何 IP 地址</b>,所以面板和展示页想泄漏也泄漏不出去。 /// 只有汇总的数字:<b>不采集 CPU 型号、主机名、网卡名和任何 IP 地址</b>,所以面板和展示页想泄漏也泄漏不出去。
/// Linux 读 <c>/proc</c> 和 <c>/sys</c>;容器通过 HOST_PROC、HOST_SYS、HOST_ROOT 指定宿主路径。 /// Linux 读 <c>/proc</c> 和 <c>/sys</c>;容器通过 HOST_PROC、HOST_SYS、HOST_ROOT 指定宿主路径。
/// Windows 用 GetSystemTimes、GlobalMemoryStatusEx 和 NetworkInterface(见 WindowsHost);macOS 等其他系统只有磁盘和运行时长。读不到的项是 null。不执行任何命令,不写文件。 /// 读不到的项是 null(例如 Windows 上的 CPU)。不执行任何命令,不写文件。
/// </summary> /// </summary>
public sealed class HostSampler public sealed class HostSampler
{ {
@@ -18,7 +18,6 @@ public sealed class HostSampler
private readonly Func<(long Total, long Used)?> _disk; private readonly Func<(long Total, long Used)?> _disk;
private readonly string _procRoot; private readonly string _procRoot;
private readonly string _sysRoot; private readonly string _sysRoot;
private readonly bool _native;
private (long Total, long Idle)? _previousCpu; private (long Total, long Idle)? _previousCpu;
private (long At, long Rx, long Tx)? _previousNet; private (long At, long Rx, long Tx)? _previousNet;
@@ -26,7 +25,6 @@ public sealed class HostSampler
string? procRoot = null, string? sysRoot = null, string? diskRoot = null) string? procRoot = null, string? sysRoot = null, string? diskRoot = null)
{ {
_time = time; _time = time;
_native = read is null && OperatingSystem.IsWindows(); // 只有默认读法才走 Windows 的系统调用;测试注入 read 时仍按 Linux 的文本解析
_read = read ?? ReadProc; _read = read ?? ReadProc;
_procRoot = Root(procRoot, "HOST_PROC", "/proc"); _procRoot = Root(procRoot, "HOST_PROC", "/proc");
_sysRoot = Root(sysRoot, "HOST_SYS", "/sys"); _sysRoot = Root(sysRoot, "HOST_SYS", "/sys");
@@ -39,8 +37,7 @@ public sealed class HostSampler
long now = _time.GetTimestamp(); long now = _time.GetTimestamp();
var snapshot = new HostSnapshot { AtUtcMs = _time.GetUtcNow().ToUnixTimeMilliseconds() }; var snapshot = new HostSnapshot { AtUtcMs = _time.GetUtcNow().ToUnixTimeMilliseconds() };
(long Total, long Idle)? cpuNow = _native ? WindowsCpu() : _read(Path.Combine(_procRoot, "stat")) is { } stat ? ParseCpu(stat) : null; if (_read(Path.Combine(_procRoot, "stat")) is { } stat && ParseCpu(stat) is { } cpu)
if (cpuNow is { } cpu)
{ {
if (_previousCpu is { } before && cpu.Total > before.Total) if (_previousCpu is { } before && cpu.Total > before.Total)
{ {
@@ -51,8 +48,7 @@ public sealed class HostSampler
_previousCpu = cpu; _previousCpu = cpu;
} }
(long Total, long Used)? memoryNow = _native ? WindowsMemory() : _read(Path.Combine(_procRoot, "meminfo")) is { } meminfo ? ParseMemory(meminfo) : null; if (_read(Path.Combine(_procRoot, "meminfo")) is { } meminfo && ParseMemory(meminfo) is { } memory)
if (memoryNow is { } memory)
{ {
snapshot.MemTotal = memory.Total; snapshot.MemTotal = memory.Total;
snapshot.MemUsed = memory.Used; snapshot.MemUsed = memory.Used;
@@ -64,21 +60,11 @@ public sealed class HostSampler
snapshot.DiskUsed = disk.Used; snapshot.DiskUsed = disk.Used;
} }
(long Rx, long Tx, long? LinkMbps)? netNow = null; if (_read(Path.Combine(_procRoot, "net/dev")) is { } dev && ParseNetwork(dev) is { } net)
if (_native)
{
netNow = WindowsNetwork();
}
else if (_read(Path.Combine(_procRoot, "net/dev")) is { } dev && ParseNetwork(dev) is { } parsed)
{
netNow = (parsed.Rx, parsed.Tx, ReadLinkSpeed(dev));
}
if (netNow is { } net)
{ {
snapshot.NetRxTotal = net.Rx; snapshot.NetRxTotal = net.Rx;
snapshot.NetTxTotal = net.Tx; snapshot.NetTxTotal = net.Tx;
snapshot.NetLinkMbps = net.LinkMbps; snapshot.NetLinkMbps = ReadLinkSpeed(dev);
if (_previousNet is { } before && net.Rx >= before.Rx && net.Tx >= before.Tx) if (_previousNet is { } before && net.Rx >= before.Rx && net.Tx >= before.Tx)
{ {
double seconds = Math.Max(0.001, _time.GetElapsedTime(before.At, now).TotalSeconds); double seconds = Math.Max(0.001, _time.GetElapsedTime(before.At, now).TotalSeconds);
@@ -97,12 +83,6 @@ public sealed class HostSampler
return snapshot; return snapshot;
} }
private static (long Total, long Idle)? WindowsCpu() => OperatingSystem.IsWindows() ? WindowsHost.Cpu() : null;
private static (long Total, long Used)? WindowsMemory() => OperatingSystem.IsWindows() ? WindowsHost.Memory() : null;
private static (long Rx, long Tx, long? LinkMbps)? WindowsNetwork() => OperatingSystem.IsWindows() ? WindowsHost.Network() : null;
/// <summary>第一行 <c>cpu user nice system idle iowait irq softirq steal …</c>:总时间与空闲时间(idle + iowait)。</summary> /// <summary>第一行 <c>cpu user nice system idle iowait irq softirq steal …</c>:总时间与空闲时间(idle + iowait)。</summary>
public static (long Total, long Idle)? ParseCpu(string stat) public static (long Total, long Idle)? ParseCpu(string stat)
{ {
@@ -78,9 +78,8 @@ public sealed class PeerHub
{ {
private const int DedupeWindow = 1024; private const int DedupeWindow = 1024;
/// <summary>超过这么久没有收到 Probe 的任何帧就认为会话已死(Probe 每 30 秒至少发一次时钟同步)。 /// <summary>超过这么久没有收到 Probe 的任何帧就认为会话已死(Probe 每 30 秒至少发一次时钟同步)。</summary>
/// 要比线路可能的中断长得多:差的线路上几十秒甚至一两分钟的黑洞时有发生(TCP RTT 能到 30 秒),75 秒就关会话会把一次中断变成几分钟的数据空白(要重新握手,而握手在差线路上也慢)。</summary> private static readonly TimeSpan IdleLimit = TimeSpan.FromSeconds(75);
private static readonly TimeSpan IdleLimit = TimeSpan.FromSeconds(180);
private readonly RpcRouter<PeerContext> _router; private readonly RpcRouter<PeerContext> _router;
private readonly SessionRegistry _sessions; private readonly SessionRegistry _sessions;
@@ -214,6 +213,8 @@ 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 }; 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> /// <summary>向一个 Probe 下发它的线路任务集(Probe 连上时由 <see cref="OnSessionAsync"/> 先发一份)。</summary>
public async ValueTask PushToAsync(ProbeSession probe, LineTaskSet set) => await PushAsync(probe, set, CancellationToken.None); public async ValueTask PushToAsync(ProbeSession probe, LineTaskSet set) => await PushAsync(probe, set, CancellationToken.None);
+1 -1
View File
@@ -43,7 +43,7 @@ catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidO
using var restartScope = restart; using var restartScope = restart;
using (identity) using (identity)
{ {
await using var app = new AgentApp(config, identity, Path.GetDirectoryName(Path.GetFullPath(configPath))!, time, log, restart); using var app = new AgentApp(config, identity, Path.GetDirectoryName(Path.GetFullPath(configPath))!, time, log, restart);
await ServiceShell.RunAsync(app.RunAsync, log, restart, time); await ServiceShell.RunAsync(app.RunAsync, log, restart, time);
} }
@@ -24,7 +24,7 @@ public sealed class TaskManager
private readonly StreamRegistry _streams; private readonly StreamRegistry _streams;
private readonly BlockEnv _env; private readonly BlockEnv _env;
private readonly TargetRunners _targets; private readonly TargetRunners _targets;
private readonly Func<IReadOnlyList<int>> _quicPorts; private readonly Func<IReadOnlyList<int>> _udpPorts;
private readonly Func<IReadOnlyList<int>> _tcpPorts; private readonly Func<IReadOnlyList<int>> _tcpPorts;
private readonly TrustAnchor _trust; private readonly TrustAnchor _trust;
private readonly Dictionary<ProbeSession, Dictionary<string, LineEntry>> _sessionLines = []; private readonly Dictionary<ProbeSession, Dictionary<string, LineEntry>> _sessionLines = [];
@@ -33,14 +33,14 @@ public sealed class TaskManager
private long _version; private long _version;
private BudgetGuard? _guard; private BudgetGuard? _guard;
public TaskManager(Func<BudgetGuard> guardFactory, StreamRegistry streams, BlockEnv env, TargetRunners targets, Func<IReadOnlyList<int>> quicPorts, Func<IReadOnlyList<int>> tcpPorts, TrustAnchor? trust = null) public TaskManager(Func<BudgetGuard> guardFactory, StreamRegistry streams, BlockEnv env, TargetRunners targets, Func<IReadOnlyList<int>> udpPorts, Func<IReadOnlyList<int>> tcpPorts, TrustAnchor? trust = null)
{ {
_trust = trust ?? TrustAnchor.None; _trust = trust ?? TrustAnchor.None;
_guardFactory = guardFactory; _guardFactory = guardFactory;
_streams = streams; _streams = streams;
_env = env; _env = env;
_targets = targets; _targets = targets;
_quicPorts = quicPorts; _udpPorts = udpPorts;
_tcpPorts = tcpPorts; _tcpPorts = tcpPorts;
} }
@@ -229,8 +229,7 @@ public sealed class TaskManager
{ {
Version = _version, Version = _version,
Tasks = [.. entries.Values.Select(e => e.Task)], Tasks = [.. entries.Values.Select(e => e.Task)],
QuicPorts = [.. _quicPorts()], UdpPorts = [.. _udpPorts()],
LegacyUdpPorts = [.. _quicPorts()],
TcpPorts = [.. _tcpPorts()], TcpPorts = [.. _tcpPorts()],
}; };
@@ -107,7 +107,7 @@ public sealed class TestRunner
// 按需测试的数据端口由 Agent 决定:Backend 给的端口如果不是本机监听的端口(模板里是出厂默认值,每个 Agent 可以改, // 按需测试的数据端口由 Agent 决定:Backend 给的端口如果不是本机监听的端口(模板里是出厂默认值,每个 Agent 可以改,
// 而 Backend 并不知道 TCP 数据端口),就用本机监听的第一个端口。块头里记录的是实际用的端口。 // 而 Backend 并不知道 TCP 数据端口),就用本机监听的第一个端口。块头里记录的是实际用的端口。
IReadOnlyList<int> ports = _ports(kind.Blocks.Transport == "quic_datagram" ? "quic_datagram" : "tcp"); IReadOnlyList<int> ports = _ports(kind.Blocks.Transport == "udp" ? "udp" : "tcp");
bool listening = phase.Params.TryGetValue("port", out System.Text.Json.JsonElement requested) && requested.TryGetInt32(out int port) && ports.Contains(port); bool listening = phase.Params.TryGetValue("port", out System.Text.Json.JsonElement requested) && requested.TryGetInt32(out int port) && ports.Contains(port);
if (!listening) if (!listening)
{ {
+2 -3
View File
@@ -23,7 +23,7 @@
2. **安全默认值**:Fleet 永远不提供 exec、shell、文件读写、远程下发代码这类方法;节点只响应本地注册过的方法(本地策略 `allow_methods` 还能收窄);调用对方不支持的方法在本地直接返回 `fleet_unsupported`。 2. **安全默认值**:Fleet 永远不提供 exec、shell、文件读写、远程下发代码这类方法;节点只响应本地注册过的方法(本地策略 `allow_methods` 还能收窄);调用对方不支持的方法在本地直接返回 `fleet_unsupported`。
3. **权限分离**:注册(`fleet-enroll`,一次性)和常驻业务进程是两个进程;常驻进程不含注册代码,也不使用 `HttpClient`。 3. **权限分离**:注册(`fleet-enroll`,一次性)和常驻业务进程是两个进程;常驻进程不含注册代码,也不使用 `HttpClient`。
4. **NativeAOT**:节点侧只依赖 BCL,泛型扩展点通过产品传入的 `JsonTypeInfo<T>` 序列化,不使用反射。 4. **NativeAOT**:节点侧只依赖 BCL,泛型扩展点通过产品传入的 `JsonTypeInfo<T>` 序列化,不使用反射。
5. **更新不在 Fleet 里**:节点进程没有自更新能力。Quadlet 装的节点由退出后 systemd 重新拉镜像、Backend 和脚本装的节点由宿主机上的更新器验签更新,见 [`docs/ops/deployment.md`](../../docs/ops/deployment.md);纯二进制节点的更新是待办,见 [`TODO.md`](../../TODO.md)。 5. **更新不在 Fleet 里**:节点进程没有自更新能力。容器节点的签名更新由宿主机上的更新器完成,见 [`docs/deployment.md`](../../docs/deployment.md);纯二进制节点的更新是待办,见 [`TODO.md`](../../TODO.md)。
## 身份 ## 身份
@@ -56,7 +56,7 @@
- JSON-RPC 2.0;方法名 `领域.动作`,`fleet.*` 保留给框架,产品用自己的前缀(MoShiro 是 `moshiro.*`)。 - JSON-RPC 2.0;方法名 `领域.动作`,`fleet.*` 保留给框架,产品用自己的前缀(MoShiro 是 `moshiro.*`)。
- 二进制帧 `u8 channel | 正文`:`0`–`15` 框架保留,产品从 `16` 开始。 - 二进制帧 `u8 channel | 正文`:`0`–`15` 框架保留,产品从 `16` 开始。
- 错误:`error.data.code` 为 snake_case,框架的以 `fleet_` 开头,产品可以扩展。 - 错误:`error.data.code` 为 snake_case,框架的以 `fleet_` 开头,产品可以扩展。
- **方法必须声明**:每个方法在声明表(`MethodTable`)里写明发起方、请求还是通知、哪些角色可以参与;不存在双向方法,同一个方法名在一个平面里只有一个发起方。`RpcRouter` 在注册、分发和发送三处核对,违规回 `fleet_protocol_violation`,超过上限断开连接。详见 [`docs/security/security.md`](../../docs/security/security.md) 第 8 节。 - **方法必须声明**:每个方法在声明表(`MethodTable`)里写明发起方、请求还是通知、哪些角色可以参与;不存在双向方法,同一个方法名在一个平面里只有一个发起方。`RpcRouter` 在注册、分发和发送三处核对,违规回 `fleet_protocol_violation`,超过上限断开连接。详见 [`docs/security.md`](../../docs/security.md) 第 8 节。
- 与传输无关:同一套路由跑在 wss 上,也跑在 TLS 帧(`u32 长度 | u8 类型 | 正文`)上。 - 与传输无关:同一套路由跑在 wss 上,也跑在 TLS 帧(`u32 长度 | u8 类型 | 正文`)上。
| 保留方法 | 方向 | 说明 | | 保留方法 | 方向 | 说明 |
@@ -64,7 +64,6 @@
| `fleet.challenge` / `fleet.auth` | 服务端→节点 / 节点→服务端 | 签名认证(`auth` 用途,nonce 30 秒内有效) | | `fleet.challenge` / `fleet.auth` | 服务端→节点 / 节点→服务端 | 签名认证(`auth` 用途,nonce 30 秒内有效) |
| `fleet.hello` | 节点→服务端 | 版本、能力、本地策略摘要、当前时间 → 返回时钟偏差;产品的元数据也放在这里 | | `fleet.hello` | 节点→服务端 | 版本、能力、本地策略摘要、当前时间 → 返回时钟偏差;产品的元数据也放在这里 |
| `fleet.heartbeat` | 节点→服务端 | 每 10 秒一次,产品可以附加计数 | | `fleet.heartbeat` | 节点→服务端 | 每 10 秒一次,产品可以附加计数 |
| `fleet.stopping` | 节点→服务端 | 有序关闭前通知服务端,携带 `reason`(例如 `stopping` 或 `updating`),用于准确区分离线和计划停机 |
| `fleet.state.sync` | 服务端→节点 | `{version, doc}` → `{applied_version, rejected[]}` | | `fleet.state.sync` | 服务端→节点 | `{version, doc}` → `{applied_version, rejected[]}` |
| `fleet.outbox.ack` | 服务端→节点 | `{ids[], duplicates[]}` | | `fleet.outbox.ack` | 服务端→节点 | `{ids[], duplicates[]}` |
| `fleet.revoked` | 服务端→节点 | 节点被禁用或接管,随后断开 | | `fleet.revoked` | 服务端→节点 | 节点被禁用或接管,随后断开 |
@@ -0,0 +1,43 @@
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,4 +27,6 @@ public static class FleetErrors
public static readonly ErrorCode JoinTokenExpired = new("fleet_join_token_expired"); public static readonly ErrorCode JoinTokenExpired = new("fleet_join_token_expired");
public static readonly ErrorCode ProofInvalid = new("fleet_proof_invalid"); public static readonly ErrorCode ProofInvalid = new("fleet_proof_invalid");
public static readonly ErrorCode FrameTooLarge = new("fleet_frame_too_large"); 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,4 +51,16 @@ public static class VarInt
return false; return false;
} }
public static bool TryReadUInt32LittleEndian(ReadOnlySpan<byte> source, out uint value)
{
if (source.Length < 4)
{
value = 0;
return false;
}
value = BinaryPrimitives.ReadUInt32LittleEndian(source);
return true;
}
} }
+1 -8
View File
@@ -181,7 +181,7 @@ public static class ServiceShell
// 先取令牌、再取设置:中间被改动的话令牌会被取消,下一轮重新排 // 先取令牌、再取设置:中间被改动的话令牌会被取消,下一轮重新排
CancellationToken changed = restart.ChangeToken; CancellationToken changed = restart.ChangeToken;
RestartSchedule? schedule = restart.Current; RestartSchedule? schedule = restart.Current;
using CancellationTokenSource wait = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, changed, restart.NowToken); using CancellationTokenSource wait = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, changed);
try try
{ {
if (schedule is null) if (schedule is null)
@@ -196,13 +196,6 @@ public static class ServiceShell
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
if (restart.NowReason is { } reason)
{
log.Info("stopping", ("result", reason));
await cts.CancelAsync();
return;
}
continue; // 进程在退出,或者设置被改了:循环条件和下一轮会处理 continue; // 进程在退出,或者设置被改了:循环条件和下一轮会处理
} }
@@ -8,9 +8,7 @@ public sealed class RestartController : IDisposable
private readonly RestartSchedule? _local; private readonly RestartSchedule? _local;
private readonly object _gate = new(); private readonly object _gate = new();
private RestartSchedule? _remote; private RestartSchedule? _remote;
private readonly CancellationTokenSource _now = new();
private CancellationTokenSource _changed = new(); private CancellationTokenSource _changed = new();
private string? _nowReason;
public RestartController(RestartSchedule? local = null) public RestartController(RestartSchedule? local = null)
{ {
@@ -43,32 +41,6 @@ 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> /// <summary>面板下发的设置;为空表示面板关闭了定时重启。返回 false 表示文本不合法(节点忽略它)。</summary>
public bool SetRemote(string? text) public bool SetRemote(string? text)
{ {
@@ -96,7 +68,6 @@ public sealed class RestartController : IDisposable
lock (_gate) lock (_gate)
{ {
_changed.Dispose(); _changed.Dispose();
_now.Dispose();
} }
} }
} }
@@ -25,7 +25,7 @@ public sealed partial class TrustFileContext : JsonSerializerContext;
/// <summary> /// <summary>
/// 节点的信任根:安装命令里带来的创世指纹和属主,在注册时固定到本地,之后 Backend 改不了。 /// 节点的信任根:安装命令里带来的创世指纹和属主,在注册时固定到本地,之后 Backend 改不了。
/// 操作员集合的新版本随期望状态下发,节点只接受从固定的创世出发、签名完整、版本更新的链。 /// 操作员集合的新版本随期望状态下发,节点只接受从固定的创世出发、签名完整、版本更新的链。
/// 属主只是被固定和保存:节点现在没有 extension,等做 extension 时由节点本地强制(见 docs/security/access.md 第 9 节)。 /// 属主只是被固定和保存:节点现在没有 extension,等做 extension 时由节点本地强制(见 docs/access.md 第 8 节)。
/// </summary> /// </summary>
public sealed class TrustAnchor : IServerVerifier 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); public sealed record OperatorSet(int Version, string Previous, string RpId, IReadOnlyList<string> Origins, IReadOnlyList<OperatorKey> Keys, IReadOnlyList<byte[]>? Servers = null);
/// <summary> /// <summary>
/// 信任根:操作员(passkey)→ 操作员集合 → 节点本地固定的属主。设计见 docs/security/access.md 第 8 节。 /// 信任根:操作员(passkey)→ 操作员集合 → 节点本地固定的属主。设计见 docs/access.md 第 7 节。
/// 安装命令里带着创世指纹和属主(passkey 的凭证 id):节点把它们固定在本地,之后只认从创世出发、由操作员 passkey 签过名的集合版本, /// 安装命令里带着创世指纹和属主(passkey 的凭证 id):节点把它们固定在本地,之后只认从创世出发、由操作员 passkey 签过名的集合版本,
/// 属主必须是集合里的凭证。Backend 能转发集合,但签不出新的版本,也改不了节点已经固定的东西。 /// 属主必须是集合里的凭证。Backend 能转发集合,但签不出新的版本,也改不了节点已经固定的东西。
/// 全是纯函数,只用 BCL,节点和 Backend 共用。语句是 <c>key=value</c> 的文本,第一行是带版本的标题;签名覆盖整段文本。 /// 全是纯函数,只用 BCL,节点和 Backend 共用。语句是 <c>key=value</c> 的文本,第一行是带版本的标题;签名覆盖整段文本。
@@ -7,7 +7,7 @@ namespace Fleet.Identity;
/// <summary> /// <summary>
/// 验证一次 WebAuthn 断言(<c>navigator.credentials.get</c> 的结果)。只支持 ES256(P-256 的 ECDSA),纯函数,只用 BCL: /// 验证一次 WebAuthn 断言(<c>navigator.credentials.get</c> 的结果)。只支持 ES256(P-256 的 ECDSA),纯函数,只用 BCL:
/// 面板用它验证 passkey 登录,节点验证操作员集合的签名也用它(docs/security/access.md 第 8 节)。 /// 面板用它验证 passkey 登录,节点以后用它验证 passkey 签出的委托(docs/access.md 第 7 节)。
/// 不解析 CBOR——注册时的 attestation 解析只在 Backend 里。 /// 不解析 CBOR——注册时的 attestation 解析只在 Backend 里。
/// </summary> /// </summary>
public static class WebAuthnAssertion public static class WebAuthnAssertion
+18 -23
View File
@@ -58,9 +58,6 @@ public sealed class FleetLinkOptions
/// <summary>附加到 <c>fleet.heartbeat</c> 的进程内计数(不能包含主机信息)。</summary> /// <summary>附加到 <c>fleet.heartbeat</c> 的进程内计数(不能包含主机信息)。</summary>
public Func<JsonElement?>? HeartbeatExtra { get; init; } public Func<JsonElement?>? HeartbeatExtra { get; init; }
/// <summary>Optional reason for an orderly client shutdown, so the server can distinguish updates from service stops.</summary>
public Func<string>? ShutdownReason { get; init; }
public TimeSpan HeartbeatInterval { get; init; } = TimeSpan.FromSeconds(10); public TimeSpan HeartbeatInterval { get; init; } = TimeSpan.FromSeconds(10);
public int MaxFrameBytes { get; init; } = 16 << 20; public int MaxFrameBytes { get; init; } = 16 << 20;
@@ -75,6 +72,9 @@ public sealed class FleetLinkOptions
/// 服务器必须在挑战里证明自己,之后这条链路用应用层加密;否则按旧的方式(没有固定信任根的节点)。 /// 服务器必须在挑战里证明自己,之后这条链路用应用层加密;否则按旧的方式(没有固定信任根的节点)。
/// </summary> /// </summary>
public IServerVerifier? ServerVerifier { get; init; } public IServerVerifier? ServerVerifier { get; init; }
/// <summary>握手时自述正在运行的二进制(哈希和 CI 签名的清单);没有就不上报(旧行为)。</summary>
public Func<BinaryAttestation?>? Attestation { get; init; }
} }
/// <summary> /// <summary>
@@ -184,8 +184,7 @@ public sealed class FleetLink
_secure = transport.Secure; _secure = transport.Secure;
var session = new RpcSession<LinkContext>(transport, _router, new LinkContext(this), _time); var session = new RpcSession<LinkContext>(transport, _router, new LinkContext(this), _time);
// Keep the active WebSocket receive alive until the planned shutdown marker and Close frame have been sent. using CancellationTokenSource sessionCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
using var sessionCts = new CancellationTokenSource();
Task run = session.RunAsync(sessionCts.Token); Task run = session.RunAsync(sessionCts.Token);
try try
{ {
@@ -196,7 +195,7 @@ public sealed class FleetLink
NowUtcMs = _time.GetUtcNow().ToUnixTimeMilliseconds(), NowUtcMs = _time.GetUtcNow().ToUnixTimeMilliseconds(),
Meta = _options.HelloMeta?.Invoke(), Meta = _options.HelloMeta?.Invoke(),
}; };
Result<HelloResult> helloResult = await session.RequestAsync(FleetMethods.Hello, hello, FleetRpcContext.Default.HelloParams, FleetRpcContext.Default.HelloResult, ct); Result<HelloResult> helloResult = await session.RequestAsync(FleetMethods.Hello, hello, FleetRpcContext.Default.HelloParams, FleetRpcContext.Default.HelloResult, sessionCts.Token);
if (!helloResult.IsSuccess) if (!helloResult.IsSuccess)
{ {
return "hello failed: " + helloResult.Error.Code; return "hello failed: " + helloResult.Error.Code;
@@ -212,23 +211,7 @@ public sealed class FleetLink
await OnConnected(session, sessionCts.Token); await OnConnected(session, sessionCts.Token);
} }
Task cancellation = Task.Delay(Timeout.Infinite, ct); Task finished = await Task.WhenAny(run, pump, heartbeat);
Task finished = await Task.WhenAny(run, pump, heartbeat, cancellation);
if (ct.IsCancellationRequested)
{
try
{
using var closeTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(1), _time);
string reason = _options.ShutdownReason?.Invoke() ?? "stopping";
await session.NotifyAsync(FleetMethods.Stopping, new StoppingParams { Reason = reason }, FleetRpcContext.Default.StoppingParams, closeTimeout.Token);
await session.CloseAsync(reason, closeTimeout.Token);
}
catch (Exception ex) when (ex is OperationCanceledException or WebSocketException or IOException or ObjectDisposedException or InvalidOperationException)
{
// The socket may already be gone; shutdown must still complete.
}
}
await sessionCts.CancelAsync(); await sessionCts.CancelAsync();
await Task.WhenAll(Quiet(run), Quiet(pump), Quiet(heartbeat)); await Task.WhenAll(Quiet(run), Quiet(pump), Quiet(heartbeat));
return finished == run ? "closed by server" : "link task failed"; return finished == run ? "closed by server" : "link task failed";
@@ -243,6 +226,16 @@ public sealed class FleetLink
} }
/// <summary>挑战 → 签名 → 认证结果。失败返回原因;服务端禁用该节点时不再重试。</summary> /// <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) private async Task<string?> AuthenticateAsync(SecureTransport transport, CancellationToken ct)
{ {
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15), _time); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15), _time);
@@ -277,6 +270,7 @@ public sealed class FleetLink
Signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.AuthSecure, _options.NodeId.Value, nonce.Nonce, serverEphemeralText, nodeEphemeralText)), Signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.AuthSecure, _options.NodeId.Value, nonce.Nonce, serverEphemeralText, nodeEphemeralText)),
NodeEphemeral = nodeEphemeralText, NodeEphemeral = nodeEphemeralText,
}; };
Attest(authParams);
RpcMessage secureRequest = RpcMessage.Request(1, FleetMethods.Auth, authParams, FleetRpcContext.Default.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); await transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(secureRequest, FleetRpcContext.Default.RpcMessage)), linked.Token);
transport.Enable(LinkCipher.ForNode(ephemeral, serverEphemeral, nonce.Nonce)); transport.Enable(LinkCipher.ForNode(ephemeral, serverEphemeral, nonce.Nonce));
@@ -284,6 +278,7 @@ public sealed class FleetLink
else else
{ {
authParams = new AuthParams { NodeId = _options.NodeId.Value, Signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.Auth, _options.NodeId.Value, nonce.Nonce)) }; 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); RpcMessage request = RpcMessage.Request(1, FleetMethods.Auth, authParams, FleetRpcContext.Default.AuthParams);
await transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(request, FleetRpcContext.Default.RpcMessage)), linked.Token); await transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(request, FleetRpcContext.Default.RpcMessage)), linked.Token);
} }
+6 -15
View File
@@ -29,8 +29,7 @@ public sealed class PeerListenerOptions
public int MaxSessions { get; init; } = 8; public int MaxSessions { get; init; } = 8;
/// <summary>敲门、TLS、挑战应答整个握手的上限。线路差的时候(RTT 好几秒、还丢包)握手本来就要很久,10 秒做不完就会一直连不上;敲门已经挡住了不认识的连接,放宽不会放大被占连接的风险。</summary> public TimeSpan HandshakeTimeout { get; init; } = TimeSpan.FromSeconds(10);
public TimeSpan HandshakeTimeout { get; init; } = PeerTimings.Handshake;
} }
/// <summary> /// <summary>
@@ -210,8 +209,7 @@ public sealed class PeerListener
} }
/// <summary>发起点对点会话(Probe 一侧):按 SPKI 指纹固定对端证书,用身份私钥签名,派生同样的数据面凭据。</summary> /// <summary>发起点对点会话(Probe 一侧):按 SPKI 指纹固定对端证书,用身份私钥签名,派生同样的数据面凭据。</summary>
/// <summary>点对点会话的套接字:开启 TCP keepalive(空闲 30 秒后每 10 秒探测,连续 6 次无响应才断开,约 90 秒),主机真的没了的时候连接不会一直挂着; /// <summary>点对点会话的套接字:开启 TCP keepalive(空闲 15 秒后每 5 秒探测,连续 3 次无响应就断开,约 30 秒),主机丢失或路径被切断时连接不会一直挂着。</summary>
/// 但线路短暂中断(几十秒的黑洞)不能把会话杀掉——线路中断本来就是要测的东西,会话得扛过去,恢复之后接着测。</summary>
internal static class PeerSockets internal static class PeerSockets
{ {
public static void EnableKeepAlive(Socket socket) public static void EnableKeepAlive(Socket socket)
@@ -219,9 +217,9 @@ internal static class PeerSockets
try try
{ {
socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true); socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true);
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, 30); socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, 15);
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, 10); socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, 5);
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, 6); socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, 3);
} }
catch (Exception ex) when (ex is SocketException or ArgumentException or PlatformNotSupportedException) catch (Exception ex) when (ex is SocketException or ArgumentException or PlatformNotSupportedException)
{ {
@@ -230,13 +228,6 @@ internal static class PeerSockets
} }
} }
/// <summary>点对点会话的时间常数,Probe 和 Agent 两侧共用。</summary>
public static class PeerTimings
{
/// <summary>建立会话(连接、敲门、TLS、挑战应答)的总时限。</summary>
public static readonly TimeSpan Handshake = TimeSpan.FromSeconds(30);
}
public static class PeerConnector public static class PeerConnector
{ {
public static async Task<(PeerSessionInfo Info, TlsFrameTransport Transport)> ConnectAsync( public static async Task<(PeerSessionInfo Info, TlsFrameTransport Transport)> ConnectAsync(
@@ -249,7 +240,7 @@ public static class PeerConnector
PeerSockets.EnableKeepAlive(client.Client); PeerSockets.EnableKeepAlive(client.Client);
try try
{ {
using var timeout = new CancellationTokenSource(PeerTimings.Handshake, time); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10), time);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token); using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
await client.ConnectAsync(host, port, linked.Token); await client.ConnectAsync(host, port, linked.Token);
Stream stream = client.GetStream(); Stream stream = client.GetStream();
+17 -9
View File
@@ -10,7 +10,6 @@ public static class FleetMethods
public const string Auth = "fleet.auth"; public const string Auth = "fleet.auth";
public const string Hello = "fleet.hello"; public const string Hello = "fleet.hello";
public const string Heartbeat = "fleet.heartbeat"; public const string Heartbeat = "fleet.heartbeat";
public const string Stopping = "fleet.stopping";
public const string StateSync = "fleet.state.sync"; public const string StateSync = "fleet.state.sync";
public const string OutboxAck = "fleet.outbox.ack"; public const string OutboxAck = "fleet.outbox.ack";
public const string Revoked = "fleet.revoked"; public const string Revoked = "fleet.revoked";
@@ -29,7 +28,6 @@ public static class FleetMethodTable
MethodSpec.Request(FleetMethods.Auth, RpcSide.Node).AsInline(), MethodSpec.Request(FleetMethods.Auth, RpcSide.Node).AsInline(),
MethodSpec.Request(FleetMethods.Hello, RpcSide.Node).AsInline(), MethodSpec.Request(FleetMethods.Hello, RpcSide.Node).AsInline(),
MethodSpec.Notification(FleetMethods.Heartbeat, RpcSide.Node).AsInline(), MethodSpec.Notification(FleetMethods.Heartbeat, RpcSide.Node).AsInline(),
MethodSpec.Notification(FleetMethods.Stopping, RpcSide.Node).AsInline(),
MethodSpec.Request(FleetMethods.StateSync, RpcSide.Server), MethodSpec.Request(FleetMethods.StateSync, RpcSide.Server),
MethodSpec.Notification(FleetMethods.OutboxAck, RpcSide.Server), MethodSpec.Notification(FleetMethods.OutboxAck, RpcSide.Server),
MethodSpec.Notification(FleetMethods.Revoked, RpcSide.Server), MethodSpec.Notification(FleetMethods.Revoked, RpcSide.Server),
@@ -42,6 +40,12 @@ public static class FleetMethodTable
]); ]);
} }
/// <summary>二进制通道:0–15 保留给框架,产品从 16 开始。</summary>
public static class FleetChannels
{
public const byte FirstProductChannel = 16;
}
public sealed class ChallengeParams public sealed class ChallengeParams
{ {
[JsonPropertyName("nonce")] [JsonPropertyName("nonce")]
@@ -71,6 +75,17 @@ public sealed class AuthParams
/// <summary>节点的临时 ECDH 公钥(SPKI,base64):有它就是安全链路,签名覆盖双方的临时公钥,认证结果起所有帧都是加密的。</summary> /// <summary>节点的临时 ECDH 公钥(SPKI,base64):有它就是安全链路,签名覆盖双方的临时公钥,认证结果起所有帧都是加密的。</summary>
[JsonPropertyName("node_eph")] [JsonPropertyName("node_eph")]
public string? NodeEphemeral { get; set; } 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 public sealed class AuthResult
@@ -158,12 +173,6 @@ public sealed class RevokedParams
public string Reason { get; set; } = ""; public string Reason { get; set; } = "";
} }
public sealed class StoppingParams
{
[JsonPropertyName("reason")]
public string Reason { get; set; } = "stopping";
}
[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] [JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
[JsonSerializable(typeof(RpcMessage))] [JsonSerializable(typeof(RpcMessage))]
[JsonSerializable(typeof(ChallengeParams))] [JsonSerializable(typeof(ChallengeParams))]
@@ -172,7 +181,6 @@ public sealed class StoppingParams
[JsonSerializable(typeof(HelloParams))] [JsonSerializable(typeof(HelloParams))]
[JsonSerializable(typeof(HelloResult))] [JsonSerializable(typeof(HelloResult))]
[JsonSerializable(typeof(HeartbeatParams))] [JsonSerializable(typeof(HeartbeatParams))]
[JsonSerializable(typeof(StoppingParams))]
[JsonSerializable(typeof(StateSyncParams))] [JsonSerializable(typeof(StateSyncParams))]
[JsonSerializable(typeof(StateSyncResult))] [JsonSerializable(typeof(StateSyncResult))]
[JsonSerializable(typeof(OutboxAckParams))] [JsonSerializable(typeof(OutboxAckParams))]
@@ -19,6 +19,21 @@ public interface IServerIdentity
} }
/// <summary>操作员集合的链(passkey 签的,创世在前)。没有初始化信任根时是空的。节点注册时用它确认「这个面板确实是我的操作员的」。</summary> /// <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 public interface IOperatorTrustSource
{ {
ValueTask<IReadOnlyList<SignedStatementDto>> ChainAsync(CancellationToken ct); ValueTask<IReadOnlyList<SignedStatementDto>> ChainAsync(CancellationToken ct);
@@ -38,7 +53,7 @@ public enum NodeEventKind
Heartbeat, Heartbeat,
} }
public sealed record NodeEvent(NodeEventKind Kind, NodeRecord Node, long AtMs, HelloParams? Hello = null, JsonElement? Extra = null, string? Reason = null); public sealed record NodeEvent(NodeEventKind Kind, NodeRecord Node, long AtMs, HelloParams? Hello = null, JsonElement? Extra = null);
public interface INodeEventSink public interface INodeEventSink
{ {
@@ -24,9 +24,11 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier
IEnumerable<IInbox> inboxes, IEnumerable<IInbox> inboxes,
IEnumerable<INodeEventSink> sinks, IEnumerable<INodeEventSink> sinks,
FleetRouter router, FleetRouter router,
IServerIdentity? serverIdentity = null) IServerIdentity? serverIdentity = null,
IBinaryAttestor? binaryAttestor = null)
{ {
ServerIdentity = serverIdentity; ServerIdentity = serverIdentity;
BinaryAttestor = binaryAttestor;
Store = store; Store = store;
Options = options; Options = options;
Time = time; Time = time;
@@ -43,6 +45,9 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier
/// <summary>服务器的身份密钥;没有时节点连接用旧的明文认证。</summary> /// <summary>服务器的身份密钥;没有时节点连接用旧的明文认证。</summary>
internal IServerIdentity? ServerIdentity { get; } internal IServerIdentity? ServerIdentity { get; }
/// <summary>节点二进制的把关;没有时不检查。</summary>
internal IBinaryAttestor? BinaryAttestor { get; }
internal FleetServerOptions Options { get; } internal FleetServerOptions Options { get; }
internal TimeProvider Time { get; } internal TimeProvider Time { get; }
@@ -110,7 +110,8 @@ public static class FleetServerExtensions
provider.GetServices<IInbox>(), provider.GetServices<IInbox>(),
provider.GetServices<INodeEventSink>(), provider.GetServices<INodeEventSink>(),
provider.GetRequiredService<FleetRouter>(), provider.GetRequiredService<FleetRouter>(),
provider.GetService<IServerIdentity>())); provider.GetService<IServerIdentity>(),
provider.GetService<IBinaryAttestor>()));
services.AddSingleton<IFleetConnections>(provider => provider.GetRequiredService<FleetLinkHost>()); services.AddSingleton<IFleetConnections>(provider => provider.GetRequiredService<FleetLinkHost>());
services.AddSingleton<IStateNotifier>(provider => provider.GetRequiredService<FleetLinkHost>()); services.AddSingleton<IStateNotifier>(provider => provider.GetRequiredService<FleetLinkHost>());
return builder; return builder;
@@ -88,6 +88,18 @@ internal sealed partial class NodeConnection
return false; 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); _session = new NodeSessionInfo(node.Id, _host.Time.GetUtcNow().ToUnixTimeMilliseconds(), node.NodeVersion, [], null);
await SendAsync( await SendAsync(
RpcMessage.Success(message.Id.Value, new AuthResult { Status = StatusName(node.Status) }, FleetRpcContext.Default.AuthResult), RpcMessage.Success(message.Id.Value, new AuthResult { Status = StatusName(node.Status) }, FleetRpcContext.Default.AuthResult),
@@ -64,17 +64,6 @@ internal sealed partial class NodeConnection
case FleetMethods.Heartbeat: case FleetMethods.Heartbeat:
await HandleHeartbeatAsync(message, ct); await HandleHeartbeatAsync(message, ct);
return; return;
case FleetMethods.Stopping:
try
{
string? reason = message.Params?.Deserialize(FleetRpcContext.Default.StoppingParams)?.Reason;
_closeReason = reason is "updating" ? "updating" : "stopping";
}
catch (JsonException)
{
_closeReason = "stopping";
}
return;
} }
if (_node!.Status != NodeStatus.Active) if (_node!.Status != NodeStatus.Active)
@@ -102,7 +102,7 @@ internal sealed partial class NodeConnection
private async Task FireAsync(NodeEventKind kind, CancellationToken ct, HelloParams? hello = null, JsonElement? extra = null) private async Task FireAsync(NodeEventKind kind, CancellationToken ct, HelloParams? hello = null, JsonElement? extra = null)
{ {
var nodeEvent = new NodeEvent(kind, _node!, _host.Time.GetUtcNow().ToUnixTimeMilliseconds(), hello, extra, kind == NodeEventKind.Offline ? _closeReason : null); var nodeEvent = new NodeEvent(kind, _node!, _host.Time.GetUtcNow().ToUnixTimeMilliseconds(), hello, extra);
foreach (INodeEventSink sink in _host.Sinks) foreach (INodeEventSink sink in _host.Sinks)
{ {
try try
@@ -17,7 +17,6 @@ internal sealed partial class NodeConnection
ValueWebSocketReceiveResult result = await _socket.ReceiveAsync(buffer, ct); ValueWebSocketReceiveResult result = await _socket.ReceiveAsync(buffer, ct);
if (result.MessageType == WebSocketMessageType.Close) if (result.MessageType == WebSocketMessageType.Close)
{ {
_closeReason ??= _socket.CloseStatusDescription;
return null; return null;
} }
+225 -746
View File
File diff suppressed because it is too large. Load diff
+4 -6
View File
@@ -13,9 +13,7 @@ Backend ──任务──> Agent <──测量会话──> Probe
Probe 用 `probe.json` 读取面板地址(`fleet.server`)、身份目录(`fleet.identity_dir`,由 `fleet-enroll` 注册时生成)和本地测试限制。连上面板之后,面板下发要连接的 Agent 列表;Probe 将列表缓存到同目录的 `agents.json`,面板暂时连不上时靠它继续测量。身份目录里有私钥,请限制访问权限,不要提交到版本控制。 Probe 用 `probe.json` 读取面板地址(`fleet.server`)、身份目录(`fleet.identity_dir`,由 `fleet-enroll` 注册时生成)和本地测试限制。连上面板之后,面板下发要连接的 Agent 列表;Probe 将列表缓存到同目录的 `agents.json`,面板暂时连不上时靠它继续测量。身份目录里有私钥,请限制访问权限,不要提交到版本控制。
Agent 测量连接失败时,Probe 按 1、2、4、8、16 秒递增等待,再以 32 秒间隔重试 5 次,之后最多每 60 秒重试一次;连接恢复后退避重新从 1 秒开始。管理端把没有任何已启用 Agent 目标的 Probe 标为「暂停探测」,不会将链路断开误显示为已连接。 容器节点的运行方式(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 运行。
## 构建 ## 构建
@@ -32,7 +30,7 @@ dotnet build probe/src/MoShiro.Probe -c Release
dotnet publish probe/src/MoShiro.Probe -c Release -r <RID> -p:PublishAot=true 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/ops/deployment.md`](../../docs/ops/deployment.md)。 也可在仓库根目录运行 `bash scripts/publish.sh probe <RID>`,再使用 `scripts/package.sh probe <RID>` 生成部署包。支持平台和安装流程见 [`../../docs/deployment.md`](../../docs/deployment.md)。
## 配置与运行 ## 配置与运行
@@ -56,6 +54,6 @@ moshiro-probe --config /etc/moshiro/probe.json
- Probe 只执行编译进程序的测量模块。它不落盘测量数据,也不能执行命令或自行更新(更新由宿主机上的更新器完成,Probe 只写清单和请求文件)。 - Probe 只执行编译进程序的测量模块。它不落盘测量数据,也不能执行命令或自行更新(更新由宿主机上的更新器完成,Probe 只写清单和请求文件)。
- 测量模块由 `backend/shared/MoShiro.Measurement/` 与 Agent 共用;Probe 负责发起连接、调度任务和记录本端数据。 - 测量模块由 `backend/shared/MoShiro.Measurement/` 与 Agent 共用;Probe 负责发起连接、调度任务和记录本端数据。
- 测量模型和协议细节见 [`../../docs/design/measurement.md`](../../docs/design/measurement.md) 与 [`../../docs/design/protocol.md`](../../docs/design/protocol.md);整体架构见 [`../../docs/design/architecture.md`](../../docs/design/architecture.md)。 - 测量模型和协议细节见 [`../../docs/measurement.md`](../../docs/measurement.md) 与 [`../../docs/protocol.md`](../../docs/protocol.md);整体架构见 [`../../docs/architecture.md`](../../docs/architecture.md)。
Podman 系用 Quadlet(`packaging/quadlet/moshiro-probe.container`,默认自动更新),Docker 用 `packaging/compose/probe/compose.yaml`(手动更新);配置、身份和缓存放在命名 volume 中,无需宿主配置目录、keep-id 或手动 chown。首次导入和自动更新见部署文档。 Docker 与 rootless/rootful Podman 共用 `packaging/compose/probe/compose.yaml`,配置、身份和缓存放在命名 volume 中,无需宿主配置目录、keep-id 或手动 chown。首次导入和自动更新见部署文档。
@@ -19,7 +19,7 @@ public sealed record ConnectionEnv(NodeIdentity Identity, string ProbeId, ProbeL
/// 每 10 秒把已完成的行切成列块放进发件箱 → 响应按需测试的阶段。会话断开时所有角色停止(凭据属于会话),按退避重连。 /// 每 10 秒把已完成的行切成列块放进发件箱 → 响应按需测试的阶段。会话断开时所有角色停止(凭据属于会话),按退避重连。
/// 会话内的所有状态(套接字、发件箱、角色)随会话一起创建和释放。 /// 会话内的所有状态(套接字、发件箱、角色)随会话一起创建和释放。
/// </summary> /// </summary>
public sealed class AgentConnection : IDisposable public sealed class AgentConnection
{ {
private static readonly TimeSpan s_flushInterval = TimeSpan.FromSeconds(ProtocolLimits.ChunkSeconds); private static readonly TimeSpan s_flushInterval = TimeSpan.FromSeconds(ProtocolLimits.ChunkSeconds);
private static readonly TimeSpan s_clockInterval = TimeSpan.FromSeconds(30); private static readonly TimeSpan s_clockInterval = TimeSpan.FromSeconds(30);
@@ -30,18 +30,6 @@ public sealed class AgentConnection : IDisposable
private readonly ConnectionEnv _env; private readonly ConnectionEnv _env;
private volatile ConnectionSession? _session; private volatile ConnectionSession? _session;
private int _previousQuicSourcePort;
private QuicDatagramTransport? _activeDatagrams;
private readonly object _handshakeGate = new();
private readonly Queue<double> _handshakeSamplesMs = new();
private readonly SemaphoreSlim _retrySignal = new(0, 1);
private ProbeLinkAttemptState _attemptState = ProbeLinkAttemptState.Connecting;
private string? _lastError;
private DateTimeOffset? _retryAt;
private long _handshakeAttempts;
private long _handshakeSuccesses;
private long _handshakeFailures;
private long _handshakeTimeouts;
public AgentConnection(KnownAgent endpoint, ConnectionEnv env) public AgentConnection(KnownAgent endpoint, ConnectionEnv env)
{ {
@@ -57,116 +45,28 @@ public sealed class AgentConnection : IDisposable
public long ClockOffsetUs => _session?.Clock.OffsetUs ?? 0; public long ClockOffsetUs => _session?.Clock.OffsetUs ?? 0;
/// <summary>管理端手动配对时,跳过当前退避等待,马上再拨一次。</summary>
public void RetryNow()
{
if (IsConnected)
{
return;
}
try
{
_retrySignal.Release();
}
catch (SemaphoreFullException)
{
// 已经有一次手动重试排队。
}
catch (ObjectDisposedException)
{
// 对应线路正在被移除。
}
}
public void Dispose() => _retrySignal.Dispose();
public ProbeQuicLinkHealth QuicHealth()
{
lock (_handshakeGate)
{
double? p95 = null;
if (_handshakeSamplesMs.Count > 0)
{
double[] sorted = [.. _handshakeSamplesMs.Order()];
p95 = sorted[(int)Math.Ceiling(sorted.Length * 0.95) - 1];
}
QuicTransportSnapshot? transport = Volatile.Read(ref _activeDatagrams)?.GetTransportSnapshot();
return new ProbeQuicLinkHealth
{
AgentId = Endpoint.AgentId,
IpVersion = Endpoint.IpVersion,
Connected = IsConnected,
State = _attemptState,
LastError = _lastError,
RetryAttempt = _lastRetryAttempt,
RetryInMs = _attemptState == ProbeLinkAttemptState.RetryWait && _retryAt is { } retryAt
? Math.Max(0, (long)(retryAt - _env.Time.GetUtcNow()).TotalMilliseconds)
: null,
HandshakeAttempts = _handshakeAttempts,
HandshakeSuccesses = _handshakeSuccesses,
HandshakeFailures = _handshakeFailures,
HandshakeTimeouts = _handshakeTimeouts,
HandshakeP95Ms = p95,
HandshakeSampleCount = _handshakeSamplesMs.Count,
TransportPacketLossRate = transport?.PacketLossRate,
TransportPacketsSent = transport?.PacketsSent,
TransportCongestionEvents = transport?.CongestionEvents,
TransportEcnCongestionEvents = transport?.EcnCongestionEvents,
PathMtu = transport?.PathMtu,
};
}
}
private int _lastRetryAttempt;
public async Task RunAsync(CancellationToken ct) public async Task RunAsync(CancellationToken ct)
{ {
var backoff = new ProbeReconnectBackoff(); var backoff = new Backoff(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(20));
while (!ct.IsCancellationRequested) while (!ct.IsCancellationRequested)
{ {
SetAttempt(ProbeLinkAttemptState.Connecting, null, null, 0);
try try
{ {
string reason = await RunOnceAsync(backoff, ct); string reason = await RunOnceAsync(backoff, ct);
SetAttempt(ProbeLinkAttemptState.RetryWait, reason, null, backoff.Attempts);
_env.Log.Warn("agent session ended", ("agent", Endpoint.AgentId), ("err", reason)); _env.Log.Warn("agent session ended", ("agent", Endpoint.AgentId), ("err", reason));
} }
catch (OperationCanceledException) when (ct.IsCancellationRequested) catch (OperationCanceledException) when (ct.IsCancellationRequested)
{ {
return; return;
} }
catch (Exception ex) catch (Exception ex) when (ex is IOException or System.Net.Sockets.SocketException or InvalidOperationException or System.Security.Authentication.AuthenticationException or OperationCanceledException or FormatException)
{ {
SetAttempt(ProbeLinkAttemptState.RetryWait, $"{ex.GetType().Name}: {ex.Message}", null, backoff.Attempts + 1); _env.Log.Warn("agent connection failed", ("agent", Endpoint.AgentId), ("err", ex.Message));
_env.Log.Error("agent connection attempt failed; retry will continue", ("agent", Endpoint.AgentId), ("exception", ex.GetType().FullName), ("err", ex.Message));
} }
try try
{ {
TimeSpan delay = backoff.Next(); await Task.Delay(backoff.Next(), _env.Time, ct);
lock (_handshakeGate)
{
_attemptState = ProbeLinkAttemptState.RetryWait;
_lastRetryAttempt = backoff.Attempts;
_retryAt = _env.Time.GetUtcNow() + delay;
}
using var waitCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
Task delayTask = Task.Delay(delay, _env.Time, waitCts.Token);
Task retryTask = _retrySignal.WaitAsync(waitCts.Token);
Task completed = await Task.WhenAny(delayTask, retryTask);
await waitCts.CancelAsync();
if (completed == retryTask)
{
await retryTask;
_env.Log.Info("manual link retry requested", ("agent", Endpoint.AgentId));
}
else
{
await delayTask;
try { await retryTask; }
catch (OperationCanceledException) { }
}
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
@@ -175,7 +75,7 @@ public sealed class AgentConnection : IDisposable
} }
} }
private async Task<string> RunOnceAsync(ProbeReconnectBackoff backoff, CancellationToken ct) private async Task<string> RunOnceAsync(Backoff backoff, CancellationToken ct)
{ {
AddressFamily family = Endpoint.IpVersion == IpVersions.V6 ? AddressFamily.InterNetworkV6 : AddressFamily.InterNetwork; AddressFamily family = Endpoint.IpVersion == IpVersions.V6 ? AddressFamily.InterNetworkV6 : AddressFamily.InterNetwork;
using var resolveTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(10), _env.Time); using var resolveTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(10), _env.Time);
@@ -192,22 +92,10 @@ public sealed class AgentConnection : IDisposable
byte[] agentKey = Convert.FromBase64String(Endpoint.PublicKey); byte[] agentKey = Convert.FromBase64String(Endpoint.PublicKey);
(PeerSessionInfo info, TlsFrameTransport transport) = await PeerConnector.ConnectAsync( (PeerSessionInfo info, TlsFrameTransport transport) = await PeerConnector.ConnectAsync(
address.ToString(), Endpoint.TcpPort, agentKey, _env.Identity, _env.ProbeId, ProbeApp.Version, 8 << 20, _env.Time, ct); address.ToString(), Endpoint.TcpPort, agentKey, _env.Identity, _env.ProbeId, ProbeApp.Version, 8 << 20, _env.Time, ct);
var data = new DataSession(info.Credentials.SessionIdValue, info.Credentials.Key);
var remote = (IPEndPoint)info.Remote!; var remote = (IPEndPoint)info.Remote!;
IReadOnlyList<int> quicPorts = Endpoint.EffectiveQuicPorts; var data = new DataSession(info.Credentials.SessionIdValue, info.Credentials.Key);
int quicPort = quicPorts.Count == 0 ? 7443 : quicPorts[0]; using var udp = new UdpMux(new SingleSession(data), null, _env.Clock, new DataPlaneCounters(), _env.Log, remote.AddressFamily == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any, []);
await using var datagrams = new QuicDatagramTransport( udp.Bind();
data,
_env.Clock,
new DataPlaneCounters(),
_env.Log,
new IPEndPoint(remote.Address, quicPort),
agentKey,
Volatile.Read(ref _previousQuicSourcePort));
Volatile.Write(ref _activeDatagrams, datagrams);
datagrams.Bind();
using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
var outbox = new Outbox(8 << 20); var outbox = new Outbox(8 << 20);
var clock = new ClockEstimator(); var clock = new ClockEstimator();
var shipper = new FragmentShipper(outbox, new NodeId(Endpoint.AgentId), new ProbeId(_env.ProbeId), clock, _env.Time); var shipper = new FragmentShipper(outbox, new NodeId(Endpoint.AgentId), new ProbeId(_env.ProbeId), clock, _env.Time);
@@ -216,7 +104,7 @@ public sealed class AgentConnection : IDisposable
Clock = _env.Clock, Clock = _env.Clock,
Log = _env.Log, Log = _env.Log,
Session = () => data, Session = () => data,
Datagrams = datagrams, Udp = udp,
PeerAddress = remote.Address, PeerAddress = remote.Address,
PeerSpkiSha256 = NodeIdentity.Fingerprint(agentKey), PeerSpkiSha256 = NodeIdentity.Fingerprint(agentKey),
KnockKey = Knock.DeriveKey(_env.Identity, agentKey), KnockKey = Knock.DeriveKey(_env.Identity, agentKey),
@@ -228,57 +116,16 @@ public sealed class AgentConnection : IDisposable
var rpc = new RpcSession<ConnectionSession>(transport, s_router, session, _env.Time); var rpc = new RpcSession<ConnectionSession>(transport, s_router, session, _env.Time);
rpc.OnBinary = (_, _) => ValueTask.CompletedTask; rpc.OnBinary = (_, _) => ValueTask.CompletedTask;
using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
Task run = rpc.RunAsync(cts.Token); Task run = rpc.RunAsync(cts.Token);
Task receive = datagrams.RunAsync(cts.Token); Task receive = udp.RunAsync(cts.Token);
Task pump = PumpAsync(rpc, outbox, cts.Token); Task pump = PumpAsync(rpc, outbox, cts.Token);
try try
{ {
// 先同步时钟(块头里要带偏移);Agent 随后推送线路任务集 // 先同步时钟(块头里要带偏移);Agent 随后推送线路任务集
await SyncClockAsync(rpc, clock, rounds: 5, cts.Token); await SyncClockAsync(rpc, clock, rounds: 5, cts.Token);
lock (_handshakeGate)
{
_handshakeAttempts++;
}
try
{
SetAttempt(ProbeLinkAttemptState.Connecting, null, null, 0);
await datagrams.ConnectAsync(cts.Token);
lock (_handshakeGate)
{
_handshakeSuccesses++;
if (datagrams.HandshakeDurationMs is { } elapsed)
{
_handshakeSamplesMs.Enqueue(elapsed);
while (_handshakeSamplesMs.Count > 128)
{
_handshakeSamplesMs.Dequeue();
}
}
}
}
catch (Exception ex) when (!(ex is OperationCanceledException && cts.IsCancellationRequested))
{
lock (_handshakeGate)
{
_handshakeFailures++;
if (ex is TimeoutException)
{
_handshakeTimeouts++;
}
}
throw;
}
if (datagrams.BoundPorts.Count > 0)
{
Volatile.Write(ref _previousQuicSourcePort, datagrams.BoundPorts[0]);
}
backoff.Reset(); backoff.Reset();
_retrySignal.Wait(0, CancellationToken.None); // 若“立即重试”恰好赶在本次拨号成功前到达,本次成功已满足请求。
_session = session; _session = session;
SetAttempt(ProbeLinkAttemptState.Connected, null, null, 0);
_env.Log.Info("agent session up", ("agent", Endpoint.AgentId), ("offset_us", clock.OffsetUs)); _env.Log.Info("agent session up", ("agent", Endpoint.AgentId), ("offset_us", clock.OffsetUs));
Task maintenance = MaintainAsync(rpc, session, clock, cts.Token); Task maintenance = MaintainAsync(rpc, session, clock, cts.Token);
Task finished = await Task.WhenAny(run, receive, pump, maintenance); Task finished = await Task.WhenAny(run, receive, pump, maintenance);
@@ -289,7 +136,6 @@ public sealed class AgentConnection : IDisposable
finally finally
{ {
_session = null; _session = null;
Volatile.Write(ref _activeDatagrams, null);
await cts.CancelAsync(); await cts.CancelAsync();
await session.StopAllAsync(); await session.StopAllAsync();
await Quiet(run); await Quiet(run);
@@ -299,20 +145,6 @@ public sealed class AgentConnection : IDisposable
} }
} }
private void SetAttempt(ProbeLinkAttemptState state, string? error, DateTimeOffset? retryAt, int attempt)
{
lock (_handshakeGate)
{
_attemptState = state;
if (error is not null || state == ProbeLinkAttemptState.Connected)
{
_lastError = error;
}
_retryAt = retryAt;
_lastRetryAttempt = attempt;
}
}
private static async Task Quiet(Task task) private static async Task Quiet(Task task)
{ {
try try
@@ -73,15 +73,14 @@ public sealed class AgentDirectory
_trust.Update(result.TrustChain); _trust.Update(result.TrustChain);
foreach (AgentEndpoint agent in result.Agents) foreach (AgentEndpoint agent in result.Agents)
{ {
List<int> ports = agent.QuicPorts.Count > 0 ? agent.QuicPorts : agent.LegacyUdpPorts ?? [];
if (!string.IsNullOrWhiteSpace(agent.Host)) if (!string.IsNullOrWhiteSpace(agent.Host))
{ {
next.Add(new KnownAgent { AgentId = agent.AgentId.Value, Host = agent.Host, TcpPort = agent.TcpPort, QuicPorts = [.. ports], PublicKey = agent.PublicKey }); next.Add(new KnownAgent { AgentId = agent.AgentId.Value, Host = agent.Host, TcpPort = agent.TcpPort, PublicKey = agent.PublicKey });
} }
if (!string.IsNullOrWhiteSpace(agent.Ipv6Host)) if (!string.IsNullOrWhiteSpace(agent.Ipv6Host))
{ {
next.Add(new KnownAgent { AgentId = agent.AgentId.Value, Host = agent.Ipv6Host, TcpPort = agent.TcpPort, QuicPorts = [.. ports], PublicKey = agent.PublicKey, IpVersion = IpVersions.V6 }); next.Add(new KnownAgent { AgentId = agent.AgentId.Value, Host = agent.Ipv6Host, TcpPort = agent.TcpPort, PublicKey = agent.PublicKey, IpVersion = IpVersions.V6 });
} }
} }
lock (_gate) lock (_gate)
@@ -112,5 +111,5 @@ public sealed class AgentDirectory
a.Count == b.Count && a.Zip(b).All(p => Equal(p.First, p.Second)); a.Count == b.Count && a.Zip(b).All(p => Equal(p.First, p.Second));
public static bool Equal(KnownAgent a, KnownAgent b) => public static bool Equal(KnownAgent a, KnownAgent b) =>
a.AgentId == b.AgentId && a.IpVersion == b.IpVersion && a.Host == b.Host && a.TcpPort == b.TcpPort && a.QuicPorts.SequenceEqual(b.QuicPorts) && a.PublicKey == b.PublicKey; a.AgentId == b.AgentId && a.IpVersion == b.IpVersion && a.Host == b.Host && a.TcpPort == b.TcpPort && a.PublicKey == b.PublicKey;
} }
@@ -1,31 +0,0 @@
namespace MoShiro.Probe;
/// <summary>Reconnect cadence for a single Probe-to-Agent path: 1, 2, 4, 8, 16, then five 32-second waits, then once per minute.</summary>
public sealed class ProbeReconnectBackoff
{
private static readonly TimeSpan[] s_delays =
[
TimeSpan.FromSeconds(1),
TimeSpan.FromSeconds(2),
TimeSpan.FromSeconds(4),
TimeSpan.FromSeconds(8),
TimeSpan.FromSeconds(16),
TimeSpan.FromSeconds(32),
TimeSpan.FromSeconds(32),
TimeSpan.FromSeconds(32),
TimeSpan.FromSeconds(32),
TimeSpan.FromSeconds(32),
];
private int _attempt;
public int Attempts => Volatile.Read(ref _attempt);
public TimeSpan Next()
{
int attempt = Interlocked.Increment(ref _attempt) - 1;
return attempt < s_delays.Length ? s_delays[attempt] : TimeSpan.FromMinutes(1);
}
public void Reset() => Interlocked.Exchange(ref _attempt, 0);
}
+8 -113
View File
@@ -1,6 +1,5 @@
using Fleet.Core; using Fleet.Core;
using Fleet.Hosting; using Fleet.Hosting;
using System.Collections.Concurrent;
using System.Text.Json; using System.Text.Json;
using Fleet.Identity; using Fleet.Identity;
using Fleet.Link; using Fleet.Link;
@@ -20,40 +19,31 @@ public sealed class ProbeApp : IDisposable
private readonly ConnectionEnv _env; private readonly ConnectionEnv _env;
private readonly Dictionary<string, Running> _connections = []; private readonly Dictionary<string, Running> _connections = [];
private readonly ConcurrentDictionary<string, long> _pendingRetries = new(StringComparer.Ordinal);
private readonly SemaphoreSlim _changed = new(0); private readonly SemaphoreSlim _changed = new(0);
private readonly INodeLog _log; private readonly INodeLog _log;
private readonly FleetLink _link; private readonly FleetLink _link;
private readonly ReportingSection _reporting;
private readonly TimeProvider _time;
/// <param name="identity">注册时由节点自己生成:<c>Identity</c> 用来连面板,<c>PeerIdentity</c> 是另一把独立的密钥,只用来连 Agent。</param> /// <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) public ProbeApp(ProbeFile file, LoadedNodeIdentity identity, string configDirectory, TimeProvider time, INodeLog log, RestartController? restart = null)
{ {
_log = log; _log = log;
_time = time;
_reporting = file.Reporting;
var trust = TrustAnchor.Load(file.Fleet.IdentityDir, log); var trust = TrustAnchor.Load(file.Fleet.IdentityDir, log);
var directory = new AgentDirectory(new ConfigStore(configDirectory), configDirectory, log, trust); 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); _env = new ConnectionEnv(identity.PeerIdentity, identity.NodeId.Value, file.Limits, directory, new MonoClock(time), time, log);
directory.Changed += () => _changed.Release(); directory.Changed += () => _changed.Release();
RestartUpdater? update = RestartUpdater.FromEnvironment("probe", reason => restart?.RequestNow(reason), time, log); var update = new UpdateBridge(configDirectory, new ConfigStore(configDirectory).Write, log);
NodeBinary.Prime(); // 正在运行的二进制的哈希:先在后台算好,握手时用
var options = new FleetLinkOptions var options = new FleetLinkOptions
{ {
Server = new Uri(file.Fleet.Server), Server = new Uri(file.Fleet.Server),
Identity = identity.Identity, Identity = identity.Identity,
ServerVerifier = trust, ServerVerifier = trust,
Attestation = update.Attest,
NodeId = identity.NodeId, NodeId = identity.NodeId,
NodeVersion = Version, NodeVersion = Version,
Capabilities = [MoShiroCapabilities.Base], Capabilities = [MoShiroCapabilities.Base],
ShutdownReason = () => restart?.NowReason == "update" ? "updating" : "stopping", 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 = () =>
{
UpdateStatus? updateStatus = update?.Status();
NodeRuntimeStatus runtimeStatus = updateStatus?.State == "applying" ? NodeRuntimeStatus.Updating : _env.Directory.Agents.Count == 0 ? NodeRuntimeStatus.ProbePaused : NodeRuntimeStatus.Running;
return JsonSerializer.SerializeToElement(new ProbeHeartbeatExtra { RuntimeStatus = runtimeStatus, Update = updateStatus, Ipv6 = Ipv6Diagnostics.Sample(null, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")), QuicLinks = SnapshotQuicLinks() }, MoShiroJsonContext.Default.ProbeHeartbeatExtra);
},
HelloMeta = () => JsonSerializer.SerializeToElement(new ProbeHelloMeta { Owners = [.. trust.Controllers], Platform = PlatformDetector.Detect(), Ipv6 = Ipv6Diagnostics.Sample(null, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")) }, MoShiroJsonContext.Default.ProbeHelloMeta), 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>( var list = new StateHandler<ProbeAgentList>(
@@ -65,19 +55,14 @@ public sealed class ProbeApp : IDisposable
return ValueTask.FromResult(new StateSyncResult { AppliedVersion = version }); return ValueTask.FromResult(new StateSyncResult { AppliedVersion = version });
}); });
var router = new RpcRouter<LinkContext>(RpcSide.Node, MoShiroMethodTable.Table, NodeRoles.Probe) var router = new RpcRouter<LinkContext>(RpcSide.Node, MoShiroMethodTable.Table, NodeRoles.Probe)
.OnNotify(MoShiroMethods.ProbeLinkRetry, MoShiroJsonContext.Default.ProbeLinkRetryParams, (_, retry, _) =>
{
RetryAgent(retry.AgentId.Value);
return ValueTask.CompletedTask;
})
.OnNotify(MoShiroMethods.UpdateOffer, MoShiroJsonContext.Default.UpdateOfferParams, (_, offer, _) => .OnNotify(MoShiroMethods.UpdateOffer, MoShiroJsonContext.Default.UpdateOfferParams, (_, offer, _) =>
{ {
update?.Offer(offer); update.WriteOffer(offer);
return ValueTask.CompletedTask; return ValueTask.CompletedTask;
}) })
.OnNotify(MoShiroMethods.UpdateApply, MoShiroJsonContext.Default.UpdateOfferParams, (_, _, _) => .OnNotify(MoShiroMethods.UpdateApply, MoShiroJsonContext.Default.UpdateOfferParams, (_, _, _) =>
{ {
update?.RequestApply(); update.RequestApply();
return ValueTask.CompletedTask; return ValueTask.CompletedTask;
}) })
.OnNotify(MoShiroMethods.Schedule, MoShiroJsonContext.Default.ScheduleParams, (_, schedule, _) => .OnNotify(MoShiroMethods.Schedule, MoShiroJsonContext.Default.ScheduleParams, (_, schedule, _) =>
@@ -114,71 +99,19 @@ public sealed class ProbeApp : IDisposable
} }
} }
private void RetryAgent(string agentId, bool queueIfMissing = true)
{
AgentConnection[] matches;
lock (_connections)
{
matches = [.. _connections.Values.Select(r => r.Connection).Where(c => c.Endpoint.AgentId == agentId)];
}
if (matches.Length == 0)
{
if (queueIfMissing)
{
_pendingRetries[agentId] = _time.GetUtcNow().AddSeconds(30).ToUnixTimeMilliseconds();
}
_log.Warn("manual link retry requested but no matching Agent path is configured", ("agent", agentId));
return;
}
_pendingRetries.TryRemove(agentId, out _);
foreach (AgentConnection connection in matches)
{
connection.RetryNow();
}
}
private void RetryPending()
{
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
foreach ((string agentId, long expiresAt) in _pendingRetries.ToArray())
{
if (expiresAt <= now)
{
_pendingRetries.TryRemove(agentId, out _);
}
else
{
RetryAgent(agentId, queueIfMissing: false);
}
}
}
private List<ProbeQuicLinkHealth> SnapshotQuicLinks()
{
lock (_connections)
{
return [.. _connections.Values.Select(r => r.Connection.QuicHealth())];
}
}
public async Task RunAsync(CancellationToken ct) public async Task RunAsync(CancellationToken ct)
{ {
_log.Info("moshiro probe starting", MoShiro.Protocol.BuildInfo.Fields);
if (_env.Directory.Agents.Count == 0) if (_env.Directory.Agents.Count == 0)
{ {
_log.Info("no agents known yet, waiting for the panel to send the list"); _log.Info("no agents known yet, waiting for the panel to send the list");
} }
Task link = _link.RunAsync(ct); Task link = _link.RunAsync(ct);
Task host = ReportHostAsync(ct);
try try
{ {
while (!ct.IsCancellationRequested) while (!ct.IsCancellationRequested)
{ {
await ReconcileAsync(_env.Directory.Agents, ct); await ReconcileAsync(_env.Directory.Agents, ct);
RetryPending();
await _changed.WaitAsync(ct); await _changed.WaitAsync(ct);
} }
} }
@@ -189,41 +122,7 @@ public sealed class ProbeApp : IDisposable
finally finally
{ {
await ReconcileAsync([], CancellationToken.None); await ReconcileAsync([], CancellationToken.None);
await Task.WhenAny(Task.WhenAll(link, host), Task.Delay(TimeSpan.FromSeconds(5), CancellationToken.None)); await Task.WhenAny(link, 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)
{
// 停止
} }
} }
@@ -305,10 +204,6 @@ public sealed class ProbeApp : IDisposable
Dispose(); Dispose();
} }
public void Dispose() public void Dispose() => _cts.Dispose();
{
Connection.Dispose();
_cts.Dispose();
}
} }
} }
@@ -1,5 +1,4 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using MoShiro.Measurement;
using MoShiro.Protocol; using MoShiro.Protocol;
namespace MoShiro.Probe; namespace MoShiro.Probe;
@@ -19,10 +18,6 @@ public sealed class ProbeFile
[JsonPropertyName("limits")] [JsonPropertyName("limits")]
public ProbeLimits Limits { get; set; } = new(); 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 public sealed class ProbeFleetSection
@@ -57,15 +52,6 @@ public sealed class KnownAgent
[JsonPropertyName("tcp_port")] [JsonPropertyName("tcp_port")]
public int TcpPort { get; set; } 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> /// <summary>Agent 的 TLS 证书公钥(SPKI,base64):证书按它的 SHA-256 指纹固定,敲门密钥由它派生。</summary>
[JsonPropertyName("public_key")] [JsonPropertyName("public_key")]
public string PublicKey { get; set; } = ""; public string PublicKey { get; set; } = "";
@@ -1,34 +0,0 @@
using MoShiro.Probe;
namespace MoShiro.Probe.Tests;
public sealed class ProbeReconnectBackoffTests
{
[Fact]
public void Next_UsesRequestedEscalationThenCapsAtOneMinute()
{
var backoff = new ProbeReconnectBackoff();
TimeSpan[] expected =
[
TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(4),
TimeSpan.FromSeconds(8), TimeSpan.FromSeconds(16),
TimeSpan.FromSeconds(32), TimeSpan.FromSeconds(32), TimeSpan.FromSeconds(32),
TimeSpan.FromSeconds(32), TimeSpan.FromSeconds(32),
TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1),
];
Assert.Equal(expected, expected.Select(_ => backoff.Next()));
}
[Fact]
public void Reset_RestartsAtOneSecond()
{
var backoff = new ProbeReconnectBackoff();
_ = backoff.Next();
_ = backoff.Next();
backoff.Reset();
Assert.Equal(TimeSpan.FromSeconds(1), backoff.Next());
}
}
@@ -26,7 +26,7 @@ public static class BlockCatalog
{ {
public static readonly IReadOnlyList<BlockPair> All = public static readonly IReadOnlyList<BlockPair> All =
[ [
new("udp_echo", (e, s) => new DatagramEchoDialer(e, s), (e, s) => new DatagramEchoListener(e, s)), new("udp_echo", (e, s) => new UdpEchoDialer(e, s), (e, s) => new UdpEchoListener(e, s)),
new("tcp_echo", (e, s) => new TcpEchoDialer(e, s), (e, s) => new TcpEchoListener(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("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"]), 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 internal sealed class EchoRows
{ {
/// <summary>UDP:没有在这段时间内回来的包就是丢了。</summary> /// <summary>UDP:没有在这段时间内回来的包就是丢了。</summary>
public const long DatagramGraceUs = 2_000_000; public const long UdpGraceUs = 2_000_000;
/// <summary> /// <summary>
/// TCP 不会丢包,只会延迟(重传、队头阻塞):一次几秒的中断之后,积压的回显会陆续回来,RTT 就是那几秒。 /// TCP 不会丢包,只会延迟(重传、队头阻塞):一次几秒的中断之后,积压的回显会陆续回来,RTT 就是那几秒。
@@ -65,27 +65,26 @@ internal sealed class EchoRows
/// <summary> /// <summary>
/// UDP 回显,拨号方(Probe):按声明的速率发包,t1 在发出时记录,回复到达时回填 t4。回复丢失的行 t4 缺失(Agent 一侧再补 t2/t3)。 /// UDP 回显,拨号方(Probe):按声明的速率发包,t1 在发出时记录,回复到达时回填 t4。回复丢失的行 t4 缺失(Agent 一侧再补 t2/t3)。
/// </summary> /// </summary>
public sealed class DatagramEchoDialer : BlockBase public sealed class UdpEchoDialer : BlockBase
{ {
private readonly EchoRows _rows; private readonly EchoRows _rows;
public DatagramEchoDialer(BlockEnv env, BlockSpec spec) public UdpEchoDialer(BlockEnv env, BlockSpec spec)
: base(env, spec) : base(env, spec)
{ {
_rows = new EchoRows(spec.Kind, 16384, EchoRows.DatagramGraceUs, settleOnReply: false); _rows = new EchoRows(spec.Kind, 16384, EchoRows.UdpGraceUs, settleOnReply: false);
} }
public override long Produced => _rows.Count; public override long Produced => _rows.Count;
public override async Task RunAsync(CancellationToken ct) public override async Task RunAsync(CancellationToken ct)
{ {
QuicDatagramTransport datagrams = Env.Datagrams!; UdpMux udp = Env.Udp!;
DataSession session = Env.Session() ?? throw new BlockFaultException("no data session"); DataSession session = Env.Session() ?? throw new BlockFaultException("no data session");
await datagrams.WaitUntilConnectedAsync(ct);
IPEndPoint target = PeerEndpoint(Spec.Port); IPEndPoint target = PeerEndpoint(Spec.Port);
int size = (int)Spec.Int("size"); int size = (int)Spec.Int("size");
byte[] packet = new byte[size]; byte[] packet = new byte[size];
datagrams.Register(Spec.StreamId, OnPacket); udp.Register(Spec.StreamId, OnPacket);
try try
{ {
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1.0 / Spec.Int("rate_hz")), Env.Time); using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1.0 / Spec.Int("rate_hz")), Env.Time);
@@ -93,8 +92,8 @@ public sealed class DatagramEchoDialer : BlockBase
{ {
long t1 = Env.Clock.NowUs(); long t1 = Env.Clock.NowUs();
uint seq = _rows.Add(size, t1); uint seq = _rows.Add(size, t1);
MeasurementDatagram.Write(packet, new MeasurementDatagramHeader(MeasurementDatagramType.EchoRequest, session.SessionId, Spec.StreamId, seq, t1), session.Key); UdpPacket.Write(packet, new UdpHeader(UdpPacketType.EchoRequest, session.SessionId, Spec.StreamId, seq, t1), session.Key);
datagrams.Send(session, target, packet); udp.Send(target, packet);
} }
} }
catch (OperationCanceledException) catch (OperationCanceledException)
@@ -103,7 +102,7 @@ public sealed class DatagramEchoDialer : BlockBase
} }
finally finally
{ {
datagrams.Unregister(Spec.StreamId); udp.Unregister(Spec.StreamId);
} }
} }
@@ -119,9 +118,9 @@ public sealed class DatagramEchoDialer : BlockBase
return 1; return 1;
} }
private void OnPacket(in QuicDatagramDelivery delivery) private void OnPacket(in UdpDelivery delivery)
{ {
if (delivery.Header.Type == MeasurementDatagramType.EchoReply) if (delivery.Header.Type == UdpPacketType.EchoReply)
{ {
_rows.Reply(delivery.Header.Seq, delivery.ReceiveUs); _rows.Reply(delivery.Header.Seq, delivery.ReceiveUs);
} }
@@ -129,11 +128,11 @@ public sealed class DatagramEchoDialer : BlockBase
} }
/// <summary>UDP 回显,监听方(Agent):校验后立即回复一个等长的包,记录 t2(到达)和 t3(回复发出),按序号留给合并。</summary> /// <summary>UDP 回显,监听方(Agent):校验后立即回复一个等长的包,记录 t2(到达)和 t3(回复发出),按序号留给合并。</summary>
public sealed class DatagramEchoListener : BlockBase public sealed class UdpEchoListener : BlockBase
{ {
private readonly RowStore _records; private readonly RowStore _records;
public DatagramEchoListener(BlockEnv env, BlockSpec spec) public UdpEchoListener(BlockEnv env, BlockSpec spec)
: base(env, spec) : base(env, spec)
{ {
_records = new RowStore(spec.Kind.FindStream("msg")!, Side.Agent, 32768); _records = new RowStore(spec.Kind.FindStream("msg")!, Side.Agent, 32768);
@@ -143,26 +142,26 @@ public sealed class DatagramEchoListener : BlockBase
public override async Task RunAsync(CancellationToken ct) public override async Task RunAsync(CancellationToken ct)
{ {
Env.Datagrams!.Register(Spec.StreamId, OnPacket); Env.Udp!.Register(Spec.StreamId, OnPacket);
try try
{ {
await WaitUntilCancelledAsync(ct); await WaitUntilCancelledAsync(ct);
} }
finally finally
{ {
Env.Datagrams.Unregister(Spec.StreamId); Env.Udp.Unregister(Spec.StreamId);
} }
} }
private void OnPacket(in QuicDatagramDelivery delivery) private void OnPacket(in UdpDelivery delivery)
{ {
if (delivery.Header.Type != MeasurementDatagramType.EchoRequest) if (delivery.Header.Type != UdpPacketType.EchoRequest)
{ {
return; // Data:探活包,只用来保持 NAT 映射和让准入策略学到源地址 return; // Data:探活包,只用来保持 NAT 映射和让准入策略学到源地址
} }
long t2 = delivery.ReceiveUs; long t2 = delivery.ReceiveUs;
delivery.ReplyAs(MeasurementDatagramType.EchoReply); delivery.ReplyAs(UdpPacketType.EchoReply);
Span<long> row = [t2, Env.Clock.NowUs()]; Span<long> row = [t2, Env.Clock.NowUs()];
_records.SetRow(delivery.Header.Seq, row); _records.SetRow(delivery.Header.Seq, row);
} }
@@ -30,11 +30,11 @@ public sealed class TrainListener : BlockBase
public override async Task RunAsync(CancellationToken ct) public override async Task RunAsync(CancellationToken ct)
{ {
// Probe 开始前发来的探活包(Data)要被接受,准入策略才能据此学到它当前的源地址(NAT 映射);它们本身没有内容 // Probe 开始前发来的探活包(Data)要被接受,准入策略才能据此学到它当前的源地址(NAT 映射);它们本身没有内容
Env.Datagrams!.Register(Spec.StreamId, static (in QuicDatagramDelivery _) => { }); Env.Udp!.Register(Spec.StreamId, static (in UdpDelivery _) => { });
try try
{ {
DataSession session = await WaitForPeerAsync(ct); DataSession session = await WaitForPeerAsync(ct);
IPEndPoint target = session.DatagramRemoteEndPoint!; IPEndPoint target = session.UdpEndpoint!;
TimeSpan duration = TimeSpan.FromSeconds(Spec.Int("duration_s")); TimeSpan duration = TimeSpan.FromSeconds(Spec.Int("duration_s"));
var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var thread = new Thread(() => var thread = new Thread(() =>
@@ -58,7 +58,7 @@ public sealed class TrainListener : BlockBase
} }
finally finally
{ {
Env.Datagrams.Unregister(Spec.StreamId); Env.Udp.Unregister(Spec.StreamId);
} }
} }
@@ -68,7 +68,7 @@ public sealed class TrainListener : BlockBase
while (true) while (true)
{ {
DataSession? session = Env.Session(); DataSession? session = Env.Session();
if (session?.DatagramRemoteEndPoint is not null) if (session?.UdpEndpoint is not null)
{ {
return session; return session;
} }
@@ -120,8 +120,8 @@ public sealed class TrainListener : BlockBase
for (; seq < due; seq++) for (; seq < due; seq++)
{ {
long sendUs = Env.Clock.NowUs(); long sendUs = Env.Clock.NowUs();
MeasurementDatagram.Write(packet, new MeasurementDatagramHeader(MeasurementDatagramType.Data, session.SessionId, Spec.StreamId, seq, sendUs), session.Key); UdpPacket.Write(packet, new UdpHeader(UdpPacketType.Data, session.SessionId, Spec.StreamId, seq, sendUs), session.Key);
Env.Datagrams!.Send(session, target, packet); Env.Udp!.Send(target, packet);
Span<long> row = [size, sendUs]; Span<long> row = [size, sendUs];
_records.SetRow(seq, row); _records.SetRow(seq, row);
Interlocked.Exchange(ref _sent, seq + 1L); Interlocked.Exchange(ref _sent, seq + 1L);
@@ -159,19 +159,18 @@ public sealed class TrainDialer : BlockBase
public override async Task RunAsync(CancellationToken ct) public override async Task RunAsync(CancellationToken ct)
{ {
QuicDatagramTransport datagrams = Env.Datagrams!; UdpMux udp = Env.Udp!;
DataSession session = Env.Session() ?? throw new BlockFaultException("no data session"); DataSession session = Env.Session() ?? throw new BlockFaultException("no data session");
await datagrams.WaitUntilConnectedAsync(ct);
_startUs = Env.Clock.NowUs(); _startUs = Env.Clock.NowUs();
datagrams.Register(Spec.StreamId, OnPacket); udp.Register(Spec.StreamId, OnPacket);
try try
{ {
byte[] hello = new byte[MeasurementDatagram.MinBytes + 20]; byte[] hello = new byte[UdpPacket.MinBytes + 20];
IPEndPoint target = PeerEndpoint(Spec.Port); IPEndPoint target = PeerEndpoint(Spec.Port);
for (uint seq = 0; seq < 3; seq++) for (uint seq = 0; seq < 3; seq++)
{ {
MeasurementDatagram.Write(hello, new MeasurementDatagramHeader(MeasurementDatagramType.Data, session.SessionId, Spec.StreamId, seq, Env.Clock.NowUs()), session.Key); UdpPacket.Write(hello, new UdpHeader(UdpPacketType.Data, session.SessionId, Spec.StreamId, seq, Env.Clock.NowUs()), session.Key);
datagrams.Send(session, target, hello); udp.Send(target, hello);
} }
await WaitUntilCancelledAsync(ct); await WaitUntilCancelledAsync(ct);
@@ -179,7 +178,7 @@ public sealed class TrainDialer : BlockBase
finally finally
{ {
_endUs = Env.Clock.NowUs(); _endUs = Env.Clock.NowUs();
datagrams.Unregister(Spec.StreamId); udp.Unregister(Spec.StreamId);
} }
} }
@@ -229,10 +228,10 @@ public sealed class TrainDialer : BlockBase
private static System.Text.Json.JsonElement Number(long value) => private static System.Text.Json.JsonElement Number(long value) =>
System.Text.Json.JsonDocument.Parse(value.ToString(System.Globalization.CultureInfo.InvariantCulture)).RootElement.Clone(); System.Text.Json.JsonDocument.Parse(value.ToString(System.Globalization.CultureInfo.InvariantCulture)).RootElement.Clone();
private void OnPacket(in QuicDatagramDelivery delivery) private void OnPacket(in UdpDelivery delivery)
{ {
long seq = delivery.Header.Seq; long seq = delivery.Header.Seq;
if (delivery.Header.Type == MeasurementDatagramType.Data && seq < _maxSeq && !_received.IsSet(seq, _tRecv)) if (delivery.Header.Type == UdpPacketType.Data && seq < _maxSeq && !_received.IsSet(seq, _tRecv))
{ {
_received.Set(seq, _tRecv, delivery.ReceiveUs); // 只记第一次到达(重复的包不覆盖) _received.Set(seq, _tRecv, delivery.ReceiveUs); // 只记第一次到达(重复的包不覆盖)
} }
@@ -11,8 +11,6 @@ namespace MoShiro.Measurement;
/// </summary> /// </summary>
public class DataSession public class DataSession
{ {
private IDatagramConnection? _datagramConnection;
public DataSession(long sessionId, byte[] key) public DataSession(long sessionId, byte[] key)
{ {
SessionId = sessionId; SessionId = sessionId;
@@ -23,26 +21,13 @@ public class DataSession
public byte[] Key { get; } public byte[] Key { get; }
public IDatagramConnection? DatagramConnection => Volatile.Read(ref _datagramConnection);
public void BindDatagramConnection(IDatagramConnection connection) => Volatile.Write(ref _datagramConnection, connection);
/// <summary>对端最近一次通过认证的 UDP 源地址(监听方把单向数据发往这里;拨号方不需要)。</summary> /// <summary>对端最近一次通过认证的 UDP 源地址(监听方把单向数据发往这里;拨号方不需要)。</summary>
public virtual IPEndPoint? DatagramRemoteEndPoint => null; public virtual IPEndPoint? UdpEndpoint => null;
/// <summary>TCP 首帧的随机数只能用一次。拨号方不接受连接,所以默认放行。</summary> /// <summary>TCP 首帧的随机数只能用一次。拨号方不接受连接,所以默认放行。</summary>
public virtual bool UseNonce(ReadOnlySpan<byte> nonce) => true; 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 public interface IDataSessions
{ {
DataSession? Find(long sessionId); DataSession? Find(long sessionId);
@@ -75,7 +60,7 @@ public enum Admission
public interface IAdmission public interface IAdmission
{ {
/// <summary>UDP 包:<paramref name="source"/> 是认证通过的源地址(实现可以据此学习 NAT 重绑定)。</summary> /// <summary>UDP 包:<paramref name="source"/> 是认证通过的源地址(实现可以据此学习 NAT 重绑定)。</summary>
Admission AdmitDatagram(DataSession session, in MeasurementDatagramHeader header, SocketAddress source, long nowUs); Admission AdmitPacket(DataSession session, in UdpHeader header, SocketAddress source, long nowUs);
/// <summary>TCP 回显的一条消息(只限速)。</summary> /// <summary>TCP 回显的一条消息(只限速)。</summary>
bool AdmitMessage(DataSession session, uint streamId, long nowUs); bool AdmitMessage(DataSession session, uint streamId, long nowUs);
@@ -216,7 +201,7 @@ public sealed class BlockEnv
/// <summary>当前的数据面会话(拨号方固定;监听方是 Agent 当前的 Probe 会话,可能没有)。</summary> /// <summary>当前的数据面会话(拨号方固定;监听方是 Agent 当前的 Probe 会话,可能没有)。</summary>
public required Func<DataSession?> Session { get; init; } public required Func<DataSession?> Session { get; init; }
public QuicDatagramTransport? Datagrams { get; init; } public UdpMux? Udp { get; init; }
/// <summary>监听方的准入策略(限速等);拨号方为 null。</summary> /// <summary>监听方的准入策略(限速等);拨号方为 null。</summary>
public IAdmission? Admission { get; init; } public IAdmission? Admission { get; init; }
@@ -253,7 +238,7 @@ public sealed class BlockEnv
Clock = Clock, Clock = Clock,
Log = Log, Log = Log,
Session = () => session, Session = () => session,
Datagrams = Datagrams, Udp = Udp,
Admission = Admission, Admission = Admission,
Counters = Counters, Counters = Counters,
TcpListen = TcpListen, TcpListen = TcpListen,
@@ -17,7 +17,7 @@ public sealed class NodeLimits
public int MaxConcurrentTests { get; init; } = 1; public int MaxConcurrentTests { get; init; } = 1;
/// <summary>任务参数里的 <c>port</c> 只能取这些端口(本机配置的 listen 列表)。</summary> /// <summary>任务参数里的 <c>port</c> 只能取这些端口(本机配置的 listen 列表)。</summary>
public IReadOnlyList<int> AllowedQuicPorts { get; init; } = [7443]; public IReadOnlyList<int> AllowedUdpPorts { get; init; } = [7443];
public IReadOnlyList<int> AllowedTcpPorts { get; init; } = [7444]; public IReadOnlyList<int> AllowedTcpPorts { get; init; } = [7444];
} }
@@ -143,7 +143,7 @@ public sealed class BudgetGuard
} }
int port = (int)values.GetInt("port"); int port = (int)values.GetInt("port");
return kind.Blocks.Transport == "quic_datagram" ? _limits.AllowedQuicPorts.Contains(port) : _limits.AllowedTcpPorts.Contains(port); return kind.Blocks.Transport == "udp" ? _limits.AllowedUdpPorts.Contains(port) : _limits.AllowedTcpPorts.Contains(port);
} }
private static long EstimatePps(string kindId, ParamValues values) => kindId switch private static long EstimatePps(string kindId, ParamValues values) => kindId switch
@@ -1,7 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<IsAotCompatible>true</IsAotCompatible> <IsAotCompatible>true</IsAotCompatible>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<InternalsVisibleTo Include="MoShiro.Measurement.Tests" /> <InternalsVisibleTo Include="MoShiro.Measurement.Tests" />
@@ -1,110 +0,0 @@
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));
}
}
@@ -1,341 +0,0 @@
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);
}
@@ -1,610 +0,0 @@
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);
}
}
}
@@ -1,195 +0,0 @@
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];
}
}
@@ -1,118 +0,0 @@
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);
}
@@ -1,7 +0,0 @@
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();
}
@@ -1,215 +0,0 @@
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, IAsyncDisposable
{
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();
}
public async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
await DisposeNativeResourcesAsync().ConfigureAwait(false);
}
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);
}
}
@@ -0,0 +1,216 @@
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,14 +0,0 @@
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;
}
@@ -1,125 +0,0 @@
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;
}
}
}
@@ -1,153 +0,0 @@
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() ?? "" : "";
}
@@ -0,0 +1,129 @@
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);
}
@@ -1,19 +0,0 @@
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( public static readonly KindSpec UdpEcho = new(
"udp_echo", "udp_echo",
"QUIC Datagram echo", "UDP echo",
"Fixed-rate QUIC Datagram echo with four timestamps per packet.", "Fixed-rate UDP echo with four timestamps per packet.",
Scope.Line, Scope.Line,
new BlockSet("quic_datagram", "fixed", "fixed", "echo", []), new BlockSet("udp", "fixed", "fixed", "echo", []),
[ [
P.Int("rate_hz", 10, 1, 200, "Hz"), P.Int("rate_hz", 10, 1, 200, "Hz"),
P.Int("size", 64, 44, 1400, "B"), P.Int("size", 64, 44, 1400, "B"),
@@ -41,10 +41,10 @@ public static class Kinds
public static readonly KindSpec UdpTrain = new( public static readonly KindSpec UdpTrain = new(
"udp_train", "udp_train",
"QUIC Datagram train", "UDP train",
"Fixed-rate or laddered one-way QUIC Datagram stream (Agent to Probe) for loss and delay variation under load.", "Fixed-rate or laddered one-way UDP stream (Agent to Probe) for loss and jitter under load.",
Scope.Line, Scope.Line,
new BlockSet("quic_datagram", "ladder", "fixed", "sink", []), new BlockSet("udp", "ladder", "fixed", "sink", []),
[ [
P.Number("mbps", 10, 0.1, 1000, "Mbit/s"), P.Number("mbps", 10, 0.1, 1000, "Mbit/s"),
P.Int("size", 1250, 200, 1400, "B"), P.Int("size", 1250, 200, 1400, "B"),
@@ -18,7 +18,7 @@
<ItemGroup> <ItemGroup>
<BuildInfoLine Include="namespace MoShiro.Protocol%3B" /> <BuildInfoLine Include="namespace MoShiro.Protocol%3B" />
<BuildInfoLine Include="/// &lt;summary&gt;这个程序是什么时候、从哪个版本构建出来的(构建时生成)。&lt;/summary&gt;" /> <BuildInfoLine Include="/// &lt;summary&gt;这个程序是什么时候、从哪个版本构建出来的(构建时生成)。&lt;/summary&gt;" />
<BuildInfoLine Include="public static partial class BuildInfo" /> <BuildInfoLine Include="public static class BuildInfo" />
<BuildInfoLine Include="{" /> <BuildInfoLine Include="{" />
<BuildInfoLine Include=" public const string Version = &quot;$(Version)&quot;%3B" /> <BuildInfoLine Include=" public const string Version = &quot;$(Version)&quot;%3B" />
<BuildInfoLine Include=" public const string Tag = &quot;$(MOSHIRO_TAG)&quot;%3B" /> <BuildInfoLine Include=" public const string Tag = &quot;$(MOSHIRO_TAG)&quot;%3B" />
@@ -3,6 +3,7 @@ namespace MoShiro.Protocol;
public static class ProtocolVersion public static class ProtocolVersion
{ {
public const int Container = 1; public const int Container = 1;
public const int Wire = 1;
} }
public static class ProtocolLimits public static class ProtocolLimits
@@ -38,4 +39,5 @@ public static class MoShiroCapabilities
{ {
public const string Base = "moshiro.v1"; public const string Base = "moshiro.v1";
public const string KindsPrefix = "moshiro.kinds:"; public const string KindsPrefix = "moshiro.kinds:";
public const string BlocksPrefix = "moshiro.blocks:";
} }
@@ -30,15 +30,14 @@ public static class MoShiroMethodTable
MethodSpec.Request(MoShiroMethods.TestRun, RpcSide.Server, NodeRoles.Agent), MethodSpec.Request(MoShiroMethods.TestRun, RpcSide.Server, NodeRoles.Agent),
MethodSpec.Request(MoShiroMethods.TestCancel, RpcSide.Server, NodeRoles.Agent), MethodSpec.Request(MoShiroMethods.TestCancel, RpcSide.Server, NodeRoles.Agent),
MethodSpec.Request(MoShiroMethods.ProbeDisconnect, RpcSide.Server, NodeRoles.Agent), MethodSpec.Request(MoShiroMethods.ProbeDisconnect, RpcSide.Server, NodeRoles.Agent),
MethodSpec.Notification(MoShiroMethods.ProbeLinkRetry, RpcSide.Server, NodeRoles.Probe),
MethodSpec.Notification(MoShiroMethods.UpdateOffer, RpcSide.Server), MethodSpec.Notification(MoShiroMethods.UpdateOffer, RpcSide.Server),
MethodSpec.Notification(MoShiroMethods.UpdateApply, RpcSide.Server), MethodSpec.Notification(MoShiroMethods.UpdateApply, RpcSide.Server),
MethodSpec.Notification(MoShiroMethods.Schedule, RpcSide.Server), MethodSpec.Notification(MoShiroMethods.Schedule, RpcSide.Server),
// 节点面:节点 → Backend // 节点面:Agent → Backend
MethodSpec.Notification(MoShiroMethods.TestStatus, RpcSide.Node, NodeRoles.Agent), MethodSpec.Notification(MoShiroMethods.TestStatus, RpcSide.Node, NodeRoles.Agent),
MethodSpec.Notification(MoShiroMethods.Event, RpcSide.Node, NodeRoles.Agent), MethodSpec.Notification(MoShiroMethods.Event, RpcSide.Node, NodeRoles.Agent),
MethodSpec.Notification(MoShiroMethods.Host, RpcSide.Node), // Agent 和 Probe 都上报主机资源占用的汇总 MethodSpec.Notification(MoShiroMethods.Host, RpcSide.Node, NodeRoles.Agent),
// 对等面:Probe(拨号方)→ Agent(监听方) // 对等面:Probe(拨号方)→ Agent(监听方)
MethodSpec.Request(MoShiroMethods.Clock, RpcSide.Dialer), MethodSpec.Request(MoShiroMethods.Clock, RpcSide.Dialer),
@@ -10,7 +10,6 @@ public static class MoShiroMethods
public const string TestRun = "moshiro.test.run"; public const string TestRun = "moshiro.test.run";
public const string TestCancel = "moshiro.test.cancel"; public const string TestCancel = "moshiro.test.cancel";
public const string ProbeDisconnect = "moshiro.probe.disconnect"; public const string ProbeDisconnect = "moshiro.probe.disconnect";
public const string ProbeLinkRetry = "moshiro.probe.link_retry";
public const string TestStatus = "moshiro.test.status"; public const string TestStatus = "moshiro.test.status";
public const string Event = "moshiro.event"; public const string Event = "moshiro.event";
public const string Host = "moshiro.host"; public const string Host = "moshiro.host";
@@ -99,13 +98,6 @@ public sealed class ProbeDisconnectParams
public ProbeId ProbeId { get; set; } public ProbeId ProbeId { get; set; }
} }
/// <summary>Backend → Probe: immediately retry the selected Agent's current IP paths.</summary>
public sealed class ProbeLinkRetryParams
{
[JsonPropertyName("agent_id")]
public NodeId AgentId { get; set; }
}
public sealed class TestStatusParams public sealed class TestStatusParams
{ {
[JsonPropertyName("test_id")] [JsonPropertyName("test_id")]
@@ -154,11 +146,8 @@ public sealed class AgentEndpoint
[JsonPropertyName("tcp_port")] [JsonPropertyName("tcp_port")]
public int TcpPort { get; set; } public int TcpPort { get; set; }
[JsonPropertyName("quic_ports")]
public List<int> QuicPorts { get; set; } = [];
[JsonPropertyName("udp_ports")] [JsonPropertyName("udp_ports")]
public List<int> LegacyUdpPorts { get; set; } = []; public List<int> UdpPorts { get; set; } = [];
/// <summary>Agent 面向 Probe 的 TLS 证书公钥(SPKI,base64):证书按它的 SHA-256 指纹固定,敲门密钥由它和 Probe 的身份密钥派生。</summary> /// <summary>Agent 面向 Probe 的 TLS 证书公钥(SPKI,base64):证书按它的 SHA-256 指纹固定,敲门密钥由它和 Probe 的身份密钥派生。</summary>
[JsonPropertyName("public_key")] [JsonPropertyName("public_key")]
@@ -186,11 +175,8 @@ public sealed class AgentEnrollMeta
[JsonPropertyName("probe_tcp_port")] [JsonPropertyName("probe_tcp_port")]
public int ProbeTcpPort { get; set; } = 7443; public int ProbeTcpPort { get; set; } = 7443;
[JsonPropertyName("probe_quic_ports")]
public List<int> ProbeQuicPorts { get; set; } = [];
[JsonPropertyName("probe_udp_ports")] [JsonPropertyName("probe_udp_ports")]
public List<int>? LegacyProbeUdpPorts { get; set; } public List<int> ProbeUdpPorts { get; set; } = [7443];
[JsonPropertyName("public_host")] [JsonPropertyName("public_host")]
public string? PublicHost { get; set; } public string? PublicHost { get; set; }
@@ -310,13 +296,6 @@ public sealed class UpdateStatus
/// <summary>Probe 的 <c>fleet.heartbeat</c> 附加字段:更新器的状态。</summary> /// <summary>Probe 的 <c>fleet.heartbeat</c> 附加字段:更新器的状态。</summary>
public sealed class ProbeHeartbeatExtra public sealed class ProbeHeartbeatExtra
{ {
/// <summary>Node operating state; registration approval and transport presence are reported separately.</summary>
[JsonPropertyName("runtime_status")]
public NodeRuntimeStatus RuntimeStatus { get; set; } = NodeRuntimeStatus.Running;
[JsonPropertyName("quic_links")]
public List<ProbeQuicLinkHealth>? QuicLinks { get; set; }
[JsonPropertyName("ipv6")] [JsonPropertyName("ipv6")]
public Ipv6Diagnostic? Ipv6 { get; set; } public Ipv6Diagnostic? Ipv6 { get; set; }
@@ -324,76 +303,6 @@ public sealed class ProbeHeartbeatExtra
public UpdateStatus? Update { get; set; } 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("state")]
public ProbeLinkAttemptState State { get; set; } = ProbeLinkAttemptState.Connecting;
[JsonPropertyName("last_error")]
public string? LastError { get; set; }
[JsonPropertyName("retry_attempt")]
public int RetryAttempt { get; set; }
[JsonPropertyName("retry_in_ms")]
public long? RetryInMs { 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; }
}
[JsonConverter(typeof(JsonStringEnumConverter<ProbeLinkAttemptState>))]
public enum ProbeLinkAttemptState
{
[JsonStringEnumMemberName("connecting")]
Connecting,
[JsonStringEnumMemberName("retry_wait")]
RetryWait,
[JsonStringEnumMemberName("connected")]
Connected,
}
/// <summary>Probe 的 <c>fleet.hello</c> 附加字段:系统、架构和构建信息(只用于展示和更新)。</summary> /// <summary>Probe 的 <c>fleet.hello</c> 附加字段:系统、架构和构建信息(只用于展示和更新)。</summary>
public sealed class ProbeHelloMeta public sealed class ProbeHelloMeta
{ {
@@ -436,11 +345,8 @@ public sealed class AgentHelloMeta
[JsonPropertyName("probe_tcp_port")] [JsonPropertyName("probe_tcp_port")]
public int? ProbeTcpPort { get; set; } public int? ProbeTcpPort { get; set; }
[JsonPropertyName("probe_quic_ports")]
public List<int>? ProbeQuicPorts { get; set; }
[JsonPropertyName("probe_udp_ports")] [JsonPropertyName("probe_udp_ports")]
public List<int>? LegacyProbeUdpPorts { get; set; } public List<int>? ProbeUdpPorts { get; set; }
[JsonPropertyName("local_limits")] [JsonPropertyName("local_limits")]
public LocalLimits? LocalLimits { get; set; } public LocalLimits? LocalLimits { get; set; }
@@ -514,10 +420,6 @@ public sealed class ConnectedProbe
/// <summary><c>fleet.heartbeat</c> 的附加字段:只有进程内的计数,没有主机信息。</summary> /// <summary><c>fleet.heartbeat</c> 的附加字段:只有进程内的计数,没有主机信息。</summary>
public sealed class AgentHeartbeatExtra public sealed class AgentHeartbeatExtra
{ {
/// <summary>Node operating state; registration approval and transport presence are reported separately.</summary>
[JsonPropertyName("runtime_status")]
public NodeRuntimeStatus RuntimeStatus { get; set; } = NodeRuntimeStatus.Running;
[JsonPropertyName("ipv6")] [JsonPropertyName("ipv6")]
public Ipv6Diagnostic? Ipv6 { get; set; } public Ipv6Diagnostic? Ipv6 { get; set; }
@@ -580,11 +482,8 @@ public sealed class LineTaskSet
public List<LineTask> Tasks { get; set; } = []; public List<LineTask> Tasks { get; set; } = [];
/// <summary>Agent 的数据面端口(Probe 把包发到这里)。</summary> /// <summary>Agent 的数据面端口(Probe 把包发到这里)。</summary>
[JsonPropertyName("quic_ports")]
public List<int> QuicPorts { get; set; } = [];
[JsonPropertyName("udp_ports")] [JsonPropertyName("udp_ports")]
public List<int> LegacyUdpPorts { get; set; } = []; public List<int> UdpPorts { get; set; } = [];
[JsonPropertyName("tcp_ports")] [JsonPropertyName("tcp_ports")]
public List<int> TcpPorts { get; set; } = []; public List<int> TcpPorts { get; set; } = [];
@@ -684,7 +583,6 @@ public static class FragmentKeys
[JsonSerializable(typeof(TestRunResult))] [JsonSerializable(typeof(TestRunResult))]
[JsonSerializable(typeof(TestCancelParams))] [JsonSerializable(typeof(TestCancelParams))]
[JsonSerializable(typeof(ProbeDisconnectParams))] [JsonSerializable(typeof(ProbeDisconnectParams))]
[JsonSerializable(typeof(ProbeLinkRetryParams))]
[JsonSerializable(typeof(HostSnapshot))] [JsonSerializable(typeof(HostSnapshot))]
[JsonSerializable(typeof(TestStatusParams))] [JsonSerializable(typeof(TestStatusParams))]
[JsonSerializable(typeof(AgentEventParams))] [JsonSerializable(typeof(AgentEventParams))]
@@ -1,23 +0,0 @@
using System.Text.Json.Serialization;
namespace MoShiro.Protocol;
/// <summary>Live process/measurement state. Enrollment approval remains a separate node status.</summary>
[JsonConverter(typeof(JsonStringEnumConverter<NodeRuntimeStatus>))]
public enum NodeRuntimeStatus
{
[JsonStringEnumMemberName("initializing")]
Initializing,
[JsonStringEnumMemberName("running")]
Running,
[JsonStringEnumMemberName("probe_paused")]
ProbePaused,
[JsonStringEnumMemberName("maintenance")]
Maintenance,
[JsonStringEnumMemberName("stopping")]
Stopping,
[JsonStringEnumMemberName("offline")]
Offline,
[JsonStringEnumMemberName("updating")]
Updating,
}
@@ -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) public static void WriteAuth(Span<byte> destination, long sessionId, uint streamId, ReadOnlySpan<byte> nonce, ReadOnlySpan<byte> key)
{ {
BinaryPrimitives.WriteUInt16LittleEndian(destination, MeasurementDatagram.Magic); BinaryPrimitives.WriteUInt16LittleEndian(destination, UdpPacket.Magic);
destination[2] = MeasurementDatagram.Version; destination[2] = UdpPacket.Version;
BinaryPrimitives.WriteInt64LittleEndian(destination[3..], sessionId); BinaryPrimitives.WriteInt64LittleEndian(destination[3..], sessionId);
BinaryPrimitives.WriteUInt32LittleEndian(destination[11..], streamId); BinaryPrimitives.WriteUInt32LittleEndian(destination[11..], streamId);
nonce[..NonceBytes].CopyTo(destination[15..]); nonce[..NonceBytes].CopyTo(destination[15..]);
@@ -35,7 +35,7 @@ public static class TcpWire
sessionId = 0; sessionId = 0;
streamId = 0; streamId = 0;
nonce = default; nonce = default;
if (frame.Length != AuthBytes || BinaryPrimitives.ReadUInt16LittleEndian(frame) != MeasurementDatagram.Magic || frame[2] != MeasurementDatagram.Version) if (frame.Length != AuthBytes || BinaryPrimitives.ReadUInt16LittleEndian(frame) != UdpPacket.Magic || frame[2] != UdpPacket.Version)
{ {
return false; return false;
} }
@@ -3,7 +3,7 @@ using System.Security.Cryptography;
namespace MoShiro.Protocol; namespace MoShiro.Protocol;
public enum MeasurementDatagramType : byte public enum UdpPacketType : byte
{ {
EchoRequest = 1, EchoRequest = 1,
EchoReply = 2, EchoReply = 2,
@@ -12,7 +12,7 @@ public enum MeasurementDatagramType : byte
Data = 3, Data = 3,
} }
public readonly record struct MeasurementDatagramHeader(MeasurementDatagramType Type, long SessionId, uint StreamId, uint Seq, long SendTsUs); public readonly record struct UdpHeader(UdpPacketType Type, long SessionId, uint StreamId, uint Seq, long SendTsUs);
/// <summary> /// <summary>
/// 数据面的 UDP 包(Fleet 之外的线格式): /// 数据面的 UDP 包(Fleet 之外的线格式):
@@ -20,7 +20,7 @@ public readonly record struct MeasurementDatagramHeader(MeasurementDatagramType
/// mac = HMAC-SHA256(session_key, 前面全部字节) 截断到 16 字节。所有整数小端。 /// mac = HMAC-SHA256(session_key, 前面全部字节) 截断到 16 字节。所有整数小端。
/// 接收方对不合法的包静默丢弃并计数,不回复(防放大)。 /// 接收方对不合法的包静默丢弃并计数,不回复(防放大)。
/// </summary> /// </summary>
public static class MeasurementDatagram public static class UdpPacket
{ {
public const ushort Magic = 0x4D4E; // "MN" public const ushort Magic = 0x4D4E; // "MN"
public const byte Version = 1; public const byte Version = 1;
@@ -30,19 +30,19 @@ public static class MeasurementDatagram
public const int MaxBytes = 1472; public const int MaxBytes = 1472;
/// <summary>检查长度、magic、version、type 并解析头部(不校验 MAC)。</summary> /// <summary>检查长度、magic、version、type 并解析头部(不校验 MAC)。</summary>
public static bool TryParseHeader(ReadOnlySpan<byte> packet, out MeasurementDatagramHeader header) public static bool TryParseHeader(ReadOnlySpan<byte> packet, out UdpHeader header)
{ {
header = default; header = default;
if (packet.Length is < MinBytes or > MaxBytes if (packet.Length is < MinBytes or > MaxBytes
|| BinaryPrimitives.ReadUInt16LittleEndian(packet) != Magic || BinaryPrimitives.ReadUInt16LittleEndian(packet) != Magic
|| packet[2] != Version || packet[2] != Version
|| packet[3] is < (byte)MeasurementDatagramType.EchoRequest or > (byte)MeasurementDatagramType.Data) || packet[3] is < (byte)UdpPacketType.EchoRequest or > (byte)UdpPacketType.Data)
{ {
return false; return false;
} }
header = new MeasurementDatagramHeader( header = new UdpHeader(
(MeasurementDatagramType)packet[3], (UdpPacketType)packet[3],
BinaryPrimitives.ReadInt64LittleEndian(packet[4..]), BinaryPrimitives.ReadInt64LittleEndian(packet[4..]),
BinaryPrimitives.ReadUInt32LittleEndian(packet[12..]), BinaryPrimitives.ReadUInt32LittleEndian(packet[12..]),
BinaryPrimitives.ReadUInt32LittleEndian(packet[16..]), BinaryPrimitives.ReadUInt32LittleEndian(packet[16..]),
@@ -51,7 +51,7 @@ public static class MeasurementDatagram
} }
/// <summary>写入整个包(长度就是 <paramref name="destination"/> 的长度),填充为零,末尾是 MAC。</summary> /// <summary>写入整个包(长度就是 <paramref name="destination"/> 的长度),填充为零,末尾是 MAC。</summary>
public static void Write(Span<byte> destination, in MeasurementDatagramHeader header, ReadOnlySpan<byte> key) public static void Write(Span<byte> destination, in UdpHeader header, ReadOnlySpan<byte> key)
{ {
if (destination.Length is < MinBytes or > MaxBytes) if (destination.Length is < MinBytes or > MaxBytes)
{ {
@@ -16,7 +16,7 @@ public sealed class MeasurementTests
Params = parameters.ToDictionary(p => p.Key, p => Json(p.Raw)), Params = parameters.ToDictionary(p => p.Key, p => Json(p.Raw)),
}; };
private static BudgetGuard Guard(NodeLimits? limits = null) => new(limits ?? new NodeLimits { AllowedQuicPorts = [7443], AllowedTcpPorts = [7444] }, BlockCatalog.AgentKinds); private static BudgetGuard Guard(NodeLimits? limits = null) => new(limits ?? new NodeLimits { AllowedUdpPorts = [7443], AllowedTcpPorts = [7444] }, BlockCatalog.AgentKinds);
[Fact] [Fact]
public void BudgetGuard_AcceptsDefaults_RejectsUnknownKindsBadParamsAndForeignPorts() public void BudgetGuard_AcceptsDefaults_RejectsUnknownKindsBadParamsAndForeignPorts()
@@ -34,7 +34,7 @@ public sealed class MeasurementTests
[Fact] [Fact]
public void BudgetGuard_AccumulatesTheBudget_AndRejectsWhatDoesNotFit() public void BudgetGuard_AccumulatesTheBudget_AndRejectsWhatDoesNotFit()
{ {
BudgetGuard guard = Guard(new NodeLimits { MaxTotalMbps = 15, AllowedQuicPorts = [7443] }); BudgetGuard guard = Guard(new NodeLimits { MaxTotalMbps = 15, AllowedUdpPorts = [7443] });
double mbps = 0; double mbps = 0;
long pps = 0; long pps = 0;
Assert.True(guard.CheckPhase("udp_train", new Dictionary<string, JsonElement> { ["mbps"] = Json("10") }).Accepted); Assert.True(guard.CheckPhase("udp_train", new Dictionary<string, JsonElement> { ["mbps"] = Json("10") }).Accepted);
@@ -298,7 +298,7 @@ public sealed class MeasurementTests
[Fact] [Fact]
public void EchoRows_Udp_GivesUpOnAMissingReplyAfterTwoSeconds() public void EchoRows_Udp_GivesUpOnAMissingReplyAfterTwoSeconds()
{ {
var rows = new EchoRows(Kinds.UdpEcho, 64, EchoRows.DatagramGraceUs, settleOnReply: false); var rows = new EchoRows(Kinds.UdpEcho, 64, EchoRows.UdpGraceUs, settleOnReply: false);
rows.Add(64, 0); rows.Add(64, 0);
rows.Add(64, 1_000_000); rows.Add(64, 1_000_000);
RawStream cut = rows.Take(2_500_000, all: false)!; RawStream cut = rows.Take(2_500_000, all: false)!;
@@ -5,7 +5,6 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="xunit.v3" /> <PackageReference Include="xunit.v3" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
<ProjectReference Include="..\..\MoShiro.Measurement\MoShiro.Measurement.csproj" /> <ProjectReference Include="..\..\MoShiro.Measurement\MoShiro.Measurement.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -1,113 +0,0 @@
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));
}
}
@@ -1,17 +0,0 @@
using System.Text.Json;
namespace MoShiro.Protocol.Tests;
public sealed class NodeRuntimeStatusTests
{
[Fact]
public void HeartbeatStatus_UsesStableSnakeCaseEnumValues()
{
var heartbeat = new AgentHeartbeatExtra { RuntimeStatus = NodeRuntimeStatus.ProbePaused };
string json = JsonSerializer.Serialize(heartbeat, MoShiroJsonContext.Default.AgentHeartbeatExtra);
Assert.Contains("\"runtime_status\":\"probe_paused\"", json, StringComparison.Ordinal);
Assert.Equal(NodeRuntimeStatus.ProbePaused,
JsonSerializer.Deserialize(json, MoShiroJsonContext.Default.AgentHeartbeatExtra)!.RuntimeStatus);
}
}
@@ -26,18 +26,10 @@ 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); "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( public static readonly MetricDef LossRun = Metric.RunLength(
"loss.run", s_udpMsg, Ops.LossRuns(Dir.Down), "Loss runs (legacy downstream alias)", "Legacy downstream-only consecutive loss metric.", "loss.run", s_udpMsg, Ops.LossRuns, "Loss runs", "Lengths of consecutive lost packets.",
new MetricExport("loss.run_max", Stat.Max, Unit.Packets, 2, 10), new MetricExport("loss.run_max", Stat.Max, Unit.Packets, 2, 10),
new MetricExport("loss.run_mean", Stat.Mean, Unit.Packets)); 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( 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)); "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 = public static readonly IReadOnlyList<MetricDef> All =
[ [
LinkMetrics.Rtt, LinkMetrics.LossUp, LinkMetrics.LossDown, LinkMetrics.LossTotal, LinkMetrics.Rtt, LinkMetrics.LossUp, LinkMetrics.LossDown, LinkMetrics.LossTotal,
LinkMetrics.JitterUp, LinkMetrics.JitterDown, LinkMetrics.LossRun, LinkMetrics.LossRunUp, LinkMetrics.LossRunDown, LinkMetrics.Reorder, LinkMetrics.Dup, LinkMetrics.SamplesUdp, LinkMetrics.JitterUp, LinkMetrics.JitterDown, LinkMetrics.LossRun, LinkMetrics.Reorder, LinkMetrics.Dup, LinkMetrics.SamplesUdp,
TcpMetrics.Rtt, TcpMetrics.Slow, TcpMetrics.Stall, TcpMetrics.StallMax, TcpMetrics.Retrans, TcpMetrics.Srtt, TcpMetrics.BulkRate, TcpMetrics.BulkPeak, TcpMetrics.SamplesTcp, 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.ConnectSuccess, SessionMetrics.ConnectTcp, SessionMetrics.ConnectTls,
SessionMetrics.FailTimeout, SessionMetrics.FailReset, SessionMetrics.FailTls, SessionMetrics.FailTimeout, SessionMetrics.FailReset, SessionMetrics.FailTls,
@@ -22,8 +22,7 @@ public static class MetricRegistry
/// <summary>指标分组:前端按分组展示,新增指标只需要把它加进分组。</summary> /// <summary>指标分组:前端按分组展示,新增指标只需要把它加进分组。</summary>
public static readonly IReadOnlyList<PresetDef> Presets = public static readonly IReadOnlyList<PresetDef> Presets =
[ [
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", "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("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("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("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"]), new("session", "Session", ["hold.resets.per_h", "hold.lifetime_max"]),
@@ -1,6 +1,6 @@
namespace MoShiro.Backend.Analysis; namespace MoShiro.Backend.Analysis;
/// <summary>TCP 建连、长连接、QUIC Datagram 压测(legacy kind udp_train)的指标。</summary> /// <summary>连接建立、长连接、UDP 压测(udp_train)的指标。</summary>
public static class SessionMetrics public static class SessionMetrics
{ {
private static readonly string[] s_conn = ["connect.conn"]; 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); "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( public static readonly MetricDef TrainLoss = Metric.Ratio(
"train.loss", s_train, Ops.TrainLoss, "Datagram train loss", "Loss of a fixed-rate QUIC Datagram stream.", good: 1, poor: 10); "train.loss", s_train, Ops.TrainLoss, "Train loss", "Loss of a fixed-rate UDP stream.", good: 1, poor: 10);
public static readonly MetricDef TrainJitter = Metric.Histogram( public static readonly MetricDef TrainJitter = Metric.Histogram(
"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); "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);
public static readonly MetricDef TrainRate = Metric.Histogram( public static readonly MetricDef TrainRate = Metric.Histogram(
"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); "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);
public static readonly MetricDef SamplesConnect = Metric.Counter( 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); "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 string Title => "Line quality";
public override int Version => 2; public override int Version => 1;
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 IReadOnlyList<string> Needs { get; } = ["rtt.p95", "loss.total", "jitter.down.p95", "jitter.up.p95", "loss.run_max"];
public override ModelScore Score(MetricView view) => public override ModelScore Score(MetricView view) =>
Combine( Combine(
@@ -24,8 +24,7 @@ public sealed class BaseModel : ServiceModelBase
Dim(view, "loss.total", "Packet loss", 4, s_loss), Dim(view, "loss.total", "Packet loss", 4, s_loss),
Dim(view, "jitter.down.p95", "Jitter (down)", 1.5, s_jitter), Dim(view, "jitter.down.p95", "Jitter (down)", 1.5, s_jitter),
Dim(view, "jitter.up.p95", "Jitter (up)", 1.5, s_jitter), Dim(view, "jitter.up.p95", "Jitter (up)", 1.5, s_jitter),
Dim(view, "loss.run.up_max", "Longest upstream loss run", 0.5, s_run), Dim(view, "loss.run_max", "Longest loss run", 1, s_run),
Dim(view, "loss.run.down_max", "Longest downstream loss run", 0.5, s_run),
], ],
estimated: false); estimated: false);
} }
@@ -273,14 +273,7 @@ internal static class JitterEmitter
/// <summary>连续丢失段的长度(按发送顺序)。</summary> /// <summary>连续丢失段的长度(按发送顺序)。</summary>
internal sealed class LossRunsOp : MetricOp internal sealed class LossRunsOp : MetricOp
{ {
private readonly Dir _direction; public override string Signature => "loss_runs/1";
public LossRunsOp(Dir direction)
{
_direction = direction;
}
public override string Signature => $"loss_runs/{_direction}/2";
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink) public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
{ {
@@ -299,13 +292,7 @@ internal sealed class LossRunsOp : MetricOp
continue; continue;
} }
bool lost = _direction switch if (c.IsReplyLost(i))
{
Dir.Up => c.IsUpLost(i),
Dir.Down => !c.IsUpLost(i) && c.IsReplyLost(i),
_ => c.IsReplyLost(i),
};
if (lost)
{ {
if (run == 0) if (run == 0)
{ {
@@ -328,59 +315,6 @@ 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> /// <summary>乱序:按到达顺序,序号比之前见过的最大序号还小。重复的序号不算乱序。</summary>
internal sealed class ReorderOp : MetricOp internal sealed class ReorderOp : MetricOp
{ {
@@ -11,9 +11,7 @@ public static class Ops
public static MetricOp OwdVar(Dir dir) => new OwdVarOp(dir); public static MetricOp OwdVar(Dir dir) => new OwdVarOp(dir);
public static MetricOp LossRuns(Dir direction) => new LossRunsOp(direction); public static MetricOp LossRuns { get; } = new LossRunsOp();
public static MetricOp LossRunMax(Dir direction) => new LossRunMaxOp(direction);
public static MetricOp Reorder { get; } = new ReorderOp(); public static MetricOp Reorder { get; } = new ReorderOp();
@@ -143,7 +143,7 @@ public static class AgentEndpoints
return Problems.From(http, ownerError); return Problems.From(http, ownerError);
} }
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)); var plan = new InstallPlan(genesis, controllers, InstallScripts.Sha256(InstallScripts.Sh), InstallScripts.Sha256(InstallScripts.Ps1));
http.SetDetail($"{request.Role ?? JoinTokenService.AgentRole}: {request.Note}"); http.SetDetail($"{request.Role ?? JoinTokenService.AgentRole}: {request.Note}");
Result<CreatedJoinToken> result = await tokens.CreateAsync( 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); 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.CacheControl = "no-store";
http.Response.Headers.Location = "/api/admin/join-tokens"; 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), 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); 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);
} }
private static async Task<IResult> RevokeTokenAsync(JoinTokenService tokens, string id, bool? purge, CancellationToken ct) private static async Task<IResult> RevokeTokenAsync(JoinTokenService tokens, string id, bool? purge, CancellationToken ct)
@@ -179,32 +179,15 @@ public static class AgentEndpoints
? TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.ProbeDto) ? TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.ProbeDto)
: Problems.Create(http, MoShiroErrors.ProbeNotFound, "probe not found"); : Problems.Create(http, MoShiroErrors.ProbeNotFound, "probe not found");
private static async Task<IResult> PatchProbeAsync(HttpContext http, ProbeService probes, NodeInfoService nodes, PatchProbeRequest request, string id, CancellationToken ct) private static async Task<IResult> PatchProbeAsync(HttpContext http, ProbeService probes, PatchProbeRequest request, string id, CancellationToken ct)
{ {
if (request.Name is null && request.Isp is null) if (request.Name is null)
{ {
return Problems.From(http, ValidationError.Single("name", "name or isp is required")); return Problems.From(http, ValidationError.Single("name", "is required"));
} }
if (request.Isp is not null && request.Isp != "auto" && IspClassifier.NameOf(request.Isp) is null) 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);
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) private static async Task<IResult> RevokeProbeAsync(HttpContext http, ProbeService probes, string id, CancellationToken ct)
@@ -25,6 +25,9 @@ public static class BuildEndpoints
group.MapPost("/nodes/{id}/update", RequestUpdateAsync) 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) .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); .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) group.MapGet("/backend/update", BackendUpdateAsync)
.Api("backend_update_get", "Current channel and host-verified backend update", Tag).Returns<BackendUpdateDto>().ProducesProblems(401, 403) .Api("backend_update_get", "Current channel and host-verified backend update", Tag).Returns<BackendUpdateDto>().ProducesProblems(401, 403)
.Requires(Capability.UpdateRead); .Requires(Capability.UpdateRead);
@@ -36,6 +39,15 @@ public static class BuildEndpoints
.Requires(Capability.UpdateRead); .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> /// <summary>
/// CI 登记清单的入口,<b>不需要令牌</b>:请求里的清单必须带有效的 CI 发布签名(用 Backend 内嵌的发布公钥验)才收下。 /// CI 登记清单的入口,<b>不需要令牌</b>:请求里的清单必须带有效的 CI 发布签名(用 Backend 内嵌的发布公钥验)才收下。
/// 这样登记不依赖面板里的任何凭证(清库、重装、还没有 passkey 的时候照常工作),也没有一把能被偷的长期令牌; /// 这样登记不依赖面板里的任何凭证(清库、重装、还没有 passkey 的时候照常工作),也没有一把能被偷的长期令牌;
@@ -48,12 +60,12 @@ public static class BuildEndpoints
.Returns<ReleaseDto>(StatusCodes.Status201Created).ProducesProblems(400, 403, 409); .Returns<ReleaseDto>(StatusCodes.Status201Created).ProducesProblems(400, 403, 409);
} }
private static async Task<IResult> OfferReleaseAsync(HttpContext http, ReleaseService releases, ReleaseKeyService keys, PublishReleaseRequest request) private static async Task<IResult> OfferReleaseAsync(HttpContext http, ReleaseService releases, PublishReleaseRequest request)
{ {
string manifest = request.Manifest ?? ""; string manifest = request.Manifest ?? "";
string signature = request.Signature ?? ""; string signature = request.Signature ?? "";
if (manifest.Length is 0 or > ReleaseService.MaxManifestBytes || signature.Length is 0 or > ReleaseService.MaxSignatureBytes if (manifest.Length is 0 or > ReleaseService.MaxManifestBytes || signature.Length is 0 or > ReleaseService.MaxSignatureBytes
|| !await keys.VerifyAsync(manifest, signature, http.RequestAborted)) || !ReleaseSignature.Verify(InstallScripts.ReleaseKey(), manifest, signature))
{ {
return Problems.Create(http, MoShiroErrors.Forbidden, "the manifest does not carry a valid release signature"); return Problems.Create(http, MoShiroErrors.Forbidden, "the manifest does not carry a valid release signature");
} }
@@ -61,13 +73,14 @@ public static class BuildEndpoints
return await PublishReleaseAsync(http, releases, request); return await PublishReleaseAsync(http, releases, request);
} }
private static async Task<IResult> GetAsync(HttpContext http, NodeInfoService nodes, ReleaseService releases, BackendUpdates backendUpdates, NodeOwnerService owners, CancellationToken ct) private static async Task<IResult> GetAsync(HttpContext http, NodeInfoService nodes, ReleaseService releases, BackendUpdates backendUpdates, BinaryBindingService bindings, NodeOwnerService owners, CancellationToken ct)
{ {
IReadOnlyDictionary<string, IReadOnlyList<NodeOwner>> ownerOf = await owners.ListAsync(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)) var items = (await nodes.ListAsync(ct))
.OrderBy(n => n.Kind, StringComparer.Ordinal) .OrderBy(n => n.Kind, StringComparer.Ordinal)
.ThenBy(n => n.NodeId, 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))), 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))), 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))
.ToList(); .ToList();
http.Response.Headers.CacheControl = "no-store"; http.Response.Headers.CacheControl = "no-store";
return TypedResults.Json(new BuildInfoDto(NodeEndpoints.Backend(withCommit: true), items, ToDto(await backendUpdates.GetAsync(ct))), ApiJsonContext.Default.BuildInfoDto); return TypedResults.Json(new BuildInfoDto(NodeEndpoints.Backend(withCommit: true), items, ToDto(await backendUpdates.GetAsync(ct))), ApiJsonContext.Default.BuildInfoDto);
@@ -125,6 +138,17 @@ 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) => 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); 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);
} }
@@ -20,12 +20,9 @@ public static class LinkEndpoints
group.MapPut("/links/{probe_id}/{agent_id}", SetAsync) group.MapPut("/links/{probe_id}/{agent_id}", SetAsync)
.Api("links_set", "Force a link on or off, or give it back to the automatic setting (enabled: null)", Tag).Returns<LinkDto>().ProducesProblems(400, 401, 403, 404) .Api("links_set", "Force a link on or off, or give it back to the automatic setting (enabled: null)", Tag).Returns<LinkDto>().ProducesProblems(400, 401, 403, 404)
.Requires(Capability.FleetWrite); .Requires(Capability.FleetWrite);
group.MapPost("/links/{probe_id}/{agent_id}/retry", RetryAsync)
.Api("links_retry", "Enable and immediately retry the Probe-to-Agent connection", Tag).Returns<LinkRetryDto>().ProducesProblems(401, 403, 404)
.Requires(Capability.FleetWrite);
} }
private static async Task<IResult> ListAsync(AgentService agents, ProbeService probes, LinkService links, ProbeQuicRuntime quic, TimeProvider time, CancellationToken ct) private static async Task<IResult> ListAsync(AgentService agents, ProbeService probes, LinkService links, CancellationToken ct)
{ {
LinkSnapshot snapshot = await links.SnapshotAsync(ct); LinkSnapshot snapshot = await links.SnapshotAsync(ct);
IReadOnlyList<AgentView> agentViews = await agents.ListAsync(ct); IReadOnlyList<AgentView> agentViews = await agents.ListAsync(ct);
@@ -34,14 +31,14 @@ public static class LinkEndpoints
{ {
foreach (AgentView agent in agentViews.OrderBy(a => a.Agent.Name, StringComparer.Ordinal)) foreach (AgentView agent in agentViews.OrderBy(a => a.Agent.Name, StringComparer.Ordinal))
{ {
items.Add(ToDto(snapshot, probe, agent, quic, time)); items.Add(ToDto(snapshot, probe, agent));
} }
} }
return TypedResults.Json(new LinkMatrixDto(snapshot.Auto, items), ApiJsonContext.Default.LinkMatrixDto); return TypedResults.Json(new LinkMatrixDto(snapshot.Auto, items), ApiJsonContext.Default.LinkMatrixDto);
} }
private static async Task<IResult> SetAsync(HttpContext http, AgentService agents, ProbeService probes, LinkService links, ProbeQuicRuntime quic, TimeProvider time, string probe_id, string agent_id, SetLinkRequest request, CancellationToken ct) private static async Task<IResult> SetAsync(HttpContext http, AgentService agents, ProbeService probes, LinkService links, string probe_id, string agent_id, SetLinkRequest request, CancellationToken ct)
{ {
if (await probes.GetAsync(probe_id, ct) is not { Revoked: false } probe) if (await probes.GetAsync(probe_id, ct) is not { Revoked: false } probe)
{ {
@@ -55,71 +52,10 @@ public static class LinkEndpoints
await links.SetAsync(probe_id, agent_id, request.Enabled, ct); await links.SetAsync(probe_id, agent_id, request.Enabled, ct);
await agents.AfterAgentSetChangedAsync(ct); // 重新下发:Probe 的 Agent 列表、Agent 允许的 Probe await agents.AfterAgentSetChangedAsync(ct); // 重新下发:Probe 的 Agent 列表、Agent 允许的 Probe
if (request.Enabled == true) return TypedResults.Json(ToDto(await links.SnapshotAsync(ct), probe, agent), ApiJsonContext.Default.LinkDto);
{
await agents.NotifyProbeLinkRetryAsync(new NodeId(probe_id), agent.Agent.Id, ct);
}
return TypedResults.Json(ToDto(await links.SnapshotAsync(ct), probe, agent, quic, time), ApiJsonContext.Default.LinkDto);
} }
private static async Task<IResult> RetryAsync(HttpContext http, AgentService agents, ProbeService probes, LinkService links, ProbeQuicRuntime quic, TimeProvider time, string probe_id, string agent_id, CancellationToken ct) private static LinkDto ToDto(LinkSnapshot snapshot, ProbeView probe, AgentView agent) =>
{ new(probe.Probe.Id, probe.Probe.Name, agent.Agent.Id.Value, agent.Agent.Name, snapshot.IsLinked(probe.Probe.Id, agent.Agent.Id.Value), snapshot.OverrideOf(probe.Probe.Id, agent.Agent.Id.Value),
if (await probes.GetAsync(probe_id, ct) is not { Revoked: false } probe) agent.Probes.Any(p => p.Id == probe.Probe.Id));
{
return Problems.Create(http, MoShiroErrors.ProbeNotFound, "probe not found");
}
if (await agents.GetAsync(new NodeId(agent_id), ct) is not { } agent)
{
return Problems.Create(http, MoShiroErrors.AgentNotFound, "agent not found");
}
await links.SetAsync(probe_id, agent_id, true, ct);
await agents.AfterAgentSetChangedAsync(ct);
bool queued = await agents.NotifyProbeLinkRetryAsync(new NodeId(probe_id), agent.Agent.Id, ct);
LinkDto link = ToDto(await links.SnapshotAsync(ct), probe, agent, quic, time);
return TypedResults.Json(new LinkRetryDto(queued, link), ApiJsonContext.Default.LinkRetryDto, statusCode: StatusCodes.Status202Accepted);
}
private static LinkDto ToDto(LinkSnapshot snapshot, ProbeView probe, AgentView agent, ProbeQuicRuntime quicRuntime, TimeProvider time)
{
bool linked = snapshot.IsLinked(probe.Probe.Id, agent.Agent.Id.Value);
ProbeQuicLinkSnapshot? quicSnapshot = quicRuntime.Get(probe.Probe.Id, agent.Agent.Id.Value, IpVersions.V4);
ProbeQuicLinkHealth? health = quicSnapshot?.Health;
bool fresh = quicSnapshot is not null && time.GetUtcNow().ToUnixTimeMilliseconds() - quicSnapshot.AtMs <= 30_000;
string state;
string? reason = null;
if (!linked) { state = "disabled"; reason = "线路策略已关闭"; }
else if (probe.RuntimeStatus != NodeRuntimeStatus.Running) { state = "probe_unavailable"; reason = $"Probe 状态:{probe.RuntimeStatus}"; }
else if (agent.RuntimeStatus != NodeRuntimeStatus.Running) { state = "agent_unavailable"; reason = $"Agent 状态:{agent.RuntimeStatus}"; }
else if (health is null || !fresh)
{
state = "waiting_report";
reason = health is null ? "Probe 尚未上报此线路的 QUIC 拨号状态" : "Probe 的线路状态上报已过期";
}
else if (health.Connected && agent.Probes.Any(p => p.Id == probe.Probe.Id))
{
state = "connected";
}
else if (health.State == ProbeLinkAttemptState.RetryWait)
{
state = "retry_wait";
reason = health.LastError is { Length: > 0 } error ? $"QUIC 连接失败:{error}" : "QUIC 连接失败,等待自动重试";
}
else if (agent.Probes.All(p => p.Id != probe.Probe.Id))
{
state = "control_disconnected";
reason = health.LastError ?? "Probe 尚未建立到 Agent 的测量控制会话";
}
else
{
state = "quic_connecting";
reason = health.LastError ?? "控制会话已建立,正在建立 QUIC 测量会话";
}
bool connected = state == "connected";
return new LinkDto(probe.Probe.Id, probe.Probe.Name, agent.Agent.Id.Value, agent.Agent.Name, linked,
snapshot.OverrideOf(probe.Probe.Id, agent.Agent.Id.Value), connected, probe.RuntimeStatus, agent.RuntimeStatus,
state, reason, health?.LastError, health?.RetryAttempt ?? 0, health?.RetryInMs);
}
} }
public sealed record LinkRetryDto(bool Queued, LinkDto Link);
@@ -1,54 +0,0 @@
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; namespace MoShiro.Backend.Api;
/// <summary> /// <summary>
/// 信任根(docs/security/access.md 第 8 节):操作员集合由 passkey 签名,节点从安装命令里固定的创世指纹出发验证它; /// 信任根(docs/access.md 第 7 节):操作员集合由 passkey 签名,节点从安装命令里固定的创世指纹出发验证它;
/// 带操作员委托的加入码也在这里创建——委托是 passkey 对「这个码」签的,面板自己签不出来。 /// 带操作员委托的加入码也在这里创建——委托是 passkey 对「这个码」签的,面板自己签不出来。
/// </summary> /// </summary>
public static class TrustEndpoints public static class TrustEndpoints
+2 -1
View File
@@ -105,6 +105,8 @@ public static class ApiModule
RouteGroupBuilder admin = app.MapGroup("/api/admin"); RouteGroupBuilder admin = app.MapGroup("/api/admin");
AuthEndpoints.MapLogin(admin); AuthEndpoints.MapLogin(admin);
BuildEndpoints.MapPublish(admin.MapGroup(string.Empty).AddEndpointFilter(CapabilityGuard.InvokeAsync));
RouteGroupBuilder guarded = admin.MapGroup(string.Empty).AddEndpointFilter(CapabilityGuard.InvokeAsync); RouteGroupBuilder guarded = admin.MapGroup(string.Empty).AddEndpointFilter(CapabilityGuard.InvokeAsync);
AuthEndpoints.MapSession(guarded); AuthEndpoints.MapSession(guarded);
AgentEndpoints.Map(guarded); AgentEndpoints.Map(guarded);
@@ -114,7 +116,6 @@ public static class ApiModule
TrustEndpoints.Map(guarded); TrustEndpoints.Map(guarded);
SystemEndpoints.Map(guarded); SystemEndpoints.Map(guarded);
BuildEndpoints.Map(guarded); BuildEndpoints.Map(guarded);
ReleaseKeyEndpoints.Map(guarded);
LinkEndpoints.Map(guarded); LinkEndpoints.Map(guarded);
OpsEndpoints.Map(app); OpsEndpoints.Map(app);
@@ -31,28 +31,7 @@ public static class Mappers
public static OverviewLineDto ToDto(OverviewLine line) => public static OverviewLineDto ToDto(OverviewLine line) =>
new( 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.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, ToDto(line.Line.QuicHealth)); line.Line.Agent.Online, line.Line.Connected, line.Line.Agent.EndpointChanges, ToDto(line.Summary), Summary(line.LastTest), line.Line.IpVersion);
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), health.State switch
{
ProbeLinkAttemptState.Connected => "connected",
ProbeLinkAttemptState.RetryWait => "retry_wait",
_ => "connecting",
}, health.RetryAttempt, health.RetryInMs);
}
public static HostDto ToDto(HostSnapshot h) => 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)); 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));
@@ -62,7 +41,7 @@ public static class Mappers
public static ProbeDto ToDto(ProbeView v) => public static ProbeDto ToDto(ProbeView v) =>
new( new(
v.Probe.Id, v.Probe.Name, Time(v.Probe.CreatedAtMs), Time(v.Probe.RevokedAtMs), v.Revoked, v.Online, v.Probe.Id, v.Probe.Name, Time(v.Probe.CreatedAtMs), Time(v.Probe.RevokedAtMs), v.Revoked, v.Online,
[.. v.Connections.Select(c => new ProbeConnectionDto(c.AgentId.Value, c.AgentName, c.AgentOnline, c.Version, c.Remote, Time(c.SinceMs), c.IpVersion))], v.RuntimeStatus, ProbeIpv6Of(v.Probe), v.Probe.AddedBy); [.. v.Connections.Select(c => new ProbeConnectionDto(c.AgentId.Value, c.AgentName, c.AgentOnline, c.Version, c.Remote, Time(c.SinceMs), c.IpVersion))], ProbeIpv6Of(v.Probe), v.Probe.AddedBy);
public static SeriesDto ToDto(SeriesResult r, bool live) => public static SeriesDto ToDto(SeriesResult r, bool live) =>
new( new(
@@ -123,8 +102,8 @@ public static class Mappers
? new HeartbeatDto(h.Sessions, h.PpsIn, h.PpsOut, h.DroppedInvalid, h.DroppedRateLimited, h.QueueBytes, Time(view.HeartbeatAtMs ?? 0)) ? new HeartbeatDto(h.Sessions, h.PpsIn, h.PpsOut, h.DroppedInvalid, h.DroppedRateLimited, h.QueueBytes, Time(view.HeartbeatAtMs ?? 0))
: null; : null;
return new AgentDto( 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.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.Labels, a.PublicVisible,
a.Kinds, a.AgentVersion, limits, a.TaskVersion, a.AppliedTaskVersion, rejected, heartbeat, view.EndpointChanges, view.RuntimeStatus, Time(a.CreatedAtMs), Time(a.LastSeenAtMs), a.Ipv6Host, a.Ipv6Supported, Ipv6Of(view), !a.PublicHostManual, !a.Ipv6HostManual, a.AddedBy); 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);
} }
public static Ipv6DiagnosticDto Ipv6Of(AgentView view) public static Ipv6DiagnosticDto Ipv6Of(AgentView view)
@@ -1,6 +1,5 @@
using Fleet.Core; using Fleet.Core;
using System.Text.Json; using System.Text.Json;
using MoShiro.Protocol;
namespace MoShiro.Backend.Api; namespace MoShiro.Backend.Api;
@@ -43,7 +42,6 @@ public sealed record AgentDto(
string PublicHost, string PublicHost,
int ProbeTcpPort, int ProbeTcpPort,
IReadOnlyList<int> ProbeUdpPorts, IReadOnlyList<int> ProbeUdpPorts,
IReadOnlyList<int> ProbeQuicPorts,
IReadOnlyList<string> Labels, IReadOnlyList<string> Labels,
bool PublicVisible, bool PublicVisible,
IReadOnlyList<string> Kinds, IReadOnlyList<string> Kinds,
@@ -54,7 +52,6 @@ public sealed record AgentDto(
IReadOnlyList<RejectedDto> Rejected, IReadOnlyList<RejectedDto> Rejected,
HeartbeatDto? Heartbeat, HeartbeatDto? Heartbeat,
int EndpointChanges, int EndpointChanges,
NodeRuntimeStatus RuntimeStatus,
DateTimeOffset CreatedAt, DateTimeOffset CreatedAt,
DateTimeOffset? LastSeenAt, DateTimeOffset? LastSeenAt,
string Ipv6Host = "", string Ipv6Host = "",
@@ -86,20 +83,6 @@ public sealed record SetupRequest(string? ClaimCode, string? Username, string? P
public sealed record InstallCommandsDto(string Linux, string Macos, string Windows); 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> /// <summary>下一版里新增的 passkey。</summary>
public sealed record ProposedCredentialDto(string Id, string Name, DateTimeOffset CreatedAt); public sealed record ProposedCredentialDto(string Id, string Name, DateTimeOffset CreatedAt);
@@ -117,7 +100,7 @@ public sealed record TrustProposalDto(string Statement, int Version, string Chal
/// <param name="InstallMatchesRelease">和 CI 签名的发布清单里记的哈希是否一致;没有清单或清单里没记时为空。不一致说明这个面板提供的脚本不是 CI 构建出来的那份。</param> /// <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 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, ComposeInstallDto Compose, QuadletInstallDto Quadlet); public sealed record CreatedJoinTokenDto(JoinTokenDto Token, string Plaintext, InstallCommandsDto InstallCommands);
/// <summary>Agent 眼中连着的一个 Probe(Agent 上报)。</summary> /// <summary>Agent 眼中连着的一个 Probe(Agent 上报)。</summary>
public sealed record ConnectedProbeDto(string ProbeId, string? Version, string? Remote, DateTimeOffset ConnectedSince, string IpVersion = "ipv4"); public sealed record ConnectedProbeDto(string ProbeId, string? Version, string? Remote, DateTimeOffset ConnectedSince, string IpVersion = "ipv4");
@@ -125,17 +108,16 @@ public sealed record ConnectedProbeDto(string ProbeId, string? Version, string?
/// <summary>某个 Probe 现在连着的一个 Agent。</summary> /// <summary>某个 Probe 现在连着的一个 Agent。</summary>
public sealed record ProbeConnectionDto(string AgentId, string AgentName, bool AgentOnline, string? Version, string? Remote, DateTimeOffset ConnectedSince, string IpVersion = "ipv4"); public sealed record ProbeConnectionDto(string AgentId, string AgentName, bool AgentOnline, string? Version, string? Remote, DateTimeOffset ConnectedSince, string IpVersion = "ipv4");
public sealed record ProbeDto(string Id, string Name, DateTimeOffset CreatedAt, DateTimeOffset? RevokedAt, bool Revoked, bool Online, IReadOnlyList<ProbeConnectionDto> Connections, NodeRuntimeStatus RuntimeStatus, Ipv6DiagnosticDto? Ipv6 = null, string? AddedBy = null); public sealed record ProbeDto(string Id, string Name, DateTimeOffset CreatedAt, DateTimeOffset? RevokedAt, bool Revoked, bool Online, IReadOnlyList<ProbeConnectionDto> Connections, Ipv6DiagnosticDto? Ipv6 = null, string? AddedBy = null);
/// <summary>一对(Probe,Agent)的线路开关:<c>linked</c> 是最终结果,<c>override</c> 是手动设置(<c>null</c> = 跟随自动连接),<c>connected</c> 是现在有没有连着。</summary> /// <summary>一对(Probe,Agent)的线路开关:<c>linked</c> 是最终结果,<c>override</c> 是手动设置(<c>null</c> = 跟随自动连接),<c>connected</c> 是现在有没有连着。</summary>
public sealed record LinkDto(string ProbeId, string ProbeName, string AgentId, string AgentName, bool Linked, bool? Override, bool Connected, NodeRuntimeStatus ProbeRuntimeStatus, NodeRuntimeStatus AgentRuntimeStatus, string ConnectionState = "connecting", string? ConnectionReason = null, string? LastError = null, int RetryAttempt = 0, long? RetryInMs = null); public sealed record LinkDto(string ProbeId, string ProbeName, string AgentId, string AgentName, bool Linked, bool? Override, bool Connected);
public sealed record LinkMatrixDto(bool AutoLink, IReadOnlyList<LinkDto> Items); public sealed record LinkMatrixDto(bool AutoLink, IReadOnlyList<LinkDto> Items);
public sealed record SetLinkRequest(bool? Enabled); public sealed record SetLinkRequest(bool? Enabled);
/// <summary>Isp:telecom / unicom / mobile / broadnet / other 是手动指定,auto 是回到自动识别;不传就不改。</summary> public sealed record PatchProbeRequest(string? Name);
public sealed record PatchProbeRequest(string? Name, string? Isp = null);
public sealed record TaskDto( public sealed record TaskDto(
string Id, string Id,
@@ -203,10 +185,13 @@ 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); public sealed record RecomputeDto(string State, IReadOnlyList<string> Metrics, long WindowsDone, long WindowsTotal, DateTimeOffset? From, DateTimeOffset? To, int QueueLength);
/// <summary>管理端看的构建信息:每个节点自报的版本、tag、提交和构建时间,以及系统。</summary> /// <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> /// <summary>节点的一个属主:节点报上来的(安装时固定在节点本地的)passkey 凭证 id,以及它在面板上对应的 passkey 名称和账户(对不上任何已注册的 passkey 时为空)。</summary>
public sealed record NodeOwnerDto(string CredentialId, string? Passkey, string? Account); 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, 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, NodeBinaryDto? Binary = 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> /// <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); public sealed record NodeUpdateDto(string State, string? CurrentDigest, string? AvailableTag, string? AvailableVersion, string? Error, DateTimeOffset? At);
@@ -216,14 +201,6 @@ public sealed record ReleaseDto(string Component, long Seq, string Tag, string V
public sealed record PublishReleaseRequest(string? Manifest, string? Signature); 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 BackendUpdateDto(string Channel, bool Enabled, string State, string? CurrentTag, string? CurrentDigest, ReleaseDto? Available, string? Error);
public sealed record ConfirmBackendUpdateRequest(long Seq); public sealed record ConfirmBackendUpdateRequest(long Seq);
@@ -22,7 +22,6 @@ namespace MoShiro.Backend.Api;
[JsonSerializable(typeof(DistributionDto))] [JsonSerializable(typeof(DistributionDto))]
[JsonSerializable(typeof(HeatmapDto))] [JsonSerializable(typeof(HeatmapDto))]
[JsonSerializable(typeof(LineDto))] [JsonSerializable(typeof(LineDto))]
[JsonSerializable(typeof(QuicLineHealthDto))]
[JsonSerializable(typeof(TestDto))] [JsonSerializable(typeof(TestDto))]
[JsonSerializable(typeof(PageDto<TestDto>))] [JsonSerializable(typeof(PageDto<TestDto>))]
[JsonSerializable(typeof(PageDto<KindDto>))] [JsonSerializable(typeof(PageDto<KindDto>))]
@@ -41,16 +40,14 @@ namespace MoShiro.Backend.Api;
[JsonSerializable(typeof(AgentPatchRequest))] [JsonSerializable(typeof(AgentPatchRequest))]
[JsonSerializable(typeof(LinkDto))] [JsonSerializable(typeof(LinkDto))]
[JsonSerializable(typeof(LinkMatrixDto))] [JsonSerializable(typeof(LinkMatrixDto))]
[JsonSerializable(typeof(LinkRetryDto))]
[JsonSerializable(typeof(SetLinkRequest))] [JsonSerializable(typeof(SetLinkRequest))]
[JsonSerializable(typeof(BuildDto))] [JsonSerializable(typeof(BuildDto))]
[JsonSerializable(typeof(ReleaseDto))] [JsonSerializable(typeof(ReleaseDto))]
[JsonSerializable(typeof(PageDto<ReleaseDto>))] [JsonSerializable(typeof(PageDto<ReleaseDto>))]
[JsonSerializable(typeof(PublishReleaseRequest))] [JsonSerializable(typeof(PublishReleaseRequest))]
[JsonSerializable(typeof(ReleaseKeysDto))]
[JsonSerializable(typeof(SetReleaseKeyRequest))]
[JsonSerializable(typeof(AboutDto))] [JsonSerializable(typeof(AboutDto))]
[JsonSerializable(typeof(NodeBuildDto))] [JsonSerializable(typeof(NodeBuildDto))]
[JsonSerializable(typeof(NodeBinaryDto))]
[JsonSerializable(typeof(NodeOwnerDto))] [JsonSerializable(typeof(NodeOwnerDto))]
[JsonSerializable(typeof(BuildInfoDto))] [JsonSerializable(typeof(BuildInfoDto))]
[JsonSerializable(typeof(BackendUpdateDto))] [JsonSerializable(typeof(BackendUpdateDto))]
@@ -88,8 +85,6 @@ namespace MoShiro.Backend.Api;
[JsonSerializable(typeof(TrustStateDto))] [JsonSerializable(typeof(TrustStateDto))]
[JsonSerializable(typeof(SetupStatusDto))] [JsonSerializable(typeof(SetupStatusDto))]
[JsonSerializable(typeof(SetupRequest))] [JsonSerializable(typeof(SetupRequest))]
[JsonSerializable(typeof(ComposeInstallDto))]
[JsonSerializable(typeof(QuadletInstallDto))]
[JsonSerializable(typeof(ProposedCredentialDto))] [JsonSerializable(typeof(ProposedCredentialDto))]
[JsonSerializable(typeof(PasskeyDto))] [JsonSerializable(typeof(PasskeyDto))]
[JsonSerializable(typeof(PageDto<PasskeyDto>))] [JsonSerializable(typeof(PageDto<PasskeyDto>))]
@@ -28,30 +28,7 @@ public sealed record OverviewLineDto(
int EndpointChanges, int EndpointChanges,
SummaryDto Summary, SummaryDto Summary,
TestSummaryDto? LastTest, 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,
string State = "connecting",
int RetryAttempt = 0,
long? RetryInMs = null);
public sealed record OverviewDto(string Range, DateTimeOffset From, DateTimeOffset To, string Step, string Source, bool Degraded, IReadOnlyList<OverviewLineDto> Lines); public sealed record OverviewDto(string Range, DateTimeOffset From, DateTimeOffset To, string Step, string Source, bool Degraded, IReadOnlyList<OverviewLineDto> Lines);
@@ -113,8 +90,7 @@ public sealed record LineDto(
IReadOnlyList<string> Kinds, IReadOnlyList<string> Kinds,
TestSummaryDto? LastTest, TestSummaryDto? LastTest,
string IpVersion = "ipv4", 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); public sealed record DimensionDto(string Id, string Label, double Weight, double? Value, double? Score);
@@ -201,9 +177,9 @@ public sealed record HostDto(
long? UptimeSeconds, long? UptimeSeconds,
DateTimeOffset At); DateTimeOffset At);
/// <summary>一个节点(Agent 或 Probe)的最新主机状态;<c>stale</c> 表示超过 15 秒没有更新(离线,或者节点关闭了上报)。</summary> /// <summary>一个 Agent 的最新主机状态;<c>stale</c> 表示超过 15 秒没有更新(离线,或者 Agent 关闭了上报)。</summary>
/// <summary>节点的展示信息:只有系统、架构、国家代码、运营商,没有任何地址;站点打开「显示构建信息」之后还有版本和 tag。</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, bool IspManual = false); 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);
/// <summary>一个程序的构建信息:版本、tag、提交(短)和构建时间(UTC,ISO 8601)。没设置的是 <c>null</c>。</summary> /// <summary>一个程序的构建信息:版本、tag、提交(短)和构建时间(UTC,ISO 8601)。没设置的是 <c>null</c>。</summary>
public sealed record BuildDto(string Version, string? Tag, string? Commit, string? BuiltAt); public sealed record BuildDto(string Version, string? Tag, string? Commit, string? BuiltAt);
@@ -211,10 +187,9 @@ public sealed record BuildDto(string Version, string? Tag, string? Commit, strin
/// <summary>公开的「关于」:站点没有打开「显示构建信息」时只有 <c>show_build: false</c>。</summary> /// <summary>公开的「关于」:站点没有打开「显示构建信息」时只有 <c>show_build: false</c>。</summary>
public sealed record AboutDto(bool ShowBuild, BuildDto? Backend); public sealed record AboutDto(bool ShowBuild, BuildDto? Backend);
/// <param name="Kind"><c>agent</c> 或 <c>probe</c>。</param> public sealed record HostItemDto(string AgentId, string Name, bool Online, bool Stale, DateTimeOffset? LastSeenAt, HostDto? Host);
public sealed record HostItemDto(string Id, string Kind, string Name, bool Online, bool Stale, DateTimeOffset? LastSeenAt, HostDto? Host);
public sealed record LiveHostDto(string Id, string Kind, bool Online, HostDto Host); public sealed record LiveHostDto(string AgentId, bool Online, HostDto Host);
/// <summary>主机资源占用的历史(列式,按步长取平均)。</summary> /// <summary>主机资源占用的历史(列式,按步长取平均)。</summary>
public sealed record HostSeriesDto(string Id, DateTimeOffset From, DateTimeOffset To, string Step, IReadOnlyList<long> T, IReadOnlyDictionary<string, double?[]> Values); public sealed record HostSeriesDto(string AgentId, DateTimeOffset From, DateTimeOffset To, string Step, IReadOnlyList<long> T, IReadOnlyDictionary<string, double?[]> Values);
@@ -6,7 +6,7 @@ namespace MoShiro.Backend.Api;
/// <summary> /// <summary>
/// 一行安装脚本:<c>packaging/install/</c> 下的 <c>install.sh</c> 和 <c>install.ps1</c> 作为嵌入资源打进 Backend,<b>原样返回,不替换任何值</b>。 /// 一行安装脚本:<c>packaging/install/</c> 下的 <c>install.sh</c> 和 <c>install.ps1</c> 作为嵌入资源打进 Backend,<b>原样返回,不替换任何值</b>。
/// 静态,所以它的 SHA-256 在一个版本内固定:CI 构建时算出来写进签名的发布清单,面板给的命令先核对哈希再执行(docs/security/access.md 第 10 节)。 /// 静态,所以它的 SHA-256 在一个版本内固定:CI 构建时算出来写进签名的发布清单,面板给的命令先核对哈希再执行(docs/access.md 第 15 节)。
/// 角色、面板地址、代码都走命令里的环境变量。 /// 角色、面板地址、代码都走命令里的环境变量。
/// </summary> /// </summary>
public static class InstallScripts public static class InstallScripts
@@ -19,18 +19,6 @@ public static class InstallScripts
public static string Script(string resource) => Read(resource); 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> /// <summary>脚本内容的 SHA-256(小写十六进制,按 UTF-8 字节)。</summary>
public static string Sha256(string resource) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(Read(resource)))); public static string Sha256(string resource) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(Read(resource))));
@@ -10,11 +10,6 @@
<EmbeddedResource Include="..\..\..\packaging\install\install.sh" LogicalName="install.sh" /> <EmbeddedResource Include="..\..\..\packaging\install\install.sh" LogicalName="install.sh" />
<EmbeddedResource Include="..\..\..\packaging\install\install.ps1" LogicalName="install.ps1" /> <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\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> </ItemGroup>
<!-- 前端构建产物(web 目录下 npm run build)内嵌进 Backend;没构建时不嵌入,Backend 只提供 API --> <!-- 前端构建产物(web 目录下 npm run build)内嵌进 Backend;没构建时不嵌入,Backend 只提供 API -->
<ItemGroup Condition="Exists('..\..\..\web\display\dist\index.html')"> <ItemGroup Condition="Exists('..\..\..\web\display\dist\index.html')">
@@ -33,15 +33,6 @@ 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)) 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) .Api("install_manifest_signature", "Base64 DER ECDSA P-256 signature of the manifest", Tag)
.Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(404); .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")) 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) .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"); .Produces(StatusCodes.Status200OK, contentType: "text/plain");
@@ -49,8 +40,9 @@ public static class OpsEndpoints
private static async Task<IResult> ManifestAsync(HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code, bool signature) private static async Task<IResult> ManifestAsync(HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code, bool signature)
{ {
string? role = string.IsNullOrEmpty(code) ? null : await tokens.RoleOfCodeAsync(code, http.RequestAborted); string baseUrl = options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}";
ReleaseOffer? offer = role is null ? null : await releases.GetOrFetchAsync(role, http.RequestAborted); 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);
if (offer is null) if (offer is null)
{ {
return Problems.Create(http, MoShiroErrors.NotFound, "no signed release is registered for this install code"); return Problems.Create(http, MoShiroErrors.NotFound, "no signed release is registered for this install code");
@@ -7,27 +7,22 @@ using MoShiro.Protocol;
namespace MoShiro.Backend.Api; namespace MoShiro.Backend.Api;
/// <summary> /// <summary>Agent 的主机资源占用:最新值(实时的部分走 SSE 的 <c>hosts</c> 主题)和历史曲线。只有汇总的数字,没有型号和 IP。</summary>
/// Agent 和 Probe 的主机资源占用:最新值(实时的部分走 SSE 的 <c>hosts</c> 主题)和历史曲线。只有汇总的数字,没有型号和 IP。
/// 和别的公开接口一样:Agent 看得见才给(隐藏的只有登录后才看得见),Probe 没被吊销就给。
/// </summary>
public static class HostEndpoints public static class HostEndpoints
{ {
private const string Tag = "public"; private const string Tag = "public";
private const string AgentKind = "agent";
private const string ProbeKind = "probe";
public static void Map(RouteGroupBuilder group) public static void Map(RouteGroupBuilder group)
{ {
group.MapGet("/hosts", ListAsync) group.MapGet("/hosts", ListAsync)
.Api("hosts_list", "Latest host resource usage of every visible agent and probe (no hardware model, no addresses)", Tag) .Api("hosts_list", "Latest host resource usage of every visible agent (no hardware model, no addresses)", Tag)
.Returns<PageDto<HostItemDto>>().ProducesProblems(403); .Returns<PageDto<HostItemDto>>().ProducesProblems(403);
group.MapGet("/hosts/{node_id}", HistoryAsync) group.MapGet("/agents/{agent_id}/host", HistoryAsync)
.Api("hosts_history", "Host resource usage history of one agent or probe (columnar, averaged per step)", Tag) .Api("hosts_history", "Host resource usage history of one agent (columnar, averaged per step)", Tag)
.Returns<HostSeriesDto>().ProducesProblems(403, 404, 422); .Returns<HostSeriesDto>().ProducesProblems(403, 404, 422);
} }
private static async Task<IResult> ListAsync(HttpContext http, AgentService agents, ProbeService probes, HostStatusService hosts, CancellationToken ct) private static async Task<IResult> ListAsync(HttpContext http, AgentService agents, HostStatusService hosts, CancellationToken ct)
{ {
var items = new List<HostItemDto>(); var items = new List<HostItemDto>();
foreach (AgentView view in await agents.ListAsync(ct)) foreach (AgentView view in await agents.ListAsync(ct))
@@ -38,39 +33,18 @@ public static class HostEndpoints
} }
(HostSnapshot? host, bool stale) = hosts.Latest(view.Agent.Id); (HostSnapshot? host, bool stale) = hosts.Latest(view.Agent.Id);
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))); 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)));
}
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); return TypedResults.Json(new PageDto<HostItemDto>(items, null), ApiJsonContext.Default.PageDtoHostItemDto);
} }
private static async Task<IResult> HistoryAsync(HttpContext http, AgentService agents, ProbeService probes, HostStatusService hosts, TimeProvider time, string node_id, string? range, CancellationToken ct) private static async Task<IResult> HistoryAsync(HttpContext http, AgentService agents, HostStatusService hosts, TimeProvider time, string agent_id, string? range, CancellationToken ct)
{ {
bool visible; AgentView? view = await agents.GetAsync(new NodeId(agent_id), ct);
if (node_id.StartsWith(AgentIds.Prefix, StringComparison.Ordinal)) if (view is not { Agent.Status: Fleet.Server.NodeStatus.Active } || !(view.Agent.PublicVisible || PublicGuard.CanSeeHidden(http)))
{ {
AgentView? view = await agents.GetAsync(new NodeId(node_id), ct); return Problems.Create(http, MoShiroErrors.AgentNotFound, "agent not found");
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) if (!RangeResolver.TryParseSpan(range ?? "1h", out long span) || span < Steps.Hour || span > 30 * Steps.Day)
@@ -80,7 +54,7 @@ public static class HostEndpoints
long now = time.GetUtcNow().ToUnixTimeMilliseconds(); long now = time.GetUtcNow().ToUnixTimeMilliseconds();
long from = now - span; long from = now - span;
IReadOnlyList<HostSample> samples = await hosts.HistoryAsync(new NodeId(node_id), from, now + 60_000, ct); IReadOnlyList<HostSample> samples = await hosts.HistoryAsync(view.Agent.Id, from, now + 60_000, ct);
// 最多约 300 个点:按分钟的整数倍取步长,每个步长内取平均 // 最多约 300 个点:按分钟的整数倍取步长,每个步长内取平均
long stepMs = Math.Max(60_000, (long)Math.Ceiling(span / 300.0 / 60_000) * 60_000); long stepMs = Math.Max(60_000, (long)Math.Ceiling(span / 300.0 / 60_000) * 60_000);
@@ -118,6 +92,6 @@ public static class HostEndpoints
["rx_bps"] = Average(s => s.NetRxBps), ["rx_bps"] = Average(s => s.NetRxBps),
["tx_bps"] = Average(s => s.NetTxBps), ["tx_bps"] = Average(s => s.NetTxBps),
}; };
return TypedResults.Json(new HostSeriesDto(node_id, Mappers.Time(first), Mappers.Time(now), Steps.Format(stepMs), t, values), ApiJsonContext.Default.HostSeriesDto); return TypedResults.Json(new HostSeriesDto(view.Agent.Id.Value, Mappers.Time(first), Mappers.Time(now), Steps.Format(stepMs), t, values), ApiJsonContext.Default.HostSeriesDto);
} }
} }
Loaded 100 of 326 files, more files were not shown because too many files have changed in this diff. Show more