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
534 changed files with 12590 additions and 27323 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/index-backend-image.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
packaging/install/*.sh text eol=lf
packaging/install/*.ps1 text eol=lf
*.sh text eol=lf
*.init text eol=lf
*.service text eol=lf
*.container text eol=lf
*.Containerfile text eol=lf
*.ps1 text eol=lf
*.yml text eol=lf
*.md text eol=lf
-46
View File
@@ -1,46 +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]"'
# 后端镜像:和 dev-image.yml(Agent 镜像)并行,各自的 restore 只还原自己要用的依赖。构建好之后、推送之前由 ci/wait-for-checks.sh 等 verify 和 web-build。
# 前端用 web-build workflow 的产物(ci/shared-web.sh)。
concurrency: 1
steps:
- name: restore-linux-dependencies
image: bash
environment:
NUGET_PACKAGES: /home/justme/woodpecker/cache/nuget/packages
commands:
- python3 ci/guard-step.py restore-backend-dependencies 1800 bash ci/restore-linux-dependencies.sh dev-backend
depends_on: []
- 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:
- python3 ci/guard-step.py build-linux-amd64-backend 1800 bash ci/build-dev-image.sh backend
depends_on:
- restore-linux-dependencies
-90
View File
@@ -1,90 +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]"'
# Agent 镜像:三个 RID 的 Agent 构建,构建完发布。后端镜像在 dev-backend-image.yml 里并行构建:Woodpecker 一个 workflow 里的步骤按依赖深度分层,
# 下一层要等上一层全部结束,发布放在后端构建(最慢)后面会白等。
# 镜像构建和 verify(后端快速测试)、web-build 并行跑,不再等它们:构建好之后、推送之前由 ci/wait-for-checks.sh 等这两个 workflow 都成功
# (任何一个失败就不推;提交信息写 [SKIP TESTS] 只跳过后端测试,verify 照样是成功)。机器有 6 个 workflow 槽(WOODPECKER_MAX_WORKFLOWS),四个同时跑放得下。
concurrency: 1
steps:
- name: restore-linux-dependencies
image: bash
environment:
NUGET_PACKAGES: /home/justme/woodpecker/cache/nuget/packages
commands:
- python3 ci/guard-step.py restore-linux-dependencies 3600 bash ci/restore-linux-dependencies.sh dev-agent
depends_on: []
- name: build-arm64-agent-musl
image: bash
environment:
GITEA_BASE: http://127.0.0.1:3100
GITEA_TOKEN:
from_secret: gitea_token
RELEASE_SIGNING_KEY:
from_secret: release_signing_key
commands:
- python3 ci/guard-step.py build-arm64-agent-musl 3600 bash ci/build-linux-agent.sh linux-musl-arm64
depends_on:
- restore-linux-dependencies
- name: build-amd64-agent
image: bash
environment:
GITEA_BASE: http://127.0.0.1:3100
GITEA_TOKEN:
from_secret: gitea_token
RELEASE_SIGNING_KEY:
from_secret: release_signing_key
commands:
- python3 ci/guard-step.py build-amd64-agent 3600 bash ci/build-linux-agent.sh linux-x64
depends_on:
- restore-linux-dependencies
- name: build-arm64-agent
image: bash
environment:
GITEA_BASE: http://127.0.0.1:3100
GITEA_TOKEN:
from_secret: gitea_token
RELEASE_SIGNING_KEY:
from_secret: release_signing_key
commands:
- python3 ci/guard-step.py build-arm64-agent 3600 bash ci/build-linux-agent.sh linux-arm64
depends_on:
- restore-linux-dependencies
- name: publish-agent-images
image: bash
environment:
GITEA_BASE: http://127.0.0.1:3100
GITEA_TOKEN:
from_secret: gitea_token
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:
- python3 ci/guard-step.py publish-agent-images 900 bash ci/release-node-images.sh
depends_on:
- build-amd64-agent
- build-arm64-agent-musl
-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] 的推送只出镜像,不跑慢测试;这个 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
- python3 ci/guard-step.py backend-e2e-tests 1500 bash ci/verify-backend.sh slow
+4 -5
View File
@@ -14,11 +14,10 @@ when:
depends_on:
- linux-release
- linux-agent
- windows-release
steps:
- name: publish-agent-oci
- name: publish-agent-probe-oci
image: bash
environment:
GITEA_BASE: http://127.0.0.1:3100
@@ -32,7 +31,7 @@ steps:
RELEASE_SIGNING_KEY:
from_secret: release_signing_key
commands:
- python3 ci/guard-step.py publish-agent-oci 900 bash ci/release-node-images.sh
- bash ci/release-node-images.sh
depends_on: []
- name: publish-gitea-release
@@ -44,6 +43,6 @@ steps:
RELEASE_SIGNING_KEY:
from_secret: release_signing_key
commands:
- python3 ci/guard-step.py publish-gitea-release 1800 bash ci/publish-gitea-release.sh
- bash ci/publish-gitea-release.sh
depends_on:
- publish-agent-oci
- publish-agent-probe-oci
-64
View File
@@ -1,64 +0,0 @@
labels:
ci: domain-linux-native
platform: linux/amd64
clone:
- name: git
image: plugin-git
settings:
lfs: false
tags: true
when:
- event: tag
ref: refs/tags/v*
concurrency: 1
steps:
- name: restore-linux-dependencies
image: bash
environment:
NUGET_PACKAGES: /home/justme/woodpecker/cache/nuget/packages
commands:
- python3 ci/guard-step.py restore-linux-dependencies 3600 bash ci/restore-linux-dependencies.sh agent
depends_on: []
- name: build-arm64-agent-musl
image: bash
environment:
GITEA_BASE: http://127.0.0.1:3100
GITEA_TOKEN:
from_secret: gitea_token
RELEASE_SIGNING_KEY:
from_secret: release_signing_key
commands:
- python3 ci/guard-step.py build-arm64-agent-musl 3600 bash ci/build-linux-agent.sh linux-musl-arm64
depends_on:
- restore-linux-dependencies
- name: build-amd64-agent
image: bash
environment:
GITEA_BASE: http://127.0.0.1:3100
GITEA_TOKEN:
from_secret: gitea_token
RELEASE_SIGNING_KEY:
from_secret: release_signing_key
commands:
- python3 ci/guard-step.py build-amd64-agent 3600 bash ci/build-linux-agent.sh linux-x64
depends_on:
- restore-linux-dependencies
- name: build-arm64-agent
image: bash
environment:
GITEA_BASE: http://127.0.0.1:3100
GITEA_TOKEN:
from_secret: gitea_token
RELEASE_SIGNING_KEY:
from_secret: release_signing_key
commands:
- python3 ci/guard-step.py build-arm64-agent 3600 bash ci/build-linux-agent.sh linux-arm64
depends_on:
- restore-linux-dependencies
+9 -18
View File
@@ -22,44 +22,35 @@ steps:
environment:
NPM_CONFIG_CACHE: /home/justme/.npm
commands:
# 版本取自 tag:先写进工作区(仓库里的版本号只是占位),前端才会带上对的版本
- python3 ci/guard-step.py web-build 900 bash ci/build-release-web.sh
- npm ci --prefix web --prefer-offline
- timeout 600 npm run build --prefix web
depends_on: []
- name: restore-linux-dependencies
image: bash
environment:
NUGET_PACKAGES: /home/justme/woodpecker/cache/nuget/packages
commands:
- python3 ci/guard-step.py restore-linux-dependencies 1800 bash ci/restore-linux-dependencies.sh release
depends_on:
- web-build
- name: linux-amd64-backend
image: bash
environment:
NUGET_PACKAGES: /home/justme/woodpecker/cache/nuget/packages
NUGET_PACKAGES: /home/justme/.nuget/packages
ZOT_REGISTRY: registry.oeo.one
ZOT_USERNAME: justme
ZOT_SECRET:
from_secret: zot_secret
commands:
- python3 ci/guard-step.py linux-amd64-backend 1800 bash ci/release-linux.sh amd64 backend
- bash ci/release-linux.sh amd64 backend
depends_on:
- restore-linux-dependencies
- web-build
- name: linux-arm64-backend
image: bash
environment:
NUGET_PACKAGES: /home/justme/woodpecker/cache/nuget/packages
NUGET_PACKAGES: /home/justme/.nuget/packages
ZOT_REGISTRY: registry.oeo.one
ZOT_USERNAME: justme
ZOT_SECRET:
from_secret: zot_secret
commands:
- python3 ci/guard-step.py linux-arm64-backend 1800 bash ci/release-linux.sh arm64 backend
- bash ci/release-linux.sh arm64 backend
depends_on:
- restore-linux-dependencies
- web-build
- name: backend-multiarch-index
image: bash
@@ -73,7 +64,7 @@ steps:
RELEASE_SIGNING_KEY:
from_secret: release_signing_key
commands:
- python3 ci/guard-step.py backend-multiarch-index 900 bash ci/index-backend-image.sh
- bash ci/index-backend-image.sh
depends_on:
- linux-amd64-backend
- linux-arm64-backend
+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 -8
View File
@@ -11,21 +11,16 @@ clone:
when:
- event: pull_request
- event: push
branch: main
branch: dev
# On Gitea PR events, CI_COMMIT_MESSAGE is the PR title.
steps:
- name: openwrt-updater-tests
image: bash
commands:
- python3 ci/guard-step.py openwrt-updater-tests 600 bash ci/test-ci-scripts.sh
- name: backend-tests
image: bash
# [SKIP TESTS] omits Backend tests only; web-build remains a dependent workflow.
when:
- evaluate: 'not (CI_COMMIT_MESSAGE contains "[SKIP TESTS]")'
environment:
NUGET_PACKAGES: /home/justme/woodpecker/cache/nuget/packages
NUGET_PACKAGES: /home/justme/.nuget/packages
commands:
# 增量编译 + 快的那批测试(约 20 秒);慢的端到端测试在 e2e workflow 里和出镜像并行跑。见 ci/verify-backend.sh
- python3 ci/guard-step.py backend-tests 900 bash ci/verify-backend.sh fast
- cd backend && dotnet test --solution MoShiro.slnx -c Release
+7 -12
View File
@@ -7,26 +7,21 @@ clone:
image: plugin-git
settings:
lfs: false
# [CI] 构建要算开发版本号(ci/version.sh:自最近 tag 的提交数),要拿到 tag
tags: true
when:
- event: pull_request
- event: push
branch: main
branch: dev
depends_on:
- verify
# 和 verify 并行:前端构建不依赖后端测试,两个同时跑
steps:
- name: web-build
image: bash
environment:
NPM_CONFIG_CACHE: /home/justme/.npm
commands:
# node_modules 按锁文件哈希缓存,展示端和管理端并行构建,再检查 CSS,见 ci/web-build.sh
# main 上带 [CI] 的提交:先写入开发版本号,构建完把产物交给同一条流水线的开发镜像(ci/shared-web.sh),前端只构建一次
- |
if [ "$CI_PIPELINE_EVENT" = push ] && printf '%s' "$CI_COMMIT_MESSAGE" | grep -qF '[CI]'; then
export WEB_PUBLISH_SHARED=1
bash ci/version.sh apply
fi
python3 ci/guard-step.py web-build 900 bash ci/web-build.sh
- npm ci --prefix web --prefer-offline
- npm run build --prefix web
- npm run lint:css --prefix web
+147 -3
View File
@@ -15,24 +15,128 @@ when:
concurrency: 1
steps:
- name: build-macos-x64-agent
- name: build-linux-x64-agent
image: powershell
environment: &build-environment
GITEA_BASE: http://localhost:3100
GITEA_TOKEN:
from_secret: gitea_token
NUGET_PACKAGES: 'C:\Users\JustMe-NAS\.nuget\packages'
RID: osx-x64
RID: linux-x64
COMPONENT: agent
commands: &build-commands
- Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force
- ./ci/release-cross-platform.ps1 -RuntimeIdentifier $env:RID -Component $env:COMPONENT
depends_on: []
- name: build-linux-x64-probe
image: powershell
environment: &rid-linux-x64-probe
<<: *build-environment
COMPONENT: probe
commands: *build-commands
depends_on: []
- name: build-linux-x64-enroll
image: powershell
environment: &rid-linux-x64-enroll
<<: *build-environment
COMPONENT: fleet-enroll
commands: *build-commands
depends_on: []
- name: build-linux-arm64-agent
image: powershell
environment: &rid-linux-arm64-agent
<<: *build-environment
RID: linux-arm64
commands: *build-commands
depends_on: []
- name: build-linux-arm64-probe
image: powershell
environment: &rid-linux-arm64-probe
<<: *rid-linux-arm64-agent
COMPONENT: probe
commands: *build-commands
depends_on: []
- name: build-linux-arm64-enroll
image: powershell
environment: &rid-linux-arm64-enroll
<<: *rid-linux-arm64-agent
COMPONENT: fleet-enroll
commands: *build-commands
depends_on: []
- name: build-linux-musl-x64-agent
image: powershell
environment: &rid-linux-musl-x64-agent
<<: *build-environment
RID: linux-musl-x64
commands: *build-commands
depends_on: []
- name: build-linux-musl-x64-probe
image: powershell
environment: &rid-linux-musl-x64-probe
<<: *rid-linux-musl-x64-agent
COMPONENT: probe
commands: *build-commands
depends_on: []
- name: build-linux-musl-x64-enroll
image: powershell
environment: &rid-linux-musl-x64-enroll
<<: *rid-linux-musl-x64-agent
COMPONENT: fleet-enroll
commands: *build-commands
depends_on: []
- name: build-linux-musl-arm64-agent
image: powershell
environment: &rid-linux-musl-arm64-agent
<<: *build-environment
RID: linux-musl-arm64
commands: *build-commands
depends_on: []
- name: build-linux-musl-arm64-probe
image: powershell
environment: &rid-linux-musl-arm64-probe
<<: *rid-linux-musl-arm64-agent
COMPONENT: probe
commands: *build-commands
depends_on: []
- name: build-linux-musl-arm64-enroll
image: powershell
environment: &rid-linux-musl-arm64-enroll
<<: *rid-linux-musl-arm64-agent
COMPONENT: fleet-enroll
commands: *build-commands
depends_on: []
- name: build-macos-x64-agent
image: powershell
environment: &rid-macos-x64-agent
<<: *build-environment
RID: osx-x64
commands: *build-commands
depends_on: []
- name: build-macos-x64-probe
image: powershell
environment: &rid-macos-x64-probe
<<: *rid-macos-x64-agent
COMPONENT: probe
commands: *build-commands
depends_on: []
- name: build-macos-x64-enroll
image: powershell
environment: &rid-macos-x64-enroll
<<: *build-environment
<<: *rid-macos-x64-agent
COMPONENT: fleet-enroll
commands: *build-commands
depends_on: []
@@ -45,6 +149,14 @@ steps:
commands: *build-commands
depends_on: []
- name: build-macos-arm64-probe
image: powershell
environment: &rid-macos-arm64-probe
<<: *rid-macos-arm64-agent
COMPONENT: probe
commands: *build-commands
depends_on: []
- name: build-macos-arm64-enroll
image: powershell
environment: &rid-macos-arm64-enroll
@@ -61,6 +173,14 @@ steps:
commands: *build-commands
depends_on: []
- name: build-windows-x64-probe
image: powershell
environment: &rid-windows-x64-probe
<<: *rid-windows-x64-agent
COMPONENT: probe
commands: *build-commands
depends_on: []
- name: build-windows-x64-enroll
image: powershell
environment: &rid-windows-x64-enroll
@@ -77,6 +197,14 @@ steps:
commands: *build-commands
depends_on: []
- name: build-windows-arm64-probe
image: powershell
environment: &rid-windows-arm64-probe
<<: *rid-windows-arm64-agent
COMPONENT: probe
commands: *build-commands
depends_on: []
- name: build-windows-arm64-enroll
image: powershell
environment: &rid-windows-arm64-enroll
@@ -92,11 +220,27 @@ steps:
- Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force
- ./ci/package-cross-platform.ps1
depends_on:
- build-linux-x64-agent
- build-linux-x64-probe
- build-linux-x64-enroll
- build-linux-arm64-agent
- build-linux-arm64-probe
- build-linux-arm64-enroll
- build-linux-musl-x64-agent
- build-linux-musl-x64-probe
- build-linux-musl-x64-enroll
- build-linux-musl-arm64-agent
- build-linux-musl-arm64-probe
- build-linux-musl-arm64-enroll
- build-macos-x64-agent
- build-macos-x64-probe
- build-macos-x64-enroll
- build-macos-arm64-agent
- build-macos-arm64-probe
- build-macos-arm64-enroll
- build-windows-x64-agent
- build-windows-x64-probe
- build-windows-x64-enroll
- build-windows-arm64-agent
- build-windows-arm64-probe
- build-windows-arm64-enroll
+28 -74
View File
@@ -1,89 +1,43 @@
# Agent Instructions
MoShiro is a self-hosted network path quality monitor. Agents on home broadband, NAT networks, or public VPS hosts run active measurements and record raw per-packet and per-event data. The Backend stores it, derives metrics, scores lines per service model, and serves a REST/SSE API to two web apps. Documentation is in Chinese under [`docs/`](docs/); source comments are mostly Chinese too. [`README.md`](README.md) is the entry point.
## Project
## Mental model
MoShiro is a self-hosted network path quality monitor. Backend coordinates Agents and Probes, stores measurements, and serves the REST/SSE API. `web/display` is the read-only overview; `web/admin` manages nodes, tests, and settings.
| Term | Meaning |
| --- | --- |
| Agent | Unified collector process (NativeAOT, BCL only), listening and dialing by default. Each Agent connects to the Backend over `wss` and reports outgoing link health and incoming sessions. |
| Line | One directed (dialer, listener) Agent pair, per IP family. Id `<agent_id>~<dialer_id>`, IPv6 adds `~ipv6`; series key `line:<dialer_id>` |
| Kind | A declared measurement method (`udp_echo`, `tcp_echo`, `connect`, ...) composed from building blocks. Defined once in `MoShiro.Protocol/Catalog/Kinds` |
| RawChunk | Self-describing columnar block, cut every 10 s, appended to segment files. The Backend never trusts node-reported statistics |
| Metric, Model | `MetricDef` is a one-line declaration over accumulators; models score metrics at query time. Both are recomputable from raw data |
| Plan | A sequence of on-demand test phases (for example `hy2_ladder`). Exists only in the Backend (`Application/Tests/Plans.cs`) |
| Fleet | Product-agnostic node framework under `backend/fleet/` (identity, enrollment, RPC, link, outbox, state sync) |
| Operator set | Passkey-signed trust root. Nodes pin its genesis fingerprint and owner at install time and verify the chain themselves |
## Branch and workspace safety
## Invariants (violations fail the build or review)
- Treat `main` as the stable release branch and `dev` as the integration branch. Keep ordinary implementation work on `dev` or a feature branch; integrate into `main` through a pull request.
- A push to `main` builds images only if its commit message contains `[CI]` (for a merged PR, put it in the PR or merge commit title). Without `[CI]` nothing is built; use `[CI SKIP]` to skip the pipeline entirely. When squashing or pushing to `main`, add `[CI]` unless a build is explicitly not wanted.
- Before editing, inspect `git status`. Preserve pre-existing user changes: do not reset, clean, stash, or include unrelated files in a commit.
- Do not commit credentials, API keys, registry passwords, or generated runtime data. CI credentials belong in Woodpecker secrets (`zot_secret`, `gitea_token`, `release_signing_key` for signing release manifests). CI registers the signed manifests at `/api/release/offer`, which needs no token because the panel verifies the signature. The release signing private key never goes into the repository or the panel; only the public key in `packaging/update/` does.
- Keep development OCI images in the `moshiro-dev/` repository path and release images in `moshiro/`. Do not let a development build overwrite release tags such as `latest`.
- Node processes are kept to measurement and reporting by build-time restrictions: process start, dynamic loading, `HttpClient`, and file writes outside `Fleet.Hosting.ConfigStore` are banned (`backend/BannedSymbols.Node.txt`). Capabilities such as updates or command execution are not part of the core; adding or changing one needs a design review against `docs/security/security.md`.
- `backend/fleet/**` must not reference `MoShiro.*` (`ArchitectureTests`). `MoShiro.Measurement` contains no service concepts such as REALITY or Hysteria2.
- Every RPC method is declared in a method table (`FleetMethodTable`, `MoShiroMethodTable`) with direction, type and roles. Add the declaration before the handler.
- `Backend.Api` must not reference `Storage`. Error-code to HTTP mapping happens once, in the Api layer.
- Server-sent parameters pass `ParamSpec` validation and then the node-local hard limits (`BudgetGuard`). Never bypass either.
- Public API responses contain no IP, city or ASN.
- Code style is enforced at build time (`.editorconfig`, `BannedSymbols.txt`, warnings as errors). Details in [`docs/dev/coding-style.md`](docs/dev/coding-style.md).
## Project layout
## Where to change what
- `backend/src/MoShiro.Backend/`: service entry point and composition.
- `backend/src/MoShiro.Backend.Api/`: REST endpoints, contracts, and OpenAPI.
- `backend/agent/`, `backend/probe/`: node applications and their READMEs.
- `backend/fleet/`, `backend/shared/`: reusable node framework, measurement engine, protocols, and data formats.
- `web/display/`, `web/admin/`: Vite applications; `web/shared/`: API client, generated types, charts, and shared UI.
- `packaging/`, `scripts/`, `.woodpecker/`, `docs/`: container/package builds, CI workflows, and project documentation.
| Task | Location |
| --- | --- |
| New measurement method | Kind declaration in `backend/shared/MoShiro.Protocol/Catalog/Kinds`; new building block only if needed in `MoShiro.Measurement/Blocks` plus `BlockCatalog` |
| New metric | `MetricDef` in `backend/src/MoShiro.Backend.Analysis/Metrics`, add to `MetricRegistry.All` and a preset if shown |
| New service model | `Analysis/Models` |
| New RPC method | `MoShiro.Protocol/Rpc` (params, `MoShiroMethodTable`), then `router.On(...)` in `AgentApp`; Backend-side handlers register in `Backend.Gateway` (`FleetGateway`) |
| New REST endpoint | `Backend.Api/Public` or `Admin` (typed results, operationId, capability), contract DTO in `Api/Contracts`, then regenerate `backend/openapi/v1.json` and web types |
| New capability | `Capability` constants in `Backend.Application/Auth/Capabilities.cs`; every `/api/admin` endpoint must declare one |
| Schema change | Append a migration in `Storage.Sqlite/Schema.cs` and bump `CurrentVersion` |
| Agent-to-Agent connection states, retry cadence, health reporting | `backend/agent/src/MoShiro.Agent/Dial` (`DialConnection` control layer, `TaskLink` QUIC channel, `LinkStatus`), `LinkHealth` in `MoShiro.Protocol/Rpc`, [`docs/design/architecture.md`](docs/design/architecture.md) §4.3 |
| Install or update behaviour | `packaging/` (install scripts, Quadlet, compose, updater) and `docs/ops/deployment.md` |
| Web UI | `web/display` (anonymous read-only), `web/admin`, shared code in `web/shared`. Colors only in `web/shared/theme/tokens.css` |
## Local development and builds
## Documentation
Requirements: .NET SDK 10 and Node.js/npm. The SDK selection is defined by the root `global.json`; NuGet package versions are centralized in the root `Directory.Packages.props`.
Index: [`README.md`](README.md#文档). Prefer the code and `backend/openapi/v1.json` when they disagree with a document, and fix the document. Update docs in the same change as behaviour: `docs/design` (architecture, protocol, measurement, analysis, storage, query), `docs/security`, `docs/api`, `docs/ops`, `docs/dev`, and `docs/roadmap.md` for unimplemented work. Do not record dates or change history in docs.
- All .NET projects: `dotnet build MoShiro.slnx -c Release` from the repository root.
- Backend build: `cd backend && dotnet build MoShiro.slnx -c Release`
- Backend tests: `cd backend && dotnet test --solution MoShiro.slnx -c Release`
- Frontend dependencies/build: `npm ci --prefix web && npm run build --prefix web`
- Frontend style check: `npm run lint:css --prefix web`
- Local backend: from `backend/`, run `dotnet run --project src/MoShiro.Backend -- --urls http://localhost:5000`.
- Local web apps: from the repository root, run `npm ci --prefix web` then `npm run dev --prefix web`. The current dev config uses display port 5173 and admin port 5175; `/admin/` is proxied through the display server. Check the Vite configs when working on another branch because ports can differ.
- After changing API contracts, regenerate/check `backend/openapi/v1.json` and run `npm run gen:api --prefix web` so frontend API types stay aligned.
Writing style for docs, READMEs, comments and commit messages (the `stop-slop` skill lists the patterns in detail; it is written for English, the same rules apply to Chinese):
- State the fact directly. Drop "not X but Y" contrasts, negation lists, rhetorical setups, and aphorisms that read like pull-quotes.
- Name the actor. Prefer "the Agent rejects the task" over "the task is rejected".
- No filler openers ("it is worth noting"), no closing summaries, no adverbs doing emphasis. Avoid bold for emphasis.
- Do not claim absolutes ("never", "always", "cannot") unless the code or a build-time check enforces it. Describe current behaviour and mark planned work as planned.
- Give concrete names, paths, values and commands instead of general statements.
## Workspace safety
- Inspect `git status` before editing. Preserve existing uncommitted changes; do not reset, clean, stash, or commit unrelated files.
- `main` is the only working branch. Commit to it directly; use a pull request when review is required.
- Never commit credentials, API keys, registry passwords, or generated runtime data. CI credentials are Woodpecker secrets (`zot_secret`, `gitea_token`, `release_signing_key`).
- The release signing private key must not enter the repository or the panel. Only the public key under `packaging/update/` is versioned.
## Build and test
Requirements: .NET SDK 10 (pinned by `global.json`) and Node.js with npm. NuGet versions are centralized in `Directory.Packages.props`.
| Task | Command |
| --- | --- |
| Build all .NET projects | `dotnet build MoShiro.slnx -c Release` |
| Backend tests | `cd backend && dotnet test --solution MoShiro.slnx -c Release` |
| Run Backend | `cd backend && dotnet run --project src/MoShiro.Backend -- --urls http://localhost:5000` |
| Web dependencies and build | `npm ci --prefix web && npm run build --prefix web` |
| Web dev servers | `npm run dev --prefix web` (ports are set in the Vite configs) |
| Style check | `npm run lint:css --prefix web` |
| Regenerate API types | `npm run gen:api --prefix web` |
First run of the Backend prints a claim code in its log; the admin UI asks for it. Slow end-to-end tests carry `[Trait("Category", "Slow")]`. After changing API contracts, update `backend/openapi/v1.json` and regenerate the web types. Run only the validation the task requires; do not start a full tag or release build unless requested.
Follow the user's requested validation scope. Do not start a full tag/release build unless requested.
## CI and releases
Triggers, tags and image paths are defined in `.woodpecker/*.yml` and [`docs/ops/deployment.md`](docs/ops/deployment.md). Verify them there before changing or describing them. The rules below are the stable contract.
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.
- Every push to `main` runs the fast Backend tests and the web build. Both must pass before images are published.
- A commit message containing `[CI]` builds and pushes development images. `[CI SKIP]` skips the pipeline; `[SKIP TESTS]` skips only the Backend tests. Slow end-to-end tests run only for pushes without `[CI]` and for pull requests.
- A tag `vMAJOR.MINOR.PATCH` runs the full cross-platform release pipeline. No version-bump commit is needed.
- Versions are produced by `ci/version.sh`. The `<Version>` in `backend/Directory.Build.props` and the `version` in `web` `package.json` files are placeholders (`0.0.0-dev`) and must not be edited.
- Linux Agent builds use `ci/build-linux-agent.sh` for glibc on amd64/arm64 and musl on arm64 in both dev and tag workflows. OCI and OpenWrt installers reuse these native outputs; Windows runners build only Windows/macOS.
- Build stores keep NuGet cache mounts, retain image layers for 7 days, and clean temporary outputs under store locks (shared for native builds, exclusive for cleanup). Do not use `buildah prune` on these stores: it removes restore caches too.
- Development images use the `moshiro-dev/` repository path and release images use `moshiro/`. A development build must never overwrite a release tag such as `latest`.
- Release manifests are registered at `/api/release/offer`, which requires no token because the panel verifies the signature.
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.
+5 -2
View File
@@ -27,8 +27,11 @@
<Folder Name="/backend/agent/src/">
<Project Path="backend/agent/src/MoShiro.Agent/MoShiro.Agent.csproj" />
</Folder>
<Folder Name="/backend/agent/tests/">
<Project Path="backend/agent/tests/MoShiro.Agent.Tests/MoShiro.Agent.Tests.csproj" />
<Folder Name="/backend/probe/src/">
<Project Path="backend/probe/src/MoShiro.Probe/MoShiro.Probe.csproj" />
</Folder>
<Folder Name="/backend/probe/tests/">
<Project Path="backend/probe/tests/MoShiro.Probe.Tests/MoShiro.Probe.Tests.csproj" />
</Folder>
<Folder Name="/backend/src/">
<Project Path="backend/src/MoShiro.Backend.Analysis/MoShiro.Backend.Analysis.csproj" />
+90 -42
View File
@@ -1,70 +1,118 @@
# MoShiro
MoShiro 旨在解决跨网络线路质量难以量化的问题:
MoShiro 是一个自托管的**网络线路质量观测工具**。它在若干 Probe(例如家宽出口)与多台 Agent(例如海外 VPS)之间做主动网络测量,只记录逐包、逐事件的原始数据;Backend 保存这些原始数据,推算延迟、丢包、抖动、吞吐等指标,并回答「如果在这条线路上部署某类服务,体验会怎样」;Web 界面用来查看、比较和管理。
- 家宽到海外 VPS 的延迟、丢包、抖动随时段波动,单点 ping 无法反映真实体验;
- 不同代理、游戏、流媒体服务对线路的敏感度各异,缺少统一的评价口径。
## 项目边界
它通过 Agent 之间的主动测量采集原始数据,由 Backend 推算指标,并按服务模型(REALITY、Hysteria2、Minecraft 等)对线路评分与排序。
**它做什么**
## 组件
- 主动测量:组合 TCP、UDP、TLS、ICMP 等采集积木,只记录时间、序号、大小这类原始事实,**不转发也不记录业务内容**。
- 线路分析:Backend 从原始数据生成时间序列指标,按服务模型(REALITY、Hysteria2、Minecraft 等)评分并对多条线路排序、对比;模型和指标可以事后重算。
- 管理:节点注册与审批、Probe 和 Agent 之间连哪些线路、任务与按需测试、节点版本与更新,都在管理端完成。
| 组件 | 职责 |
| --- | --- |
| Agent | 统一采集节点,默认同时监听和拨号;可部署在 VPS、家宽或 NAT 后 |
| Backend | 注册、线路编排、存储、分析和 REST / SSE |
| Web | 匿名只读展示端 `display` 与管理端 `admin` |
**它不做什么**
- **节点是纯采集器**:Agent 和 Probe 没有终端、命令执行、文件读写和自更新能力,也不加载外部代码。更新由节点宿主机上的更新器完成,节点进程只放面板转来的发布清单。
- **不是主机监控**:Agent 只上报 CPU、内存、磁盘、网卡流量和运行时长这类只读的汇总数字,不采集型号、主机名、进程和任何 IP;Probe 不上报主机资源。
- **不暴露地址**:公开接口里没有 IP、城市、ASN;国家和运营商只由 Backend 查询(节点自己不访问任何外部服务)。
- 暂不包含告警(见文末待办)。
## 信任关系
```text
Backend ---- REST / SSE ---- Web
^ ^
| | 每个 Agent 独立建立 wss,汇报自身及线路状态
| |
Agent A <=====> Agent B
直连测量
管理员 ── 账号密码(Cookie 会话 + CSRF)或 API 令牌(按能力授权)──▶ Backend
访客 ── 匿名只读,站点设为公开时才可用(没有 IP、城市、ASN)──────────────▶ Backend 公开接口
节点 ── 加入令牌一次性注册,之后用自己的 ECDSA P-256 密钥签名认证 ───────▶ Backend(只存公钥)
Probe ── 用同一把密钥签名建立测量会话 ───────────────────────────────────▶ Agent(只放行面板名单里的 Probe 公钥)
CI ── 发布私钥对「镜像 digest 清单」签名 ──▶ Backend 原样转发 ──▶ 节点宿主机的更新器用本机公钥验签
```
Agent 默认向其他可拨号 Agent 建立测量线路,A→B 和 B→A 分别记录;节点不连接自身。
管理端统一展示 Agent,在线路画板中按方向开关测量、查看连接状态和立即重试。
家宽或 NAT 后的 Agent 仍可拨出;入站测量需要其他节点能够访问其监听地址和端口。
可在本地 `agent.json` 中显式设置 `listen.enabled: false` 关闭监听,节点身份和程序保持统一。
- **Backend 不信任节点上报的内容**:数据按线路隔离存放;节点的身份靠签名,数据库泄露也无法冒充节点。
- **节点不信任 Backend 的下发**:任务超出节点本机的硬上限(目标网段、码率、包速率、监听端口)就被拒绝;节点不执行任何「执行类」指令。
- **更新不依赖面板的诚实**:清单必须由发布私钥签名;节点宿主机的更新器用本机公钥验签、只认本机配置的镜像仓库、按 digest 拉取、拒绝旧的 `seq`。面板被攻破也推不出恶意镜像。
## 快速开始
## 架构
环境:.NET SDK 10(版本见 `global.json`)、Node.js 与 npm。
```text
Web(展示端 · 管理端)
▲ REST + SSE
┌──────────────────────────────────────────────────────────┐
│ Backend(面板) │
│ Api · Registry · Planner · Links │
│ Releases · Analysis · LiveHub │
│ Store(段文件 + SQLite) │
└──────────────────────────────────────────────────────────┘
▲ ▲
│ wss 长连接(节点发起) │ wss 长连接(节点发起)
│ 任务、数据、状态、更新通知 │ 身份、Agent 列表、状态、更新通知
┌────────────────────┐ ┌──────────────────────┐
│ Agent × 10+ │ ◀── 测量会话 ── │ Probe × N │
│ 有公网 IP,监听 │ │ 家宽等出口,NAT 后 │
└────────────────────┘ └──────────────────────┘
测量会话:TLS 控制通道 + UDP/TCP 数据通道,由 Probe 主动拨出
```
两种连接:**Probe 和 Agent 都用长连接连 Backend**(身份、任务、状态、更新通知),**测量只走 Probe → Agent**(Probe 主动拨出,Agent 不需要连 Probe,家宽也不需要入站端口)。哪些 Probe 连哪些 Agent 由 Backend 决定:默认自动全连,也可以在管理端手动选择。
```text
采集(只记录) 传输(有界、至少一次) 分析(注册制、可重算) 展示
Probe / Agent 积木 ──▶ 原始列块(10 秒一块)──▶ Backend 段文件 ──▶ 指标与服务模型 ──▶ REST / SSE ──▶ Web
```
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/`。
- **业务方法**:`moshiro.*`(按需测试、Probe 断开、事件、主机汇总、更新通知等),状态文档有 `task_set`(Agent 的任务集)和 `probe_agents`(Probe 的 Agent 列表)。
- **测量数据面**: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`,前端据此生成类型。
细节见 [`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)。
## 如何构建
环境:.NET SDK 10(`global.json` 指定版本)、Node.js 与 npm。在仓库根目录:
```sh
cd backend && dotnet run --project src/MoShiro.Backend -- --urls http://localhost:5000 # Backend
npm ci --prefix web && npm run dev --prefix web # Web(另开终端,仓库根目录)
dotnet build MoShiro.slnx -c Release # Backend、Agent、Probe、Fleet、测试和 CI 工具
npm ci --prefix web && npm run build --prefix web # 展示端和管理端,产物在 web/display/dist、web/admin/dist
```
展示端 <http://localhost:5173/>,管理端 <http://localhost:5173/admin/>。首次访问管理端进入认领向导,认领码输出在后端日志。
本地运行:先在 `backend/` 里 `dotnet run --project src/MoShiro.Backend -- --urls http://localhost:5000`,再 `npm run dev --prefix web`,展示端是 <http://localhost:5173/>,管理端是 <http://localhost:5173/admin/>。
构建与测试:
测试和检查:
```sh
dotnet build MoShiro.slnx -c Release
dotnet test --solution MoShiro.slnx
npm run build --prefix web
dotnet test --solution MoShiro.slnx # 后端和节点的测试
npm run lint:css --prefix web # 前端样式(颜色只能写在主题令牌里)
npm run gen:api --prefix web # 后端改了接口之后重新生成前端类型
```
Backend、Agent、Probe 以 OCI 镜像发布(Linux amd64、arm64),Agent 和 Probe 另有 Linux glibc/musl、macOS、Windows 的二进制包。NativeAOT 的构建细节、CI 触发规则(`[CI]`、`[SKIP TESTS]`、版本 tag)、镜像路径、容器节点的运行和更新见 [`docs/deployment.md`](docs/deployment.md);前端见 [`web/README.md`](web/README.md),后端见 [`backend/README.md`](backend/README.md)。
## 文档
| 主题 | 文档 |
| 文档 | 内容 |
| --- | --- |
| 架构与数据链路 | [architecture](docs/design/architecture.md) |
| 线协议与方法 | [protocol](docs/design/protocol.md) |
| 采集引擎与任务 | [measurement](docs/design/measurement.md) |
| 分析、存储、查询 | [analysis](docs/design/analysis.md) · [storage](docs/design/storage.md) · [query](docs/design/query.md) |
| 安全:威胁模型、访问控制、注册与身份 | [security](docs/security/security.md) · [access](docs/security/access.md) · [enrollment](docs/security/enrollment.md) |
| REST / SSE 接口 | [api](docs/api/api.md) |
| 构建、发布、CI、部署与更新 | [deployment](docs/ops/deployment.md) |
| 仓库结构与代码风格 | [project-layout](docs/dev/project-layout.md) · [coding-style](docs/dev/coding-style.md) |
| 路线图 | [roadmap](docs/roadmap.md) |
| 子项目 | [backend](backend/README.md) · [fleet](backend/fleet/README.md) · [agent](backend/agent/README.md) · [web](web/README.md) |
| [`docs/architecture.md`](docs/architecture.md) | 架构、数据链路、安全边界、设计决策 |
| [`docs/security.md`](docs/security.md) | 零信任设计稿(部分已实现):威胁模型、入网信任链、监听隐身、extension 授权 |
| [`docs/access.md`](docs/access.md) | 访问控制与入网信任链设计(尚未实现):账户、角色、能力、API key、提权窗口、passkey 到节点的签名链 |
| [`docs/protocol.md`](docs/protocol.md)、[`backend/fleet/README.md`](backend/fleet/README.md) | 线协议、Fleet 节点框架(身份、注册、连接) |
| [`docs/enrollment.md`](docs/enrollment.md) | 注册、身份、Probe 与 Agent 的接入 |
| [`docs/measurement.md`](docs/measurement.md)、[`docs/analysis.md`](docs/analysis.md) | 采集引擎与任务模型、分析层与指标 |
| [`docs/storage.md`](docs/storage.md)、[`docs/query.md`](docs/query.md) | 存储、展示数据查询 |
| [`docs/api.md`](docs/api.md)、[`docs/api-guidelines.md`](docs/api-guidelines.md) | API 规范与约束 |
| [`docs/deployment.md`](docs/deployment.md) | 构建、发布、CI、部署与更新 |
| [`docs/project-layout.md`](docs/project-layout.md)、[`docs/coding-style.md`](docs/coding-style.md) | 仓库结构与依赖规则、代码风格 |
贡献与自动化代理的工作约定见 [`AGENTS.md`](AGENTS.md)。
代码目录:`backend/`(Backend、`fleet/` 节点框架、`shared/` 测量引擎和协议、`agent/`、`probe/`、`tests/`)、`web/`(`display` 展示端、`admin` 管理端、`shared`)、`packaging/` 和 `scripts/`(部署与维护)、`ci/` 和 `.woodpecker/`(流水线)。
## 待办
见 [`TODO.md`](TODO.md)。
## 许可证
[Apache License 2.0](LICENSE)
本项目采用 [Apache License 2.0](LICENSE)。
+48
View File
@@ -0,0 +1,48 @@
# TODO
按主题记录还没做的事,做完就删掉对应的条目。已经实现的东西以 [`docs/`](docs/) 和代码为准。
## 测量
- 路由跳数、路径漂移和 trace
- QUIC 握手耗时:TCP 建连和 TLS 握手已经有了(`connect` 任务)。.NET 的 QUIC 依赖系统里的 msquic 原生库,和节点「只依赖 OpenSSL、NativeAOT 单文件」冲突,要做只能自己写最小的 QUIC Initial 握手探测
- 网页长截图
- pcap / eBPF 采集
## 任务与面板
- 单个 Agent 的任务(目前是线路模板加按 Agent 的覆盖,没有只属于某一个 Agent 的独立任务)
- 原生 webhook 投递;告警(基于 webhook)。实现之前先定约束(只允许 HTTPS、禁止私网和回环、不跟随重定向、限制超时和响应大小、解析后再检查地址),见 [`docs/security.md`](docs/security.md) 第 9.2 节
## 部署
- 后端自动下载前端资源:用 Podman、Docker 部署时,默认拉取最新的前端资源(现在前端要自己构建,镜像里没有)。
[security.md](docs/security.md)
## 访客页面
- 人机验证:访客页面要通过验证才能拉数据,前端访问时静默触发。候选 tiago2/cap 和 Cloudflare Turnstile。
## 更新
- 更新遥测(现在面板只有每个节点的「当前更新状态」)
- **回滚路径的实机测试**:容器节点更新后观察 20 秒、失败回滚,只有单元和脚本测试,没有在真实节点上触发过。
- **更新的灰度和自动策略**:按标签分批发布;`POLICY=auto` 的长期验证。
- **纯二进制节点的更新**(`fleet-updater`、`fleet-release`、Windows 服务和 OpenWrt procd 适配器)。设计要点,实现时照这个来:
- 信任来自签名:只接受离线发布密钥签名的清单,发布源可以是面板、GitHub Releases 或任意 HTTP 服务;面板被攻破也推不出恶意二进制。
- 本地说了算:更新策略(`auto` / `notify` / `pinned:<version>`)和信任的公钥写在节点本地配置里,面板只能「建议」;支持多把公钥以便轮换。
- 清单带 `app`、`channel`、`version`、`min_from`、各 RID 制品的 `sha256` 和大小、`key_id`、签名(对规范化 JSON 签名);本地记录已装过的最高版本,拒绝降级和旧清单重放。
- 流程:下载 → 校验 → 解包到 `versions/<v>/` → 原子切换 `current`(Linux 符号链接,Windows 目录联结)→ 重启服务 → 等健康信标确认 → 失败切回旧版本并把该版本标记为坏版本。
- 权限分离:更新进程只能写安装目录和重启被管理的服务,读不到节点身份私钥。
- OpenWrt:`keep_versions = 1`,下载前检查闪存剩余空间。
- 容器节点已经实现的那一套(清单签名、`seq` 防重放、按 digest 拉取、回滚)见 [`docs/deployment.md`](docs/deployment.md)。
## 安全
- **零信任**:设计稿在 [`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) 的项目边界。
- 对 UDP 包、控制帧、RawChunk、JSON-RPC 做模糊测试;伪造签名和重放的专项测试。
## 打包
- OpenWrt:procd 脚本、ipk/apk 包、sysupgrade 保留列表,在 aarch64 路由器上长时间运行验证。
- Windows 服务适配。
+1 -7
View File
@@ -13,17 +13,11 @@
<UseArtifactsOutput>true</UseArtifactsOutput>
<ArtifactsPath>$(MSBuildThisFileDirectory)artifacts</ArtifactsPath>
<Deterministic>true</Deterministic>
<Version>0.0.0-dev</Version>
<Version>0.3.3</Version>
<NuGetAudit>false</NuGetAudit>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" PrivateAssets="all" />
<AdditionalFiles Include="$(MSBuildThisFileDirectory)BannedSymbols.txt" />
</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>
+5 -2
View File
@@ -27,8 +27,11 @@
<Folder Name="/agent/src/">
<Project Path="agent/src/MoShiro.Agent/MoShiro.Agent.csproj" />
</Folder>
<Folder Name="/agent/tests/">
<Project Path="agent/tests/MoShiro.Agent.Tests/MoShiro.Agent.Tests.csproj" />
<Folder Name="/probe/src/">
<Project Path="probe/src/MoShiro.Probe/MoShiro.Probe.csproj" />
</Folder>
<Folder Name="/probe/tests/">
<Project Path="probe/tests/MoShiro.Probe.Tests/MoShiro.Probe.Tests.csproj" />
</Folder>
<Folder Name="/backend/src/">
<Project Path="src/MoShiro.Backend.Analysis/MoShiro.Backend.Analysis.csproj" />
+4 -4
View File
@@ -36,9 +36,9 @@ dotnet test --solution MoShiro.slnx
dotnet publish src/MoShiro.Backend -c Release -r <RID> -p:PublishAot=true
```
例如 `<RID>` 可为 `win-x64`、`linux-x64` 或 `linux-arm64`。NativeAOT 发布需要对应平台的 C/C++ 工具链。仓库根目录的 `scripts/publish.sh backend <RID>` 可构建单个平台的 Backend 可执行文件;容器发布由 Woodpecker tag 流水线完成,详见 [`../docs/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 的多平台二进制包单独发布,不包含在 Backend 镜像中;Linux runner 构建三个 Linux RID,Windows runner 构建 macOS 和 Windows。
正式发布通过 Woodpecker tag 流水线生成 Linux/amd64 与 Linux/arm64 OCI 镜像,容器内监听 `8080`,数据写入挂载的 `/data` 卷。Agent 与 Probe 的多平台二进制包单独发布,不包含在 Backend 镜像中。
## 本地运行
@@ -49,6 +49,6 @@ cd backend
dotnet run --project src/MoShiro.Backend -- --urls http://localhost:5000
```
没有默认管理员:第一次打开面板时按页面引导自己创建管理员、注册 passkey、建立信任根,页面要的认领码写在后端日志里(或用 `MoShiro__ClaimCode` 指定)。`MoShiro__InitialAdminUser` / `MoShiro__InitialAdminPassword` 只给自动化用。数据目录、公开地址、Cookie 和 CORS 等设置通过 `MoShiro` 配置节传入;完整配置说明见 [`../docs/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)。
+1 -1
View File
@@ -8,7 +8,7 @@
<EventSourceSupport>false</EventSourceSupport>
<DebuggerSupport>false</DebuggerSupport>
<HttpActivityPropagationSupport>false</HttpActivityPropagationSupport>
<Http3Support>true</Http3Support>
<Http3Support>false</Http3Support>
<MetricsSupport>false</MetricsSupport>
<UseSizeOptimizedLinq>true</UseSizeOptimizedLinq>
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
+14 -16
View File
@@ -1,22 +1,20 @@
# MoShiro Agent
Agent 是统一的采集节点,默认同时监听和拨号。每个 Agent 独立连接 Backend,接收任务集和拨号目标;节点之间通过 TLS 控制会话、QUIC Datagram 和 TCP 数据连接完成测量。
Agent 是部署在 VPS 或其他可达网络节点上的采集进程。它向 Backend 注册并保持连接,接受 Probe 的测量会话,执行线路任务中的 Agent 侧工作,再将两端数据合并后上报。
Agent 仅开放配置的控制和数据端口。它不提供远程终端、命令执行或文件管理;本地配置中的网络范围、速率和并发限制由节点自行执行,Backend 下发的任务不能越过这些限制。
## 工作流程
```text
Agent A <==== 直接测量 ====> Agent B
| |
+--------- wss -------------+
|
Backend
Probe ──TLS 控制 / UDP、TCP 测量──> Agent ──长连接与数据上报──> Backend
▲
fleet-enroll 注册
```
Backend 默认安排 Agent 之间的双向有向线路,自连接被排除。拨号方通过心跳上报控制层、TCP 和 QUIC 状态、失败原因及重试时间;监听方上报接入会话。Backend 汇总这些报告,供 Agent 详情和线路矩阵使用。测量数据由监听方合并后上传。
本地 `listen.enabled` 默认 `true`。可显式关闭监听,适用于无需入站测量的节点;这仍是同一种 Agent。NAT 后的节点可主动拨出,入站可达性取决于地址和端口映射。
## 构建
需要 .NET SDK 10(版本见根目录 `global.json`)。从 `backend/` 目录构建:
需要 .NET SDK 10。为应用 根目录的 `global.json` 的 SDK 设置,请从 `backend/` 目录构建:
```sh
cd backend
@@ -31,7 +29,7 @@ dotnet publish agent/src/MoShiro.Agent -c Release -r <RID> -p:PublishAot=true
dotnet publish fleet/tools/fleet-enroll -c Release -r <RID> -p:PublishAot=true
```
也可在仓库根目录运行 `bash scripts/publish.sh agent <RID>` 和 `bash scripts/publish.sh fleet-enroll <RID>`。部署包和平台说明见 [`../../docs/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)。
## 注册与运行
@@ -49,7 +47,7 @@ fleet-enroll --server https://<backend> --join <token> --name <agent-name> --ide
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。
@@ -57,9 +55,9 @@ IPv6 线路需要 Agent 实际绑定 IPv6。新配置的默认 `listen.bind` 为
## 设计说明
- 每个 拨号方 使用独立的 TLS 会话和数据密钥;拨号方 断开时对应的会话资源会释放。
- 每个 Probe 使用独立的 TLS 会话和数据密钥;Probe 断开时对应的会话资源会释放。
- 数据面会校验会话认证、重放窗口和速率限制;无效数据包会静默丢弃。
- 测量引擎和协议由 `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。首次导入和自动更新见部署文档。
+60 -171
View File
@@ -10,17 +10,13 @@ using MoShiro.Protocol;
namespace MoShiro.Agent;
/// <summary>
/// 节点的组装点:与 Backend 的链路、监听侧(数据面和点对点监听,<c>listen.enabled</c> 为 false 时不启动)、拨号侧(Backend 下发的拨号目标)都在这里接起来。
/// 没有全局状态,测试可以在同一进程里启动多个。
/// </summary>
public sealed class AgentApp : IDisposable, IAsyncDisposable
/// <summary>Agent 的组装点:数据面、点对点监听、与 Backend 的链路都在这里接起来。没有全局状态,测试可以在同一进程里启动多个。</summary>
public sealed class AgentApp : IDisposable
{
public const string Version = MoShiro.Protocol.BuildInfo.Version;
private readonly AgentConfig _config;
private readonly bool _listening;
private readonly bool? _ipv6Supported;
private readonly bool _ipv6Supported;
private readonly KnockGate _knock;
private readonly TrustAnchor _trust;
private readonly RestartController _restart;
@@ -28,7 +24,6 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable
private readonly LoadedNodeIdentity _identity;
private readonly TimeProvider _time;
private readonly INodeLog _log;
private readonly INodeLogSource? _logSource;
private readonly MonoClock _clock;
private readonly AgentCounters _counters = new();
private readonly SessionRegistry _sessions = new();
@@ -36,34 +31,27 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable
private readonly TaskCompletionSource _ready = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly AgentAdmission _admission;
private readonly Outbox _outbox = new(64 << 20);
private readonly RestartUpdater? _update;
private readonly OpenWrtUpdater? _nativeUpdate;
private readonly DialerHost _dialer;
private readonly UpdateBridge _update;
private readonly BackendUplink _uplink;
private readonly QuicDatagramTransport _datagrams;
private readonly UdpMux _udp;
private readonly TcpAcceptor _tcp;
private readonly PeerListener _peers;
private readonly PeerHub _hub;
private readonly ChunkAssembler _chunks;
private readonly RateChangeLog _rateChanges = new();
private readonly TaskManager _tasks;
private readonly TestRunner _tests;
private readonly FleetLink _link;
private int _disposed;
/// <param name="dataDirectory">配置目录(容器里的 <c>/etc/moshiro</c>):宿主机更新器和节点在这里交换清单、请求和状态。</param>
public AgentApp(AgentConfig config, LoadedNodeIdentity identity, string dataDirectory, TimeProvider time, INodeLog log, RestartController? restart = null)
{
_ownedRestart = restart is null ? new RestartController() : null;
_restart = restart ?? _ownedRestart!;
_update = RestartUpdater.FromEnvironment("agent", _restart.RequestNow, time, log);
_nativeUpdate = OpenWrtUpdater.FromEnvironment(time, log);
_update = new UpdateBridge(dataDirectory, new ConfigStore(dataDirectory).Write, log);
NodeBinary.Prime(); // 正在运行的二进制的哈希:先在后台算好,握手时用
_config = config;
_listening = config.Listen.Enabled;
_identity = identity;
_time = time;
_log = log;
_logSource = log as INodeLogSource;
_clock = new MonoClock(time);
_uplink = new BackendUplink(time);
@@ -73,44 +61,37 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable
bind = IPAddress.Any;
}
_ipv6Supported = _listening ? bind.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 : null;
_ipv6Supported = bind.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6;
_admission = new AgentAdmission(_streams);
QuicSettings quic = config.Quic.ToSettings();
if (quic.Validate() is { } invalid)
{
throw new InvalidOperationException("agent.json quic: " + invalid);
}
_datagrams = new QuicDatagramTransport(_sessions, _admission, _clock, _counters.Data, log, bind, config.Listen.QuicPorts, identity.Certificate, quic);
_udp = new UdpMux(_sessions, _admission, _clock, _counters.Data, log, bind, config.Listen.UdpPorts);
// 预握手敲门:控制端口和数据端口共用一个关卡(限速和去重一起算);名册就是期望状态里的 peers
_trust = TrustAnchor.Load(config.Fleet.IdentityDir, log);
_dialer = new DialerHost(new DialEnv(identity.PeerIdentity, identity.NodeId.Value, config.Limits, quic, new DialDirectory(new ConfigStore(dataDirectory), dataDirectory, log, _trust), _clock, time, log, _rateChanges), time, log);
_knock = new KnockGate(identity.PeerIdentity, RosterKeys, time);
_tcp = new TcpAcceptor(bind, config.Listen.TcpPorts, identity.Certificate, _sessions, _counters.Data, _knock);
// 监听方积木的环境:和拨号方的拨号方共用同一套积木,区别只在这里装配的部分(准入策略、TCP 监听)
// 监听方积木的环境:和 Probe 的拨号方共用同一套积木,区别只在这里装配的部分(准入策略、TCP 监听)
var env = new BlockEnv
{
Clock = _clock,
Log = log,
Session = () => null, // 每个拨号方会话各用自己绑定的环境(BlockEnv.WithSession)
Datagrams = _datagrams,
Session = () => null, // 每个 Probe 会话各用自己绑定的环境(BlockEnv.WithSession)
Udp = _udp,
TcpListen = _tcp,
Admission = _admission,
Counters = _counters.Data,
MaxPacketsPerSecond = config.Limits.MaxTotalPps,
MaxBulkMbps = config.Limits.MaxTotalMbps,
};
_chunks = new ChunkAssembler(_streams, _clock, _counters, _outbox, identity.NodeId, time, log);
var targets = new TargetRunners(_chunks, identity.NodeId, _clock, time, log, _rateChanges);
_tasks = new TaskManager(CreateGuard, _streams, env, targets, () => _datagrams.BoundPorts, () => _tcp.BoundPorts, _trust, acceptLineTasks: _listening);
_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);
var chunks = new ChunkAssembler(_streams, _clock, _counters, _outbox, identity.NodeId, time, log);
var targets = new TargetRunners(chunks, identity.NodeId, _clock, time, log);
_tasks = new TaskManager(CreateGuard, _streams, env, targets, () => _udp.BoundPorts, () => _tcp.BoundPorts, _trust);
_hub = new PeerHub(_sessions, _clock, chunks, _tasks, _uplink, time, log);
_tests = new TestRunner(_tasks, _sessions, _streams, env, _uplink, config.Limits.MaxConcurrentTests, transport => transport == "udp" ? _udp.BoundPorts : _tcp.BoundPorts, time, log);
_tasks.OnLineTasksChanged = (dialer, set) => _ = _hub.PushToAsync(dialer, set).AsTask();
_tasks.OnLineTasksChanged = (probe, set) => _ = _hub.PushToAsync(probe, set).AsTask();
_tasks.OnPeersChanged = _hub.CloseNotIn;
_hub.OnDialerLost = dialer => _tests.DialerLost(dialer);
_admission.OnEndpointChanged = dialer => _ = _uplink.EmitEventAsync(AgentEventKinds.EndpointChanged, dialer.DatagramRemoteEndPoint?.ToString()).AsTask();
_hub.OnProbeLost = probe => _tests.ProbeLost(probe);
_admission.OnEndpointChanged = probe => _ = _uplink.EmitEventAsync(AgentEventKinds.EndpointChanged, probe.UdpEndpoint?.ToString()).AsTask();
_peers = new PeerListener(
new PeerListenerOptions
@@ -133,31 +114,13 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
_dialer.Dispose();
_datagrams.Dispose();
_ownedRestart?.Dispose();
}
public async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
_dialer.Dispose();
await _datagrams.DisposeAsync().ConfigureAwait(false);
_udp.Dispose();
_ownedRestart?.Dispose();
}
internal StreamRegistry Streams => _streams;
public IReadOnlyList<int> QuicPorts => _datagrams.BoundPorts;
public IReadOnlyList<int> UdpPorts => _udp.BoundPorts;
public IReadOnlyList<int> TcpPorts => _tcp.BoundPorts;
@@ -170,56 +133,40 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable
public FleetLink Link => _link;
/// <summary>拨号侧(测试和状态查看用)。</summary>
public DialerHost Dialer => _dialer;
/// <summary>对等面(拨号方连进来的会话)的路由器,测试用。</summary>
/// <summary>对等面(Probe 连进来的会话)的路由器,测试用。</summary>
public RpcRouter<PeerContext> PeerRouter => _hub.Router;
/// <summary>期望状态里是否已经有这个拨号方的公钥(测试用:握手会按它校验)。</summary>
public bool IsPeerKnown(string dialerId, string publicKeyBase64) =>
_tasks.LookupPeerKey(dialerId) is { } key && Convert.ToBase64String(key) == publicKeyBase64;
/// <summary>期望状态里是否已经有这个 Probe 的公钥(测试用:握手会按它校验)。</summary>
public bool IsPeerKnown(string probeId, string publicKeyBase64) =>
_tasks.LookupPeerKey(probeId) is { } key && Convert.ToBase64String(key) == publicKeyBase64;
public TestRunner Tests => _tests;
public async Task RunAsync(CancellationToken ct)
{
_log.Info("moshiro agent starting", MoShiro.Protocol.BuildInfo.Fields);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct);
Task quic = Task.CompletedTask;
Task tcp = Task.CompletedTask;
Task peers = Task.CompletedTask;
if (_listening)
{
// 数据面和控制端口先开始监听,再连接 Backend:hello 里上报的是实际端口
_datagrams.Bind();
_udp.Bind();
_tcp.Bind();
quic = _datagrams.RunAsync(linked.Token);
tcp = _tcp.RunAsync(linked.Token);
peers = _peers.RunAsync(linked.Token);
Task udp = _udp.RunAsync(linked.Token);
Task tcp = _tcp.RunAsync(linked.Token);
Task peers = _peers.RunAsync(linked.Token);
await Task.WhenAny(_peers.Started.Task, peers);
if (peers.IsFaulted)
{
await linked.CancelAsync();
await Task.WhenAll(quic, tcp, peers); // 抛出监听失败的原因(端口被占用等)
}
_log.Info("agent listening", ("control", ControlPort), ("quic", string.Join(',', QuicPorts)), ("tcp", string.Join(',', TcpPorts)));
}
else
{
_log.Info("agent is dial-only: listening is disabled");
await Task.WhenAll(udp, tcp, peers); // 抛出监听失败的原因(端口被占用等)
}
_ready.TrySetResult();
_log.Info("agent listening", ("control", ControlPort), ("udp", string.Join(',', UdpPorts)), ("tcp", string.Join(',', TcpPorts)));
try
{
await Task.WhenAll(quic, tcp, peers, _link.RunAsync(linked.Token), _dialer.RunAsync(linked.Token), ReportHostAsync(linked.Token), _chunks.RunTrafficReportingAsync(linked.Token));
await Task.WhenAll(udp, tcp, peers, _link.RunAsync(linked.Token), ReportHostAsync(linked.Token));
}
finally
{
await _tasks.StopAsync();
_chunks.FlushTraffic();
await linked.CancelAsync();
}
}
@@ -256,14 +203,14 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable
MaxTotalPps = _config.Limits.MaxTotalPps,
AllowedTargetCidrs = _config.Limits.AllowedTargetCidrs,
MaxConcurrentTests = _config.Limits.MaxConcurrentTests,
AllowedQuicPorts = _datagrams.BoundPorts,
AllowedUdpPorts = _udp.BoundPorts,
AllowedTcpPorts = _tcp.BoundPorts,
},
_listening ? BlockCatalog.AgentKinds : BlockCatalog.TargetKinds);
BlockCatalog.AgentKinds);
private FleetLink BuildLink()
{
var router = new RpcRouter<LinkContext>(RpcSide.Node, MoShiroMethodTable.Table);
var router = new RpcRouter<LinkContext>(RpcSide.Node, MoShiroMethodTable.Table, NodeRoles.Agent);
HashSet<string>? allowed = _config.Policy.AllowMethods is { } list ? [.. list] : null;
bool Allow(string method) => allowed is null || allowed.Contains(method);
@@ -289,8 +236,7 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable
{
router.OnNotify(MoShiroMethods.UpdateOffer, MoShiroJsonContext.Default.UpdateOfferParams, (_, offer, _) =>
{
_update?.Offer(offer);
_nativeUpdate?.Offer();
_update.WriteOffer(offer);
return ValueTask.CompletedTask;
});
}
@@ -312,45 +258,18 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable
{
router.OnNotify(MoShiroMethods.UpdateApply, MoShiroJsonContext.Default.UpdateOfferParams, (_, _, _) =>
{
_update?.RequestApply();
_nativeUpdate?.RequestApply();
_update.RequestApply();
return ValueTask.CompletedTask;
});
}
if (Allow(MoShiroMethods.DialerDisconnect))
if (Allow(MoShiroMethods.ProbeDisconnect))
{
router.On(
MoShiroMethods.DialerDisconnect,
MoShiroJsonContext.Default.DialerDisconnectParams,
MoShiroMethods.ProbeDisconnect,
MoShiroJsonContext.Default.ProbeDisconnectParams,
MoShiroJsonContext.Default.TestRunResult,
async (_, request, _) => Result<TestRunResult>.Ok(new TestRunResult { Accepted = await _hub.DisconnectAsync(request.DialerId.Value) }));
}
if (Allow(MoShiroMethods.LogsFetch))
{
router.On(
MoShiroMethods.LogsFetch,
MoShiroJsonContext.Default.LogsFetchParams,
MoShiroJsonContext.Default.LogsFetchResult,
(_, request, _) =>
{
NodeLogPage page = _logSource?.Read(request.AfterId, request.Limit, request.MinLevel) ?? new NodeLogPage([], 0);
return ValueTask.FromResult(Result<LogsFetchResult>.Ok(new LogsFetchResult
{
LastId = page.LastId,
Items = [.. page.Items.Select(e => new LogEntryDto { Id = e.Id, Type = e.Type, AtUtcMs = e.Record.AtUtcMs, Level = e.Record.Level, Message = e.Record.Message, Fields = e.Record.Fields })],
}));
});
}
if (Allow(MoShiroMethods.LinkRetry))
{
router.OnNotify(MoShiroMethods.LinkRetry, MoShiroJsonContext.Default.LinkRetryParams, (_, request, _) =>
{
_dialer.RetryListener(request.ListenerId.Value);
return ValueTask.CompletedTask;
});
async (_, request, _) => Result<TestRunResult>.Ok(new TestRunResult { Accepted = await _hub.DisconnectAsync(request.ProbeId.Value) }));
}
var options = new FleetLinkOptions
@@ -358,35 +277,21 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable
Server = new Uri(_config.Fleet.Server),
Identity = _identity.Identity,
ServerVerifier = _trust,
Attestation = _update.Attest,
NodeId = _identity.NodeId,
NodeVersion = Version,
MinServerVersion = () => Environment.GetEnvironmentVariable("MOSHIRO_MIN_SERVER_VERSION") is { Length: > 0 } required ? required : _config.Fleet.MinServerVersion,
Capabilities = [MoShiroCapabilities.Base, MoShiroCapabilities.KindsPrefix + string.Join(',', _listening ? BlockCatalog.AgentKinds : BlockCatalog.TargetKinds)],
Capabilities = [MoShiroCapabilities.Base, MoShiroCapabilities.KindsPrefix + string.Join(',', BlockCatalog.AgentKinds)],
HelloMeta = HelloMeta,
HeartbeatExtra = HeartbeatExtra,
ShutdownReason = () => _restart.NowReason == "update" ? "updating" : "stopping",
};
var link = new FleetLink(
options,
router,
[
new StateHandler<TaskSet>(TaskSet.StateType, MoShiroJsonContext.Default.TaskSet, _tasks.ApplyAsync),
new StateHandler<DialTargets>(DialTargets.StateType, MoShiroJsonContext.Default.DialTargets, (version, targets, _) =>
{
_dialer.Directory.Apply(targets);
return ValueTask.FromResult(new StateSyncResult { AppliedVersion = version });
}),
],
_outbox,
_time,
_log);
var link = new FleetLink(options, router, [new StateHandler<TaskSet>(TaskSet.StateType, MoShiroJsonContext.Default.TaskSet, _tasks.ApplyAsync)], _outbox, _time, _log);
// 链路重新连上之后补报:在线的拨号方都告诉 Backend(Backend 重启或链路抖动期间错过了 dialer_connected)
// 链路重新连上之后补报:在线的 Probe 都告诉 Backend(Backend 重启或链路抖动期间错过了 probe_connected)
link.OnConnected = async (_, _) =>
{
foreach (ConnectedDialer dialer in _hub.ConnectedDialers())
foreach (ConnectedProbe probe in _hub.ConnectedProbes())
{
await _uplink.EmitEventAsync(AgentEventKinds.DialerConnected, dialer.Id.Value, dialer);
await _uplink.EmitEventAsync(AgentEventKinds.ProbeConnected, probe.Id.Value, probe);
}
};
return link;
@@ -397,11 +302,16 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable
private JsonElement? HelloMeta()
{
var meta = new NodeHelloMeta
var meta = new AgentHelloMeta
{
Owners = [.. _trust.Controllers],
Ipv6 = Ipv6Diagnostics.Sample(_ipv6Supported, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")),
Ipv6Supported = _ipv6Supported,
Addresses = [.. LocalAddresses.Sample()],
Platform = MoShiro.Measurement.PlatformDetector.Detect(),
TlsPublicKey = Convert.ToBase64String(_identity.Certificate.PublicKey.ExportSubjectPublicKeyInfo()),
ProbeTcpPort = ControlPort,
ProbeUdpPorts = [.. UdpPorts],
LocalLimits = new LocalLimits
{
MaxTotalMbps = _config.Limits.MaxTotalMbps,
@@ -409,17 +319,8 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable
AllowedTargetCidrs = _config.Limits.AllowedTargetCidrs,
MaxConcurrentTests = _config.Limits.MaxConcurrentTests,
},
Listen = !_listening
? null
: new ListenInfo
{
TcpPort = ControlPort,
QuicPorts = [.. QuicPorts],
Ipv6Supported = _ipv6Supported == true,
Addresses = [.. LocalAddresses.Sample()],
},
};
return JsonSerializer.SerializeToElement(meta, MoShiroJsonContext.Default.NodeHelloMeta);
return JsonSerializer.SerializeToElement(meta, MoShiroJsonContext.Default.AgentHelloMeta);
}
private long _lastIn;
@@ -432,33 +333,21 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable
long seconds = Math.Max(1, (nowMs - _lastAtMs) / 1000);
long packetsIn = _counters.PacketsIn;
long packetsOut = _counters.PacketsOut;
NodeLogSummary? logSummary = _logSource?.TakeSummary();
UpdateStatus? updateStatus = _nativeUpdate?.Status() ?? _update?.Status();
var extra = new NodeHeartbeatExtra
var extra = new AgentHeartbeatExtra
{
RuntimeStatus = updateStatus?.State == "applying" ? NodeRuntimeStatus.Updating : !_listening && _dialer.Directory.Targets.Count == 0 ? NodeRuntimeStatus.Idle : NodeRuntimeStatus.Running,
Ipv6 = Ipv6Diagnostics.Sample(_ipv6Supported, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")),
Links = _dialer.SnapshotLinks(),
Dialers = [.. _hub.ConnectedDialers()],
Probes = [.. _hub.ConnectedProbes()],
Sessions = _sessions.All.Count,
PpsIn = _lastAtMs == 0 ? 0 : (packetsIn - _lastIn) / seconds,
PpsOut = _lastAtMs == 0 ? 0 : (packetsOut - _lastOut) / seconds,
DroppedInvalid = _counters.DroppedInvalid,
DroppedRateLimited = _counters.DroppedRateLimited,
QueueBytes = _outbox.PendingBytes,
Update = updateStatus,
RateChanges = _rateChanges.Take(64),
LogSummary = logSummary is null ? null : new LogSummaryDto
{
LastId = logSummary.LastId,
Info = logSummary.Info,
Warn = logSummary.Warn,
Error = logSummary.Error,
Types = [.. logSummary.Types.Select(t => new LogTypeCountDto { Type = t.Type, Level = t.Level, Count = t.Count })],
},
Update = _update.ReadStatus(),
};
_lastIn = packetsIn;
_lastOut = packetsOut;
_lastAtMs = nowMs;
return JsonSerializer.SerializeToElement(extra, MoShiroJsonContext.Default.NodeHeartbeatExtra);
return JsonSerializer.SerializeToElement(extra, MoShiroJsonContext.Default.AgentHeartbeatExtra);
}
}
+14 -39
View File
@@ -1,5 +1,4 @@
using System.Text.Json.Serialization;
using MoShiro.Measurement;
namespace MoShiro.Agent;
@@ -15,9 +14,6 @@ public sealed class AgentConfig
[JsonPropertyName("listen")]
public ListenSection Listen { get; set; } = new();
[JsonPropertyName("quic")]
public QuicSection Quic { get; set; } = new();
[JsonPropertyName("limits")]
public LimitsSection Limits { get; set; } = new();
@@ -32,6 +28,16 @@ public sealed class AgentConfig
public string? RestartAt { get; set; }
}
public sealed class ReportingSection
{
/// <summary>上报主机资源占用(CPU、内存、磁盘、流量、运行时长,只有汇总数字,没有型号和 IP)。设为 false 就一个字也不上报。</summary>
[JsonPropertyName("host_stats")]
public bool HostStats { get; set; } = true;
[JsonPropertyName("host_interval_s")]
public int HostIntervalSeconds { get; set; } = 2;
}
public sealed class FleetSection
{
[JsonPropertyName("server")]
@@ -43,53 +49,24 @@ public sealed class FleetSection
/// <summary>节点自己要求有属主(passkey):没有就不运行。见 <c>OwnerGuard</c>。</summary>
[JsonPropertyName("require_owner")]
public bool RequireOwner { get; set; } = true;
/// <summary>面板至少要是哪个版本才继续连接(如 <c>0.4.2-dev.80</c>);空 = 不要求。不够就不发任何东西,5 秒后重新握手(面板可能正在更新)。环境变量 <c>MOSHIRO_MIN_SERVER_VERSION</c> 优先。</summary>
[JsonPropertyName("min_server_version")]
public string? MinServerVersion { get; set; }
}
public sealed class ListenSection
{
/// <summary>是否接受拨号方的连接。关闭时节点只拨出:不开任何端口,在 Backend 的目录里也不是拨号目标(家宽和 NAT 后的节点用这个配置)。</summary>
[JsonPropertyName("enabled")]
public bool Enabled { get; set; } = true;
[JsonPropertyName("bind")]
public string Bind { get; set; } = "::";
/// <summary>拨号方 控制通道(TLS,点对点会话)。</summary>
/// <summary>Probe 控制通道(TLS,点对点会话)。</summary>
[JsonPropertyName("control_port")]
public int ControlPort { get; set; } = 7443;
[JsonPropertyName("quic_ports")]
public List<int> QuicPorts { get; set; } = [7443];
[JsonPropertyName("udp_ports")]
public List<int> UdpPorts { get; set; } = [7443];
[JsonPropertyName("tcp_ports")]
public List<int> TcpPorts { get; set; } = [7444];
}
/// <summary>数据通道 QUIC 连接的传输参数;不写的项用 MsQuic 默认值。监听和拨出的连接都用这里的值。</summary>
public sealed class QuicSection
{
[JsonPropertyName("min_mtu")]
public int? MinMtu { get; set; }
[JsonPropertyName("max_mtu")]
public int? MaxMtu { get; set; }
[JsonPropertyName("pacing")]
public bool? Pacing { get; set; }
[JsonPropertyName("idle_timeout_ms")]
public int? IdleTimeoutMs { get; set; }
[JsonPropertyName("keep_alive_ms")]
public int? KeepAliveMs { get; set; }
public QuicSettings ToSettings() => new() { MinMtu = MinMtu, MaxMtu = MaxMtu, Pacing = Pacing, IdleTimeoutMs = IdleTimeoutMs, KeepAliveMs = KeepAliveMs };
}
public sealed class LimitsSection
{
[JsonPropertyName("max_total_mbps")]
@@ -98,7 +75,7 @@ public sealed class LimitsSection
[JsonPropertyName("max_total_pps")]
public long MaxTotalPps { get; set; } = 20_000;
/// <summary>单个 Agent 任务(外部目标)能探测的网段。默认为空:只允许公网地址,内网、回环、链路本地等一律拒绝;写了就只允许列出的网段(可以包含内网)。</summary>
/// <summary>默认为空:不允许执行任何目标任务。</summary>
[JsonPropertyName("allowed_target_cidrs")]
public List<string> AllowedTargetCidrs { get; set; } = [];
@@ -114,6 +91,4 @@ public sealed class PolicySection
}
[JsonSerializable(typeof(AgentConfig))]
[JsonSerializable(typeof(DialCache))]
[JsonSerializable(typeof(OpenWrtHealth))]
public sealed partial class AgentConfigContext : System.Text.Json.Serialization.JsonSerializerContext;
@@ -6,7 +6,7 @@ using MoShiro.Protocol;
namespace MoShiro.Agent;
/// <summary>
/// 拨号方 的列块到达之后:找到对应的流 → 与 Agent 在数据面记录的列合并 → 盖章(块头、时钟锚点)→ 编码 → 放进发件箱(通道 16)。
/// Probe 的列块到达之后:找到对应的流 → 与 Agent 在数据面记录的列合并 → 盖章(块头、时钟锚点)→ 编码 → 放进发件箱(通道 16)。
/// 找不到对应流的列块(任务已经被撤销、测试已经结束)被丢弃并计数;这只会发生在配置变更的瞬间。
/// </summary>
public sealed class ChunkAssembler
@@ -18,7 +18,6 @@ public sealed class ChunkAssembler
private readonly NodeId _agent;
private readonly TimeProvider _time;
private readonly INodeLog _log;
private readonly TaskTrafficReporter _traffic;
public ChunkAssembler(StreamRegistry streams, MonoClock clock, AgentCounters counters, Outbox outbox, NodeId agent, TimeProvider time, INodeLog log)
{
@@ -29,24 +28,23 @@ public sealed class ChunkAssembler
_agent = agent;
_time = time;
_log = log;
_traffic = new TaskTrafficReporter(log, time);
}
/// <summary>
/// 处理某个拨号方交来的一个列块(已经解码)。返回是否被接受。
/// 块头里的 <c>dialer_id</c> 以认证过的会话为准(拨号方 自报的值不可信),别的拨号方的流也不会被命中。
/// 处理某个 Probe 交来的一个列块(已经解码)。返回是否被接受。
/// 块头里的 <c>probe_id</c> 以认证过的会话为准(Probe 自报的值不可信),别的 Probe 的流也不会被命中。
/// </summary>
public bool Accept(RawChunk fragment, DialerSession dialer)
public bool Accept(RawChunk fragment, ProbeSession probe)
{
RawChunkHeader header = fragment.Header;
StreamInfo? stream = header switch
{
{ TestId: { } test, Phase: { } phase } => _streams.FindByTest(test, phase) is { } found && ReferenceEquals(found.Owner, dialer) ? found : null,
{ TaskId: { } task } => _streams.FindByTask(task, dialer),
{ TestId: { } test, Phase: { } phase } => _streams.FindByTest(test, phase) is { } found && ReferenceEquals(found.Owner, probe) ? found : null,
{ TaskId: { } task } => _streams.FindByTask(task, probe),
_ => null,
};
header.DialerId = new NodeId(dialer.DialerId);
header.IpVersion = dialer.IpVersion; // 地址族以认证过的控制会话为准,不接受列块自报值
header.ProbeId = new ProbeId(probe.ProbeId);
header.IpVersion = probe.IpVersion; // 地址族以认证过的控制会话为准,不接受列块自报值
KindSpec? spec = stream is null ? null : Kinds.Find(stream.Kind);
if (stream is null || spec is null || spec.Id != header.Kind)
{
@@ -70,20 +68,9 @@ public sealed class ChunkAssembler
return true;
}
public Task RunTrafficReportingAsync(CancellationToken ct) => _traffic.RunAsync(ct);
public void FlushTraffic() => _traffic.Flush();
/// <summary>Agent 自己采集的块(目标任务)直接发出。</summary>
public void Emit(RawChunk chunk)
{
byte[] payload = RawChunkCodec.Encode(chunk, out int uncompressedBytes);
_outbox.Enqueue(MoShiroChannels.RawChunk, chunk.Header.ChunkId.Value, payload);
_traffic.Record(
chunk.Header.TaskId?.Value ?? chunk.Header.TestId?.Value ?? "unknown",
chunk.Header.Kind,
chunk.Streams.Sum(stream => (long)stream.Count),
uncompressedBytes,
payload.Length);
_outbox.Enqueue(MoShiroChannels.RawChunk, chunk.Header.ChunkId.Value, RawChunkCodec.Encode(chunk));
}
}
@@ -1,107 +0,0 @@
using Fleet.Core;
using Fleet.Rpc;
namespace MoShiro.Agent;
/// <summary>Aggregates per-task Agent → Backend chunk sizes into low-volume diagnostic logs.</summary>
internal sealed class TaskTrafficReporter
{
private static readonly TimeSpan s_window = TimeSpan.FromMinutes(1);
private readonly object _gate = new();
private readonly INodeLog _log;
private readonly TimeProvider _time;
private readonly Dictionary<(string Task, string Kind), Totals> _totals = [];
private long _windowStarted;
public TaskTrafficReporter(INodeLog log, TimeProvider time)
{
_log = log;
_time = time;
_windowStarted = time.GetTimestamp();
}
public void Record(string task, string kind, long records, int uncompressedBytes, int compressedBytes)
{
lock (_gate)
{
var key = (task, kind);
if (!_totals.TryGetValue(key, out Totals? totals))
{
totals = new Totals();
_totals.Add(key, totals);
}
totals.Chunks++;
totals.Records += records;
totals.UncompressedBytes += uncompressedBytes;
totals.CompressedBytes += compressedBytes;
}
}
public async Task RunAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(s_window, _time);
try
{
while (await timer.WaitForNextTickAsync(ct))
{
Flush();
}
}
catch (OperationCanceledException)
{
// The final snapshot is flushed by the Agent shutdown path.
}
}
public void Flush()
{
List<Snapshot> completed;
long windowSeconds;
lock (_gate)
{
if (_totals.Count == 0)
{
_windowStarted = _time.GetTimestamp();
return;
}
TimeSpan elapsed = _time.GetElapsedTime(_windowStarted);
windowSeconds = Math.Max(1, (long)elapsed.TotalSeconds);
completed = [.. _totals.Select(pair => new Snapshot(pair.Key.Task, pair.Key.Kind, pair.Value.Chunks, pair.Value.Records, pair.Value.UncompressedBytes, pair.Value.CompressedBytes))];
_totals.Clear();
_windowStarted = _time.GetTimestamp();
}
foreach (Snapshot item in completed)
{
double savedPercent = item.UncompressedBytes == 0
? 0
: 100d * (item.UncompressedBytes - item.CompressedBytes) / item.UncompressedBytes;
long queuedBytes = item.CompressedBytes + (long)item.Chunks * OutboxFrame.HeaderBytes;
_log.Info(
"task upload traffic",
("link", "agent-backend"),
("task", item.Task),
("kind", item.Kind),
("chunks", item.Chunks),
("records", item.Records),
("raw_bytes", item.UncompressedBytes),
("compressed_bytes", item.CompressedBytes),
("queued_bytes", queuedBytes),
("saved_pct", Math.Round(savedPercent, 1)),
("window_s", windowSeconds));
}
}
private sealed class Totals
{
public long Chunks;
public long Records;
public long UncompressedBytes;
public long CompressedBytes;
}
private sealed record Snapshot(string Task, string Kind, long Chunks, long Records, long UncompressedBytes, long CompressedBytes);
}
@@ -34,19 +34,19 @@ public sealed class AgentCounters
public void FragmentDiscarded() => Interlocked.Increment(ref _fragmentsDiscarded);
}
public sealed record PeerContext(DialerSession Session, MonoClock Clock);
public sealed record PeerContext(ProbeSession Session, MonoClock Clock);
/// <summary>
/// 一个已经认证的拨号方会话:数据面的 <c>session_id</c> 和密钥(由点对点握手里的 ECDH 派生)、最近一次的 UDP 源地址、
/// 一个已经认证的 Probe 会话:数据面的 <c>session_id</c> 和密钥(由点对点握手里的 ECDH 派生)、最近一次的 UDP 源地址、
/// 每个流的重放窗口。数据面的包不绑定五元组,NAT 映射变化之后自动跟随新的源地址。
/// </summary>
public sealed class DialerSession : DataSession
public sealed class ProbeSession : DataSession
{
private readonly ConcurrentDictionary<uint, ReplayWindow> _windows = new();
private readonly ConcurrentDictionary<ulong, byte> _nonces = new();
private SocketAddress? _datagramSource;
private SocketAddress? _udpSource;
public DialerSession(PeerSessionInfo info)
public ProbeSession(PeerSessionInfo info)
: base(info.Credentials.SessionIdValue, info.Credentials.Key)
{
Info = info;
@@ -54,20 +54,16 @@ public sealed class DialerSession : DataSession
public PeerSessionInfo Info { get; }
public string DialerId => Info.PeerId;
public override string DiagnosticPeerId => DialerId;
public string? CloseReason { get; set; }
public string ProbeId => Info.PeerId;
public string IpVersion => Info.Remote is IPEndPoint endpoint && endpoint.Address.AddressFamily == AddressFamily.InterNetworkV6 && !endpoint.Address.IsIPv4MappedToIPv6 ? IpVersions.V6 : IpVersions.V4;
public string PathKey => DialerId + "~" + IpVersion;
public string PathKey => ProbeId + "~" + IpVersion;
/// <summary>拨号方 的程序版本(握手里自报,只用于展示)。</summary>
/// <summary>Probe 的程序版本(握手里自报,只用于展示)。</summary>
public string? Version => Info.Version;
/// <summary>拨号方 的远端地址(经过 NAT 之后的公网 IP)。</summary>
/// <summary>Probe 的远端地址(经过 NAT 之后的公网 IP)。</summary>
public string? RemoteAddress => (Info.Remote as IPEndPoint)?.Address.ToString();
public long ConnectedAtUtcMs { get; init; }
@@ -75,11 +71,11 @@ public sealed class DialerSession : DataSession
public RpcSession<PeerContext>? Rpc { get; set; }
/// <summary>最近一次认证通过的 UDP 包的源地址(Agent 把单向数据发往这里)。</summary>
public override IPEndPoint? DatagramRemoteEndPoint
public override IPEndPoint? UdpEndpoint
{
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);
}
}
@@ -87,7 +83,7 @@ public sealed class DialerSession : DataSession
/// <summary>记录 UDP 源地址;NAT 映射变化后地址变了返回 true(上报 endpoint_changed)。第一次看到地址不算变化。</summary>
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))
{
return false;
@@ -100,7 +96,7 @@ public sealed class DialerSession : DataSession
copy[i] = source[i];
}
Volatile.Write(ref _datagramSource, copy);
Volatile.Write(ref _udpSource, copy);
return previous is not null;
}
@@ -133,59 +129,59 @@ public sealed class DialerSession : DataSession
public void ForgetStream(uint streamId) => _windows.TryRemove(streamId, out _);
}
/// <summary>每个拨号方的 IPv4、IPv6 会话独立;重连只替换同地址族的旧会话。</summary>
/// <summary>每个 Probe 的 IPv4、IPv6 会话独立;重连只替换同地址族的旧会话。</summary>
public sealed class SessionRegistry : IDataSessions
{
private readonly object _gate = new();
private readonly Dictionary<string, DialerSession> _byDialer = new(StringComparer.Ordinal);
private readonly Dictionary<long, DialerSession> _bySession = [];
private readonly Dictionary<string, ProbeSession> _byProbe = new(StringComparer.Ordinal);
private readonly Dictionary<long, ProbeSession> _bySession = [];
public IReadOnlyList<DialerSession> All
public IReadOnlyList<ProbeSession> All
{
get
{
lock (_gate)
{
return [.. _byDialer.Values];
return [.. _byProbe.Values];
}
}
}
public DialerSession? Get(string dialerId, string ipVersion = IpVersions.V4)
public ProbeSession? Get(string probeId, string ipVersion = IpVersions.V4)
{
lock (_gate)
{
return _byDialer.GetValueOrDefault(dialerId + "~" + ipVersion);
return _byProbe.GetValueOrDefault(probeId + "~" + ipVersion);
}
}
/// <summary>登记新会话;返回同一个拨号方的旧会话(如果有)。</summary>
public DialerSession? Replace(DialerSession session)
/// <summary>登记新会话;返回同一个 Probe 的旧会话(如果有)。</summary>
public ProbeSession? Replace(ProbeSession session)
{
lock (_gate)
{
_byDialer.TryGetValue(session.PathKey, out DialerSession? old);
_byProbe.TryGetValue(session.PathKey, out ProbeSession? old);
if (old is not null)
{
_bySession.Remove(old.SessionId);
}
_byDialer[session.PathKey] = session;
_byProbe[session.PathKey] = session;
_bySession[session.SessionId] = session;
return old;
}
}
public void Remove(DialerSession session)
public void Remove(ProbeSession session)
{
lock (_gate)
{
if (_byDialer.TryGetValue(session.PathKey, out DialerSession? current) && ReferenceEquals(current, session))
if (_byProbe.TryGetValue(session.PathKey, out ProbeSession? current) && ReferenceEquals(current, session))
{
_byDialer.Remove(session.PathKey);
_byProbe.Remove(session.PathKey);
}
if (_bySession.TryGetValue(session.SessionId, out DialerSession? bySession) && ReferenceEquals(bySession, session))
if (_bySession.TryGetValue(session.SessionId, out ProbeSession? bySession) && ReferenceEquals(bySession, session))
{
_bySession.Remove(session.SessionId);
}
@@ -217,8 +213,8 @@ public sealed class StreamInfo
KindSpec spec = Kinds.Find(kind)!;
DeclaredPps = kind switch
{
"quic_ping" or "tcp_ping" => Kinds.PeakRateHz(kind, parameters),
"quic_stream" => parameters.GetNumber("mbps") * 1_000_000 / 8 / Math.Max(1, parameters.GetInt("size")),
"udp_echo" or "tcp_echo" => parameters.GetInt("rate_hz"),
"udp_train" => parameters.GetNumber("mbps") * 1_000_000 / 8 / Math.Max(1, parameters.GetInt("size")),
_ => 20,
};
Port = spec.Params.Any(p => p.Name == "port") ? (int)parameters.GetInt("port") : 0;
@@ -236,8 +232,8 @@ public sealed class StreamInfo
public int? Phase { get; init; }
/// <summary>这个流属于哪个拨号方会话:别的会话的包即使带着这个流编号也不会被接受。</summary>
public DialerSession? Owner { get; init; }
/// <summary>这个流属于哪个 Probe 会话:别的会话的包即使带着这个流编号也不会被接受。</summary>
public ProbeSession? Owner { get; init; }
public int Port { get; }
@@ -290,7 +286,7 @@ public sealed class StreamRegistry : IRecordSource
public bool TryGet(uint id, out StreamInfo stream) => _streams.TryGetValue(id, out stream!);
public StreamInfo? FindByTask(TaskId task, DialerSession owner) => _streams.Values.FirstOrDefault(s => s.TaskId == task && ReferenceEquals(s.Owner, owner));
public StreamInfo? FindByTask(TaskId task, ProbeSession owner) => _streams.Values.FirstOrDefault(s => s.TaskId == task && ReferenceEquals(s.Owner, owner));
public StreamInfo? FindByTest(TestId test, int phase) => _streams.Values.FirstOrDefault(s => s.TestId == test && s.Phase == phase);
@@ -314,11 +310,11 @@ public sealed class AgentAdmission : IAdmission
}
/// <summary>NAT 映射变化,源地址变了:上报 <c>endpoint_changed</c>。</summary>
public Action<DialerSession>? 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 DialerSession dialer || !_streams.TryGet(header.StreamId, out StreamInfo? stream) || !ReferenceEquals(stream.Owner, dialer) || !dialer.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))
{
return Admission.Invalid;
}
@@ -328,9 +324,9 @@ public sealed class AgentAdmission : IAdmission
return Admission.RateLimited;
}
if (dialer.UpdateSource(source))
if (probe.UpdateSource(source))
{
OnEndpointChanged?.Invoke(dialer);
OnEndpointChanged?.Invoke(probe);
}
return Admission.Accepted;
@@ -1,39 +0,0 @@
namespace MoShiro.Agent;
/// <summary>Reconnect cadence for the control connection of a single dialer-to-listener path: 1, 2, 4, 8, 16, then five 32-second waits, then once per minute.</summary>
public sealed class ControlBackoff
{
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 const int MaxMinutes = 10;
private int _attempt;
public int Attempts => Volatile.Read(ref _attempt);
public TimeSpan Next()
{
int attempt = Interlocked.Increment(ref _attempt) - 1;
if (attempt < s_delays.Length)
{
return s_delays[attempt];
}
// 表走完还连不上(对端关了、这台机器没有这条路由):一分钟起继续翻倍,最长十分钟,不再每分钟白白重试一次、写三条日志
return TimeSpan.FromMinutes(Math.Min(MaxMinutes, 1 << Math.Min(attempt - s_delays.Length, 4)));
}
public void Reset() => Interlocked.Exchange(ref _attempt, 0);
}
@@ -1,393 +0,0 @@
using System.Net;
using System.Net.Sockets;
using System.Text.Json;
using Fleet.Core;
using Fleet.Identity;
using Fleet.Link;
using Fleet.Rpc;
using MoShiro.Measurement;
using MoShiro.Protocol;
namespace MoShiro.Agent;
/// <summary>所有连接共用的东西。</summary>
public sealed record DialEnv(NodeIdentity Identity, string NodeId, LimitsSection Limits, QuicSettings Quic, DialDirectory Directory, MonoClock Clock, TimeProvider Time, INodeLog Log, RateChangeLog RateChanges);
/// <summary>
/// 与一个 Agent 的点对点连接,分两层:
/// <list type="bullet">
/// <item>控制层:TCP+TLS 长连接。认证握手 → 同步时钟 → 按 Agent 下发的 <c>line_task_set</c> 运行积木 → 每 10 秒把已完成的行切成列块放进发件箱,
/// 发件箱和控制 RPC 共用这条连接。控制层断开才算与 Agent 失联:积木停止(数据面凭据属于控制会话),按 <see cref="ControlBackoff"/> 重拨。</item>
/// <item>任务层:TCP 类积木各自拨号;QUIC 数据通道由 <see cref="TaskLink"/> 在控制层在线期间单独维护,断开只重拨 QUIC。</item>
/// </list>
/// 发件箱属于这条连接,不属于某次控制会话:控制层重连之后重发还没确认的数据块。状态与日志由 <see cref="LinkStatus"/> 统一维护。
/// </summary>
public sealed class DialConnection : IDisposable
{
private static readonly TimeSpan s_flushInterval = TimeSpan.FromSeconds(ProtocolLimits.ChunkSeconds);
private static readonly TimeSpan s_clockInterval = TimeSpan.FromSeconds(30);
/// <summary>Agent 的任何帧(时钟回复、任务集)都算存活;比时钟同步间隔长得多,容得下差线路上的黑洞,但不会让一个 Agent 已经丢掉的会话永远挂着。</summary>
private static readonly TimeSpan s_idleLimit = TimeSpan.FromSeconds(120);
private static readonly RpcRouter<DialSession> s_router = BuildRouter();
/// <summary>对等面入站方法的路由器(只读,测试用)。</summary>
public static RpcRouter<DialSession> Router => s_router;
private readonly DialEnv _env;
private readonly LinkStatus _status;
private readonly Outbox _outbox = new(8 << 20);
private readonly SemaphoreSlim _retrySignal = new(0, 1);
private INodeLog _attemptLog;
private string _phase = "idle";
private long _attemptStarted;
private volatile DialSession? _session;
private volatile TaskLink? _task;
private QuicDatagramTransport? _activeDatagrams;
public DialConnection(DialEndpoint endpoint, DialEnv env)
{
Endpoint = endpoint;
_env = env;
_attemptLog = env.Log;
_status = new LinkStatus(endpoint.NodeId, endpoint.IpVersion, env.Time, env.Log);
}
public DialEndpoint Endpoint { get; }
/// <summary>控制层在线,且 QUIC 数据通道可发送。</summary>
public bool IsConnected => _session is not null && Volatile.Read(ref _activeDatagrams)?.IsConnected == true;
public int RoleCount => _session?.RoleCount ?? 0;
public long ClockOffsetUs => _session?.Clock.OffsetUs ?? 0;
/// <summary>当前线路状态(随心跳上报给面板)。</summary>
public LinkHealth Health() =>
_status.Snapshot(_outbox, _session?.TcpBlockCount ?? 0, Volatile.Read(ref _activeDatagrams)?.GetTransportSnapshot());
/// <summary>管理端手动配对:控制层在退避就马上再拨;控制层在线而 QUIC 在重拨,就跳过 QUIC 的等待。</summary>
public void RetryNow()
{
if (_status.Control == ControlState.Up)
{
if (_status.Quic == QuicState.Retrying)
{
_task?.RetryNow();
}
return;
}
try
{
_retrySignal.Release();
}
catch (SemaphoreFullException)
{
// 已经有一次手动重试排队。
}
catch (ObjectDisposedException)
{
// 对应线路正在被移除。
}
}
public void Dispose() => _retrySignal.Dispose();
/// <summary>控制层循环:拨号,会话结束后按退避重拨。</summary>
public async Task RunAsync(CancellationToken ct)
{
var backoff = new ControlBackoff();
while (!ct.IsCancellationRequested)
{
_attemptStarted = _env.Time.GetTimestamp();
_attemptLog = new ContextNodeLog(_env.Log, ("agent", Endpoint.NodeId), ("ip_version", Endpoint.IpVersion),
("attempt_id", Guid.NewGuid().ToString("N")));
_status.Log = _attemptLog;
_phase = "dns";
_status.SetControl(ControlState.Dialing, attempt: backoff.Attempts);
LinkFault fault;
try
{
fault = await RunOnceAsync(backoff, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
return;
}
catch (Exception ex)
{
fault = LinkFaults.From(ex, _phase);
}
try
{
TimeSpan delay = backoff.Next();
_status.SetControl(ControlState.Backoff, fault, backoff.Attempts, 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;
_attemptLog.Info("manual link retry requested");
}
else
{
await delayTask;
try { await retryTask; }
catch (OperationCanceledException) { }
}
}
catch (OperationCanceledException)
{
return;
}
}
}
/// <summary>一次控制会话:建立 → 运行 → 结束。建立失败抛出异常;建立之后结束返回结束原因。</summary>
private async Task<LinkFault> RunOnceAsync(ControlBackoff backoff, CancellationToken ct)
{
_attemptLog.Info("agent connection attempt started", ("phase", _phase), ("host", Endpoint.Host),
("tcp_port", Endpoint.TcpPort), ("quic_ports", string.Join(',', Endpoint.QuicPorts)));
IPAddress address = await ResolveAsync(ct);
byte[] agentKey = Convert.FromBase64String(Endpoint.PublicKey);
(PeerSessionInfo info, TlsFrameTransport transport) = await PeerConnector.ConnectAsync(
address.ToString(), Endpoint.TcpPort, agentKey, _env.Identity, _env.NodeId, AgentApp.Version, 8 << 20, _env.Time, _attemptLog, ct,
onPhase: OnPeerPhase);
var data = new DataSession(info.Credentials.SessionIdValue, info.Credentials.Key);
var remote = (IPEndPoint)info.Remote!;
List<int> quicPorts = Endpoint.QuicPorts;
int quicPort = quicPorts.Count == 0 ? 7443 : quicPorts[0];
var datagrams = new QuicDatagramTransport(
data,
_env.Clock,
new DataPlaneCounters(),
_attemptLog,
new IPEndPoint(remote.Address, quicPort),
agentKey,
_env.Quic);
Volatile.Write(ref _activeDatagrams, datagrams);
using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
_outbox.ResetSent();
var clock = new ClockEstimator();
var shipper = new FragmentShipper(_outbox, new NodeId(Endpoint.NodeId), new NodeId(_env.NodeId), clock, _env.Time);
var blockEnv = new BlockEnv
{
Clock = _env.Clock,
Log = _attemptLog,
Session = () => data,
Datagrams = datagrams,
PeerAddress = remote.Address,
PeerSpkiSha256 = NodeIdentity.Fingerprint(agentKey),
KnockKey = Knock.DeriveKey(_env.Identity, agentKey),
Fragments = shipper,
MaxPacketsPerSecond = _env.Limits.MaxTotalPps,
MaxBulkMbps = _env.Limits.MaxTotalMbps,
OnRateChange = change =>
{
change.Listener = Endpoint.NodeId;
change.IpVersion = Endpoint.IpVersion;
_env.RateChanges.Add(change);
},
};
var session = new DialSession(blockEnv, clock, _outbox, _env);
var rpc = new RpcSession<DialSession>(transport, s_router, session, _env.Time);
rpc.OnBinary = (_, _) => ValueTask.CompletedTask;
Task run = rpc.RunAsync(cts.Token);
Task pump = PumpAsync(rpc, _outbox, cts.Token);
Task quic = Task.CompletedTask;
Task maintenance = Task.CompletedTask;
TaskLink? taskLink = null;
try
{
// 先同步时钟(块头里要带偏移);Agent 随后推送线路任务集
_phase = "clock_sync";
_status.SetControl(ControlState.Syncing);
await SyncClockAsync(rpc, clock, rounds: 5, cts.Token);
_attemptLog.Info("agent clock synchronized", ("phase", _phase), ("offset_us", clock.OffsetUs));
backoff.Reset();
_retrySignal.Wait(0, CancellationToken.None); // 若“立即重试”恰好赶在本次拨号成功前到达,本次成功已满足请求。
_session = session;
_phase = "control_session";
_status.SetControl(ControlState.Up);
taskLink = new TaskLink(datagrams, _status, _env.Time);
_task = taskLink;
quic = taskLink.RunAsync(cts.Token);
maintenance = MaintainAsync(rpc, session, clock, cts.Token);
_attemptLog.Info("agent control session up", ("phase", _phase), ("remote", remote), ("quic_port", quicPort), ("offset_us", clock.OffsetUs));
Task finished = await Task.WhenAny(run, pump, maintenance, quic);
LinkFault fault = finished.IsFaulted && finished.Exception?.GetBaseException() is { } error
? LinkFaults.From(error, _phase)
: LinkFaults.Closed(_phase, finished == run
? "closed by the Agent control connection"
: finished == pump
? "outbox pump stopped"
: finished == quic
? "QUIC link loop stopped"
: "connection maintenance stopped");
await cts.CancelAsync();
await BoundedAsync("stop tasks", () => Task.WhenAll(Quiet(run), Quiet(pump), Quiet(maintenance), Quiet(quic)));
return fault;
}
finally
{
_task = null;
_session = null;
await cts.CancelAsync();
session.FlushLineBlocks(); // 停止前把已稳定的行交给发件箱,重连之后补传
await BoundedAsync("stop blocks", session.StopAllAsync);
await BoundedAsync("wait control", () => Quiet(run));
await BoundedAsync("wait pump", () => Quiet(pump));
await BoundedAsync("wait quic", () => Quiet(quic));
taskLink?.Dispose();
await BoundedAsync("dispose control", () => rpc.DisposeAsync().AsTask());
Volatile.Write(ref _activeDatagrams, null);
await BoundedAsync("dispose quic", () => datagrams.DisposeAsync().AsTask());
_status.SetQuic(QuicState.Idle);
}
}
private async Task<IPAddress> ResolveAsync(CancellationToken ct)
{
AddressFamily family = Endpoint.IpVersion == IpVersions.V6 ? AddressFamily.InterNetworkV6 : AddressFamily.InterNetwork;
using var resolveTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(10), _env.Time);
using CancellationTokenSource resolveCt = CancellationTokenSource.CreateLinkedTokenSource(ct, resolveTimeout.Token);
IPAddress[] addresses = IPAddress.TryParse(Endpoint.Host, out IPAddress? literal)
? [literal]
: await Dns.GetHostAddressesAsync(Endpoint.Host, family, resolveCt.Token);
IPAddress address = addresses.FirstOrDefault(a => a.AddressFamily == family && !a.IsIPv4MappedToIPv6)
?? throw new SocketException((int)SocketError.HostNotFound);
_attemptLog.Info("agent address resolved", ("phase", _phase), ("remote", address),
("elapsed_ms", (long)_env.Time.GetElapsedTime(_attemptStarted).TotalMilliseconds));
return address;
}
private void OnPeerPhase(string phase)
{
_phase = phase;
if (phase == "peer_auth")
{
_status.SetControl(ControlState.Authenticating);
}
}
private async Task BoundedAsync(string step, Func<Task> work)
{
if (!await Bounded.WaitAsync(work(), Bounded.Teardown, _env.Time))
{
_attemptLog.Warn("agent session teardown step timed out, moving on", ("phase", _phase), ("step", step), ("timeout_ms", Bounded.Teardown.TotalMilliseconds));
}
}
private static async Task Quiet(Task task)
{
try
{
await task;
}
catch (Exception ex) when (ex is OperationCanceledException or IOException or ObjectDisposedException or System.Net.Sockets.SocketException or InvalidOperationException or TimeoutException)
{
// 会话结束时的正常噪声
}
}
/// <summary>回传数据:发件箱里的块经控制连接发给 Agent。</summary>
private static async Task PumpAsync(RpcSession<DialSession> rpc, Outbox outbox, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
byte[] frame = await outbox.TakeNextAsync(2 << 20, ct);
await rpc.SendBinaryAsync(frame, ct);
}
}
private async Task SyncClockAsync(RpcSession<DialSession> rpc, ClockEstimator clock, int rounds, CancellationToken ct)
{
for (int i = 0; i < rounds; i++)
{
long sent = _env.Clock.NowUs();
Result<ClockResult> reply = await rpc.RequestAsync(MoShiroMethods.Clock, new ClockParams { DialerMonoUs = sent }, MoShiroJsonContext.Default.ClockParams, MoShiroJsonContext.Default.ClockResult, ct);
if (reply.TryGet(out ClockResult? value, out _))
{
clock.Add(sent, value.ListenerMonoUs, _env.Clock.NowUs());
}
}
}
/// <summary>切块(每 10 秒)、时钟同步(每 30 秒)和控制连接的存活检查。</summary>
private async Task MaintainAsync(RpcSession<DialSession> rpc, DialSession session, ClockEstimator clock, CancellationToken ct)
{
using var flush = new PeriodicTimer(s_flushInterval, _env.Time);
long lastClockUs = _env.Clock.NowUs();
while (await flush.WaitForNextTickAsync(ct))
{
if (rpc.IdleFor > s_idleLimit)
{
// 监听方已经不认这个会话(它重启、重连、或者空闲超时关了会话,而 FIN 没有到达):拨号方这边结束它,让外层按退避重拨
throw new ControlSilentException($"the Agent sent nothing on this session for {(long)rpc.IdleFor.TotalSeconds}s");
}
session.FlushLineBlocks();
if (_env.Clock.NowUs() - lastClockUs >= s_clockInterval.TotalMicroseconds)
{
lastClockUs = _env.Clock.NowUs();
await SyncClockAsync(rpc, clock, rounds: 1, ct);
}
}
}
private static RpcRouter<DialSession> BuildRouter() =>
new RpcRouter<DialSession>(RpcSide.Dialer, MoShiroMethodTable.Table)
.On(
FleetMethods.StateSync,
FleetRpcContext.Default.StateSyncParams,
FleetRpcContext.Default.StateSyncResult,
async (session, sync, _) =>
{
var result = new StateSyncResult { AppliedVersion = sync.Version };
if (sync.Type == LineTaskSet.StateType)
{
LineTaskSet? set = sync.Doc.Deserialize(MoShiroJsonContext.Default.LineTaskSet);
if (set is null)
{
return Result<StateSyncResult>.Fail(FleetErrors.InvalidParams, "line_task_set could not be parsed");
}
result.Rejected.AddRange(await session.ApplyAsync(set));
}
return Result<StateSyncResult>.Ok(result);
})
.On(
MoShiroMethods.TestPhase,
MoShiroJsonContext.Default.TestPhaseStartParams,
MoShiroJsonContext.Default.TestPhaseStartResult,
(session, start, _) => ValueTask.FromResult(Result<TestPhaseStartResult>.Ok(new TestPhaseStartResult { Accepted = session.StartPhase(start) })))
.On(
MoShiroMethods.TestPhaseEnd,
MoShiroJsonContext.Default.TestPhaseEndParams,
MoShiroJsonContext.Default.TestPhaseEndResult,
async (session, end, ct) => Result<TestPhaseEndResult>.Ok(new TestPhaseEndResult { Fragments = await session.EndPhaseAsync(end, ct) }))
.OnNotify(
MoShiroMethods.TestAbort,
MoShiroJsonContext.Default.TestAbortParams,
async (session, abort, _) => await session.AbortAsync(abort.TestId))
.OnNotify(
FleetMethods.OutboxAck,
FleetRpcContext.Default.OutboxAckParams,
(session, ack, _) =>
{
session.Outbox.Acknowledge(ack.Ids.Concat(ack.Duplicates).Select(Guid.Parse));
return ValueTask.CompletedTask;
});
}
@@ -1,116 +0,0 @@
using System.Text.Json;
using Fleet.Core;
using Fleet.Hosting;
using MoShiro.Protocol;
namespace MoShiro.Agent;
/// <summary>
/// 本节点要拨的监听节点列表:由面板作为期望状态(<c>dial_targets</c>)直接下发;面板连不上时用上一次保存的 <c>dial_targets.json</c>。
/// 列表变化时用 <see cref="ConfigStore"/> 写回 <c>agents.json</c>(这是配置,不是测量数据;只在变化时写,保护路由器的闪存)。
/// </summary>
public sealed class DialDirectory
{
public const string FileName = "dial_targets.json";
private readonly object _gate = new();
private readonly ConfigStore _store;
private readonly INodeLog _log;
private readonly TrustAnchor _trust;
private List<DialEndpoint> _targets = [];
private long _version;
public DialDirectory(ConfigStore store, string configDirectory, INodeLog log, TrustAnchor? trust = null)
{
_trust = trust ?? TrustAnchor.None;
_store = store;
_log = log;
string cache = Path.Combine(configDirectory, FileName);
if (File.Exists(cache))
{
try
{
DialCache saved = NodeConfig.Load(cache, AgentConfigContext.Default.DialCache);
_targets = saved.Targets;
_version = saved.Version;
}
catch (Exception ex) when (ex is IOException or InvalidDataException or JsonException or InvalidOperationException)
{
_log.Warn("dial_targets.json is unreadable, waiting for the panel", ("err", ex.Message));
}
}
}
/// <summary>列表变化(调和连接)。</summary>
public event Action? Changed;
public long Version
{
get
{
lock (_gate)
{
return _version;
}
}
}
public IReadOnlyList<DialEndpoint> Targets
{
get
{
lock (_gate)
{
return [.. _targets];
}
}
}
/// <summary>应用面板下发的列表。版本不比当前新的忽略(乱序到达的旧推送)。</summary>
public void Apply(DialTargets result)
{
var next = new List<DialEndpoint>();
_trust.Update(result.TrustChain);
foreach (DialTarget target in result.Targets)
{
if (!string.IsNullOrWhiteSpace(target.Host))
{
next.Add(new DialEndpoint { NodeId = target.NodeId.Value, Host = target.Host, TcpPort = target.TcpPort, QuicPorts = [.. target.QuicPorts], PublicKey = target.PublicKey });
}
if (!string.IsNullOrWhiteSpace(target.Ipv6Host))
{
next.Add(new DialEndpoint { NodeId = target.NodeId.Value, Host = target.Ipv6Host, TcpPort = target.TcpPort, QuicPorts = [.. target.QuicPorts], PublicKey = target.PublicKey, IpVersion = IpVersions.V6 });
}
}
lock (_gate)
{
if (result.Version < _version || (result.Version == _version && Same(next, _targets)))
{
return;
}
_version = result.Version;
_targets = next;
}
try
{
_store.Write(FileName, JsonSerializer.SerializeToUtf8Bytes(new DialCache { Version = result.Version, Targets = next }, AgentConfigContext.Default.DialCache));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_log.Warn("could not save dial_targets.json", ("err", ex.Message));
}
_log.Info("dial targets updated", ("version", result.Version), ("targets", next.Count));
Changed?.Invoke();
}
private static bool Same(List<DialEndpoint> a, List<DialEndpoint> b) =>
a.Count == b.Count && a.Zip(b).All(p => Equal(p.First, p.Second));
public static bool Equal(DialEndpoint a, DialEndpoint b) =>
a.NodeId == b.NodeId && a.IpVersion == b.IpVersion && a.Host == b.Host && a.TcpPort == b.TcpPort && a.QuicPorts.SequenceEqual(b.QuicPorts) && a.PublicKey == b.PublicKey;
}
@@ -1,40 +0,0 @@
using System.Text.Json.Serialization;
using MoShiro.Protocol;
namespace MoShiro.Agent;
/// <summary>要拨的一个监听节点的一条路径(一个 IP 族):地址、端口和固定证书用的公钥。</summary>
public sealed class DialEndpoint
{
[JsonPropertyName("ip_version")]
public string IpVersion { get; set; } = IpVersions.V4;
[JsonIgnore]
public string PathKey => NodeId + "~" + IpVersion;
[JsonPropertyName("node_id")]
public string NodeId { get; set; } = "";
[JsonPropertyName("host")]
public string Host { get; set; } = "";
[JsonPropertyName("tcp_port")]
public int TcpPort { get; set; }
[JsonPropertyName("quic_ports")]
public List<int> QuicPorts { get; set; } = [7443];
/// <summary>监听方的 TLS 证书公钥(SPKI,base64):证书按它的 SHA-256 指纹固定,敲门密钥由它派生。</summary>
[JsonPropertyName("public_key")]
public string PublicKey { get; set; } = "";
}
/// <summary><c>dial_targets.json</c>:最近一次从面板同步到的拨号目标(配置,不是测量数据);面板连不上时节点靠它继续测量。</summary>
public sealed class DialCache
{
[JsonPropertyName("version")]
public long Version { get; set; }
[JsonPropertyName("targets")]
public List<DialEndpoint> Targets { get; set; } = [];
}
@@ -1,205 +0,0 @@
using System.Collections.Concurrent;
using Fleet.Link;
using Fleet.Rpc;
using MoShiro.Measurement;
using MoShiro.Protocol;
namespace MoShiro.Agent;
/// <summary>一次会话内的运行状态:线路任务的积木、按需测试的阶段。方法由路由器在单个入站队列里顺序调用。</summary>
public sealed class DialSession
{
private const int MaxBlocks = 16;
private readonly object _gate = new();
private readonly Dictionary<string, RunningBlock> _lineBlocks = [];
private readonly Dictionary<string, string> _lineKinds = [];
private readonly ConcurrentDictionary<uint, PhaseState> _phases = new();
public DialSession(BlockEnv blockEnv, ClockEstimator clock, Outbox outbox, DialEnv env)
{
BlockEnv = blockEnv;
Clock = clock;
Outbox = outbox;
Env = env;
}
public BlockEnv BlockEnv { get; }
public ClockEstimator Clock { get; }
public Outbox Outbox { get; }
public DialEnv Env { get; }
public int RoleCount
{
get
{
lock (_gate)
{
return _lineBlocks.Count;
}
}
}
/// <summary>正在运行的 TCP 类线路积木数(它们自带套接字和重拨,不依赖 QUIC 数据通道)。</summary>
public int TcpBlockCount
{
get
{
lock (_gate)
{
return _lineKinds.Values.Count(IsTcpKind);
}
}
}
private static bool IsTcpKind(string kind) => kind is "tcp_ping" or "tcp_connect" or "tls_hold" or "tcp_transfer";
/// <summary>调和线路任务:参数没有变化的积木继续运行,其余启动或停止。返回被拒绝的任务(不认识的 Kind、参数非法、超过上限)。</summary>
public async Task<List<RejectedItem>> ApplyAsync(LineTaskSet set)
{
var rejected = new List<RejectedItem>();
var wanted = new Dictionary<string, BlockSpec>();
foreach (LineTask task in set.Tasks)
{
BlockSpec? spec = BlockCatalog.DialerKinds.Contains(task.Kind)
? BlockSpec.Create(task.Kind, task.Params, task.StreamId, task.Port, new FragmentScope(task.Id, null, null))
: null;
if (spec is null)
{
rejected.Add(new RejectedItem { Id = task.Id.Value, Reason = BlockCatalog.DialerKinds.Contains(task.Kind) ? "invalid_params" : "unsupported" });
}
else if (wanted.Count >= MaxBlocks)
{
rejected.Add(new RejectedItem { Id = task.Id.Value, Reason = "budget_exceeded" });
}
else
{
wanted[task.Id.Value] = spec;
}
}
var stopped = new List<RunningBlock>();
lock (_gate)
{
foreach ((string id, RunningBlock running) in _lineBlocks.ToList())
{
if (!wanted.TryGetValue(id, out BlockSpec? spec) || spec.Signature != running.Signature)
{
stopped.Add(running);
_lineBlocks.Remove(id);
_lineKinds.Remove(id);
}
}
foreach ((string id, BlockSpec spec) in wanted)
{
if (!_lineBlocks.ContainsKey(id))
{
_lineBlocks[id] = RunningBlock.Start(BlockCatalog.Find(spec.KindId)!.Dialer(BlockEnv, spec), spec.Signature);
_lineKinds[id] = spec.KindId;
}
}
}
// 被停止的积木把已经稳定的行交出去(刚发出、还在等回复的行不交:它们不是丢失,只是没有等到)
foreach (RunningBlock running in stopped)
{
await running.StopAsync();
running.Block.Flush(FlushMode.Settled);
}
return rejected;
}
public void FlushLineBlocks()
{
List<RunningBlock> blocks;
lock (_gate)
{
blocks = [.. _lineBlocks.Values];
}
foreach (RunningBlock running in blocks)
{
running.Block.Flush(FlushMode.Settled);
}
}
/// <summary>按需测试的一个阶段:起对应的拨号方积木(单向数据流是接收端)。超过本地上限(或不认识)则拒绝。</summary>
public bool StartPhase(TestPhaseStartParams start)
{
string? direction = start.Params.TryGetValue("direction", out System.Text.Json.JsonElement d) ? d.GetString() : null;
if (_phases.ContainsKey(start.StreamId) || !BlockCatalog.DialerKinds.Contains(start.Kind) || BlockCatalog.Find(start.Kind)!.Supports(direction) is false)
{
return false;
}
if (start.Kind == "quic_stream")
{
double mbps = start.Params["mbps"].GetDouble();
long size = start.Params["size"].GetInt64();
long pps = (long)(mbps * 1_000_000 / 8 / Math.Max(1, size));
if (mbps > Env.Limits.MaxTotalMbps || pps > Env.Limits.MaxTotalPps)
{
return false;
}
}
BlockSpec? spec = BlockSpec.Create(start.Kind, start.Params, start.StreamId, start.Port, new FragmentScope(null, start.TestId, start.Phase));
if (spec is null)
{
return false;
}
_phases[start.StreamId] = new PhaseState(start.TestId, start.Kind, RunningBlock.Start(BlockCatalog.Find(start.Kind)!.Dialer(BlockEnv, spec), spec.Signature));
return true;
}
/// <summary>阶段结束:收尾、交出列块,返回块数。</summary>
public async Task<int> EndPhaseAsync(TestPhaseEndParams end, CancellationToken ct)
{
if (!_phases.TryRemove(end.StreamId, out PhaseState? phase))
{
return 0;
}
// 最后一批包(单向数据流)、最后几个回显的回复还在途中
await Task.Delay(TimeSpan.FromMilliseconds(phase.Kind == "quic_stream" ? 200 : 300), BlockEnv.Time, ct);
await phase.Running.StopAsync();
return phase.Running.Block.Flush(FlushMode.All, end.SentCount);
}
public async Task AbortAsync(TestId test)
{
foreach ((uint stream, PhaseState phase) in _phases.ToList())
{
if (phase.Test == test && _phases.TryRemove(stream, out _))
{
await phase.Running.StopAsync();
}
}
}
public async Task StopAllAsync()
{
List<RunningBlock> blocks;
lock (_gate)
{
blocks = [.. _lineBlocks.Values];
_lineBlocks.Clear();
_lineKinds.Clear();
}
blocks.AddRange(_phases.Values.Select(p => p.Running));
_phases.Clear();
foreach (RunningBlock running in blocks)
{
await running.StopAsync();
}
}
private sealed record PhaseState(TestId Test, string Kind, RunningBlock Running);
}
@@ -1,203 +0,0 @@
using System.Collections.Concurrent;
using Fleet.Core;
using MoShiro.Protocol;
namespace MoShiro.Agent;
/// <summary>
/// 拨号侧的组装点:每个拨号目标(监听节点 × IP 族)一个 <see cref="DialConnection"/>。目标列表变化时只调和连接:
/// 新增的建立,移除的断开,地址或证书指纹变化的重建,其余不受影响。
/// </summary>
public sealed class DialerHost : IDisposable
{
private readonly DialEnv _env;
private readonly Dictionary<string, Running> _connections = [];
private readonly ConcurrentDictionary<string, long> _pendingRetries = new(StringComparer.Ordinal);
private readonly SemaphoreSlim _changed = new(0);
private readonly TimeProvider _time;
private readonly INodeLog _log;
public DialerHost(DialEnv env, TimeProvider time, INodeLog log)
{
_env = env;
_time = time;
_log = log;
env.Directory.Changed += () => _changed.Release();
}
public DialDirectory Directory => _env.Directory;
/// <summary>某条路径的控制会话是否已经建立、QUIC 数据通道是否可发送(测试和状态查看用)。</summary>
public bool IsConnected(string listenerId, string ipVersion = IpVersions.V4)
{
lock (_connections)
{
return _connections.TryGetValue(listenerId + "~" + ipVersion, out Running? running) && running.Connection.IsConnected;
}
}
public DialConnection? Connection(string listenerId, string ipVersion = IpVersions.V4)
{
lock (_connections)
{
return _connections.TryGetValue(listenerId + "~" + ipVersion, out Running? running) ? running.Connection : null;
}
}
/// <summary>每条路径当前的线路健康(随心跳上报)。</summary>
public List<LinkHealth> SnapshotLinks()
{
DialConnection[] connections;
lock (_connections)
{
connections = [.. _connections.Values.Select(r => r.Connection)];
}
return [.. connections.Select(c => c.Health())];
}
/// <summary>管理端手动重试:跳过这个监听节点所有路径的退避。目标列表还没到时,30 秒内到达就补上。</summary>
public void RetryListener(string listenerId, bool queueIfMissing = true)
{
DialConnection[] matches;
lock (_connections)
{
matches = [.. _connections.Values.Select(r => r.Connection).Where(c => c.Endpoint.NodeId == listenerId)];
}
if (matches.Length == 0)
{
if (queueIfMissing)
{
_pendingRetries[listenerId] = _time.GetUtcNow().AddSeconds(30).ToUnixTimeMilliseconds();
}
_log.Warn("manual link retry requested but no matching dial target is configured", ("listener", listenerId));
return;
}
_pendingRetries.TryRemove(listenerId, out _);
foreach (DialConnection connection in matches)
{
connection.RetryNow();
}
}
public async Task RunAsync(CancellationToken ct)
{
try
{
while (!ct.IsCancellationRequested)
{
await ReconcileAsync(_env.Directory.Targets, ct);
RetryPending();
await _changed.WaitAsync(ct);
}
}
catch (OperationCanceledException)
{
// 停止
}
finally
{
await ReconcileAsync([], CancellationToken.None);
}
}
public void Dispose() => _changed.Dispose();
private void RetryPending()
{
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
foreach ((string listenerId, long expiresAt) in _pendingRetries.ToArray())
{
if (expiresAt <= now)
{
_pendingRetries.TryRemove(listenerId, out _);
}
else
{
RetryListener(listenerId, queueIfMissing: false);
}
}
}
private async Task ReconcileAsync(IReadOnlyList<DialEndpoint> targets, CancellationToken ct)
{
var wanted = targets.ToDictionary(t => t.PathKey);
var stopped = new List<Running>();
lock (_connections)
{
foreach ((string id, Running running) in _connections.ToList())
{
if (!wanted.TryGetValue(id, out DialEndpoint? endpoint) || !DialDirectory.Equal(endpoint, running.Connection.Endpoint))
{
stopped.Add(running);
_connections.Remove(id);
}
}
}
foreach (Running running in stopped)
{
await running.StopAsync();
}
if (ct.IsCancellationRequested)
{
return;
}
lock (_connections)
{
foreach ((string id, DialEndpoint endpoint) in wanted)
{
if (!_connections.ContainsKey(id))
{
_connections[id] = Running.Start(new DialConnection(endpoint, _env));
}
}
}
}
private sealed class Running : IDisposable
{
private readonly CancellationTokenSource _cts = new();
private Task _run = Task.CompletedTask;
private Running(DialConnection connection)
{
Connection = connection;
}
public DialConnection Connection { get; }
public static Running Start(DialConnection connection)
{
var running = new Running(connection);
running._run = Task.Run(() => connection.RunAsync(running._cts.Token), CancellationToken.None);
return running;
}
public async Task StopAsync()
{
await _cts.CancelAsync();
try
{
await _run.WaitAsync(TimeSpan.FromSeconds(10));
}
catch (Exception ex) when (ex is OperationCanceledException or TimeoutException)
{
// 没有及时退出:不再等
}
Dispose();
}
public void Dispose()
{
Connection.Dispose();
_cts.Dispose();
}
}
}
@@ -1,44 +0,0 @@
using System.Net.Sockets;
using System.Security.Authentication;
using MoShiro.Protocol;
namespace MoShiro.Agent;
/// <summary>控制连接在已建立之后停止应答:对端不认这个会话了,或者线路中断超过了空闲上限。</summary>
public sealed class ControlSilentException(string message) : IOException(message);
/// <summary>把连接各阶段的异常归类成上报给面板的 <see cref="LinkFault"/>:失败发生在哪个阶段、属于哪一类。</summary>
public static class LinkFaults
{
private const int MaxMessage = 200;
public static LinkFault From(Exception ex, string phase)
{
Exception inner = ex is AggregateException { InnerExceptions.Count: 1 } aggregate ? aggregate.InnerExceptions[0] : ex;
string kind = inner switch
{
ControlSilentException => "silent",
OperationCanceledException or TimeoutException => "timeout",
AuthenticationException => "tls",
SocketException socket => Kind(socket.SocketErrorCode),
IOException { InnerException: SocketException socket } => Kind(socket.SocketErrorCode),
InvalidOperationException invalid when invalid.Message.Contains("authentication", StringComparison.OrdinalIgnoreCase)
|| invalid.Message.Contains("challenge", StringComparison.OrdinalIgnoreCase) => "auth",
IOException => "closed",
_ => "other",
};
string message = kind == "timeout" && inner is OperationCanceledException ? $"{phase} did not finish in time" : inner.Message;
return new LinkFault { Phase = phase, Kind = kind, Message = message.Length > MaxMessage ? message[..MaxMessage] : message };
}
public static LinkFault Closed(string phase, string message) => new() { Phase = phase, Kind = "closed", Message = message };
private static string Kind(SocketError error) => error switch
{
SocketError.ConnectionRefused => "refused",
SocketError.ConnectionReset or SocketError.ConnectionAborted => "reset",
SocketError.HostNotFound or SocketError.NoData or SocketError.TryAgain => "dns",
SocketError.TimedOut => "timeout",
_ => "other",
};
}
@@ -1,247 +0,0 @@
using Fleet.Core;
using Fleet.Link;
using MoShiro.Measurement;
using MoShiro.Protocol;
namespace MoShiro.Agent;
/// <summary>
/// 一条线路(本节点拨号到一个监听节点的一个 IP 族)的状态:控制层和 QUIC 数据通道各一个状态机,状态迁移只在这里发生、只在这里记日志。
/// 控制层每次迁移写一条 <c>link state</c>;数据通道只在“可用 ↔ 不可用”的边界上写,重拨过程中的失败只计数。
/// </summary>
public sealed class LinkStatus
{
private const int MaxHandshakeSamples = 128;
private readonly object _gate = new();
private readonly string _listenerId;
private readonly string _ipVersion;
private readonly TimeProvider _time;
private readonly Queue<double> _handshakeMs = new();
private ControlState _control = ControlState.Dialing;
private long _controlSince;
private LinkFault? _controlFault;
private int _controlAttempt;
private long? _controlRetryAt;
private QuicState _quic = QuicState.Idle;
private long _quicSince;
private long? _quicDownSince;
private LinkFault? _quicFault;
private int _quicAttempt;
private long? _quicRetryAt;
private long _handshakeAttempts;
private long _handshakeSuccesses;
private long _handshakeFailures;
private long _handshakeTimeouts;
public LinkStatus(string listenerId, string ipVersion, TimeProvider time, INodeLog log)
{
_listenerId = listenerId;
_ipVersion = ipVersion;
_time = time;
Log = log;
_controlSince = _quicSince = Now();
}
/// <summary>当前这次尝试的日志(带 attempt_id 等上下文);控制层每次重拨前由连接更新。</summary>
public INodeLog Log { get; set; }
public ControlState Control
{
get
{
lock (_gate)
{
return _control;
}
}
}
public QuicState Quic
{
get
{
lock (_gate)
{
return _quic;
}
}
}
public bool Ready => Control == ControlState.Up && Quic == QuicState.Ready;
public void SetControl(ControlState state, LinkFault? fault = null, int attempt = 0, TimeSpan? retryIn = null)
{
ControlState from;
long inStateMs;
lock (_gate)
{
from = _control;
long now = Now();
inStateMs = now - _controlSince;
if (state != from)
{
_controlSince = now;
}
_control = state;
_controlFault = state == ControlState.Up ? null : fault ?? _controlFault;
_controlAttempt = attempt;
_controlRetryAt = retryIn is { } delay ? now + (long)delay.TotalMilliseconds : null;
}
if (state == from)
{
return;
}
string reason = state == ControlState.Backoff && fault is not null ? fault.ToString() : "";
Log.Write(state == ControlState.Backoff ? NodeLogLevel.Warn : NodeLogLevel.Info, "link state",
[
("layer", "control"), ("listener", _listenerId), ("ip_version", _ipVersion), ("from", Name(from)), ("to", Name(state)),
("in_state_ms", inStateMs), ("attempt", attempt), ("retry_in_ms", retryIn is { } r ? (long)r.TotalMilliseconds : null),
("reason", reason.Length == 0 ? null : reason),
]);
}
public void SetQuic(QuicState state, LinkFault? fault = null, int attempt = 0, TimeSpan? retryIn = null)
{
QuicState from;
long? downMs = null;
bool logged;
lock (_gate)
{
from = _quic;
long now = Now();
if (state != from)
{
_quicSince = now;
}
if (state == QuicState.Ready)
{
downMs = _quicDownSince is { } down ? now - down : null;
_quicDownSince = null;
_quicFault = null;
}
else if (state is QuicState.Retrying or QuicState.Connecting)
{
_quicDownSince ??= now;
_quicFault = fault ?? _quicFault;
}
else
{
_quicDownSince = null;
}
// 数据通道抖动时不逐次打印:恢复可用、首次失败时各一条,重拨中的失败只计数。
logged = state == QuicState.Ready || (state == QuicState.Retrying && (from == QuicState.Ready || attempt == 1));
_quic = state;
_quicAttempt = attempt;
_quicRetryAt = retryIn is { } delay ? now + (long)delay.TotalMilliseconds : null;
}
if (logged)
{
Log.Write(state == QuicState.Retrying ? NodeLogLevel.Warn : NodeLogLevel.Info, "link state",
[
("layer", "task_quic"), ("listener", _listenerId), ("ip_version", _ipVersion), ("from", Name(from)), ("to", Name(state)),
("down_ms", downMs), ("attempt", attempt), ("retry_in_ms", retryIn is { } r ? (long)r.TotalMilliseconds : null),
("reason", state == QuicState.Retrying ? fault?.ToString() : null),
]);
}
}
/// <summary>记录一次 QUIC 握手的结果。</summary>
public void Handshake(bool succeeded, double? elapsedMs, bool timedOut)
{
lock (_gate)
{
_handshakeAttempts++;
if (succeeded)
{
_handshakeSuccesses++;
if (elapsedMs is { } ms)
{
_handshakeMs.Enqueue(ms);
while (_handshakeMs.Count > MaxHandshakeSamples)
{
_handshakeMs.Dequeue();
}
}
}
else
{
_handshakeFailures++;
if (timedOut)
{
_handshakeTimeouts++;
}
}
}
}
public LinkHealth Snapshot(Outbox outbox, int tcpBlocks, QuicTransportSnapshot? transport)
{
lock (_gate)
{
long now = Now();
double? p95 = null;
if (_handshakeMs.Count > 0)
{
double[] sorted = [.. _handshakeMs.Order()];
p95 = sorted[(int)Math.Ceiling(sorted.Length * 0.95) - 1];
}
return new LinkHealth
{
ListenerId = _listenerId,
IpVersion = _ipVersion,
Control = new ControlHealth
{
State = _control,
SinceMs = _controlSince,
LastError = _controlFault,
RetryAttempt = _controlAttempt,
RetryInMs = _control == ControlState.Backoff ? RetryIn(_controlRetryAt, now) : null,
OutboxBytes = outbox.PendingBytes,
OutboxPending = outbox.PendingCount,
},
Task = new TaskHealth
{
TcpBlocks = tcpBlocks,
Quic = new QuicHealth
{
State = _quic,
SinceMs = _quicSince,
LastError = _quicFault,
RetryAttempt = _quicAttempt,
RetryInMs = _quic == QuicState.Retrying ? RetryIn(_quicRetryAt, now) : null,
HandshakeAttempts = _handshakeAttempts,
HandshakeSuccesses = _handshakeSuccesses,
HandshakeFailures = _handshakeFailures,
HandshakeTimeouts = _handshakeTimeouts,
HandshakeP95Ms = p95,
HandshakeSampleCount = _handshakeMs.Count,
TransportPacketLossRate = transport?.PacketLossRate,
TransportPacketsSent = transport?.PacketsSent,
TransportCongestionEvents = transport?.CongestionEvents,
TransportEcnCongestionEvents = transport?.EcnCongestionEvents,
PathMtu = transport?.PathMtu,
},
},
};
}
}
private static long? RetryIn(long? at, long now) => at is { } t ? Math.Max(0, t - now) : null;
private long Now() => _time.GetUtcNow().ToUnixTimeMilliseconds();
private static string Name(ControlState state) => state.ToString().ToLowerInvariant();
private static string Name(QuicState state) => state.ToString().ToLowerInvariant();
}
@@ -1,31 +0,0 @@
namespace MoShiro.Agent;
/// <summary>
/// QUIC 数据通道的重拨节奏:1、2、3、5 秒,之后固定 5 秒。数据通道断开期间的测量是缺口,所以不像控制连接那样拉长到一分钟。
/// 抖动由调用方通过 <paramref name="random"/> 给出,避免多条线路同时重拨。
/// </summary>
public sealed class QuicRedialBackoff
{
private static readonly TimeSpan[] s_delays = [TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(5)];
private const int ShortAttempts = 12;
private int _attempt;
public int Attempts => _attempt;
public TimeSpan Next(Random? random = null)
{
TimeSpan delay = s_delays[Math.Min(_attempt, s_delays.Length - 1)];
if (_attempt >= ShortAttempts)
{
// 短间隔已经连续失败了这么多次:这条路径大概率是死的(对端不通、被丢弃),握手和日志都是白费,翻倍退避,最长五分钟
delay = TimeSpan.FromSeconds(Math.Min(300, 5 * (1 << Math.Min(_attempt - ShortAttempts + 1, 6))));
}
_attempt++;
return random is null ? delay : delay * (0.8 + (0.4 * random.NextDouble()));
}
public void Reset() => _attempt = 0;
}
@@ -1,162 +0,0 @@
using Fleet.Core;
using MoShiro.Measurement;
using MoShiro.Protocol;
namespace MoShiro.Agent;
/// <summary>
/// 任务层的 QUIC 数据通道:控制层在线期间在后台维持一条 QUIC 连接,断开后按 <see cref="QuicRedialBackoff"/> 重拨。
/// 重拨期间控制连接和 TCP 类积木不受影响;UDP 类积木的发送在通道不可用时按各自的规则处理。
/// </summary>
public sealed class TaskLink : IDisposable
{
/// <summary>QUIC 连接可用至少这么久才算稳定,重拨节奏随之复位。刚连上就断的通道不会把间隔重置回 1 秒。</summary>
private static readonly TimeSpan s_stableAfter = TimeSpan.FromSeconds(10);
/// <summary>一直在发测量报文、却这么久没有收到任何一个:QUIC 路径已经是黑洞,主动关闭重拨。</summary>
private static readonly TimeSpan s_silentLimit = TimeSpan.FromSeconds(30);
private static readonly TimeSpan s_watchInterval = TimeSpan.FromSeconds(5);
private static readonly TimeSpan s_summaryInterval = TimeSpan.FromSeconds(60);
private readonly QuicDatagramTransport _datagrams;
private readonly LinkStatus _status;
private readonly TimeProvider _time;
private readonly SemaphoreSlim _retrySignal = new(0, 1);
public TaskLink(QuicDatagramTransport datagrams, LinkStatus status, TimeProvider time)
{
_datagrams = datagrams;
_status = status;
_time = time;
}
public void Dispose() => _retrySignal.Dispose();
/// <summary>管理端手动重试:跳过当前的重拨等待。</summary>
public void RetryNow()
{
try
{
_retrySignal.Release();
}
catch (SemaphoreFullException)
{
// 已有一次排队
}
}
public async Task RunAsync(CancellationToken ct)
{
var backoff = new QuicRedialBackoff();
while (!ct.IsCancellationRequested)
{
_status.SetQuic(QuicState.Connecting, attempt: backoff.Attempts);
LinkFault fault;
long readyAt = 0;
try
{
try
{
await _datagrams.ConnectAsync(ct);
}
catch (Exception ex) when (!ct.IsCancellationRequested)
{
_status.Handshake(false, null, ex is TimeoutException or OperationCanceledException);
throw;
}
_status.Handshake(true, _datagrams.HandshakeDurationMs, false);
readyAt = _time.GetTimestamp();
_status.SetQuic(QuicState.Ready);
fault = await WatchAsync(ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
return;
}
catch (Exception ex)
{
fault = LinkFaults.From(ex, "quic_handshake");
}
await _datagrams.CloseClientAsync();
if (readyAt != 0 && _time.GetElapsedTime(readyAt) >= s_stableAfter)
{
backoff.Reset();
}
TimeSpan delay = backoff.Next(Random.Shared);
_status.SetQuic(QuicState.Retrying, fault, backoff.Attempts, delay);
try
{
using var wait = CancellationTokenSource.CreateLinkedTokenSource(ct);
Task sleep = Task.Delay(delay, _time, wait.Token);
Task manual = _retrySignal.WaitAsync(wait.Token);
await Task.WhenAny(sleep, manual);
await wait.CancelAsync();
await Task.WhenAll(Settle(sleep), Settle(manual));
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
return;
}
}
}
/// <summary>连接可用期间的看护:连接关闭(对端关闭、空闲超时)或数据面静默时返回原因。</summary>
private async Task<LinkFault> WatchAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(s_watchInterval, _time);
Task closed = _datagrams.WaitForDisconnectAsync(ct);
long seenIn = _datagrams.Counters.PacketsIn;
long seenOut = _datagrams.Counters.PacketsOut;
long inChangedAt = _time.GetTimestamp();
long lastSummaryAt = inChangedAt;
while (true)
{
Task tick = timer.WaitForNextTickAsync(ct).AsTask();
if (await Task.WhenAny(closed, tick) == closed)
{
await closed;
return LinkFaults.Closed("quic_data", "QUIC connection closed");
}
await tick;
long packetsIn = _datagrams.Counters.PacketsIn;
long packetsOut = _datagrams.Counters.PacketsOut;
long now = _time.GetTimestamp();
if (packetsIn != seenIn)
{
seenIn = packetsIn;
inChangedAt = now;
}
else if (packetsOut != seenOut && _time.GetElapsedTime(inChangedAt, now) > s_silentLimit)
{
return new LinkFault { Phase = "quic_data", Kind = "silent", Message = $"no datagram received for {(long)_time.GetElapsedTime(inChangedAt, now).TotalSeconds}s while sending" };
}
seenOut = packetsOut;
if (_time.GetElapsedTime(lastSummaryAt, now) >= s_summaryInterval)
{
lastSummaryAt = now;
QuicTransportSnapshot? transport = _datagrams.GetTransportSnapshot();
_status.Log.Info("agent measurement summary", ("layer", "task_quic"), ("packets_in", packetsIn), ("packets_out", packetsOut),
("dropped_invalid", _datagrams.Counters.DroppedInvalid), ("dropped_rate_limited", _datagrams.Counters.DroppedRateLimited),
("transport_packets_sent", transport?.PacketsSent), ("path_mtu", transport?.PathMtu));
}
}
}
private static async Task Settle(Task task)
{
try
{
await task;
}
catch (OperationCanceledException)
{
// 等待被另一条路径或停止打断
}
}
}
@@ -1,13 +1,13 @@
using System.Globalization;
using MoShiro.Protocol;
namespace MoShiro.Measurement;
namespace MoShiro.Agent;
/// <summary>
/// 拨号方和监听方共用。主机资源占用的只读快照:CPU 占用、内存、根分区、网卡累计流量与瞬时网速、系统运行时长。
/// 主机资源占用的只读快照:CPU 占用、内存、根分区、网卡累计流量与瞬时网速、系统运行时长。
/// 只有汇总的数字:<b>不采集 CPU 型号、主机名、网卡名和任何 IP 地址</b>,所以面板和展示页想泄漏也泄漏不出去。
/// Linux 读 <c>/proc</c> 和 <c>/sys</c>;容器通过 HOST_PROC、HOST_SYS、HOST_ROOT 指定宿主路径。
/// Windows 用 GetSystemTimes、GlobalMemoryStatusEx 和 NetworkInterface(见 WindowsHost);macOS 等其他系统只有磁盘和运行时长。读不到的项是 null。不执行任何命令,不写文件。
/// 读不到的项是 null(例如 Windows 上的 CPU)。不执行任何命令,不写文件。
/// </summary>
public sealed class HostSampler
{
@@ -18,7 +18,6 @@ public sealed class HostSampler
private readonly Func<(long Total, long Used)?> _disk;
private readonly string _procRoot;
private readonly string _sysRoot;
private readonly bool _native;
private (long Total, long Idle)? _previousCpu;
private (long At, long Rx, long Tx)? _previousNet;
@@ -26,7 +25,6 @@ public sealed class HostSampler
string? procRoot = null, string? sysRoot = null, string? diskRoot = null)
{
_time = time;
_native = read is null && OperatingSystem.IsWindows(); // 只有默认读法才走 Windows 的系统调用;测试注入 read 时仍按 Linux 的文本解析
_read = read ?? ReadProc;
_procRoot = Root(procRoot, "HOST_PROC", "/proc");
_sysRoot = Root(sysRoot, "HOST_SYS", "/sys");
@@ -39,8 +37,7 @@ public sealed class HostSampler
long now = _time.GetTimestamp();
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 (cpuNow is { } cpu)
if (_read(Path.Combine(_procRoot, "stat")) is { } stat && ParseCpu(stat) is { } cpu)
{
if (_previousCpu is { } before && cpu.Total > before.Total)
{
@@ -51,8 +48,7 @@ public sealed class HostSampler
_previousCpu = cpu;
}
(long Total, long Used)? memoryNow = _native ? WindowsMemory() : _read(Path.Combine(_procRoot, "meminfo")) is { } meminfo ? ParseMemory(meminfo) : null;
if (memoryNow is { } memory)
if (_read(Path.Combine(_procRoot, "meminfo")) is { } meminfo && ParseMemory(meminfo) is { } memory)
{
snapshot.MemTotal = memory.Total;
snapshot.MemUsed = memory.Used;
@@ -64,21 +60,11 @@ public sealed class HostSampler
snapshot.DiskUsed = disk.Used;
}
(long Rx, long Tx, long? LinkMbps)? netNow = null;
if (_native)
{
netNow = WindowsNetwork();
}
else if (_read(Path.Combine(_procRoot, "net/dev")) is { } dev && ParseNetwork(dev) is { } parsed)
{
netNow = (parsed.Rx, parsed.Tx, ReadLinkSpeed(dev));
}
if (netNow is { } net)
if (_read(Path.Combine(_procRoot, "net/dev")) is { } dev && ParseNetwork(dev) is { } net)
{
snapshot.NetRxTotal = net.Rx;
snapshot.NetTxTotal = net.Tx;
snapshot.NetLinkMbps = net.LinkMbps;
snapshot.NetLinkMbps = ReadLinkSpeed(dev);
if (_previousNet is { } before && net.Rx >= before.Rx && net.Tx >= before.Tx)
{
double seconds = Math.Max(0.001, _time.GetElapsedTime(before.At, now).TotalSeconds);
@@ -97,12 +83,6 @@ public sealed class HostSampler
return snapshot;
}
private static (long Total, long Idle)? WindowsCpu() => OperatingSystem.IsWindows() ? WindowsHost.Cpu() : null;
private static (long Total, long Used)? WindowsMemory() => OperatingSystem.IsWindows() ? WindowsHost.Memory() : null;
private static (long Rx, long Tx, long? LinkMbps)? WindowsNetwork() => OperatingSystem.IsWindows() ? WindowsHost.Network() : null;
/// <summary>第一行 <c>cpu user nice system idle iowait irq softirq steal …</c>:总时间与空闲时间(idle + iowait)。</summary>
public static (long Total, long Idle)? ParseCpu(string stat)
{
@@ -23,6 +23,4 @@
<Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
<Import Project="Sdk.targets" Sdk="StuDev.AotAnywhere"
Condition="'$(UseAotAnywhere)' == 'true'" />
<Import Project="..\..\..\shared\MoShiro.Measurement\StaticMsQuic.targets"
Condition="'$(StaticMsQuic)' == 'true'" />
</Project>
@@ -1,76 +0,0 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Fleet.Core;
using Fleet.Hosting;
using MoShiro.Measurement;
using MoShiro.Protocol;
namespace MoShiro.Agent;
/// <summary>
/// Native supervisor bridge: writes only fixed wake-up flags and a health beacon in tmpfs.
/// Never downloads, installs, verifies releases, or executes commands. The root-owned host
/// updater independently fetches and verifies its locally configured release feed.
/// </summary>
public sealed class OpenWrtUpdater
{
private readonly ConfigStore _runtime;
private readonly string _statusPath;
private readonly TimeProvider _time;
private readonly INodeLog _log;
public OpenWrtUpdater(string runtimeDirectory, string statusPath, TimeProvider time, INodeLog log)
{
_runtime = new ConfigStore(runtimeDirectory);
_statusPath = statusPath;
_time = time;
_log = log;
}
public static OpenWrtUpdater? FromEnvironment(TimeProvider time, INodeLog log) =>
Environment.GetEnvironmentVariable(RestartUpdater.EnvironmentVariable) == "openwrt"
? new OpenWrtUpdater("/var/run/moshiro-agent", "/var/run/moshiro-update/status.json", time, log)
: null;
public void Offer() => Signal("check.request");
public void RequestApply() => Signal("apply.request");
private void Signal(string fileName)
{
try
{
_runtime.Write(fileName, "check"u8.ToArray());
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_log.Warn("could not signal the native updater", ("err", ex.Message));
}
}
/// <summary>Called by the Fleet heartbeat: evidence that this version reached Backend.</summary>
public UpdateStatus? Status()
{
try
{
var health = new OpenWrtHealth(BuildInfo.Version, Environment.ProcessId, _time.GetUtcNow().ToUnixTimeSeconds());
_runtime.Write("health.json", JsonSerializer.SerializeToUtf8Bytes(health, AgentConfigContext.Default.OpenWrtHealth));
if (!File.Exists(_statusPath) || new FileInfo(_statusPath).Length > 8192)
{
return null;
}
return JsonSerializer.Deserialize(File.ReadAllBytes(_statusPath), MoShiroJsonContext.Default.UpdateStatus);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
{
_log.Warn("could not read native update status", ("err", ex.Message));
return null;
}
}
}
public sealed record OpenWrtHealth(
[property: JsonPropertyName("version")] string Version,
[property: JsonPropertyName("pid")] int Pid,
[property: JsonPropertyName("at")] long At);
+47 -69
View File
@@ -22,7 +22,7 @@ public sealed class BackendUplink
public bool IsConnected => _link?.CurrentSession is not null;
public async ValueTask EmitEventAsync(string kind, string? detail = null, ConnectedDialer? dialer = null)
public async ValueTask EmitEventAsync(string kind, string? detail = null, ConnectedProbe? probe = null)
{
RpcSession<LinkContext>? session = _link?.CurrentSession;
if (session is null)
@@ -30,7 +30,7 @@ public sealed class BackendUplink
return;
}
var parameters = new AgentEventParams { Kind = kind, AtUtcMs = _time.GetUtcNow().ToUnixTimeMilliseconds(), Detail = detail, Dialer = dialer };
var parameters = new AgentEventParams { Kind = kind, AtUtcMs = _time.GetUtcNow().ToUnixTimeMilliseconds(), Detail = detail, Probe = probe };
await SafeAsync(() => session.NotifyAsync(MoShiroMethods.Event, parameters, MoShiroJsonContext.Default.AgentEventParams, CancellationToken.None));
}
@@ -71,16 +71,15 @@ public sealed class BackendUplink
}
/// <summary>
/// 拨号方 的点对点会话:每次握手成功之后登记会话(同一个拨号方的新会话取代它自己的旧会话,不同拨号方的会话并存)、为这个会话起一组线路任务的流并下发线路任务集、接收拨号方的列块并交给
/// <see cref="ChunkAssembler"/>。会话结束时通知 Backend,并让正在运行的测试以 <c>dialer_offline</c> 结束。
/// Probe 的点对点会话:每次握手成功之后登记会话(同一个 Probe 的新会话取代它自己的旧会话,不同 Probe 的会话并存)、为这个会话起一组线路任务的流并下发线路任务集、接收 Probe 的列块并交给
/// <see cref="ChunkAssembler"/>。会话结束时通知 Backend,并让正在运行的测试以 <c>probe_offline</c> 结束。
/// </summary>
public sealed class PeerHub
{
private const int DedupeWindow = 1024;
/// <summary>超过这么久没有收到拨号方的任何帧就认为会话已死(拨号方 每 30 秒至少发一次时钟同步)。
/// 要比线路可能的中断长得多:差的线路上几十秒甚至一两分钟的黑洞时有发生(TCP RTT 能到 30 秒),75 秒就关会话会把一次中断变成几分钟的数据空白(要重新握手,而握手在差线路上也慢)。</summary>
private static readonly TimeSpan IdleLimit = TimeSpan.FromSeconds(180);
/// <summary>超过这么久没有收到 Probe 的任何帧就认为会话已死(Probe 每 30 秒至少发一次时钟同步)。</summary>
private static readonly TimeSpan IdleLimit = TimeSpan.FromSeconds(75);
private readonly RpcRouter<PeerContext> _router;
private readonly SessionRegistry _sessions;
@@ -106,15 +105,14 @@ public sealed class PeerHub
_log = log;
}
/// <summary>拨号方 会话断开(这个拨号方上的测试据此中止)。</summary>
public Action<DialerSession>? OnDialerLost { get; set; }
/// <summary>Probe 会话断开(这个 Probe 上的测试据此中止)。</summary>
public Action<ProbeSession>? OnProbeLost { get; set; }
public async Task OnSessionAsync(PeerSessionInfo info, IRpcTransport transport, CancellationToken ct)
{
var dialer = new DialerSession(info) { ConnectedAtUtcMs = _time.GetUtcNow().ToUnixTimeMilliseconds() };
var log = new ContextNodeLog(_log, ("dialer", dialer.DialerId), ("ip_version", dialer.IpVersion), ("control_id", Guid.NewGuid().ToString("N")));
var rpc = new RpcSession<PeerContext>(transport, _router, new PeerContext(dialer, _clock), _time);
dialer.Rpc = rpc;
var probe = new ProbeSession(info) { ConnectedAtUtcMs = _time.GetUtcNow().ToUnixTimeMilliseconds() };
var rpc = new RpcSession<PeerContext>(transport, _router, new PeerContext(probe, _clock), _time);
probe.Rpc = rpc;
var seen = new HashSet<Guid>();
var order = new Queue<Guid>();
@@ -137,7 +135,7 @@ public sealed class PeerHub
Result<RawChunk> decoded = RawChunkCodec.Decode(payload.Span);
if (decoded.TryGet(out RawChunk? fragment, out FleetError? error))
{
_chunks.Accept(fragment, dialer);
_chunks.Accept(fragment, probe);
}
else
{
@@ -149,23 +147,21 @@ public sealed class PeerHub
await rpc.NotifyAsync(FleetMethods.OutboxAck, ack, FleetRpcContext.Default.OutboxAckParams, token);
};
DialerSession? previous = _sessions.Replace(dialer);
ProbeSession? previous = _sessions.Replace(probe);
if (previous?.Rpc is { } old)
{
previous.CloseReason = "replaced";
log.Info("replacing previous dialer control session");
await old.CloseAsync("replaced", CancellationToken.None);
}
// 这个会话自己的一组流:序号从 0 开始,上一个会话留下的记录不会算到它头上
LineTaskSet initial = _tasks.OpenSession(dialer);
LineTaskSet initial = _tasks.OpenSession(probe);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct);
Task run = rpc.RunAsync(linked.Token);
Task watchdog = WatchIdleAsync(dialer, rpc, log, linked.Token);
log.Info("dialer connected", ("phase", "control_session"), ("version", info.Version ?? ""), ("remote", info.Remote));
await _uplink.EmitEventAsync(AgentEventKinds.DialerConnected, info.PeerId, Describe(dialer));
await PushAsync(dialer, initial, linked.Token);
Task watchdog = WatchIdleAsync(probe, rpc, linked.Token);
_log.Info("probe connected", ("probe", info.PeerId), ("version", info.Version ?? ""), ("remote", probe.RemoteAddress ?? ""));
await _uplink.EmitEventAsync(AgentEventKinds.ProbeConnected, info.PeerId, Describe(probe));
await PushAsync(probe, initial, linked.Token);
try
{
await run;
@@ -174,35 +170,25 @@ public sealed class PeerHub
{
// 停止
}
catch (Exception ex)
{
dialer.CloseReason ??= "control_error";
log.Warn("dialer control session failed", ("exception", ex.GetType().Name), ("err", ex.Message));
throw;
}
finally
{
_sessions.Remove(dialer);
_sessions.Remove(probe);
await linked.CancelAsync();
await watchdog;
await rpc.DisposeAsync();
await _tasks.CloseSessionAsync(dialer);
log.Info("dialer disconnected", ("phase", "control_session"),
("reason", dialer.CloseReason ?? (ct.IsCancellationRequested ? "service_stopping" : "peer_closed")),
("duration_ms", _time.GetUtcNow().ToUnixTimeMilliseconds() - dialer.ConnectedAtUtcMs),
("datagram_bound", dialer.DatagramConnection is not null), ("data_remote", dialer.DatagramRemoteEndPoint));
OnDialerLost?.Invoke(dialer);
await _uplink.EmitEventAsync(AgentEventKinds.DialerDisconnected, info.PeerId, Describe(dialer));
await _tasks.CloseSessionAsync(probe);
_log.Info("probe disconnected", ("probe", info.PeerId));
OnProbeLost?.Invoke(probe);
await _uplink.EmitEventAsync(AgentEventKinds.ProbeDisconnected, info.PeerId, Describe(probe));
}
}
/// <summary>
/// 拨号方 每 30 秒发一次时钟同步请求,所以会话里至少每 30 秒有一帧:超过 <see cref="IdleLimit"/> 没有收到任何东西,
/// 说明拨号方已经静默死亡(断电、进程被冻结),关闭会话,让 Backend 看到 dialer_disconnected,而不是一直显示在线。
/// Probe 每 30 秒发一次时钟同步请求,所以会话里至少每 30 秒有一帧:超过 <see cref="IdleLimit"/> 没有收到任何东西,
/// 说明 Probe 已经静默死亡(断电、进程被冻结),关闭会话,让 Backend 看到 probe_disconnected,而不是一直显示在线。
/// </summary>
private async Task WatchIdleAsync(DialerSession dialer, RpcSession<PeerContext> rpc, INodeLog log, CancellationToken ct)
private async Task WatchIdleAsync(ProbeSession probe, RpcSession<PeerContext> rpc, CancellationToken ct)
{
long lastSummary = _time.GetTimestamp();
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10), _time);
try
{
@@ -210,18 +196,10 @@ public sealed class PeerHub
{
if (rpc.IdleFor > IdleLimit)
{
dialer.CloseReason = "idle_timeout";
log.Warn("dialer session is silent, closing it", ("phase", "control_session"), ("idle_s", (long)rpc.IdleFor.TotalSeconds), ("limit_s", IdleLimit.TotalSeconds));
_log.Warn("probe session is silent, closing it", ("probe", probe.ProbeId), ("idle_s", (long)rpc.IdleFor.TotalSeconds));
await rpc.CloseAsync("idle", CancellationToken.None);
return;
}
if (_time.GetElapsedTime(lastSummary) >= TimeSpan.FromSeconds(60))
{
lastSummary = _time.GetTimestamp();
log.Info("dialer session summary", ("phase", "control_session"), ("remote", dialer.Info.Remote),
("control_idle_s", (long)rpc.IdleFor.TotalSeconds), ("datagram_bound", dialer.DatagramConnection is not null),
("data_remote", dialer.DatagramRemoteEndPoint));
}
}
}
catch (OperationCanceledException)
@@ -230,23 +208,24 @@ public sealed class PeerHub
}
}
/// <summary>当前连着的拨号方(心跳和 Backend 重连之后的补报都用它)。</summary>
public IReadOnlyList<ConnectedDialer> ConnectedDialers() => [.. _sessions.All.Select(Describe)];
/// <summary>当前连着的 Probe(心跳和 Backend 重连之后的补报都用它)。</summary>
public IReadOnlyList<ConnectedProbe> ConnectedProbes() => [.. _sessions.All.Select(Describe)];
private static ConnectedDialer Describe(DialerSession p) => new() { Id = new NodeId(p.DialerId), 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 };
/// <summary>向一个拨号方下发它的线路任务集(拨号方 连上时由 <see cref="OnSessionAsync"/> 先发一份)。</summary>
public async ValueTask PushToAsync(DialerSession dialer, LineTaskSet set) => await PushAsync(dialer, set, CancellationToken.None);
public IReadOnlyList<string> ConnectedProbeIds() => [.. _sessions.All.Select(p => p.ProbeId)];
/// <summary>Backend 要求断开某个拨号方(它会按退避重连,除非已经被吊销)。没有这个会话时返回 false。</summary>
public async ValueTask<bool> DisconnectAsync(string dialerId)
/// <summary>向一个 Probe 下发它的线路任务集(Probe 连上时由 <see cref="OnSessionAsync"/> 先发一份)。</summary>
public async ValueTask PushToAsync(ProbeSession probe, LineTaskSet set) => await PushAsync(probe, set, CancellationToken.None);
/// <summary>Backend 要求断开某个 Probe(它会按退避重连,除非已经被吊销)。没有这个会话时返回 false。</summary>
public async ValueTask<bool> DisconnectAsync(string probeId)
{
bool disconnected = false;
foreach (DialerSession dialer in _sessions.All)
foreach (ProbeSession probe in _sessions.All)
{
if (dialer.DialerId == dialerId && dialer.Rpc is { } rpc)
if (probe.ProbeId == probeId && probe.Rpc is { } rpc)
{
dialer.CloseReason = "disconnected";
await rpc.CloseAsync("disconnected", CancellationToken.None);
disconnected = true;
}
@@ -255,23 +234,22 @@ public sealed class PeerHub
return disconnected;
}
/// <summary>名单(期望状态里的 peers)里已经没有的拨号方:立刻关闭它的会话(吊销生效)。</summary>
/// <summary>名单(期望状态里的 peers)里已经没有的 Probe:立刻关闭它的会话(吊销生效)。</summary>
public void CloseNotIn(IReadOnlySet<string> allowed)
{
foreach (DialerSession dialer in _sessions.All)
foreach (ProbeSession probe in _sessions.All)
{
if (!allowed.Contains(dialer.DialerId) && dialer.Rpc is { } rpc)
if (!allowed.Contains(probe.ProbeId) && probe.Rpc is { } rpc)
{
dialer.CloseReason = "revoked";
_log.Info("dialer is no longer allowed, closing its session", ("dialer", dialer.DialerId));
_log.Info("probe is no longer allowed, closing its session", ("probe", probe.ProbeId));
_ = rpc.CloseAsync("revoked", CancellationToken.None).AsTask();
}
}
}
private async ValueTask PushAsync(DialerSession dialer, LineTaskSet set, CancellationToken ct)
private async ValueTask PushAsync(ProbeSession probe, LineTaskSet set, CancellationToken ct)
{
if (dialer.Rpc is null)
if (probe.Rpc is null)
{
return;
}
@@ -284,10 +262,10 @@ public sealed class PeerHub
};
try
{
Result<StateSyncResult> result = await dialer.Rpc.RequestAsync(FleetMethods.StateSync, parameters, FleetRpcContext.Default.StateSyncParams, FleetRpcContext.Default.StateSyncResult, ct);
Result<StateSyncResult> result = await probe.Rpc.RequestAsync(FleetMethods.StateSync, parameters, FleetRpcContext.Default.StateSyncParams, FleetRpcContext.Default.StateSyncResult, ct);
if (!result.IsSuccess)
{
_log.Warn("dialer rejected the line task set", ("err", result.Error.Code));
_log.Warn("probe rejected the line task set", ("err", result.Error.Code));
}
}
catch (Exception ex) when (ex is ObjectDisposedException or InvalidOperationException or IOException or OperationCanceledException)
@@ -302,5 +280,5 @@ public sealed class PeerHub
MoShiroMethods.Clock,
MoShiroJsonContext.Default.ClockParams,
MoShiroJsonContext.Default.ClockResult,
(context, _, _) => ValueTask.FromResult(Result<ClockResult>.Ok(new ClockResult { ListenerMonoUs = context.Clock.NowUs() })));
(context, _, _) => ValueTask.FromResult(Result<ClockResult>.Ok(new ClockResult { AgentMonoUs = context.Clock.NowUs() })));
}
+2 -2
View File
@@ -3,7 +3,7 @@ using Fleet.Hosting;
using MoShiro.Agent;
TimeProvider time = TimeProvider.System;
INodeLog log = new BufferedNodeLog(new ConsoleNodeLog(time), time);
INodeLog log = new ConsoleNodeLog(time);
if (PlatformCheck.VerifyCrypto() is { } problem)
{
@@ -43,7 +43,7 @@ catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidO
using var restartScope = restart;
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);
}
@@ -1,40 +0,0 @@
using System.Collections.Concurrent;
using MoShiro.Protocol;
namespace MoShiro.Agent;
/// <summary>
/// 自适应探测频率的状态变化,等着随下一次心跳上报给 Backend(Backend 存成事件,用于事后审计)。
/// 和普通日志分开存:日志环形缓冲几分钟到几小时就会被覆盖,而这里的变化要能在几天后回看。有界,满了丢最旧的。
/// </summary>
public sealed class RateChangeLog
{
public const int Capacity = 512;
private readonly ConcurrentQueue<RateChange> _queue = new();
public void Add(RateChange change)
{
_queue.Enqueue(change);
while (_queue.Count > Capacity && _queue.TryDequeue(out _))
{
}
}
/// <summary>取走最多 <paramref name="max"/> 条(最旧的在前);没有返回 null,心跳里就不带这个字段。</summary>
public List<RateChange>? Take(int max)
{
List<RateChange>? taken = null;
while ((taken?.Count ?? 0) < max)
{
if (!_queue.TryDequeue(out RateChange? change))
{
break;
}
(taken ??= []).Add(change);
}
return taken;
}
}
@@ -7,16 +7,14 @@ using MoShiro.Protocol;
namespace MoShiro.Agent;
/// <summary>
/// 目标任务(Agent 自己探测一个地址,没有对端):用和线路任务同一套积木(<see cref="BlockCatalog"/> 里的拨号方,例如 icmp_ping、connect),
/// 只是列块不交给 Agent 合并,而是自己盖章、10 秒一块直接上传。目标可以是 IP、域名或(http)URL;域名在 Agent 上解析,
/// 每个解析出的地址都必须在本地 <c>allowed_target_cidrs</c> 内(IP 目标由 <see cref="BudgetGuard"/> 在接受任务之前检查,域名在这里检查)。
/// 目标任务(Agent 自己探测一个地址,没有对端):用和线路任务同一套积木(<see cref="BlockCatalog"/> 里的拨号方,例如 icmp_echo、connect),
/// 只是列块不交给 Agent 合并,而是自己盖章、10 秒一块直接上传。目标地址必须在本地 <c>allowed_target_cidrs</c> 内
/// (由 <see cref="BudgetGuard"/> 在接受任务之前检查)。
/// </summary>
public sealed class TargetRunners
{
private static readonly TimeSpan s_chunkSpan = TimeSpan.FromSeconds(ProtocolLimits.ChunkSeconds);
private static readonly TimeSpan s_resolveRetry = TimeSpan.FromMinutes(1);
private readonly Dictionary<string, Running> _running = [];
private readonly object _gate = new();
private readonly ChunkAssembler _chunks;
@@ -24,11 +22,9 @@ public sealed class TargetRunners
private readonly MonoClock _clock;
private readonly TimeProvider _time;
private readonly INodeLog _log;
private readonly RateChangeLog? _rateChanges;
public TargetRunners(ChunkAssembler chunks, NodeId agent, MonoClock clock, TimeProvider time, INodeLog log, RateChangeLog? rateChanges = null)
public TargetRunners(ChunkAssembler chunks, NodeId agent, MonoClock clock, TimeProvider time, INodeLog log)
{
_rateChanges = rateChanges;
_chunks = chunks;
_agent = agent;
_clock = clock;
@@ -37,7 +33,7 @@ public sealed class TargetRunners
}
/// <summary>让运行中的任务与期望集合一致:参数没有变化的保持,其余启动或停止。</summary>
public void Reconcile(IReadOnlyList<(TaskSpec Spec, TaskDecision Decision)> desired, Func<IPAddress, bool>? allow = null)
public void Reconcile(IReadOnlyList<(TaskSpec Spec, TaskDecision Decision)> desired)
{
lock (_gate)
{
@@ -60,7 +56,7 @@ public sealed class TargetRunners
var cts = new CancellationTokenSource();
_running[id] = new Running(Signature(spec), cts);
_ = Task.Run(() => RunAsync(spec, decision, allow, cts.Token), CancellationToken.None);
_ = Task.Run(() => RunAsync(spec, decision, cts.Token), CancellationToken.None);
}
}
}
@@ -68,34 +64,23 @@ public sealed class TargetRunners
private static string Signature(TaskSpec spec) =>
spec.Kind + "|" + spec.Target + "|" + string.Join(',', spec.Params.OrderBy(p => p.Key, StringComparer.Ordinal).Select(p => p.Key + "=" + p.Value.GetRawText()));
private async Task RunAsync(TaskSpec spec, TaskDecision decision, Func<IPAddress, bool>? allow, CancellationToken ct)
private async Task RunAsync(TaskSpec spec, TaskDecision decision, CancellationToken ct)
{
try
{
if (!TargetHost.TryParse(spec.Target, out string host, out int targetPort, out _))
if (!TryResolve(spec.Target!, decision.Resolved!, spec.Kind, out IPAddress? address, out int port))
{
_log.Warn("target is not valid", ("task", spec.Id.Value));
_log.Warn("target is not an address", ("task", spec.Id.Value));
return;
}
// http 自己解析并检查每个地址;其余 Kind 在这里解析出一个允许的地址作为对端
IPAddress? address = null;
if (spec.Kind != TargetHost.HttpKind)
{
address = await ResolveAllowedAsync(spec, host, allow, ct);
}
int port = targetPort != 0 ? targetPort : spec.Kind == "tcp_connect" ? (int)decision.Resolved!.GetInt("port") : 0;
var env = new BlockEnv
{
Clock = _clock,
Log = _log,
Session = () => null,
PeerAddress = address,
Target = spec.Target,
AllowAddress = allow,
OnRateChange = _rateChanges is null ? null : _rateChanges.Add,
Fragments = new LocalSink(this, spec, address?.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 ? IpVersions.V6 : IpVersions.V4),
Fragments = new LocalSink(this, spec, address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 ? IpVersions.V6 : IpVersions.V4),
};
IBlock block = BlockCatalog.Find(spec.Kind)!.Dialer(env, new BlockSpec(decision.Kind!, decision.Resolved!, 0, port, new FragmentScope(spec.Id, null, null)));
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
@@ -125,32 +110,24 @@ public sealed class TargetRunners
}
}
/// <summary>解析目标,取第一个允许的地址;解析失败或没有允许的地址就每分钟重试(域名的解析结果会变)。</summary>
private async Task<IPAddress> ResolveAllowedAsync(TaskSpec spec, string host, Func<IPAddress, bool>? allow, CancellationToken ct)
private static bool TryResolve(string target, ParamValues values, string kind, out IPAddress address, out int port)
{
while (true)
port = kind == "connect" ? (int)values.GetInt("port") : 0;
if (IPEndPoint.TryParse(target, out IPEndPoint? endpoint))
{
try
address = endpoint.Address;
if (endpoint.Port != 0)
{
IPAddress[] addresses = IPAddress.TryParse(host, out IPAddress? literal) ? [literal] : await Dns.GetHostAddressesAsync(host, ct);
IPAddress? chosen = addresses.FirstOrDefault(a => allow?.Invoke(a) ?? true);
if (chosen is not null)
{
return chosen;
port = endpoint.Port;
}
_log.Warn("no address of the target is allowed", ("task", spec.Id.Value), ("host", host));
}
catch (System.Net.Sockets.SocketException ex)
{
_log.Warn("target did not resolve", ("task", spec.Id.Value), ("host", host), ("err", ex.Message));
return true;
}
await Task.Delay(s_resolveRetry, _time, ct);
}
return IPAddress.TryParse(target, out address!);
}
/// <summary>目标任务的行没有拨号方一侧可以合并:积木交出的就是完整的行,Agent 自己盖章(目标、时钟锚点)并放进发件箱。</summary>
/// <summary>目标任务的行没有 Probe 一侧可以合并:积木交出的就是完整的行,Agent 自己盖章(目标、时钟锚点)并放进发件箱。</summary>
private sealed class LocalSink : IFragmentSink
{
private readonly TargetRunners _owner;
@@ -179,7 +156,7 @@ public sealed class TargetRunners
Port = port,
StartUtcMs = startUtcMs,
DurationMs = Math.Clamp(durationMs, 1, ChunkFinalizer.MaxDurationMs),
Clock = new ChunkClock { ListenerMonoUs = anchor.ListenerMonoUs, UtcMs = anchor.UtcMs, DialerOffsetUs = 0 },
Clock = new ChunkClock { AgentMonoUs = anchor.AgentMonoUs, UtcMs = anchor.UtcMs, ProbeOffsetUs = 0 },
};
_owner._chunks.Emit(new RawChunk(header, streams));
}
@@ -9,13 +9,13 @@ namespace MoShiro.Agent;
/// <summary>
/// 期望任务集(<c>task_set</c>)的调和器:每个任务先经过 <see cref="BudgetGuard"/>(参数校验 + 本地硬上限)。
/// 通过的线路任务<b>为每个拨号方会话各登记一组流</b>(流编号和监听方积木都属于会话,互不干扰),并汇总成 <c>line_task_set</c> 下发给这个拨号方;
/// 通过的目标任务在本地运行,与拨号方无关。被拒绝的任务带着原因(<c>rejected[]</c>)回报给 Backend,其余任务照常运行。
/// 每次调用都应用完整的快照,所以是幂等的。预算(码率、包速率)按单个拨号方会话计算,会话总数由 <c>PeerListenerOptions.MaxSessions</c> 限制。
/// 通过的线路任务<b>为每个 Probe 会话各登记一组流</b>(流编号和监听方积木都属于会话,互不干扰),并汇总成 <c>line_task_set</c> 下发给这个 Probe;
/// 通过的目标任务在本地运行,与 Probe 无关。被拒绝的任务带着原因(<c>rejected[]</c>)回报给 Backend,其余任务照常运行。
/// 每次调用都应用完整的快照,所以是幂等的。预算(码率、包速率)按单个 Probe 会话计算,会话总数由 <c>PeerListenerOptions.MaxSessions</c> 限制。
/// </summary>
public sealed class TaskManager
{
// 线路任务 = 有监听方积木、并且不是只用于按需测试的 Kind(quic_stream、tcp_transfer 会占满链路);目标任务 = 可以由 Agent 自己运行的 Kind
// 线路任务 = 有监听方积木、并且不是只用于按需测试的 Kind(udp_train、tcp_bulk 会占满链路);目标任务 = 可以由 Agent 自己运行的 Kind
private static readonly HashSet<string> s_lineKinds = [.. BlockCatalog.All.Where(p => p.Listener is not null && !p.OnDemandOnly).Select(p => p.Kind)];
private static readonly HashSet<string> s_targetKinds = [.. BlockCatalog.All.Where(p => p.RunsAsTarget).Select(p => p.Kind)];
@@ -24,67 +24,65 @@ public sealed class TaskManager
private readonly StreamRegistry _streams;
private readonly BlockEnv _env;
private readonly TargetRunners _targets;
private readonly Func<IReadOnlyList<int>> _quicPorts;
private readonly Func<IReadOnlyList<int>> _udpPorts;
private readonly Func<IReadOnlyList<int>> _tcpPorts;
private readonly TrustAnchor _trust;
private readonly bool _acceptLineTasks;
private readonly Dictionary<DialerSession, Dictionary<string, LineEntry>> _sessionLines = [];
private readonly Dictionary<ProbeSession, Dictionary<string, LineEntry>> _sessionLines = [];
private volatile Dictionary<string, byte[]> _peers = new(StringComparer.Ordinal);
private Dictionary<string, (TaskSpec Task, TaskDecision Decision)> _lineSpecs = [];
private long _version;
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, bool acceptLineTasks = true)
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;
_acceptLineTasks = acceptLineTasks;
_guardFactory = guardFactory;
_streams = streams;
_env = env;
_targets = targets;
_quicPorts = quicPorts;
_udpPorts = udpPorts;
_tcpPorts = tcpPorts;
}
public BudgetGuard Guard => _guard ??= _guardFactory();
/// <summary>某个拨号方的线路任务集变化(版本递增):由 <see cref="PeerHub"/> 推送给这个拨号方。</summary>
public Action<DialerSession, LineTaskSet>? OnLineTasksChanged { get; set; }
/// <summary>某个 Probe 的线路任务集变化(版本递增):由 <see cref="PeerHub"/> 推送给这个 Probe。</summary>
public Action<ProbeSession, LineTaskSet>? OnLineTasksChanged { get; set; }
/// <summary>允许连接的拨号方变了(期望状态里的 peers):已经不在名单里的会话由 <see cref="PeerHub"/> 关闭。</summary>
/// <summary>允许连接的 Probe 变了(期望状态里的 peers):已经不在名单里的会话由 <see cref="PeerHub"/> 关闭。</summary>
public Action<IReadOnlySet<string>>? OnPeersChanged { get; set; }
/// <summary>点对点握手时查找拨号方的公钥(SPKI)。</summary>
/// <summary>点对点握手时查找 Probe 的公钥(SPKI)。</summary>
public byte[]? LookupPeerKey(string peerId) => _peers.TryGetValue(peerId, out byte[]? key) ? key : null;
/// <summary>名册里全部拨号方的公钥:敲门关卡用它们派生配对密钥。</summary>
/// <summary>名册里全部 Probe 的公钥:敲门关卡用它们派生配对密钥。</summary>
public IReadOnlyList<byte[]> PeerKeys() => [.. _peers.Values];
/// <summary>一个新的拨号方会话开始:为它登记全部线路任务的流并启动监听方积木,返回要下发给它的线路任务集。</summary>
public LineTaskSet OpenSession(DialerSession dialer)
/// <summary>一个新的 Probe 会话开始:为它登记全部线路任务的流并启动监听方积木,返回要下发给它的线路任务集。</summary>
public LineTaskSet OpenSession(ProbeSession probe)
{
lock (_gate)
{
var entries = new Dictionary<string, LineEntry>();
foreach ((string id, (TaskSpec task, TaskDecision decision)) in _lineSpecs)
{
LineEntry entry = BuildLineEntry(task, decision, dialer);
LineEntry entry = BuildLineEntry(task, decision, probe);
Start(entry);
entries[id] = entry;
}
_sessionLines[dialer] = entries;
_sessionLines[probe] = entries;
return SetFor(entries);
}
}
/// <summary>拨号方 会话结束:释放它的所有流和积木(记录随之丢弃,下一个会话从零开始)。</summary>
public async Task CloseSessionAsync(DialerSession dialer)
/// <summary>Probe 会话结束:释放它的所有流和积木(记录随之丢弃,下一个会话从零开始)。</summary>
public async Task CloseSessionAsync(ProbeSession probe)
{
List<LineEntry> entries;
lock (_gate)
{
if (!_sessionLines.Remove(dialer, out Dictionary<string, LineEntry>? lines))
if (!_sessionLines.Remove(probe, out Dictionary<string, LineEntry>? lines))
{
return;
}
@@ -110,11 +108,11 @@ public sealed class TaskManager
{
try
{
peers[peer.NodeId.Value] = Convert.FromBase64String(peer.PublicKey);
peers[peer.ProbeId.Value] = Convert.FromBase64String(peer.PublicKey);
}
catch (FormatException)
{
result.Rejected.Add(new RejectedItem { Id = peer.NodeId.Value, Reason = RejectReasons.InvalidParams });
result.Rejected.Add(new RejectedItem { Id = peer.ProbeId.Value, Reason = RejectReasons.InvalidParams });
}
}
@@ -133,7 +131,7 @@ public sealed class TaskManager
continue;
}
bool shapeOk = task.Scope == "target" ? s_targetKinds.Contains(task.Kind) : _acceptLineTasks && s_lineKinds.Contains(task.Kind);
bool shapeOk = task.Scope == "target" ? s_targetKinds.Contains(task.Kind) : s_lineKinds.Contains(task.Kind);
TaskDecision decision = shapeOk ? guard.Check(task, ref usedMbps, ref usedPps) : TaskDecision.Reject(RejectReasons.Unsupported);
if (!decision.Accepted)
{
@@ -151,29 +149,27 @@ public sealed class TaskManager
}
}
_targets.Reconcile(targets, guard.AddressAllowed);
_targets.Reconcile(targets);
// 参数没有变化的任务沿用原来的流和积木,数据面上的会话(重放窗口、记录)不被打断
var retired = new List<LineEntry>();
var changes = new List<(DialerSession Dialer, LineTaskSet Set)>();
var changes = new List<(ProbeSession Probe, LineTaskSet Set)>();
lock (_gate)
{
_lineSpecs = line;
_version = version;
foreach ((DialerSession dialer, Dictionary<string, LineEntry> current) in _sessionLines)
foreach ((ProbeSession probe, Dictionary<string, LineEntry> current) in _sessionLines)
{
var next = new Dictionary<string, LineEntry>();
bool lineTasksChanged = current.Count != line.Count;
foreach ((string id, (TaskSpec task, TaskDecision decision)) in line)
{
LineEntry candidate = BuildLineEntry(task, decision, dialer);
LineEntry candidate = BuildLineEntry(task, decision, probe);
if (current.TryGetValue(id, out LineEntry? existing) && existing.Signature == candidate.Signature)
{
next[id] = existing;
}
else
{
lineTasksChanged = true;
Start(candidate);
next[id] = candidate;
}
@@ -193,13 +189,7 @@ public sealed class TaskManager
current[id] = entry;
}
// The Agent receives a full task snapshot for peer/trust changes and target-only tasks too.
// Those changes do not affect a 拨号方's line blocks, so avoid retransmitting the same full
// line_task_set to every 拨号方. New sessions still get their initial snapshot in OpenSession.
if (lineTasksChanged)
{
changes.Add((dialer, SetFor(current)));
}
changes.Add((probe, SetFor(current)));
}
}
@@ -209,15 +199,15 @@ public sealed class TaskManager
}
OnPeersChanged?.Invoke(new HashSet<string>(peers.Keys, StringComparer.Ordinal));
foreach ((DialerSession dialer, LineTaskSet next) in changes)
foreach ((ProbeSession probe, LineTaskSet next) in changes)
{
OnLineTasksChanged?.Invoke(dialer, next);
OnLineTasksChanged?.Invoke(probe, next);
}
return result;
}
/// <summary>停止时释放所有拨号方会话的流和所有目标任务(Backend 失联的时候任务继续运行:节点自治)。</summary>
/// <summary>停止时释放所有 Probe 会话的流和所有目标任务(Backend 失联的时候任务继续运行:节点自治)。</summary>
public async Task StopAsync()
{
_targets.Reconcile([]);
@@ -239,13 +229,13 @@ public sealed class TaskManager
{
Version = _version,
Tasks = [.. entries.Values.Select(e => e.Task)],
QuicPorts = [.. _quicPorts()],
UdpPorts = [.. _udpPorts()],
TcpPorts = [.. _tcpPorts()],
};
private LineEntry BuildLineEntry(TaskSpec task, TaskDecision decision, DialerSession dialer)
private LineEntry BuildLineEntry(TaskSpec task, TaskDecision decision, ProbeSession probe)
{
var info = new StreamInfo(_streams.Allocate(), task.Kind, decision.Resolved!) { TaskId = task.Id, Owner = dialer };
var info = new StreamInfo(_streams.Allocate(), task.Kind, decision.Resolved!) { TaskId = task.Id, Owner = probe };
var lineTask = new LineTask
{
Id = task.Id,
@@ -255,7 +245,7 @@ public sealed class TaskManager
Port = info.Port,
};
var spec = new BlockSpec(decision.Kind!, decision.Resolved, info.Id, info.Port, new FragmentScope(task.Id, null, null));
return new LineEntry(lineTask, info, spec, _env.WithSession(dialer));
return new LineEntry(lineTask, info, spec, _env.WithSession(probe));
}
/// <summary>在数据面登记这个流并启动它的监听方积木。</summary>
@@ -8,9 +8,9 @@ namespace MoShiro.Agent;
/// <summary>
/// 按需测试的编排(<c>moshiro.test.run</c>):Agent 是时间的主人。每个阶段:
/// 登记数据面的流 → <c>moshiro.test.phase</c>(拨号方 准备好自己一侧的角色)→ 运行(单向数据流由 Agent 发包,回显类等待拨号方发包)
/// → <c>moshiro.test.phase_end</c>(拨号方 收尾,回复它交出的列块个数)→ 等所有列块合并上传 → <c>phase_done</c>。
/// 同一时间只能运行 <c>max_concurrent_tests</c> 个测试;拨号方 掉线或被取消时测试中止,已经上传的阶段数据保留。
/// 登记数据面的流 → <c>moshiro.test.phase</c>(Probe 准备好自己一侧的角色)→ 运行(单向数据流由 Agent 发包,回显类等待 Probe 发包)
/// → <c>moshiro.test.phase_end</c>(Probe 收尾,回复它交出的列块个数)→ 等所有列块合并上传 → <c>phase_done</c>。
/// 同一时间只能运行 <c>max_concurrent_tests</c> 个测试;Probe 掉线或被取消时测试中止,已经上传的阶段数据保留。
/// </summary>
public sealed class TestRunner
{
@@ -51,9 +51,9 @@ public sealed class TestRunner
return Result<TestRunResult>.Fail(MoShiroErrors.ValidationFailed, "ip_version must be ipv4 or ipv6");
}
if (_sessions.Get(run.DialerId.Value, run.IpVersion) is not { } dialer)
if (_sessions.Get(run.ProbeId.Value, run.IpVersion) is not { } probe)
{
return Result<TestRunResult>.Fail(MoShiroErrors.DialerOffline, $"dialer {run.DialerId.Value} is not connected to this agent");
return Result<TestRunResult>.Fail(MoShiroErrors.ProbeOffline, $"probe {run.ProbeId.Value} is not connected to this agent");
}
if (run.Phases.Count == 0)
@@ -85,7 +85,7 @@ public sealed class TestRunner
resolved.Add((phase, _tasks.Guard.CheckPhase(phase.Kind, phase.Params)));
}
var running = new RunningTest(dialer);
var running = new RunningTest(probe);
if (_running.Count >= _maxConcurrent || !_running.TryAdd(run.TestId.Value, running))
{
running.Dispose();
@@ -107,7 +107,7 @@ public sealed class TestRunner
// 按需测试的数据端口由 Agent 决定:Backend 给的端口如果不是本机监听的端口(模板里是出厂默认值,每个 Agent 可以改,
// 而 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);
if (!listening)
{
@@ -126,12 +126,12 @@ public sealed class TestRunner
return false;
}
/// <summary>某个拨号方掉线:它上面的测试中止,状态是 failed / dialer_offline;别的拨号方上的测试不受影响。</summary>
public void DialerLost(DialerSession dialer)
/// <summary>某个 Probe 掉线:它上面的测试中止,状态是 failed / probe_offline;别的 Probe 上的测试不受影响。</summary>
public void ProbeLost(ProbeSession probe)
{
foreach (RunningTest running in _running.Values)
{
if (ReferenceEquals(running.Dialer, dialer))
if (ReferenceEquals(running.Probe, probe))
{
running.Lost = true;
running.Cancel.Cancel();
@@ -147,26 +147,26 @@ public sealed class TestRunner
int? lastPhase = null;
try
{
DialerSession dialer = running.Dialer;
ProbeSession probe = running.Probe;
for (int index = 0; index < phases.Count; index++)
{
lastPhase = index;
await _uplink.SendTestStatusAsync(test, TestStatuses.Running, index);
await RunPhaseAsync(dialer, test, index, phases[index].Phase, phases[index].Decision, ct);
await RunPhaseAsync(probe, test, index, phases[index].Phase, phases[index].Decision, ct);
await _uplink.SendTestStatusAsync(test, TestStatuses.PhaseDone, index);
}
}
catch (OperationCanceledException)
{
finalStatus = running.Lost ? TestStatuses.Failed : TestStatuses.Aborted;
errorCode = running.Lost ? MoShiroErrors.DialerOffline.Value : null;
await TryAbortDialerAsync(running.Dialer, test);
errorCode = running.Lost ? MoShiroErrors.ProbeOffline.Value : null;
await TryAbortProbeAsync(running.Probe, test);
}
catch (TestFailure failure)
{
finalStatus = TestStatuses.Failed;
errorCode = failure.Code;
await TryAbortDialerAsync(running.Dialer, test);
await TryAbortProbeAsync(running.Probe, test);
}
finally
{
@@ -178,17 +178,17 @@ public sealed class TestRunner
await _uplink.SendTestStatusAsync(test, finalStatus, lastPhase, errorCode);
}
private async Task RunPhaseAsync(DialerSession dialer, TestId test, int index, TestPhase phase, TaskDecision decision, CancellationToken ct)
private async Task RunPhaseAsync(ProbeSession probe, TestId test, int index, TestPhase phase, TaskDecision decision, CancellationToken ct)
{
ParamValues values = decision.Resolved!;
var stream = new StreamInfo(_streams.Allocate(), phase.Kind, values) { TestId = test, Phase = index, Owner = dialer };
var stream = new StreamInfo(_streams.Allocate(), phase.Kind, values) { TestId = test, Phase = index, Owner = probe };
int durationSeconds = phase.DurationSeconds > 0 ? phase.DurationSeconds : (int)(values.Values.ContainsKey("duration_s") ? values.GetInt("duration_s") : 10);
var blockSpec = new BlockSpec(decision.Kind!, values, stream.Id, stream.Port, new FragmentScope(null, test, index));
stream.Running = RunningBlock.Start(BlockCatalog.Find(phase.Kind)!.Listener!(_env.WithSession(dialer), blockSpec), blockSpec.Signature);
stream.Running = RunningBlock.Start(BlockCatalog.Find(phase.Kind)!.Listener!(_env.WithSession(probe), blockSpec), blockSpec.Signature);
_streams.Add(stream);
try
{
RpcSession<PeerContext> rpc = dialer.Rpc ?? throw new TestFailure(MoShiroErrors.DialerOffline.Value);
RpcSession<PeerContext> rpc = probe.Rpc ?? throw new TestFailure(MoShiroErrors.ProbeOffline.Value);
var start = new TestPhaseStartParams
{
TestId = test,
@@ -206,7 +206,7 @@ public sealed class TestRunner
}
// 单向数据流的发送端自己运行到结束(或失败);其他积木是反应式的,等够时长即可
if (phase.Kind == "quic_stream")
if (phase.Kind == "udp_train")
{
try
{
@@ -214,7 +214,7 @@ public sealed class TestRunner
}
catch (BlockFaultException)
{
throw new TestFailure(MoShiroErrors.DialerOffline.Value);
throw new TestFailure(MoShiroErrors.ProbeOffline.Value);
}
}
else
@@ -230,7 +230,7 @@ public sealed class TestRunner
throw new TestFailure(endError.Code.Value);
}
// 列块是经拨号方的发件箱至少一次送达的:全部合并上传之后才进入下一阶段(超时就带着已有的数据继续)
// 列块是经 Probe 的发件箱至少一次送达的:全部合并上传之后才进入下一阶段(超时就带着已有的数据继续)
DateTimeOffset deadline = _time.GetUtcNow() + s_fragmentWait;
while (stream.FragmentsDone < finished.Fragments && _time.GetUtcNow() < deadline)
{
@@ -246,13 +246,13 @@ public sealed class TestRunner
{
_streams.Remove(stream.Id);
await stream.Running.StopAsync();
dialer.ForgetStream(stream.Id);
probe.ForgetStream(stream.Id);
}
}
private async Task TryAbortDialerAsync(DialerSession dialer, TestId test)
private async Task TryAbortProbeAsync(ProbeSession probe, TestId test)
{
if (dialer.Rpc is not { } rpc)
if (probe.Rpc is not { } rpc)
{
return;
}
@@ -267,15 +267,15 @@ public sealed class TestRunner
}
}
/// <summary>一个正在运行的测试:它在哪个拨号方上、怎样取消、是不是因为拨号方掉线而中止。</summary>
/// <summary>一个正在运行的测试:它在哪个 Probe 上、怎样取消、是不是因为 Probe 掉线而中止。</summary>
private sealed class RunningTest : IDisposable
{
public RunningTest(DialerSession dialer)
public RunningTest(ProbeSession probe)
{
Dialer = dialer;
Probe = probe;
}
public DialerSession Dialer { get; }
public ProbeSession Probe { get; }
public CancellationTokenSource Cancel { get; } = new();
@@ -1,34 +0,0 @@
namespace MoShiro.Agent.Tests;
public sealed class ControlBackoffTests
{
[Fact]
public void Next_UsesRequestedEscalationThenKeepsDoublingToTenMinutes()
{
var backoff = new ControlBackoff();
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(2), TimeSpan.FromMinutes(4),
TimeSpan.FromMinutes(8), TimeSpan.FromMinutes(10), TimeSpan.FromMinutes(10),
];
Assert.Equal(expected, expected.Select(_ => backoff.Next()));
}
[Fact]
public void Reset_RestartsAtOneSecond()
{
var backoff = new ControlBackoff();
_ = backoff.Next();
_ = backoff.Next();
backoff.Reset();
Assert.Equal(TimeSpan.FromSeconds(1), backoff.Next());
}
}
@@ -1,114 +0,0 @@
using System.Collections.Concurrent;
using System.Net.Sockets;
using System.Security.Authentication;
using Fleet.Core;
using Fleet.Link;
using MoShiro.Protocol;
namespace MoShiro.Agent.Tests;
public sealed class LinkStateTests
{
[Fact]
public void QuicRedialBackoff_StaysShort_AndRestartsAfterReset()
{
var backoff = new QuicRedialBackoff();
int[] seconds = [1, 2, 3, 5, 5, 5];
Assert.Equal(seconds, seconds.Select(_ => (int)backoff.Next().TotalSeconds));
// 连续失败很多次(路径是死的):翻倍退避,最长五分钟
int[] later = [.. Enumerable.Range(0, 12).Select(_ => (int)backoff.Next().TotalSeconds)];
Assert.Equal([5, 5, 5, 10, 20, 40, 80, 160, 300], later[3..]);
backoff.Reset();
Assert.Equal(TimeSpan.FromSeconds(1), backoff.Next());
TimeSpan jittered = new QuicRedialBackoff().Next(new Random(1));
Assert.InRange(jittered.TotalSeconds, 0.8, 1.2);
}
public static TheoryData<Exception, string> Faults => new()
{
{ new OperationCanceledException(), "timeout" },
{ new SocketException((int)SocketError.ConnectionRefused), "refused" },
{ new SocketException((int)SocketError.HostNotFound), "dns" },
{ new IOException("reset", new SocketException((int)SocketError.ConnectionReset)), "reset" },
{ new AuthenticationException("bad certificate"), "tls" },
{ new InvalidOperationException("peer authentication failed: unauthorized"), "auth" },
{ new ControlSilentException("no frames"), "silent" },
{ new IOException("closed"), "closed" },
};
[Theory]
[MemberData(nameof(Faults))]
public void LinkFaults_ClassifyTheFailureAndKeepThePhase(Exception exception, string kind)
{
LinkFault fault = LinkFaults.From(exception, "tls");
Assert.Equal(kind, fault.Kind);
Assert.Equal("tls", fault.Phase);
}
[Fact]
public void ControlStatus_LogsEveryTransition_AndReportsTheFaultWhileInBackoff()
{
var log = new CaptureLog();
var status = new LinkStatus("agt_1", "ipv4", TimeProvider.System, log);
status.SetControl(ControlState.Dialing);
status.SetControl(ControlState.Authenticating);
status.SetControl(ControlState.Syncing);
status.SetControl(ControlState.Up);
status.SetControl(ControlState.Backoff, new LinkFault { Phase = "control_session", Kind = "silent" }, attempt: 1, retryIn: TimeSpan.FromSeconds(1));
Assert.Equal(["authenticating", "syncing", "up", "backoff"], log.Items.Select(x => (string)x["to"]!));
LinkHealth health = status.Snapshot(new Outbox(1 << 10), 0, null);
Assert.Equal(ControlState.Backoff, health.Control.State);
Assert.Equal("silent", health.Control.LastError?.Kind);
Assert.InRange(health.Control.RetryInMs ?? -1, 1, 1000);
status.SetControl(ControlState.Up);
Assert.Null(status.Snapshot(new Outbox(1 << 10), 0, null).Control.LastError);
}
[Fact]
public void QuicStatus_LogsOnlyTheEdgesOfAnOutage_NotEveryRedial()
{
var log = new CaptureLog();
var status = new LinkStatus("agt_1", "ipv4", TimeProvider.System, log);
var fault = new LinkFault { Phase = "quic_handshake", Kind = "timeout" };
status.SetQuic(QuicState.Connecting);
status.SetQuic(QuicState.Ready);
status.SetQuic(QuicState.Retrying, fault, 1, TimeSpan.FromSeconds(1));
for (int attempt = 1; attempt <= 5; attempt++)
{
status.SetQuic(QuicState.Connecting, attempt: attempt);
status.SetQuic(QuicState.Retrying, fault, attempt + 1, TimeSpan.FromSeconds(5));
}
status.SetQuic(QuicState.Connecting, attempt: 6);
status.SetQuic(QuicState.Ready);
Assert.Equal(["ready", "retrying", "ready"], log.Items.Select(x => (string)x["to"]!));
Assert.NotNull(log.Items[^1]["down_ms"]);
Assert.True(status.Ready is false);
status.SetControl(ControlState.Up);
Assert.True(status.Ready);
}
private sealed class CaptureLog : INodeLog
{
private readonly ConcurrentQueue<Dictionary<string, object?>> _items = new();
public IReadOnlyList<Dictionary<string, object?>> Items => [.. _items];
public void Write(NodeLogLevel level, string message, params (string Key, object? Value)[] fields)
{
if (message == "link state")
{
_items.Enqueue(fields.ToDictionary(x => x.Key, x => x.Value));
}
}
}
}
@@ -1,52 +0,0 @@
using System.Text.Json;
using Fleet.Core;
using Fleet.Hosting;
using MoShiro.Protocol;
namespace MoShiro.Agent.Tests;
public sealed class OpenWrtUpdaterTests : IDisposable
{
private readonly string _directory = Path.Combine(Path.GetTempPath(), "moshiro-native-" + Guid.NewGuid().ToString("N"));
[Fact]
public void Offer_AndApply_WriteFixedFlags_NotRemoteManifestOrCommands()
{
var updater = new OpenWrtUpdater(_directory, Path.Combine(_directory, "status.json"), TimeProvider.System, NullNodeLog.Instance);
updater.Offer();
updater.RequestApply();
Assert.Equal("check", File.ReadAllText(Path.Combine(_directory, "check.request")));
Assert.Equal("check", File.ReadAllText(Path.Combine(_directory, "apply.request")));
Assert.Equal(2, Directory.GetFiles(_directory).Length);
}
[Fact]
public void Status_WritesHealthAndReadsHostStatus()
{
var store = new ConfigStore(_directory);
store.Write("status.json", "{\"state\":\"rolled_back\",\"at\":123}"u8.ToArray());
var updater = new OpenWrtUpdater(_directory, Path.Combine(_directory, "status.json"), TimeProvider.System, NullNodeLog.Instance);
Assert.Equal("rolled_back", updater.Status()?.State);
using JsonDocument health = JsonDocument.Parse(File.ReadAllBytes(Path.Combine(_directory, "health.json")));
Assert.Equal(BuildInfo.Version, health.RootElement.GetProperty("version").GetString());
Assert.Equal(Environment.ProcessId, health.RootElement.GetProperty("pid").GetInt32());
Assert.True(health.RootElement.GetProperty("at").GetInt64() > 0);
}
[Fact]
public void Status_MissingOrCorruptHostStatus_DoesNotStopCollector()
{
var updater = new OpenWrtUpdater(_directory, Path.Combine(_directory, "status.json"), TimeProvider.System, NullNodeLog.Instance);
Assert.Null(updater.Status());
new ConfigStore(_directory).Write("status.json", "not json"u8.ToArray());
Assert.Null(updater.Status());
}
public void Dispose()
{
if (Directory.Exists(_directory))
{
Directory.Delete(_directory, recursive: true);
}
}
}
@@ -1,58 +0,0 @@
using System.Net;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Fleet.Core;
using Fleet.Link;
using MoShiro.Measurement;
using MoShiro.Protocol;
namespace MoShiro.Agent.Tests;
public sealed class TaskLinkTests
{
[Fact]
public async Task QuicConnectionLoss_IsRedialedFromANewPort_WhileTheControlLayerStaysUp()
{
using ECDsa key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var request = new CertificateRequest("CN=localhost", key, HashAlgorithmName.SHA256);
using X509Certificate2 certificate = request.CreateSelfSigned(DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch.AddYears(100));
var session = new DataSession(1, new byte[32]);
var clock = new MonoClock(TimeProvider.System);
await using var server = new QuicDatagramTransport(new SingleSession(session), null, clock, new DataPlaneCounters(), NullNodeLog.Instance,
IPAddress.Loopback, [0], certificate);
server.Bind();
await using var client = new QuicDatagramTransport(session, clock, new DataPlaneCounters(), NullNodeLog.Instance,
new IPEndPoint(IPAddress.Loopback, server.BoundPorts[0]), key.ExportSubjectPublicKeyInfo());
var status = new LinkStatus("agt_1", "ipv4", TimeProvider.System, NullNodeLog.Instance);
status.SetControl(ControlState.Up);
using var link = new TaskLink(client, status, TimeProvider.System);
using var stop = new CancellationTokenSource(TimeSpan.FromSeconds(30));
Task run = link.RunAsync(stop.Token);
await WaitUntilAsync(() => status.Quic == QuicState.Ready, stop.Token);
int firstPort = client.BoundPorts[0];
await client.CloseClientAsync();
bool controlStayedUp = true;
await WaitUntilAsync(() =>
{
controlStayedUp &= status.Control == ControlState.Up;
return status.Quic == QuicState.Ready && client.BoundPorts[0] != firstPort;
}, stop.Token);
Assert.True(controlStayedUp);
LinkHealth health = status.Snapshot(new Outbox(1 << 10), 0, null);
Assert.Equal(2, health.Task.Quic.HandshakeSuccesses);
Assert.Equal(ControlState.Up, health.Control.State);
await stop.CancelAsync();
await run.WaitAsync(TimeSpan.FromSeconds(5));
}
private static async Task WaitUntilAsync(Func<bool> condition, CancellationToken ct)
{
while (!condition())
{
await Task.Delay(20, ct);
}
}
}
+11 -12
View File
@@ -3,7 +3,7 @@
「很多节点连到一个面板」这类产品都要解决同一批问题:节点怎么接入、怎么证明身份、怎么保持连接、怎么可靠上报、怎么接收配置。Fleet 把这些做成与业务无关的库,MoShiro 是它的第一个使用者;产品只写业务。Fleet 不引用任何 `MoShiro.*`,也不出现产品的名字、端口和协议前缀,由 `ArchitectureTests` 在构建时检查。
```text
┌──────────────────── 产品层(MoShiro:Protocol · Measurement · Agent · Backend)────────────────────┐
┌──────────────────── 产品层(MoShiro:Protocol · Measurement · Agent · Probe · Backend)────────────────────┐
└────────────────────────────────────────────┬─────────────────────────────────────────────────────────────┘
│ 只依赖 Fleet
┌───────────────────────────── Fleet ────────┴─────────────────────────────────────────────────────────────┐
@@ -19,11 +19,11 @@
## 设计原则
1. 与业务无关:产品通过扩展点接入:元数据类型、RPC 方法、发件箱消息类型、期望状态文档类型。
2. 安全默认值:Fleet 本身不提供 exec、shell、文件读写、远程下发代码这类方法;节点只响应本地注册过的方法(本地策略 `allow_methods` 还能收窄);调用对方不支持的方法在本地直接返回 `fleet_unsupported`。
3. 权限分离:注册(`fleet-enroll`,一次性)和常驻业务进程是两个进程;常驻进程不含注册代码,也不使用 `HttpClient`。
4. NativeAOT:节点侧只依赖 BCL,泛型扩展点通过产品传入的 `JsonTypeInfo<T>` 序列化,不使用反射。
5. 更新不在 Fleet 里:Fleet 不包含更新逻辑,更新由节点外部的服务管理器或更新器完成。Quadlet 装的节点和 Backend 由退出后 systemd 重新拉镜像、脚本装的节点由宿主机上的更新器验签更新,见 [`docs/ops/deployment.md`](../../docs/ops/deployment.md);纯二进制节点的更新见 [路线图](../../docs/roadmap.md)。
1. **与业务无关**:产品通过扩展点接入:元数据类型、RPC 方法、发件箱消息类型、期望状态文档类型。
2. **安全默认值**:Fleet 永远不提供 exec、shell、文件读写、远程下发代码这类方法;节点只响应本地注册过的方法(本地策略 `allow_methods` 还能收窄);调用对方不支持的方法在本地直接返回 `fleet_unsupported`。
3. **权限分离**:注册(`fleet-enroll`,一次性)和常驻业务进程是两个进程;常驻进程不含注册代码,也不使用 `HttpClient`。
4. **NativeAOT**:节点侧只依赖 BCL,泛型扩展点通过产品传入的 `JsonTypeInfo<T>` 序列化,不使用反射。
5. **更新不在 Fleet 里**:节点进程没有自更新能力。容器节点的签名更新由宿主机上的更新器完成,见 [`docs/deployment.md`](../../docs/deployment.md);纯二进制节点的更新是待办,见 [`TODO.md`](../../TODO.md)。
## 身份
@@ -56,7 +56,7 @@
- JSON-RPC 2.0;方法名 `领域.动作`,`fleet.*` 保留给框架,产品用自己的前缀(MoShiro 是 `moshiro.*`)。
- 二进制帧 `u8 channel | 正文`:`0`–`15` 框架保留,产品从 `16` 开始。
- 错误:`error.data.code` 为 snake_case,框架的以 `fleet_` 开头,产品可以扩展。
- 方法必须声明:每个方法在声明表(`MethodTable`)里写明发起方、请求还是通知、哪些角色可以参与;不存在双向方法,同一个方法名在一个平面里只有一个发起方。`RpcRouter` 在注册、分发和发送三处核对,违规回 `fleet_protocol_violation`,超过上限断开连接。详见 [`docs/security/security.md`](../../docs/security/security.md) 第 6 节。
- **方法必须声明**:每个方法在声明表(`MethodTable`)里写明发起方、请求还是通知、哪些角色可以参与;不存在双向方法,同一个方法名在一个平面里只有一个发起方。`RpcRouter` 在注册、分发和发送三处核对,违规回 `fleet_protocol_violation`,超过上限断开连接。详见 [`docs/security.md`](../../docs/security.md) 第 8 节。
- 与传输无关:同一套路由跑在 wss 上,也跑在 TLS 帧(`u32 长度 | u8 类型 | 正文`)上。
| 保留方法 | 方向 | 说明 |
@@ -64,7 +64,6 @@
| `fleet.challenge` / `fleet.auth` | 服务端→节点 / 节点→服务端 | 签名认证(`auth` 用途,nonce 30 秒内有效) |
| `fleet.hello` | 节点→服务端 | 版本、能力、本地策略摘要、当前时间 → 返回时钟偏差;产品的元数据也放在这里 |
| `fleet.heartbeat` | 节点→服务端 | 每 10 秒一次,产品可以附加计数 |
| `fleet.stopping` | 节点→服务端 | 有序关闭前通知服务端,携带 `reason`(例如 `stopping` 或 `updating`),用于准确区分离线和计划停机 |
| `fleet.state.sync` | 服务端→节点 | `{version, doc}` → `{applied_version, rejected[]}` |
| `fleet.outbox.ack` | 服务端→节点 | `{ids[], duplicates[]}` |
| `fleet.revoked` | 服务端→节点 | 节点被禁用或接管,随后断开 |
@@ -79,14 +78,14 @@ connect(wss) → fleet.challenge → fleet.auth → fleet.hello → 状态同步
本地策略:`allow_methods`、`max_frame_bytes`、`max_inflight`。
可靠发件箱:产品 `EnqueueAsync` → 有界队列(按字节和条数;满时 `DropOldest` 或 `Reject`)→ 发送 → 服务端收件箱(`IInbox`)持久化后 `fleet.outbox.ack` → 移除。至少一次,按 `msg_id`(UUIDv7)去重;在途数据默认限 4 MiB;丢弃会累计缺口,通过 `OnGap` 通知产品。只在内存里,不落盘。
**可靠发件箱**:产品 `EnqueueAsync` → 有界队列(按字节和条数;满时 `DropOldest` 或 `Reject`)→ 发送 → 服务端收件箱(`IInbox`)持久化后 `fleet.outbox.ack` → 移除。至少一次,按 `msg_id`(UUIDv7)去重;在途数据默认限 4 MiB;丢弃会累计缺口,通过 `OnGap` 通知产品。只在内存里,不落盘。
期望状态同步:服务端 `IStateProvider<TDoc>` 给每个节点算 `{version, doc}`,变化时 `NotifyChanged` 推送;节点 `StateHandler<TDoc>` 调和后返回 `{applied_version, rejected[]}`。下发的是完整快照,天然幂等,重连后服务端总会重发当前版本。`rejected[]` 带结构化原因(`unsupported`、`budget_exceeded`、`policy_denied`),面板可以直接展示。
**期望状态同步**:服务端 `IStateProvider<TDoc>` 给每个节点算 `{version, doc}`,变化时 `NotifyChanged` 推送;节点 `StateHandler<TDoc>` 调和后返回 `{applied_version, rejected[]}`。下发的是**完整快照**,天然幂等,重连后服务端总会重发当前版本。`rejected[]` 带结构化原因(`unsupported`、`budget_exceeded`、`policy_denied`),面板可以直接展示。
## 点对点会话
```text
节点 A(发起方,如 Agent A)── TLS(按 SPKI 校验证书)──> 节点 B(监听方,如 Agent B)
节点 A(发起方,如 Probe)── TLS(按 SPKI 校验证书)──> 节点 B(监听方,如 Agent)
<── fleet.peer.challenge ──
── fleet.peer.auth(peer 用途,A 的私钥签名)──>
B 用服务端下发的对端公钥列表验签(经 state.sync 下发),之后复用同一套 Rpc:路由、二进制通道、发件箱
@@ -100,6 +99,6 @@ connect(wss) → fleet.challenge → fleet.auth → fleet.hello → 状态同步
- `ModuleHost`:按依赖顺序启动模块、相反顺序停止,隔离故障,重启时退避。
- `NodeConfig`:源生成 JSON 读取,环境变量覆盖,启动时校验。
- `ConfigStore`:常驻进程里唯一允许写文件的地方,只能写配置目录,写入原子替换、权限 0600;父目录不存在会创建。
- `ConfigStore`:常驻进程里**唯一**允许写文件的地方,只能写配置目录,写入原子替换、权限 0600;父目录不存在会创建。
- `ServiceShell`:SIGTERM、Ctrl+C,systemd `sd_notify`、OpenWrt procd(前台运行)和 Windows 服务。
- 服务端接口:`IFleetStore`(节点、令牌、公钥、状态版本)、`IStateProvider<TDoc>`、`IInbox`、`INodeEventSink`、`IEnrollmentHook`。MoShiro 把 `IFleetStore` 放进自己的 `moshiro.db`,和业务数据由同一个单写入者写入。
@@ -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;
}
}
}
-32
View File
@@ -1,32 +0,0 @@
using System.Net.Sockets;
using System.Net.WebSockets;
namespace Fleet.Core;
/// <summary>
/// 收尾类的等待(关闭、释放、等任务结束)一律要有上限:对端没了、原生层不回调、某个处理器卡住时,
/// 没有上限的等待会让状态机永远停在「已连接」,既不重连也不报错。超时就放弃等待并交给调用方记日志;会话结束时的常见异常按正常噪声吞掉。
/// </summary>
public static class Bounded
{
/// <summary>收尾的一步最多等这么久。</summary>
public static readonly TimeSpan Teardown = TimeSpan.FromSeconds(5);
/// <summary>等 <paramref name="work"/> 最多 <paramref name="limit"/>;超时返回 false(任务继续在后台收尾,不再等它),否则返回 true。</summary>
public static async Task<bool> WaitAsync(Task work, TimeSpan limit, TimeProvider time)
{
try
{
await work.WaitAsync(limit, time);
return true;
}
catch (TimeoutException)
{
return false;
}
catch (Exception ex) when (ex is OperationCanceledException or IOException or ObjectDisposedException or InvalidOperationException or SocketException or WebSocketException)
{
return true; // 会话结束时的正常噪声:这一步已经结束了
}
}
}
@@ -1,8 +0,0 @@
namespace Fleet.Core;
/// <summary>Adds stable diagnostic context to every event in one operation.</summary>
public sealed class ContextNodeLog(INodeLog inner, params (string Key, object? Value)[] context) : INodeLog
{
public void Write(NodeLogLevel level, string message, params (string Key, object? Value)[] fields) =>
inner.Write(level, message, [.. context, .. fields]);
}
+2 -3
View File
@@ -27,7 +27,6 @@ public static class FleetErrors
public static readonly ErrorCode JoinTokenExpired = new("fleet_join_token_expired");
public static readonly ErrorCode ProofInvalid = new("fleet_proof_invalid");
public static readonly ErrorCode FrameTooLarge = new("fleet_frame_too_large");
/// <summary>对方的版本低于要求的最低版本:等一会儿(对方可能正在更新)再握手。</summary>
public static readonly ErrorCode VersionRequired = new("fleet_version_required");
/// <summary>节点现在跑的二进制和它第一次握手时绑定的不一致,也不在 CI 签名的更新清单里。</summary>
public static readonly ErrorCode BinaryMismatch = new("fleet_binary_mismatch");
}
+1 -24
View File
@@ -9,35 +9,12 @@ public enum NodeLogLevel
Error,
}
/// <summary>节点日志:字段名统一为 logfmt(<c>ts level msg agent dialer session task result err</c>)。逐包事件不记录,只累加计数。</summary>
/// <summary>节点日志:字段名统一为 logfmt(<c>ts level msg agent probe session task result err</c>)。逐包事件不记录,只累加计数。</summary>
public interface INodeLog
{
void Write(NodeLogLevel level, string message, params (string Key, object? Value)[] fields);
}
/// <summary>One structured node log line.</summary>
public sealed record NodeLogRecord(long AtUtcMs, string Level, string Message, Dictionary<string, string> Fields);
/// <summary>A retained log line: <paramref name="Id"/> grows with every line, <paramref name="Type"/> is a stable hash of the message text (the template), so counters can be sent without the text.</summary>
public sealed record NodeLogEntry(long Id, uint Type, NodeLogRecord Record);
/// <summary>How many lines of one type and level were written since the last summary.</summary>
public sealed record NodeLogTypeCount(uint Type, string Level, long Count);
/// <summary>What a node tells the control plane about its logs without sending any text: counters since the last summary and the newest line id.</summary>
public sealed record NodeLogSummary(long LastId, long Info, long Warn, long Error, IReadOnlyList<NodeLogTypeCount> Types);
public sealed record NodeLogPage(IReadOnlyList<NodeLogEntry> Items, long LastId);
/// <summary>Bounded local log retention. The control plane gets counters in heartbeats and pulls the text when it needs it.</summary>
public interface INodeLogSource
{
NodeLogSummary TakeSummary();
/// <summary>Lines with an id above <paramref name="afterId"/>, oldest first, at most <paramref name="limit"/>, at or above <paramref name="minLevel"/> (info, warn or error).</summary>
NodeLogPage Read(long afterId, int limit, string? minLevel);
}
public sealed class NullNodeLog : INodeLog
{
public static NullNodeLog Instance { get; } = new();
@@ -1,87 +0,0 @@
using System.Globalization;
namespace Fleet.Core;
/// <summary>
/// 版本号比较,给「要求的最低版本」用。认得 <c>主.次.修订</c>,可以带预发布后缀;开发构建 <c>0.4.2-dev.73-5245f03</c> 按 <c>dev.73</c> 的序号比较,
/// 提交哈希不参与比较。同一个主次修订里,正式版高于任何预发布版。认不出来的版本号不拒绝(返回 false),免得一个奇怪的版本字符串把节点挡在门外。
/// </summary>
public static class NodeVersions
{
private readonly record struct Parsed(int Major, int Minor, int Patch, bool Prerelease, long Dev, string Suffix);
/// <summary><paramref name="actual"/> 低于 <paramref name="required"/> 返回 true;任何一个为空或无法解析返回 false。</summary>
public static bool IsBelow(string? actual, string? required)
{
if (string.IsNullOrWhiteSpace(required) || string.IsNullOrWhiteSpace(actual))
{
return false;
}
if (!TryParse(actual, out Parsed have) || !TryParse(required, out Parsed need))
{
return false;
}
return Compare(have, need) < 0;
}
private static int Compare(Parsed a, Parsed b)
{
int core = (a.Major, a.Minor, a.Patch).CompareTo((b.Major, b.Minor, b.Patch));
if (core != 0)
{
return core;
}
if (a.Prerelease != b.Prerelease)
{
return a.Prerelease ? -1 : 1; // 正式版高于预发布版
}
if (!a.Prerelease)
{
return 0;
}
int dev = a.Dev.CompareTo(b.Dev);
return dev != 0 ? dev : 0;
}
private static bool TryParse(string text, out Parsed parsed)
{
parsed = default;
string value = text.Trim().TrimStart('v');
int plus = value.IndexOf('+', StringComparison.Ordinal);
if (plus >= 0)
{
value = value[..plus];
}
int dash = value.IndexOf('-', StringComparison.Ordinal);
string core = dash < 0 ? value : value[..dash];
string suffix = dash < 0 ? "" : value[(dash + 1)..];
string[] parts = core.Split('.');
if (parts.Length != 3
|| !int.TryParse(parts[0], NumberStyles.None, CultureInfo.InvariantCulture, out int major)
|| !int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out int minor)
|| !int.TryParse(parts[2], NumberStyles.None, CultureInfo.InvariantCulture, out int patch))
{
return false;
}
long dev = 0;
if (suffix.StartsWith("dev.", StringComparison.Ordinal))
{
string number = suffix[4..];
int end = number.IndexOf('-', StringComparison.Ordinal);
if (!long.TryParse(end < 0 ? number : number[..end], NumberStyles.None, CultureInfo.InvariantCulture, out dev))
{
return false;
}
}
parsed = new Parsed(major, minor, patch, suffix.Length > 0, dev, suffix);
return true;
}
}
+12
View File
@@ -51,4 +51,16 @@ public static class VarInt
return false;
}
public static bool TryReadUInt32LittleEndian(ReadOnlySpan<byte> source, out uint value)
{
if (source.Length < 4)
{
value = 0;
return false;
}
value = BinaryPrimitives.ReadUInt32LittleEndian(source);
return true;
}
}
@@ -1,147 +0,0 @@
using System.Globalization;
using Fleet.Core;
namespace Fleet.Hosting;
/// <summary>
/// Writes logs locally and keeps a bounded ring of recent lines. Heartbeats carry only counters (see <see cref="TakeSummary"/>);
/// the text leaves the node when the control plane asks for it (see <see cref="Read"/>).
/// </summary>
public sealed class BufferedNodeLog : INodeLog, INodeLogSource
{
private const int Capacity = 2048;
private const int MaxFields = 20;
private const int MaxMessageLength = 512;
private const int MaxFieldValueLength = 256;
private const int MaxPageBytes = 96 << 10;
private const int MaxSummaryTypes = 8;
private readonly object _gate = new();
private readonly LinkedList<NodeLogEntry> _ring = [];
private readonly Dictionary<(uint Type, string Level), long> _counts = [];
private readonly INodeLog _local;
private readonly TimeProvider _time;
private long _lastId;
private long _info;
private long _warn;
private long _error;
public BufferedNodeLog(INodeLog local, TimeProvider time)
{
_local = local;
_time = time;
}
public void Write(NodeLogLevel level, string message, params (string Key, object? Value)[] fields)
{
_local.Write(level, message, fields);
var safeFields = new Dictionary<string, string>(Math.Min(fields.Length, MaxFields), StringComparer.Ordinal);
foreach ((string key, object? value) in fields.Take(MaxFields))
{
string safeValue = IsSensitiveKey(key) ? "[redacted]" : Convert.ToString(value, CultureInfo.InvariantCulture) ?? "";
safeFields[key.Length <= 64 ? key : key[..64]] = Truncate(safeValue, MaxFieldValueLength);
}
string levelName = level.ToString().ToLowerInvariant();
var record = new NodeLogRecord(_time.GetUtcNow().ToUnixTimeMilliseconds(), levelName, Truncate(message, MaxMessageLength), safeFields);
uint type = TypeOf(record.Message);
lock (_gate)
{
_ring.AddLast(new NodeLogEntry(++_lastId, type, record));
if (_ring.Count > Capacity)
{
_ring.RemoveFirst();
}
_counts[(type, levelName)] = _counts.GetValueOrDefault((type, levelName)) + 1;
switch (level)
{
case NodeLogLevel.Error:
_error++;
break;
case NodeLogLevel.Warn:
_warn++;
break;
default:
_info++;
break;
}
}
}
public NodeLogSummary TakeSummary()
{
lock (_gate)
{
var summary = new NodeLogSummary(
_lastId, _info, _warn, _error,
[.. _counts.OrderByDescending(pair => pair.Value).Take(MaxSummaryTypes).Select(pair => new NodeLogTypeCount(pair.Key.Type, pair.Key.Level, pair.Value))]);
_counts.Clear();
_info = _warn = _error = 0;
return summary;
}
}
public NodeLogPage Read(long afterId, int limit, string? minLevel)
{
int rank = LevelRank(minLevel);
lock (_gate)
{
var items = new List<NodeLogEntry>(Math.Min(Math.Max(limit, 0), 200));
int bytes = 0;
foreach (NodeLogEntry entry in _ring)
{
if (entry.Id <= afterId || LevelRank(entry.Record.Level) < rank)
{
continue;
}
int entryBytes = 64 + entry.Record.Message.Length + entry.Record.Fields.Sum(pair => pair.Key.Length + pair.Value.Length + 8);
if (items.Count >= Math.Clamp(limit, 0, 200) || (items.Count > 0 && bytes + entryBytes > MaxPageBytes))
{
break;
}
items.Add(entry);
bytes += entryBytes;
}
return new NodeLogPage(items, _lastId);
}
}
/// <summary>The line's type: a stable 32-bit FNV-1a hash of its message text, so the same template always maps to the same number.</summary>
public static uint TypeOf(string message)
{
uint hash = 2166136261;
foreach (char c in message)
{
hash = (hash ^ c) * 16777619;
}
return hash;
}
private static int LevelRank(string? level) => level switch
{
"error" => 2,
"warn" => 1,
_ => 0,
};
private static bool IsSensitiveKey(string key)
{
string lower = key.ToLowerInvariant();
return lower.Contains("token", StringComparison.Ordinal)
|| lower.Contains("secret", StringComparison.Ordinal)
|| lower.Contains("password", StringComparison.Ordinal)
|| lower.Contains("private", StringComparison.Ordinal)
|| lower.Contains("signature", StringComparison.Ordinal)
|| lower.Contains("authorization", StringComparison.Ordinal)
|| lower.Contains("cookie", StringComparison.Ordinal)
|| lower == "mac";
}
private static string Truncate(string value, int maxLength) => value.Length <= maxLength ? value : value[..maxLength];
}
+2 -31
View File
@@ -156,9 +156,6 @@ public static class ServiceShell
log.Info("stopping", ("result", "SIGHUP"));
cts.Cancel();
});
// 有序退出卡住(比如某个已经死掉的会话收不到对端的关闭回复)时不能一直挂着:supervisor 的 stop 超时之前自己退出,由它重新拉起
using var exited = new CancellationTokenSource();
using CancellationTokenRegistration deadline = cts.Token.Register(() => _ = ExitIfStuckAsync(time ?? TimeProvider.System, log, exited.Token));
Task trimmer = TrimHeapAsync(cts.Token);
Task scheduled = restart is null ? Task.CompletedTask : WaitForRestartAsync(restart, time ?? TimeProvider.System, log, cts);
try
@@ -174,28 +171,9 @@ public static class ServiceShell
await cts.CancelAsync();
await trimmer;
await scheduled;
await exited.CancelAsync();
}
}
/// <summary>退出请求发出之后给有序退出的时间(要短于容器的 StopTimeout,否则 supervisor 会先强杀)。</summary>
public static readonly TimeSpan ShutdownGrace = TimeSpan.FromSeconds(10);
private static async Task ExitIfStuckAsync(TimeProvider time, INodeLog log, CancellationToken exited)
{
try
{
await Task.Delay(ShutdownGrace, time, exited);
}
catch (OperationCanceledException)
{
return; // 正常退出了
}
log.Warn("orderly shutdown did not finish in time, exiting", ("grace_s", (long)ShutdownGrace.TotalSeconds));
Environment.Exit(0);
}
private static async Task WaitForRestartAsync(RestartController restart, TimeProvider time, INodeLog log, CancellationTokenSource cts)
{
while (!cts.IsCancellationRequested)
@@ -203,7 +181,7 @@ public static class ServiceShell
// 先取令牌、再取设置:中间被改动的话令牌会被取消,下一轮重新排
CancellationToken changed = restart.ChangeToken;
RestartSchedule? schedule = restart.Current;
using CancellationTokenSource wait = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, changed, restart.NowToken);
using CancellationTokenSource wait = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, changed);
try
{
if (schedule is null)
@@ -218,13 +196,6 @@ public static class ServiceShell
}
catch (OperationCanceledException)
{
if (restart.NowReason is { } reason)
{
log.Info("stopping", ("result", reason));
await cts.CancelAsync();
return;
}
continue; // 进程在退出,或者设置被改了:循环条件和下一轮会处理
}
@@ -287,7 +258,7 @@ public static class NodeConfig
/// <summary>
/// 常驻业务进程里唯一允许写文件的地方,而且只能写配置/身份目录(原子替换,类 Unix 上权限 0600)。
/// 例如 拨号方 把最新的 Agent 列表写回 bootstrap。测量数据本来就不落盘。
/// 例如 Probe 把最新的 Agent 列表写回 bootstrap。测量数据本来就不落盘。
/// </summary>
public sealed class ConfigStore
{
@@ -3,7 +3,7 @@ using Fleet.Core;
namespace Fleet.Hosting;
/// <summary>
/// 节点自己要求有属主(passkey):拨号方和监听方共用这一份,启动时检查。
/// 节点自己要求有属主(passkey):Agent 和 Probe 共用这一份,启动时检查。
/// <para>
/// 属主是安装命令带来、注册时固定在 <c>trust.json</c> 里的 passkey 凭证 id(必须是操作员集合里的凭证)。节点据此知道自己绑定在哪个 passkey 上,
/// 才敢把更多的接口和操作交给它(extension 会话、加密内容都只认属主的 passkey);没有属主的节点没有人能授权它的敏感能力,所以不运行。
@@ -8,9 +8,7 @@ public sealed class RestartController : IDisposable
private readonly RestartSchedule? _local;
private readonly object _gate = new();
private RestartSchedule? _remote;
private readonly CancellationTokenSource _now = new();
private CancellationTokenSource _changed = new();
private string? _nowReason;
public RestartController(RestartSchedule? local = null)
{
@@ -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>
public bool SetRemote(string? text)
{
@@ -96,7 +68,6 @@ public sealed class RestartController : IDisposable
lock (_gate)
{
_changed.Dispose();
_now.Dispose();
}
}
}
@@ -25,7 +25,7 @@ public sealed partial class TrustFileContext : JsonSerializerContext;
/// <summary>
/// 节点的信任根:安装命令里带来的创世指纹和属主,在注册时固定到本地,之后 Backend 改不了。
/// 操作员集合的新版本随期望状态下发,节点只接受从固定的创世出发、签名完整、版本更新的链。
/// 属主只是被固定和保存:节点现在没有 extension,等做 extension 时由节点本地强制(见 docs/security/access.md 第 9 节)。
/// 属主只是被固定和保存:节点现在没有 extension,等做 extension 时由节点本地强制(见 docs/access.md 第 8 节)。
/// </summary>
public sealed class TrustAnchor : IServerVerifier
{
@@ -20,7 +20,7 @@ public sealed record OperatorKey(string CredentialId, byte[] PublicKeySpki);
public sealed record OperatorSet(int Version, string Previous, string RpId, IReadOnlyList<string> Origins, IReadOnlyList<OperatorKey> Keys, IReadOnlyList<byte[]>? Servers = null);
/// <summary>
/// 信任根:操作员(passkey)→ 操作员集合 → 节点本地固定的属主。设计见 docs/security/access.md 第 8 节。
/// 信任根:操作员(passkey)→ 操作员集合 → 节点本地固定的属主。设计见 docs/access.md 第 7 节。
/// 安装命令里带着创世指纹和属主(passkey 的凭证 id):节点把它们固定在本地,之后只认从创世出发、由操作员 passkey 签过名的集合版本,
/// 属主必须是集合里的凭证。Backend 能转发集合,但签不出新的版本,也改不了节点已经固定的东西。
/// 全是纯函数,只用 BCL,节点和 Backend 共用。语句是 <c>key=value</c> 的文本,第一行是带版本的标题;签名覆盖整段文本。
@@ -7,7 +7,7 @@ namespace Fleet.Identity;
/// <summary>
/// 验证一次 WebAuthn 断言(<c>navigator.credentials.get</c> 的结果)。只支持 ES256(P-256 的 ECDSA),纯函数,只用 BCL:
/// 面板用它验证 passkey 登录,节点验证操作员集合的签名也用它(docs/security/access.md 第 8 节)。
/// 面板用它验证 passkey 登录,节点以后用它验证 passkey 签出的委托(docs/access.md 第 7 节)。
/// 不解析 CBOR——注册时的 attestation 解析只在 Backend 里。
/// </summary>
public static class WebAuthnAssertion
+28 -139
View File
@@ -1,6 +1,3 @@
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
using System.Net.WebSockets;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
@@ -52,10 +49,7 @@ public sealed class FleetLinkOptions
public string NodeVersion { get; init; } = "0.0.0";
/// <summary>服务端至少要是哪个版本(每次握手重新取);空表示不要求。不够就关掉这条连接,等 <see cref="VersionRetry"/> 再握手,服务端可能正在更新。</summary>
public Func<string?>? MinServerVersion { get; init; }
/// <summary>框架之外的能力,例如 <c>moshiro.v1</c> 与 <c>moshiro.kinds:quic_ping,tcp_ping</c>。</summary>
/// <summary>框架之外的能力,例如 <c>moshiro.v1</c> 与 <c>moshiro.kinds:udp_echo,tcp_echo</c>。</summary>
public IReadOnlyList<string> Capabilities { get; init; } = [];
/// <summary>附加到 <c>fleet.hello</c> 的产品元数据。</summary>
@@ -64,12 +58,6 @@ public sealed class FleetLinkOptions
/// <summary>附加到 <c>fleet.heartbeat</c> 的进程内计数(不能包含主机信息)。</summary>
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; }
/// <summary>连上服务器的整个过程(TCP、TLS、升级)最多等多久;卡住就放弃,由外层退避重试。</summary>
public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(20);
public TimeSpan HeartbeatInterval { get; init; } = TimeSpan.FromSeconds(10);
public int MaxFrameBytes { get; init; } = 16 << 20;
@@ -84,6 +72,9 @@ public sealed class FleetLinkOptions
/// 服务器必须在挑战里证明自己,之后这条链路用应用层加密;否则按旧的方式(没有固定信任根的节点)。
/// </summary>
public IServerVerifier? ServerVerifier { get; init; }
/// <summary>握手时自述正在运行的二进制(哈希和 CI 签名的清单);没有就不上报(旧行为)。</summary>
public Func<BinaryAttestation?>? Attestation { get; init; }
}
/// <summary>
@@ -103,13 +94,6 @@ public sealed class FleetLink
private volatile RpcSession<LinkContext>? _session;
private volatile bool _stopped;
/// <summary>版本不够时重新握手的间隔:固定,不退避(对方更新完马上就能连上)。</summary>
public static readonly TimeSpan VersionRetry = TimeSpan.FromSeconds(5);
private static readonly TimeSpan s_tcpTimeout = TimeSpan.FromSeconds(4);
private TimeSpan? _fixedRetry;
private int _failedConnects;
public FleetLink(FleetLinkOptions options, RpcRouter<LinkContext> router, IEnumerable<IStateHandler> handlers, Outbox outbox, TimeProvider time, INodeLog log)
{
_options = options;
@@ -156,7 +140,7 @@ public sealed class FleetLink
{
return;
}
catch (Exception ex)
catch (Exception ex) when (ex is WebSocketException or IOException or HttpRequestException or InvalidOperationException or TimeoutException)
{
reason = ex.Message;
}
@@ -173,68 +157,12 @@ public sealed class FleetLink
return;
}
TimeSpan delay = _fixedRetry ?? _options.Backoff.Next();
_fixedRetry = null;
TimeSpan delay = _options.Backoff.Next();
_log.Warn("link down, retrying", ("err", reason), ("retry_ms", (long)delay.TotalMilliseconds));
await Task.Delay(delay, _time, ct);
}
}
/// <summary>
/// 连上面板:整个握手(TCP、TLS、升级)限时,卡住就放弃重试;每个地址的 TCP 连接也单独限时。
/// 失败之后下一次换着先试另一个地址族(IPv6 / IPv4):有的网络上到某个地址族的 TCP 能连上、之后的 TLS 却被卡住,换一个往往就通。
/// </summary>
private async Task ConnectAsync(ClientWebSocket socket, Uri uri, CancellationToken ct)
{
int attempt = Volatile.Read(ref _failedConnects);
using var handler = new SocketsHttpHandler { ConnectCallback = (context, token) => ConnectTcpAsync(context, attempt, token) };
using var invoker = new HttpMessageInvoker(handler, disposeHandler: false);
using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(_options.ConnectTimeout);
try
{
await socket.ConnectAsync(uri, invoker, timeout.Token);
Volatile.Write(ref _failedConnects, 0);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
Interlocked.Increment(ref _failedConnects);
throw new TimeoutException("connecting to the server timed out");
}
catch
{
Interlocked.Increment(ref _failedConnects);
throw;
}
}
private static async ValueTask<Stream> ConnectTcpAsync(SocketsHttpConnectionContext context, int attempt, CancellationToken ct)
{
IPAddress[] addresses = await Dns.GetHostAddressesAsync(context.DnsEndPoint.Host, ct);
// 第一次按 DNS 的顺序;之后奇数次先试 IPv6,偶数次先试 IPv4
IEnumerable<IPAddress> ordered = attempt == 0 ? addresses
: addresses.OrderBy(a => (a.AddressFamily == AddressFamily.InterNetworkV6) == (attempt % 2 == 1) ? 0 : 1);
Exception? last = null;
foreach (IPAddress address in ordered)
{
var tcp = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
using CancellationTokenSource limit = CancellationTokenSource.CreateLinkedTokenSource(ct);
limit.CancelAfter(s_tcpTimeout);
try
{
await tcp.ConnectAsync(new IPEndPoint(address, context.DnsEndPoint.Port), limit.Token);
return new NetworkStream(tcp, ownsSocket: true);
}
catch (Exception ex) when (ex is SocketException or OperationCanceledException && !ct.IsCancellationRequested)
{
tcp.Dispose();
last = ex;
}
}
throw last ?? new SocketException((int)SocketError.HostNotFound);
}
/// <summary>当前这条链路是不是应用层加密的(服务器证明了身份)。</summary>
public bool IsSecure => _secure && IsConnected;
@@ -244,7 +172,7 @@ public sealed class FleetLink
socket.Options.KeepAliveInterval = TimeSpan.FromSeconds(15);
socket.Options.KeepAliveTimeout = TimeSpan.FromSeconds(20); // 对端 20 秒没有回 Pong 就判定连接已死
var builder = new UriBuilder(_options.Server) { Scheme = _options.Server.Scheme == "https" ? "wss" : "ws", Path = RpcPath };
await ConnectAsync(socket, builder.Uri, ct);
await socket.ConnectAsync(builder.Uri, ct);
await using var transport = new SecureTransport(new WsTransport(socket, _options.MaxFrameBytes));
string? failure = await AuthenticateAsync(transport, ct);
@@ -256,8 +184,7 @@ public sealed class FleetLink
_secure = transport.Secure;
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 var sessionCts = new CancellationTokenSource();
using CancellationTokenSource sessionCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
Task run = session.RunAsync(sessionCts.Token);
try
{
@@ -268,22 +195,10 @@ public sealed class FleetLink
NowUtcMs = _time.GetUtcNow().ToUnixTimeMilliseconds(),
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.Error.Code == FleetErrors.VersionRequired)
{
_fixedRetry = VersionRetry; // 我们的版本低于服务端要求的:等自己更新,每 5 秒再试一次
}
return "hello failed: " + helloResult.Error.Code + (_fixedRetry is null ? "" : ": " + helloResult.Error.Message);
}
string? requiredServer = _options.MinServerVersion?.Invoke();
if (NodeVersions.IsBelow(helloResult.Value.ServerVersion, requiredServer))
{
_fixedRetry = VersionRetry; // 服务端版本低于我们要求的(它可能正在更新):不发任何东西,5 秒后重新握手
return $"server version {helloResult.Value.ServerVersion} is below the required {requiredServer}";
return "hello failed: " + helloResult.Error.Code;
}
Outbox.ResetSent();
@@ -296,39 +211,31 @@ public sealed class FleetLink
await OnConnected(session, sessionCts.Token);
}
Task cancellation = Task.Delay(Timeout.Infinite, ct);
Task finished = await Task.WhenAny(run, pump, heartbeat, cancellation);
string finishedReason = DescribeFinishedTask(finished, run, pump, heartbeat, session.TransportFailure);
_session = null; // 链路已经结束:上报方不能再往这条会话上写,收尾卡住时也一样
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.
}
}
Task finished = await Task.WhenAny(run, pump, heartbeat);
await sessionCts.CancelAsync();
await Bounded.WaitAsync(Task.WhenAll(Quiet(run), Quiet(pump), Quiet(heartbeat)), Bounded.Teardown, _time);
return finishedReason;
await Task.WhenAll(Quiet(run), Quiet(pump), Quiet(heartbeat));
return finished == run ? "closed by server" : "link task failed";
}
finally
{
_session = null;
await sessionCts.CancelAsync();
await Bounded.WaitAsync(Quiet(run), Bounded.Teardown, _time);
await Bounded.WaitAsync(session.DisposeAsync().AsTask(), Bounded.Teardown, _time);
await Quiet(run);
await session.DisposeAsync();
}
}
/// <summary>挑战 → 签名 → 认证结果。失败返回原因;服务端禁用该节点时不再重试。</summary>
private void Attest(AuthParams auth)
{
if (_options.Attestation?.Invoke() is { } attestation)
{
auth.Binary = attestation.Sha256;
auth.Release = attestation.Manifest;
auth.ReleaseSignature = attestation.Signature;
}
}
private async Task<string?> AuthenticateAsync(SecureTransport transport, CancellationToken ct)
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15), _time);
@@ -363,6 +270,7 @@ public sealed class FleetLink
Signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.AuthSecure, _options.NodeId.Value, nonce.Nonce, serverEphemeralText, nodeEphemeralText)),
NodeEphemeral = nodeEphemeralText,
};
Attest(authParams);
RpcMessage secureRequest = RpcMessage.Request(1, FleetMethods.Auth, authParams, FleetRpcContext.Default.AuthParams);
await transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(secureRequest, FleetRpcContext.Default.RpcMessage)), linked.Token);
transport.Enable(LinkCipher.ForNode(ephemeral, serverEphemeral, nonce.Nonce));
@@ -370,6 +278,7 @@ public sealed class FleetLink
else
{
authParams = new AuthParams { NodeId = _options.NodeId.Value, Signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.Auth, _options.NodeId.Value, nonce.Nonce)) };
Attest(authParams);
RpcMessage request = RpcMessage.Request(1, FleetMethods.Auth, authParams, FleetRpcContext.Default.AuthParams);
await transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(request, FleetRpcContext.Default.RpcMessage)), linked.Token);
}
@@ -484,29 +393,9 @@ public sealed class FleetLink
{
await task;
}
catch (OperationCanceledException)
{
// Session cancellation is expected during reconnect and shutdown.
}
catch (Exception ex) when (ex is WebSocketException or IOException or ObjectDisposedException or TimeoutException or InvalidOperationException)
catch (Exception ex) when (ex is OperationCanceledException or WebSocketException or IOException or ObjectDisposedException)
{
// 会话结束时的正常异常
}
}
private static string DescribeFinishedTask(Task finished, Task run, Task pump, Task heartbeat, string? transportFailure)
{
if (finished.IsFaulted)
{
string taskName = finished == run ? "receive" : finished == pump ? "outbox send" : "heartbeat";
return $"{taskName} failed: {finished.Exception?.GetBaseException().Message ?? "unknown error"}";
}
if (finished == run && transportFailure is not null)
{
return "transport send failed: " + transportFailure;
}
return finished == run ? "closed by server" : "link task stopped";
}
}
+15 -96
View File
@@ -22,23 +22,14 @@ public sealed class PeerListenerOptions
/// <summary>按对端 ID 查找允许连接的公钥(SPKI)。不在列表里的对端一律拒绝(期望状态里的 peers)。</summary>
public required Func<string, byte[]?> LookupPeerKey { get; init; }
/// <summary>预握手敲门:每个连接先过这一关(名册里的 拨号方 才算得出敲门包),不通过就直接断开,什么都不写。</summary>
/// <summary>预握手敲门:每个连接先过这一关(名册里的 Probe 才算得出敲门包),不通过就直接断开,什么都不写。</summary>
public required KnockGate Knock { get; init; }
public int MaxFrameBytes { get; init; } = 8 << 20;
/// <summary>
/// 同时在线的会话数上限,只数已经通过敲门的对端。每个拨号方每个地址族一个会话,所以要容得下「名册里的节点数 × 2」。
/// 之前这个上限连还没敲门的连接一起数:节点多起来之后,已建立的会话把名额占满,后到的拨号方(哪怕在名册里)一连上就被断开,
/// 而且一直连不上,重试只会得到同样的结果。
/// </summary>
public int MaxSessions { get; init; } = 64;
public int MaxSessions { get; init; } = 8;
/// <summary>还没通过敲门的连接数上限(陌生人、扫描器、还在握手的):敲门有读超时,占着名额的时间有限,但它们不能挤占已通过敲门的会话。</summary>
public int MaxPending { get; init; } = 64;
/// <summary>敲门、TLS、挑战应答整个握手的上限。线路差的时候(RTT 好几秒、还丢包)握手本来就要很久,10 秒做不完就会一直连不上;敲门已经挡住了不认识的连接,放宽不会放大被占连接的风险。</summary>
public TimeSpan HandshakeTimeout { get; init; } = PeerTimings.Handshake;
public TimeSpan HandshakeTimeout { get; init; } = TimeSpan.FromSeconds(10);
}
/// <summary>
@@ -52,7 +43,6 @@ public sealed class PeerListener
private readonly INodeLog _log;
private readonly TimeProvider _time;
private int _sessions;
private int _pending;
public PeerListener(PeerListenerOptions options, Func<PeerSessionInfo, IRpcTransport, CancellationToken, Task> onSession, INodeLog log, TimeProvider time)
{
@@ -83,9 +73,9 @@ public sealed class PeerListener
while (!ct.IsCancellationRequested)
{
TcpClient client = await listener.AcceptTcpClientAsync(ct);
if (Interlocked.Increment(ref _pending) > _options.MaxPending)
if (Interlocked.Increment(ref _sessions) > _options.MaxSessions)
{
Interlocked.Decrement(ref _pending);
Interlocked.Decrement(ref _sessions);
client.Dispose();
continue;
}
@@ -105,11 +95,6 @@ public sealed class PeerListener
private async Task HandleAsync(TcpClient client, CancellationToken ct)
{
bool admitted = false;
bool counted = false;
string phase = "knock";
long started = _time.GetTimestamp();
EndPoint? remote = client.Client.RemoteEndPoint;
try
{
client.NoDelay = true;
@@ -122,36 +107,19 @@ public sealed class PeerListener
return;
}
admitted = true;
Interlocked.Decrement(ref _pending);
if (Interlocked.Increment(ref _sessions) > _options.MaxSessions)
{
Interlocked.Decrement(ref _sessions);
_log.Warn("peer session limit reached", ("remote", remote), ("limit", _options.MaxSessions));
return;
}
counted = true;
phase = "tls";
_log.Info("peer knock accepted", ("remote", remote), ("phase", phase));
var tls = new SslStream(stream, leaveInnerStreamOpen: false);
await tls.AuthenticateAsServerAsync(
new SslServerAuthenticationOptions { ServerCertificate = _options.Certificate, ClientCertificateRequired = false, EnabledSslProtocols = System.Security.Authentication.SslProtocols.Tls12 | System.Security.Authentication.SslProtocols.Tls13 },
linked.Token);
phase = "peer_auth";
_log.Info("peer TLS established", ("remote", remote), ("phase", phase), ("tls", tls.SslProtocol));
var transport = new TlsFrameTransport(tls, _options.MaxFrameBytes);
PeerSessionInfo? info = await HandshakeAsync(transport, client.Client.RemoteEndPoint, linked.Token);
if (info is null)
{
_log.Warn("peer authentication rejected", ("remote", remote), ("phase", phase));
await transport.DisposeAsync();
return;
}
phase = "control_session";
_log.Info("peer session", ("dialer", info.PeerId), ("result", "established"), ("remote", remote),
("elapsed_ms", (long)_time.GetElapsedTime(started).TotalMilliseconds));
_log.Info("peer session", ("probe", info.PeerId), ("result", "established"));
await using (transport)
{
await _onSession(info, transport, ct);
@@ -159,29 +127,14 @@ public sealed class PeerListener
}
catch (Exception ex) when (ex is IOException or System.Security.Authentication.AuthenticationException or OperationCanceledException or SocketException or ObjectDisposedException)
{
// Invalid knocks stay silent. Only peers that passed admission get diagnostics.
if (admitted && !ct.IsCancellationRequested)
{
_log.Warn("peer control connection failed", ("remote", remote), ("phase", phase),
("elapsed_ms", (long)_time.GetElapsedTime(started).TotalMilliseconds),
("exception", ex.GetType().Name), ("err", ex.Message),
("socket_error", ex is SocketException socket ? socket.SocketErrorCode : null));
}
// 握手失败或连接断开:这个会话结束,监听继续
}
finally
{
client.Dispose();
if (!admitted)
{
Interlocked.Decrement(ref _pending);
}
if (counted)
{
Interlocked.Decrement(ref _sessions);
}
}
}
private async Task<PeerSessionInfo?> HandshakeAsync(TlsFrameTransport transport, EndPoint? remote, CancellationToken ct)
{
@@ -255,9 +208,8 @@ public sealed class PeerListener
}
}
/// <summary>发起点对点会话(拨号方 一侧):按 SPKI 指纹固定对端证书,用身份私钥签名,派生同样的数据面凭据。</summary>
/// <summary>点对点会话的套接字:开启 TCP keepalive(空闲 30 秒后每 10 秒探测,连续 6 次无响应才断开,约 90 秒),主机真的没了的时候连接不会一直挂着;
/// 但线路短暂中断(几十秒的黑洞)不能把会话杀掉——线路中断本来就是要测的东西,会话得扛过去,恢复之后接着测。</summary>
/// <summary>发起点对点会话(Probe 一侧):按 SPKI 指纹固定对端证书,用身份私钥签名,派生同样的数据面凭据。</summary>
/// <summary>点对点会话的套接字:开启 TCP keepalive(空闲 15 秒后每 5 秒探测,连续 3 次无响应就断开,约 30 秒),主机丢失或路径被切断时连接不会一直挂着。</summary>
internal static class PeerSockets
{
public static void EnableKeepAlive(Socket socket)
@@ -265,9 +217,9 @@ internal static class PeerSockets
try
{
socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true);
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, 30);
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, 10);
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, 6);
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, 15);
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, 5);
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, 3);
}
catch (Exception ex) when (ex is SocketException or ArgumentException or PlatformNotSupportedException)
{
@@ -276,51 +228,27 @@ internal static class PeerSockets
}
}
/// <summary>点对点会话的时间常数,拨号方和监听方两侧共用。</summary>
public static class PeerTimings
{
/// <summary>建立会话(连接、敲门、TLS、挑战应答)的总时限。</summary>
public static readonly TimeSpan Handshake = TimeSpan.FromSeconds(30);
}
public static class PeerConnector
{
public static Task<(PeerSessionInfo Info, TlsFrameTransport Transport)> ConnectAsync(
string host, int port, byte[] agentPublicKey, NodeIdentity identity, string peerId, string? version, int maxFrameBytes, TimeProvider time, CancellationToken ct) =>
ConnectAsync(host, port, agentPublicKey, identity, peerId, version, maxFrameBytes, time, NullNodeLog.Instance, ct);
public static async Task<(PeerSessionInfo Info, TlsFrameTransport Transport)> ConnectAsync(
string host, int port, byte[] agentPublicKey, NodeIdentity identity, string peerId, string? version, int maxFrameBytes, TimeProvider time, INodeLog log, CancellationToken ct,
Action<string>? onPhase = null)
string host, int port, byte[] agentPublicKey, NodeIdentity identity, string peerId, string? version, int maxFrameBytes, TimeProvider time, CancellationToken ct)
{
// 证书按公钥的指纹固定;敲门密钥由双方身份密钥派生
byte[] expectedSpkiSha256 = NodeIdentity.Fingerprint(agentPublicKey);
TcpClient client = IPAddress.TryParse(host, out IPAddress? address) ? new TcpClient(address.AddressFamily) : new TcpClient();
client.NoDelay = true;
PeerSockets.EnableKeepAlive(client.Client);
string phase = "tcp_connect";
onPhase?.Invoke(phase);
long started = time.GetTimestamp();
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);
await client.ConnectAsync(host, port, linked.Token);
log.Info("peer TCP connected", ("remote", client.Client.RemoteEndPoint), ("local", client.Client.LocalEndPoint),
("phase", phase), ("elapsed_ms", (long)time.GetElapsedTime(started).TotalMilliseconds));
phase = "knock_send";
onPhase?.Invoke(phase);
Stream stream = client.GetStream();
await stream.WriteAsync(Knock.Build(Knock.DeriveKey(identity, agentPublicKey), time.GetUtcNow().ToUnixTimeMilliseconds()), linked.Token);
phase = "tls";
onPhase?.Invoke(phase);
var tls = new SslStream(
stream, leaveInnerStreamOpen: false,
(_, certificate, _, _) => certificate is not null && SpkiMatches(certificate, expectedSpkiSha256));
await tls.AuthenticateAsClientAsync(new SslClientAuthenticationOptions { TargetHost = host }, linked.Token);
log.Info("peer TLS established", ("phase", phase), ("tls", tls.SslProtocol), ("elapsed_ms", (long)time.GetElapsedTime(started).TotalMilliseconds));
phase = "peer_auth";
onPhase?.Invoke(phase);
var transport = new TlsFrameTransport(tls, maxFrameBytes);
RpcFrame? first = await transport.ReceiveAsync(linked.Token);
@@ -351,19 +279,10 @@ public static class PeerConnector
throw new InvalidOperationException("session id mismatch: the key agreement did not produce the same credentials");
}
log.Info("peer authentication established", ("phase", phase), ("elapsed_ms", (long)time.GetElapsedTime(started).TotalMilliseconds));
return (new PeerSessionInfo(peerId, credentials, client.Client.RemoteEndPoint), transport);
}
catch (Exception ex)
catch
{
if (!ct.IsCancellationRequested)
{
bool timedOut = ex is OperationCanceledException;
log.Warn("peer control handshake failed", ("phase", phase), ("remote", host), ("port", port),
("elapsed_ms", (long)time.GetElapsedTime(started).TotalMilliseconds), ("timeout_ms", PeerTimings.Handshake.TotalMilliseconds),
("exception", timedOut ? "Timeout" : ex.GetType().Name), ("err", timedOut ? $"{phase} did not finish within {PeerTimings.Handshake.TotalSeconds:0}s" : ex.Message),
("socket_error", ex is SocketException socket ? socket.SocketErrorCode : null));
}
client.Dispose();
throw;
}
+7 -40
View File
@@ -7,7 +7,6 @@ namespace Fleet.Link;
/// <summary>WebSocket 传输(节点 → 服务端):文本帧是 JSON-RPC,二进制帧是发件箱。</summary>
public sealed class WsTransport : IRpcTransport
{
private static readonly TimeSpan s_sendTimeout = TimeSpan.FromSeconds(20);
private readonly WebSocket _socket;
private readonly int _maxFrameBytes;
private readonly SemaphoreSlim _sendLock = new(1, 1);
@@ -20,34 +19,16 @@ public sealed class WsTransport : IRpcTransport
public async ValueTask SendAsync(RpcFrame frame, CancellationToken ct)
{
using var timeout = new CancellationTokenSource(s_sendTimeout);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
bool lockTaken = false;
await _sendLock.WaitAsync(ct);
try
{
await _sendLock.WaitAsync(linked.Token);
lockTaken = true;
if (_socket.State != WebSocketState.Open)
{
throw new WebSocketException($"WebSocket is not open (state: {_socket.State})");
}
await _socket.SendAsync(frame.Data, frame.Kind == FrameKind.Text ? WebSocketMessageType.Text : WebSocketMessageType.Binary, endOfMessage: true, linked.Token);
}
catch (OperationCanceledException) when (timeout.IsCancellationRequested && !ct.IsCancellationRequested)
{
// A wedged send otherwise holds the lock and prevents heartbeat/reconnect from making progress.
_socket.Abort();
throw new TimeoutException($"WebSocket send timed out after {s_sendTimeout.TotalSeconds:0} seconds");
await _socket.SendAsync(frame.Data, frame.Kind == FrameKind.Text ? WebSocketMessageType.Text : WebSocketMessageType.Binary, endOfMessage: true, ct);
}
finally
{
if (lockTaken)
{
_sendLock.Release();
}
}
}
public async ValueTask<RpcFrame?> ReceiveAsync(CancellationToken ct)
{
@@ -112,13 +93,12 @@ public sealed class WsTransport : IRpcTransport
}
/// <summary>
/// TLS 帧传输(拨号方 → 监听方):<c>u32 LE 正文长度 | u8 类型(0 文本, 1 二进制) | 正文</c>。
/// TLS 帧传输(Probe → Agent):<c>u32 LE 正文长度 | u8 类型(0 文本, 1 二进制) | 正文</c>。
/// 比 WebSocket 轻得多,Agent 不需要内置 HTTP 服务端。
/// </summary>
public sealed class TlsFrameTransport : IRpcTransport
{
private const int HeaderBytes = 5;
private static readonly TimeSpan s_sendTimeout = TimeSpan.FromSeconds(20);
private readonly Stream _stream;
private readonly int _maxFrameBytes;
@@ -140,31 +120,18 @@ public sealed class TlsFrameTransport : IRpcTransport
byte[] header = new byte[HeaderBytes];
BinaryPrimitives.WriteUInt32LittleEndian(header, (uint)frame.Data.Length);
header[4] = (byte)frame.Kind;
// 对端黑洞时写会一直挂着并占着发送锁,心跳、时钟同步和重连都跟着卡住:超时就掐掉这条连接
using var timeout = new CancellationTokenSource(s_sendTimeout);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
bool lockTaken = false;
await _sendLock.WaitAsync(ct);
try
{
await _sendLock.WaitAsync(linked.Token);
lockTaken = true;
await _stream.WriteAsync(header, linked.Token);
await _stream.WriteAsync(frame.Data, linked.Token);
await _stream.FlushAsync(linked.Token);
}
catch (OperationCanceledException) when (timeout.IsCancellationRequested && !ct.IsCancellationRequested)
{
await _stream.DisposeAsync();
throw new TimeoutException($"peer send timed out after {s_sendTimeout.TotalSeconds:0} seconds");
await _stream.WriteAsync(header, ct);
await _stream.WriteAsync(frame.Data, ct);
await _stream.FlushAsync(ct);
}
finally
{
if (lockTaken)
{
_sendLock.Release();
}
}
}
public async ValueTask<RpcFrame?> ReceiveAsync(CancellationToken ct)
{
+19 -15
View File
@@ -10,7 +10,6 @@ public static class FleetMethods
public const string Auth = "fleet.auth";
public const string Hello = "fleet.hello";
public const string Heartbeat = "fleet.heartbeat";
public const string Stopping = "fleet.stopping";
public const string StateSync = "fleet.state.sync";
public const string OutboxAck = "fleet.outbox.ack";
public const string Revoked = "fleet.revoked";
@@ -18,7 +17,7 @@ public static class FleetMethods
/// <summary>
/// 框架自己的方法声明,两个平面各一份。认证和握手阶段由连接代码直接处理的(<c>Inline</c>)不经过路由器。
/// 同一个方法名在两个平面里可以有不同的发起方:<c>fleet.state.sync</c> 在节点面是服务端发给节点,在对等面是 监听方发给拨号方。
/// 同一个方法名在两个平面里可以有不同的发起方:<c>fleet.state.sync</c> 在节点面是服务端发给节点,在对等面是 Agent 发给 Probe。
/// </summary>
public static class FleetMethodTable
{
@@ -29,7 +28,6 @@ public static class FleetMethodTable
MethodSpec.Request(FleetMethods.Auth, RpcSide.Node).AsInline(),
MethodSpec.Request(FleetMethods.Hello, RpcSide.Node).AsInline(),
MethodSpec.Notification(FleetMethods.Heartbeat, RpcSide.Node).AsInline(),
MethodSpec.Notification(FleetMethods.Stopping, RpcSide.Node).AsInline(),
MethodSpec.Request(FleetMethods.StateSync, RpcSide.Server),
MethodSpec.Notification(FleetMethods.OutboxAck, RpcSide.Server),
MethodSpec.Notification(FleetMethods.Revoked, RpcSide.Server),
@@ -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
{
[JsonPropertyName("nonce")]
@@ -71,6 +75,17 @@ public sealed class AuthParams
/// <summary>节点的临时 ECDH 公钥(SPKI,base64):有它就是安全链路,签名覆盖双方的临时公钥,认证结果起所有帧都是加密的。</summary>
[JsonPropertyName("node_eph")]
public string? NodeEphemeral { get; set; }
/// <summary>正在运行的二进制的 SHA-256(小写十六进制):服务端在第一次握手时绑定,之后核对。旧节点没有这个字段。</summary>
[JsonPropertyName("binary")]
public string? Binary { get; set; }
/// <summary>安装这个二进制时宿主机更新器验过的 CI 签名清单(原文)和签名(base64):服务端用 CI 公钥验过,才相信它列出的官方哈希。</summary>
[JsonPropertyName("release")]
public string? Release { get; set; }
[JsonPropertyName("release_sig")]
public string? ReleaseSignature { get; set; }
}
public sealed class AuthResult
@@ -91,7 +106,7 @@ public sealed class HelloParams
[JsonPropertyName("now_utc_ms")]
public long NowUtcMs { get; set; }
/// <summary>产品自定义的元数据(例如 MoShiro 的 tls_spki、listen 信息、local_limits 摘要)。</summary>
/// <summary>产品自定义的元数据(例如 MoShiro 的 tls_spki、probe_ports、local_limits 摘要)。</summary>
[JsonPropertyName("meta")]
public JsonElement? Meta { get; set; }
}
@@ -104,10 +119,6 @@ public sealed class HelloResult
/// <summary>节点时钟 − 服务端时钟(毫秒,未扣除网络往返)。</summary>
[JsonPropertyName("clock_offset_ms")]
public long ClockOffsetMs { get; set; }
/// <summary>服务端的版本:节点要求了最低服务端版本时,用它判断。</summary>
[JsonPropertyName("server_version")]
public string? ServerVersion { get; set; }
}
public sealed class HeartbeatParams
@@ -162,12 +173,6 @@ public sealed class RevokedParams
public string Reason { get; set; } = "";
}
public sealed class StoppingParams
{
[JsonPropertyName("reason")]
public string Reason { get; set; } = "stopping";
}
[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
[JsonSerializable(typeof(RpcMessage))]
[JsonSerializable(typeof(ChallengeParams))]
@@ -176,7 +181,6 @@ public sealed class StoppingParams
[JsonSerializable(typeof(HelloParams))]
[JsonSerializable(typeof(HelloResult))]
[JsonSerializable(typeof(HeartbeatParams))]
[JsonSerializable(typeof(StoppingParams))]
[JsonSerializable(typeof(StateSyncParams))]
[JsonSerializable(typeof(StateSyncResult))]
[JsonSerializable(typeof(OutboxAckParams))]
+1 -1
View File
@@ -1,6 +1,6 @@
namespace Fleet.Rpc;
/// <summary>连接的哪一侧。节点面:服务端和节点;对等面:拨号方和监听方。</summary>
/// <summary>连接的哪一侧。节点面:服务端和节点;对等面:拨号方(Probe)和监听方(Agent)。</summary>
public enum RpcSide
{
Server,
+1 -1
View File
@@ -3,7 +3,7 @@ using System.Text.Json.Serialization;
namespace Fleet.Rpc;
/// <summary>
/// 节点间点对点会话的握手(拨号方 → 监听方,TLS 帧)。除了签名认证,双方各生成一个临时 ECDH 密钥,
/// 节点间点对点会话的握手(Probe → Agent,TLS 帧)。除了签名认证,双方各生成一个临时 ECDH 密钥,
/// 经过 HKDF 派生出数据面使用的 <c>session_id</c> 与 <c>session_key</c>(所以不需要单独的“下发密钥”方法)。
/// </summary>
public static class PeerMethods
+1 -1
View File
@@ -11,7 +11,7 @@ public readonly record struct RpcViolation(string Method, string Reason);
public readonly record struct RpcDispatch(RpcMessage? Response, RpcViolation? Violation);
/// <summary>
/// 方法路由,与传输方式无关,也是协议方向的<b>唯一卡口</b>:节点和服务端、拨号方和监听方 的入站分发都经过这里。
/// 方法路由,与传输方式无关,也是协议方向的<b>唯一卡口</b>:节点和服务端、Probe 和 Agent 的入站分发都经过这里。
/// <typeparamref name="TContext"/> 由传输层提供(例如调用方节点)。路由器知道自己在连接的哪一侧(<see cref="Side"/>)和声明表,
/// 注册处理函数时核对方向、类型和角色,分发时拒绝没有声明或不该由对方发起的方法,发送时核对自己有没有资格发起。
/// </summary>
+11 -35
View File
@@ -1,5 +1,4 @@
using System.Collections.Concurrent;
using System.Net.WebSockets;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using System.Threading.Channels;
@@ -17,7 +16,7 @@ public readonly record struct RpcFrame(FrameKind Kind, ReadOnlyMemory<byte> Data
/// <summary>
/// 一条双向的、保序的消息通道:文本帧承载 JSON-RPC,二进制帧承载发件箱。
/// 节点到服务端用 WebSocket,拨号方到监听方 用 TLS 帧;会话引擎对两者一视同仁。
/// 节点到服务端用 WebSocket,Probe 到 Agent 用 TLS 帧;会话引擎对两者一视同仁。
/// </summary>
public interface IRpcTransport : IAsyncDisposable
{
@@ -50,7 +49,6 @@ public sealed class RpcSession<TContext> : IAsyncDisposable
private readonly IRpcTransport _transport;
private readonly RpcRouter<TContext> _router;
private readonly TContext _context;
private static readonly TimeSpan s_dispatcherGrace = TimeSpan.FromSeconds(5);
private readonly TimeProvider _time;
private readonly RpcSessionOptions _options;
private readonly ConcurrentDictionary<long, TaskCompletionSource<RpcMessage>> _pending = new();
@@ -60,7 +58,6 @@ public sealed class RpcSession<TContext> : IAsyncDisposable
private long _lastReceive;
private int _violations;
private bool _violated;
private string? _transportFailure;
public RpcSession(IRpcTransport transport, RpcRouter<TContext> router, TContext context, TimeProvider time, RpcSessionOptions? options = null)
{
@@ -79,9 +76,6 @@ public sealed class RpcSession<TContext> : IAsyncDisposable
/// <summary>对端上一次发来任何帧到现在的时间。静默死亡(断电、进程被冻结)的对端不会关闭连接,调用方据此设置空闲超时。</summary>
public TimeSpan IdleFor => _time.GetElapsedTime(Volatile.Read(ref _lastReceive));
/// <summary>The first transport send failure observed by this session, if any.</summary>
public string? TransportFailure => Volatile.Read(ref _transportFailure);
/// <summary>读帧并分发,直到连接关闭或被取消。</summary>
public async Task RunAsync(CancellationToken ct)
{
@@ -136,8 +130,14 @@ public sealed class RpcSession<TContext> : IAsyncDisposable
}
await linked.CancelAsync();
// 某个入站处理器卡住了也不能拖住整个会话的结束(链路据此重连):它随取消令牌自己收尾
await Bounded.WaitAsync(dispatcher, s_dispatcherGrace, _time);
try
{
await dispatcher;
}
catch (OperationCanceledException)
{
// 正常结束
}
}
}
@@ -197,11 +197,11 @@ public sealed class RpcSession<TContext> : IAsyncDisposable
_router.CheckSend(message.Method, message.Id is null ? MethodKind.Notification : MethodKind.Request);
}
return SendTransportAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(message, FleetRpcContext.Default.RpcMessage)), ct);
return _transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(message, FleetRpcContext.Default.RpcMessage)), ct);
}
public ValueTask SendBinaryAsync(ReadOnlyMemory<byte> frame, CancellationToken ct) =>
SendTransportAsync(new RpcFrame(FrameKind.Binary, frame), ct);
_transport.SendAsync(new RpcFrame(FrameKind.Binary, frame), ct);
public ValueTask CloseAsync(string reason, CancellationToken ct) => _transport.CloseAsync(reason, ct);
@@ -239,30 +239,6 @@ public sealed class RpcSession<TContext> : IAsyncDisposable
}
}
private async ValueTask SendTransportAsync(RpcFrame frame, CancellationToken ct)
{
try
{
await _transport.SendAsync(frame, ct);
}
catch (Exception ex) when (!ct.IsCancellationRequested && ex is WebSocketException or IOException or TimeoutException or ObjectDisposedException)
{
Interlocked.CompareExchange(ref _transportFailure, ex.Message, null);
try
{
// A failed send must also stop the receive loop. Otherwise callers that log and swallow this
// exception can leave the session marked connected forever on an already-aborted socket.
_stop.Cancel();
}
catch (ObjectDisposedException)
{
// Disposal raced with the failed send.
}
throw;
}
}
private static RpcMessage? Parse(ReadOnlyMemory<byte> data)
{
try
@@ -5,7 +5,7 @@ public sealed class FleetServerOptions
/// <summary>节点 ID 的类型前缀,MoShiro 使用 <c>agt</c>。</summary>
public string NodeIdPrefix { get; set; } = "node";
/// <summary>加入令牌的「种类」对应的节点 ID 前缀(例如 <c>edge</c> → <c>edg</c>);没有列出的种类用 <see cref="NodeIdPrefix"/>。</summary>
/// <summary>加入令牌的「种类」对应的节点 ID 前缀(例如 <c>probe</c> → <c>prb</c>);没有列出的种类用 <see cref="NodeIdPrefix"/>。</summary>
public Dictionary<string, string> NodeIdPrefixes { get; } = new(StringComparer.Ordinal);
public int MaxFrameBytes { get; set; } = 16 << 20;
@@ -16,18 +16,6 @@ public sealed class FleetServerOptions
public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(15);
/// <summary>处理节点的一条消息(心跳、hello、收件箱)最多用这么久:钩子卡在数据库或外部查询里时,不能让这条连接停在「在线」而收不到任何心跳超时。超过就断开,节点按退避重连。</summary>
public TimeSpan HandlerTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>节点违反协议(方法没有声明、方向、类型或角色不对)的次数上限;超过就断开这条连接。</summary>
public int MaxViolations { get; set; } = 20;
}
/// <summary>握手时的版本要求:服务端的版本(告诉节点),以及节点至少要是哪个版本。每次握手都重新取,所以改配置不用重启。</summary>
public interface IFleetVersionPolicy
{
string? ServerVersion { get; }
/// <summary>空表示不要求。</summary>
string? MinNodeVersion { get; }
}
@@ -6,17 +6,6 @@ namespace Fleet.Server;
public sealed record EnrollmentContext(string? RemoteIp, JsonElement? ProductMeta);
/// <summary>
/// 节点的真实来源地址:前面有反向代理时,套接字上只有代理的地址,产品在这里决定信不信转发头。
/// 没有注册就用套接字地址。
/// </summary>
public interface IClientAddressSource
{
/// <param name="socket">套接字上看到的对端地址。</param>
/// <param name="header">按名字取请求头的所有值。</param>
string? Resolve(System.Net.IPAddress? socket, Func<string, IEnumerable<string>> header);
}
/// <summary>
/// 服务器的身份密钥:操作员用 passkey 把它的公钥签进操作员集合。有它的时候,认证挑战会带上服务器的签名,节点据此确认对面是真的服务器,
/// 并在这条链路上用应用层加密。没有注册就是旧的行为(没有服务器身份)。
@@ -30,6 +19,21 @@ public interface IServerIdentity
}
/// <summary>操作员集合的链(passkey 签的,创世在前)。没有初始化信任根时是空的。节点注册时用它确认「这个面板确实是我的操作员的」。</summary>
/// <summary>节点握手时自述的二进制(旧节点没有)。</summary>
public sealed record BinaryEvidence(string? Sha256, string? Manifest, string? Signature);
/// <summary>是否让这个节点继续:不放行时 <c>Reason</c> 会原样告诉节点(这是它自己的信息,不是秘密)。</summary>
public sealed record BinaryDecision(bool Accept, string Reason = "");
/// <summary>
/// 服务端对节点二进制的把关:节点第一次握手成功的那一刻绑定它的二进制哈希,之后哈希对不上、又不在 CI 签名的更新清单里,就不让它继续运行。
/// 没有注册实现时不做任何检查。
/// </summary>
public interface IBinaryAttestor
{
ValueTask<BinaryDecision> CheckAsync(NodeRecord node, BinaryEvidence evidence, CancellationToken ct);
}
public interface IOperatorTrustSource
{
ValueTask<IReadOnlyList<SignedStatementDto>> ChainAsync(CancellationToken ct);
@@ -49,8 +53,7 @@ public enum NodeEventKind
Heartbeat,
}
/// <param name="RemoteIp">这条连接的来源地址(经过 <see cref="IClientAddressSource"/>);没有就是 null。</param>
public sealed record NodeEvent(NodeEventKind Kind, NodeRecord Node, long AtMs, HelloParams? Hello = null, JsonElement? Extra = null, string? Reason = null, string? RemoteIp = null);
public sealed record NodeEvent(NodeEventKind Kind, NodeRecord Node, long AtMs, HelloParams? Hello = null, JsonElement? Extra = null);
public interface INodeEventSink
{
@@ -73,7 +73,7 @@ public static class FleetEnrollmentEndpoints
return Problem(StatusCodes.Status400BadRequest, FleetErrors.InvalidParams, "request body is not valid");
}
Result<EnrollResponse> result = await service.EnrollAsync(request, ClientIp(http), http.RequestAborted);
Result<EnrollResponse> result = await service.EnrollAsync(request, http.Connection.RemoteIpAddress?.ToString(), http.RequestAborted);
if (result.TryGet(out EnrollResponse? response, out FleetError? error))
{
return Results.Json(response, EnrollmentJsonContext.Default.EnrollResponse, statusCode: StatusCodes.Status201Created);
@@ -88,11 +88,6 @@ public static class FleetEnrollmentEndpoints
return Problem(status, error.Code, error.Message);
}
private static string? ClientIp(HttpContext http) =>
http.RequestServices.GetService<IClientAddressSource>() is { } source
? source.Resolve(http.Connection.RemoteIpAddress, name => http.Request.Headers[name].Select(v => v ?? ""))
: http.Connection.RemoteIpAddress?.ToString();
private static async Task<EnrollRequest?> ReadBodyAsync(HttpContext http, CancellationToken ct)
{
if (http.Request.ContentLength > MaxBodyBytes)
@@ -25,10 +25,10 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier
IEnumerable<INodeEventSink> sinks,
FleetRouter router,
IServerIdentity? serverIdentity = null,
IFleetVersionPolicy? versionPolicy = null)
IBinaryAttestor? binaryAttestor = null)
{
VersionPolicy = versionPolicy;
ServerIdentity = serverIdentity;
BinaryAttestor = binaryAttestor;
Store = store;
Options = options;
Time = time;
@@ -45,7 +45,8 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier
/// <summary>服务器的身份密钥;没有时节点连接用旧的明文认证。</summary>
internal IServerIdentity? ServerIdentity { get; }
internal IFleetVersionPolicy? VersionPolicy { get; }
/// <summary>节点二进制的把关;没有时不检查。</summary>
internal IBinaryAttestor? BinaryAttestor { get; }
internal FleetServerOptions Options { get; }
@@ -71,9 +72,9 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier
_connections.TryGetValue(node, out NodeConnection? c) ? c.Session : null;
/// <summary>处理一条 WebSocket 连接,直到连接结束。</summary>
public async Task RunConnectionAsync(WebSocket socket, string? remoteIp, CancellationToken ct)
public async Task RunConnectionAsync(WebSocket socket, CancellationToken ct)
{
await using var connection = new NodeConnection(this, socket, remoteIp);
using var connection = new NodeConnection(this, socket);
await connection.RunAsync(ct);
}
@@ -10,9 +10,6 @@ internal static partial class FleetLog
[LoggerMessage(Level = LogLevel.Information, Message = "node disconnected node={Node} reason={Reason}")]
public static partial void Disconnected(ILogger logger, string node, string reason);
[LoggerMessage(Level = LogLevel.Warning, Message = "node below the required version node={Node} version={Version} required={Required}")]
public static partial void VersionTooOld(ILogger logger, string node, string version, string required);
[LoggerMessage(Level = LogLevel.Warning, Message = "node auth rejected err={Err}")]
public static partial void AuthRejected(ILogger logger, string err);
@@ -29,10 +29,10 @@ public sealed class FleetServerBuilder
internal Func<NodeRecord, string?>? RoleOf => _roleOf;
/// <summary>
/// 方法声明表(框架的加产品的,见 <see cref="MethodTable.Combine"/>)和可选的节点角色解析(声明表里限定了角色的方法才需要)。方法表必须设置:
/// 方法声明表(框架的加产品的,见 <see cref="MethodTable.Combine"/>)和节点的角色解析(例如按节点 ID 前缀区分 Agent 和 Probe)。必须设置:
/// 路由器据此核对每个处理函数的方向、类型和角色,并拒绝没有声明或不该由节点发起的方法。
/// </summary>
public FleetServerBuilder WithMethods(MethodTable methods, Func<NodeRecord, string?>? roleOf = null)
public FleetServerBuilder WithMethods(MethodTable methods, Func<NodeRecord, string?> roleOf)
{
_methods = methods;
_roleOf = roleOf;
@@ -111,7 +111,7 @@ public static class FleetServerExtensions
provider.GetServices<INodeEventSink>(),
provider.GetRequiredService<FleetRouter>(),
provider.GetService<IServerIdentity>(),
provider.GetService<IFleetVersionPolicy>()));
provider.GetService<IBinaryAttestor>()));
services.AddSingleton<IFleetConnections>(provider => provider.GetRequiredService<FleetLinkHost>());
services.AddSingleton<IStateNotifier>(provider => provider.GetRequiredService<FleetLinkHost>());
return builder;
@@ -129,10 +129,7 @@ public static class FleetServerExtensions
}
using System.Net.WebSockets.WebSocket socket = await http.WebSockets.AcceptWebSocketAsync();
string? remote = http.RequestServices.GetService<IClientAddressSource>() is { } source
? source.Resolve(http.Connection.RemoteIpAddress, name => http.Request.Headers[name].Select(v => v ?? ""))
: http.Connection.RemoteIpAddress?.ToString();
await http.RequestServices.GetRequiredService<FleetLinkHost>().RunConnectionAsync(socket, remote, http.RequestAborted);
await http.RequestServices.GetRequiredService<FleetLinkHost>().RunConnectionAsync(socket, http.RequestAborted);
});
return app;
}
@@ -88,6 +88,18 @@ internal sealed partial class NodeConnection
return false;
}
// 二进制:第一次握手成功的这一刻绑定,之后哈希对不上又不在 CI 签名的更新清单里,就不让它继续运行
if (_host.BinaryAttestor is { } attestor)
{
BinaryDecision decision = await attestor.CheckAsync(node, new BinaryEvidence(auth!.Binary, auth.Release, auth.ReleaseSignature), ct);
if (!decision.Accept)
{
await SendAsync(RpcMessage.Failure(message.Id, new FleetError(FleetErrors.BinaryMismatch, decision.Reason)), ct);
await RejectAsync(null, "binary mismatch: " + decision.Reason, ct);
return false;
}
}
_session = new NodeSessionInfo(node.Id, _host.Time.GetUtcNow().ToUnixTimeMilliseconds(), node.NodeVersion, [], null);
await SendAsync(
RpcMessage.Success(message.Id.Value, new AuthResult { Status = StatusName(node.Status) }, FleetRpcContext.Default.AuthResult),
@@ -34,20 +34,12 @@ internal sealed partial class NodeConnection
{
if (TryParse(frame.Data, out RpcMessage? message))
{
if (!await Bounded.WaitAsync(HandleMessageAsync(message, ct), _host.Options.HandlerTimeout, _host.Time))
{
_closeReason ??= "handler timeout";
return;
}
await HandleMessageAsync(message, ct);
}
}
else
{
if (!await Bounded.WaitAsync(HandleBinaryAsync(frame.Data, ct), _host.Options.HandlerTimeout, _host.Time))
{
_closeReason ??= "handler timeout";
return;
}
await HandleBinaryAsync(frame.Data, ct);
}
}
}
@@ -72,17 +64,6 @@ internal sealed partial class NodeConnection
case FleetMethods.Heartbeat:
await HandleHeartbeatAsync(message, ct);
return;
case FleetMethods.Stopping:
try
{
string? reason = message.Params?.Deserialize(FleetRpcContext.Default.StoppingParams)?.Reason;
_closeReason = reason is "updating" ? "updating" : "stopping";
}
catch (JsonException)
{
_closeReason = "stopping";
}
return;
}
if (_node!.Status != NodeStatus.Active)
@@ -151,20 +132,6 @@ internal sealed partial class NodeConnection
return;
}
string? minVersion = _host.VersionPolicy?.MinNodeVersion;
if (NodeVersions.IsBelow(hello.NodeVersion, minVersion))
{
// 版本不够:不登记在线,告诉节点要求的版本,关闭连接。节点等几秒(它可能正在更新)再握手。
FleetLog.VersionTooOld(_host.Logger, _node!.Id.Value, hello.NodeVersion, minVersion!);
if (message.Id is not null)
{
await SendAsync(RpcMessage.Failure(message.Id, new FleetError(FleetErrors.VersionRequired, $"node version {hello.NodeVersion} is below the required {minVersion}")), ct);
}
await CloseAsync("version required");
return;
}
long nowMs = _host.Time.GetUtcNow().ToUnixTimeMilliseconds();
_session = _session! with
{
@@ -183,7 +150,7 @@ internal sealed partial class NodeConnection
await SendAsync(
RpcMessage.Success(
message.Id.Value,
new HelloResult { ServerTimeMs = nowMs, ClockOffsetMs = hello.NowUtcMs - nowMs, ServerVersion = _host.VersionPolicy?.ServerVersion },
new HelloResult { ServerTimeMs = nowMs, ClockOffsetMs = hello.NowUtcMs - nowMs },
FleetRpcContext.Default.HelloResult),
ct);
}
@@ -26,12 +26,6 @@ internal sealed partial class NodeConnection
try
{
if (_disposed)
{
return;
}
_lifetime.CancelAfter(s_closeTimeout);
if (_socket.State == WebSocketState.Open)
{
await _socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, reason, CancellationToken.None);
@@ -46,6 +40,7 @@ internal sealed partial class NodeConnection
_sendLock.Release();
}
_lifetime.CancelAfter(s_closeTimeout);
}
public async ValueTask RevokeAsync(string reason, CancellationToken ct)
@@ -75,7 +70,7 @@ internal sealed partial class NodeConnection
try
{
if (_disposed || _socket.State != WebSocketState.Open)
if (_socket.State != WebSocketState.Open)
{
return false;
}
@@ -102,7 +102,7 @@ internal sealed partial class NodeConnection
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, _remoteIp);
var nodeEvent = new NodeEvent(kind, _node!, _host.Time.GetUtcNow().ToUnixTimeMilliseconds(), hello, extra);
foreach (INodeEventSink sink in _host.Sinks)
{
try
@@ -17,7 +17,6 @@ internal sealed partial class NodeConnection
ValueWebSocketReceiveResult result = await _socket.ReceiveAsync(buffer, ct);
if (result.MessageType == WebSocketMessageType.Close)
{
_closeReason ??= _socket.CloseStatusDescription;
return null;
}
@@ -11,7 +11,7 @@ namespace Fleet.Server;
/// 一个节点的 WebSocket 会话:挑战 → 签名认证 → hello → 状态推送 / 收件箱 / 产品 RPC。
/// 收到的消息按顺序处理,所以产品的处理函数不能等待“向同一个节点发起的请求”,否则会死锁。
/// </summary>
internal sealed partial class NodeConnection : IAsyncDisposable
internal sealed partial class NodeConnection : IDisposable
{
private static readonly TimeSpan s_authTimeout = TimeSpan.FromSeconds(15);
private static readonly TimeSpan s_presenceRefresh = TimeSpan.FromSeconds(60);
@@ -31,17 +31,13 @@ internal sealed partial class NodeConnection : IAsyncDisposable
private NodeRecord? _node;
private NodeSessionInfo? _session;
private string? _closeReason;
private bool _disposed;
private bool _helloDone;
private int _violations;
private readonly string? _remoteIp;
public NodeConnection(FleetLinkHost host, WebSocket socket, string? remoteIp = null)
public NodeConnection(FleetLinkHost host, WebSocket socket)
{
_host = host;
_socket = socket;
_remoteIp = remoteIp;
}
public NodeId NodeId => _node!.Id;
@@ -138,19 +134,10 @@ internal sealed partial class NodeConnection : IAsyncDisposable
}
}
public async ValueTask DisposeAsync()
public void Dispose()
{
await _sendLock.WaitAsync();
try
{
_disposed = true;
_cipher?.Dispose();
_lifetime.Dispose();
}
finally
{
// API callers may already be waiting to send; let them observe _disposed.
_sendLock.Release();
}
_sendLock.Dispose();
}
}
@@ -1,28 +0,0 @@
using Fleet.Core;
namespace Fleet.Tests;
public sealed class BoundedTests
{
[Fact]
public async Task WaitAsync_ReturnsFalse_WhenTheWorkNeverFinishes()
{
var never = new TaskCompletionSource();
Assert.False(await Bounded.WaitAsync(never.Task, TimeSpan.FromMilliseconds(100), TimeProvider.System));
}
[Fact]
public async Task WaitAsync_ReturnsTrue_WhenTheWorkFinishes_OrFailsWithSessionNoise()
{
Assert.True(await Bounded.WaitAsync(Task.CompletedTask, TimeSpan.FromSeconds(5), TimeProvider.System));
Assert.True(await Bounded.WaitAsync(Task.FromException(new IOException("closed")), TimeSpan.FromSeconds(5), TimeProvider.System));
Assert.True(await Bounded.WaitAsync(Task.FromException(new ObjectDisposedException("x")), TimeSpan.FromSeconds(5), TimeProvider.System));
Assert.True(await Bounded.WaitAsync(Task.FromCanceled(new CancellationToken(true)), TimeSpan.FromSeconds(5), TimeProvider.System));
}
[Fact]
public async Task WaitAsync_DoesNotHideRealFailures()
{
await Assert.ThrowsAsync<FormatException>(() => Bounded.WaitAsync(Task.FromException(new FormatException()), TimeSpan.FromSeconds(5), TimeProvider.System));
}
}
@@ -1,65 +0,0 @@
using Fleet.Core;
using Fleet.Hosting;
namespace Fleet.Tests;
public sealed class BufferedNodeLogTests
{
[Fact]
public void Summary_CountsByLevelAndTypeWithoutText_AndResetsAfterEachTake()
{
var log = new BufferedNodeLog(NullNodeLog.Instance, TimeProvider.System);
for (int i = 0; i < 5; i++)
{
log.Warn("peer control handshake failed", ("attempt", i));
}
log.Info("link up");
log.Error("MsQuic QUIC transport shutdown");
NodeLogSummary first = log.TakeSummary();
Assert.Equal((1L, 5L, 1L), (first.Info, first.Warn, first.Error));
Assert.Equal(7, first.LastId);
NodeLogTypeCount top = first.Types[0];
Assert.Equal((BufferedNodeLog.TypeOf("peer control handshake failed"), "warn", 5L), (top.Type, top.Level, top.Count));
NodeLogSummary second = log.TakeSummary();
Assert.Equal((0L, 0L, 0L), (second.Info, second.Warn, second.Error));
Assert.Empty(second.Types);
Assert.Equal(7, second.LastId);
}
[Fact]
public void Read_ReturnsLinesAfterAnIdAtOrAboveALevel_OldestFirst_AndTheRingKeepsTheNewest()
{
var log = new BufferedNodeLog(NullNodeLog.Instance, TimeProvider.System);
log.Info("one");
log.Warn("two");
log.Error("three");
log.Info("four");
Assert.Equal(["two", "three"], log.Read(1, 2, null).Items.Select(e => e.Record.Message));
Assert.Equal(["three"], log.Read(0, 10, "error").Items.Select(e => e.Record.Message));
Assert.Equal(["two", "three"], log.Read(0, 10, "warn").Items.Select(e => e.Record.Message));
for (int i = 0; i < 3000; i++)
{
log.Info("filler", ("n", i));
}
NodeLogPage page = log.Read(0, 200, null);
Assert.Equal(200, page.Items.Count);
Assert.True(page.Items[0].Id > 4); // 最早的几条已经被环挤掉
Assert.Equal(3004, page.LastId);
}
[Fact]
public void SensitiveFieldsAreRedacted()
{
var log = new BufferedNodeLog(NullNodeLog.Instance, TimeProvider.System);
log.Info("auth", ("token", "abc"), ("agent", "agt_x"));
NodeLogEntry entry = Assert.Single(log.Read(0, 10, null).Items);
Assert.Equal("[redacted]", entry.Record.Fields["token"]);
Assert.Equal("agt_x", entry.Record.Fields["agent"]);
}
}
+14 -14
View File
@@ -15,14 +15,14 @@ public sealed class KnockTests
[Fact]
public void PairKey_IsTheSameOnBothSides_AndDiffersPerPair()
{
using NodeIdentity dialer = NodeIdentity.Generate();
using NodeIdentity probe = NodeIdentity.Generate();
using NodeIdentity agent = NodeIdentity.Generate();
using NodeIdentity other = NodeIdentity.Generate();
byte[] fromDialer = Knock.DeriveKey(dialer, agent.PublicKeySpki);
byte[] fromAgent = Knock.DeriveKey(agent, dialer.PublicKeySpki);
Assert.Equal(fromDialer, fromAgent);
Assert.Equal(32, fromDialer.Length);
Assert.NotEqual(fromDialer, Knock.DeriveKey(other, agent.PublicKeySpki));
byte[] fromProbe = Knock.DeriveKey(probe, agent.PublicKeySpki);
byte[] fromAgent = Knock.DeriveKey(agent, probe.PublicKeySpki);
Assert.Equal(fromProbe, fromAgent);
Assert.Equal(32, fromProbe.Length);
Assert.NotEqual(fromProbe, Knock.DeriveKey(other, agent.PublicKeySpki));
}
[Fact]
@@ -61,36 +61,36 @@ public sealed class KnockTests
public void Gate_AdmitsRosterMembersOnce_AndRejectsReplaysStrangersAndExpiredPackets()
{
using NodeIdentity agent = NodeIdentity.Generate();
using NodeIdentity dialer = NodeIdentity.Generate();
using NodeIdentity probe = NodeIdentity.Generate();
using NodeIdentity stranger = NodeIdentity.Generate();
var roster = new List<byte[]> { dialer.PublicKeySpki };
var roster = new List<byte[]> { probe.PublicKeySpki };
var gate = new KnockGate(agent, () => roster, new Clock());
long now = 1_700_000_000_000;
byte[] packet = Knock.Build(Knock.DeriveKey(dialer, agent.PublicKeySpki), now);
byte[] packet = Knock.Build(Knock.DeriveKey(probe, agent.PublicKeySpki), now);
Assert.True(gate.Accept(packet, now));
Assert.False(gate.Accept(packet, now), "the same nonce cannot be used twice");
Assert.False(gate.Accept(Knock.Build(Knock.DeriveKey(stranger, agent.PublicKeySpki), now), now), "not on the roster");
Assert.False(gate.Accept(Knock.Build(Knock.DeriveKey(dialer, agent.PublicKeySpki), now - 10 * 60_000), now), "expired");
Assert.False(gate.Accept(Knock.Build(Knock.DeriveKey(probe, agent.PublicKeySpki), now - 10 * 60_000), now), "expired");
// 名册变了:新成员马上能进,被移除的马上进不来
using NodeIdentity second = NodeIdentity.Generate();
roster.Add(second.PublicKeySpki);
Assert.True(gate.Accept(Knock.Build(Knock.DeriveKey(second, agent.PublicKeySpki), now), now));
roster.RemoveAt(0);
Assert.False(gate.Accept(Knock.Build(Knock.DeriveKey(dialer, agent.PublicKeySpki), now), now));
Assert.False(gate.Accept(Knock.Build(Knock.DeriveKey(probe, agent.PublicKeySpki), now), now));
}
[Fact]
public async Task Gate_RateLimitsEachSource_AndReadsAtMost32Bytes()
{
using NodeIdentity agent = NodeIdentity.Generate();
using NodeIdentity dialer = NodeIdentity.Generate();
using NodeIdentity probe = NodeIdentity.Generate();
var clock = new Clock();
var gate = new KnockGate(agent, () => [dialer.PublicKeySpki], clock, attemptsPerPeriod: 3);
var gate = new KnockGate(agent, () => [probe.PublicKeySpki], clock, attemptsPerPeriod: 3);
IPAddress noisy = IPAddress.Parse("203.0.113.9");
IPAddress quiet = IPAddress.Parse("203.0.113.10");
byte[] key = Knock.DeriveKey(dialer, agent.PublicKeySpki);
byte[] key = Knock.DeriveKey(probe, agent.PublicKeySpki);
for (int i = 0; i < 3; i++)
{
@@ -1,106 +0,0 @@
using System.Net;
using System.Net.Sockets;
using Fleet.Core;
using Fleet.Identity;
using Fleet.Link;
using Fleet.Rpc;
namespace Fleet.Tests;
/// <summary>面板那头只接住连接、什么都不回(线路把握手吞了、代理卡住):节点要在限定时间内放弃这次连接重试,而不是一直等着。</summary>
public sealed class LinkConnectTimeoutTests
{
[Fact]
public async Task ASilentServer_IsGivenUp_AfterTheConnectTimeout_AndRetried()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var accepted = new List<TcpClient>();
using var acceptLoop = new CancellationTokenSource();
Task accepting = Task.Run(async () =>
{
try
{
while (true)
{
accepted.Add(await listener.AcceptTcpClientAsync(acceptLoop.Token));
}
}
catch (OperationCanceledException)
{
// 测试结束
}
});
var options = new FleetLinkOptions
{
Server = new Uri($"http://127.0.0.1:{((IPEndPoint)listener.LocalEndpoint).Port}"),
Identity = NodeIdentity.Generate(),
NodeId = new NodeId("dlr_test"),
ConnectTimeout = TimeSpan.FromMilliseconds(500),
Backoff = new Backoff(TimeSpan.FromMilliseconds(10), TimeSpan.FromMilliseconds(50)),
};
var router = new RpcRouter<LinkContext>(RpcSide.Node, FleetMethodTable.Table);
var link = new FleetLink(options, router, [], new Outbox(1 << 16), TimeProvider.System, new SilentLog());
var reasons = new List<string>();
link.OnDisconnected = reason =>
{
lock (reasons)
{
reasons.Add(reason);
}
};
using var stop = new CancellationTokenSource();
Task running = link.RunAsync(stop.Token);
try
{
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(10));
while (true)
{
lock (reasons)
{
if (reasons.Count >= 2)
{
break;
}
}
await Task.Delay(50, deadline.Token);
}
lock (reasons)
{
Assert.All(reasons, reason => Assert.Contains("timed out", reason, StringComparison.OrdinalIgnoreCase));
}
Assert.True(accepted.Count >= 2, "the second attempt connects again");
}
finally
{
await stop.CancelAsync();
try
{
await running;
}
catch (OperationCanceledException) when (stop.IsCancellationRequested)
{
// Canceling the retry delay ends RunAsync with the requested cancellation.
}
await acceptLoop.CancelAsync();
await accepting;
foreach (TcpClient client in accepted)
{
client.Dispose();
}
}
}
private sealed class SilentLog : INodeLog
{
public void Write(NodeLogLevel level, string message, params (string Key, object? Value)[] fields)
{
}
}
}
+34 -124
View File
@@ -275,27 +275,6 @@ public sealed class LinkTests
await run.WaitAsync(TimeSpan.FromSeconds(5));
}
[Fact]
public async Task Session_RunEnds_EvenWhenAnInboundHandlerIsStuck()
{
(TlsFrameTransport left, TlsFrameTransport right) = await PairAsync();
MethodTable table = TestTables.Of(TestTables.Req("right.stuck"));
var stuck = new TaskCompletionSource<Result<Number>>(TaskCreationOptions.RunContinuationsAsynchronously); // 不理会取消令牌:模拟卡在原生层的处理器
var rightRouter = new RpcRouter<string>(RpcSide.Listener, table).On("right.stuck", LinkJson.Default.Number, LinkJson.Default.Number, (_, _, _) => new ValueTask<Result<Number>>(stuck.Task));
await using var leftSession = new RpcSession<string>(left, new RpcRouter<string>(RpcSide.Dialer, table), "left", TimeProvider.System);
await using var rightSession = new RpcSession<string>(right, rightRouter, "right", TimeProvider.System);
Task leftRun = leftSession.RunAsync(default);
Task rightRun = rightSession.RunAsync(default);
_ = leftSession.RequestAsync("right.stuck", new Number { Value = 1 }, LinkJson.Default.Number, LinkJson.Default.Number, default, TimeSpan.FromSeconds(30)).AsTask();
await Task.Delay(300); // 请求到达并进入处理器
await leftSession.CloseAsync("bye", default);
await rightRun.WaitAsync(TimeSpan.FromSeconds(10)); // 以前这里会一直等到处理器返回
stuck.TrySetResult(Result<Number>.Ok(new Number { Value = 0 }));
await leftRun.WaitAsync(TimeSpan.FromSeconds(5));
}
// ───────────────────────── 点对点握手 ─────────────────────────
private sealed class PeerFixture : IAsyncDisposable
@@ -303,7 +282,7 @@ public sealed class LinkTests
private readonly CancellationTokenSource _stop = new();
private readonly NodeIdentity _identity;
public PeerFixture(Func<string, byte[]?> lookup, Func<PeerSessionInfo, IRpcTransport, CancellationToken, Task>? onSession = null, int maxSessions = 64)
public PeerFixture(Func<string, byte[]?> lookup, Func<PeerSessionInfo, IRpcTransport, CancellationToken, Task>? onSession = null)
{
Certificate = NodeCertificate.LoadPfx(NodeCertificate.CreateSelfSignedPfx("agent", TimeSpan.FromDays(30), TimeProvider.System));
_identity = NodeIdentity.FromPkcs8(Certificate.GetECDsaPrivateKey()!.ExportPkcs8PrivateKey());
@@ -314,7 +293,6 @@ public sealed class LinkTests
Certificate = Certificate,
LookupPeerKey = lookup,
Knock = new KnockGate(_identity, () => KnockPeers, TimeProvider.System),
MaxSessions = maxSessions,
},
onSession ?? ((_, transport, ct) => KeepOpenAsync(transport, ct)),
NullNodeLog.Instance, TimeProvider.System);
@@ -329,10 +307,10 @@ public sealed class LinkTests
public byte[] Spki => NodeCertificate.SpkiFingerprint(Certificate);
/// <summary>Agent 的 TLS 证书公钥:拨号方 用它固定证书、派生敲门密钥。</summary>
/// <summary>Agent 的 TLS 证书公钥:Probe 用它固定证书、派生敲门密钥。</summary>
public byte[] PublicKey => Certificate.PublicKey.ExportSubjectPublicKeyInfo();
/// <summary>名册:能敲开门的 拨号方 公钥。</summary>
/// <summary>名册:能敲开门的 Probe 公钥。</summary>
public List<byte[]> KnockPeers { get; } = [];
public async ValueTask DisposeAsync()
@@ -353,90 +331,81 @@ public sealed class LinkTests
}
[Fact]
public async Task PeerHandshake_AuthenticatesTheDialer_AndBothSidesDeriveTheSameDataPlaneCredentials()
public async Task PeerHandshake_AuthenticatesTheProbe_AndBothSidesDeriveTheSameDataPlaneCredentials()
{
using NodeIdentity dialer = NodeIdentity.Generate();
using NodeIdentity probe = NodeIdentity.Generate();
var seen = new TaskCompletionSource<PeerSessionInfo>();
await using var agent = new PeerFixture(id => id == "dlr_1" ? dialer.PublicKeySpki : null, (info, transport, ct) =>
await using var agent = new PeerFixture(id => id == "prb_1" ? probe.PublicKeySpki : null, (info, transport, ct) =>
{
seen.TrySetResult(info);
return Task.Delay(Timeout.Infinite, ct).ContinueWith(_ => { }, CancellationToken.None);
});
agent.KnockPeers.Add(dialer.PublicKeySpki);
agent.KnockPeers.Add(probe.PublicKeySpki);
await agent.Listener.Started.Task;
var logs = new Fleet.Hosting.BufferedNodeLog(NullNodeLog.Instance, TimeProvider.System);
var scoped = new ContextNodeLog(logs, ("attempt_id", "test-attempt"));
(PeerSessionInfo client, TlsFrameTransport transport) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, dialer, "dlr_1", null, 1 << 20, TimeProvider.System, scoped, default);
(PeerSessionInfo client, TlsFrameTransport transport) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
await using (transport)
{
PeerSessionInfo server = await seen.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal("dlr_1", server.PeerId);
Assert.Equal("prb_1", server.PeerId);
Assert.Equal(client.Credentials.Key, server.Credentials.Key);
Assert.Equal(client.Credentials.SessionId, server.Credentials.SessionId);
NodeLogPage page = logs.Read(0, 32, null);
Assert.Equal(new[] { "tcp_connect", "tls", "peer_auth" }, page.Items.Select(x => x.Record.Fields["phase"]));
Assert.All(page.Items, entry => Assert.Equal("test-attempt", entry.Record.Fields["attempt_id"]));
}
}
[Fact]
public async Task PeerHandshake_RejectsUnknownDialers_AndForgedSignatures_WithTheSameError()
public async Task PeerHandshake_RejectsUnknownProbes_AndForgedSignatures_WithTheSameError()
{
using NodeIdentity dialer = NodeIdentity.Generate();
using NodeIdentity probe = NodeIdentity.Generate();
using NodeIdentity impostor = NodeIdentity.Generate();
await using var agent = new PeerFixture(id => id == "dlr_1" ? dialer.PublicKeySpki : null);
agent.KnockPeers.AddRange([dialer.PublicKeySpki, impostor.PublicKeySpki]); // 敲门通过之后,名册外的 ID 和伪造的签名仍然被拒绝
await using var agent = new PeerFixture(id => id == "prb_1" ? probe.PublicKeySpki : null);
agent.KnockPeers.AddRange([probe.PublicKeySpki, impostor.PublicKeySpki]); // 敲门通过之后,名册外的 ID 和伪造的签名仍然被拒绝
await agent.Listener.Started.Task;
var logs = new Fleet.Hosting.BufferedNodeLog(NullNodeLog.Instance, TimeProvider.System);
InvalidOperationException unknown = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, dialer, "dlr_unknown", null, 1 << 20, TimeProvider.System, logs, default));
NodeLogRecord failure = Assert.Single(logs.Read(0, 32, null).Items, entry => entry.Record.Message == "peer control handshake failed").Record;
Assert.Equal("peer_auth", failure.Fields["phase"]);
Assert.Equal("InvalidOperationException", failure.Fields["exception"]);
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_unknown", null, 1 << 20, TimeProvider.System, default));
InvalidOperationException forged = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, impostor, "dlr_1", null, 1 << 20, TimeProvider.System, default));
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, impostor, "prb_1", null, 1 << 20, TimeProvider.System, default));
Assert.Equal(unknown.Message, forged.Message);
Assert.Contains("fleet_unauthorized", forged.Message);
// 正主仍然可以连接
(_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, dialer, "dlr_1", null, 1 << 20, TimeProvider.System, default);
(_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
await ok.DisposeAsync();
}
[Fact]
public async Task PeerHandshake_PinsTheAgentCertificate_ByItsSpkiFingerprint()
{
using NodeIdentity dialer = NodeIdentity.Generate();
await using var agent = new PeerFixture(_ => dialer.PublicKeySpki);
agent.KnockPeers.Add(dialer.PublicKeySpki);
using NodeIdentity probe = NodeIdentity.Generate();
await using var agent = new PeerFixture(_ => probe.PublicKeySpki);
agent.KnockPeers.Add(probe.PublicKeySpki);
await agent.Listener.Started.Task;
// 公钥不对:敲门密钥对不上,Agent 直接断开(拿到的不是证书,连不是它的证书都看不到)
using NodeIdentity another = NodeIdentity.Generate();
await Assert.ThrowsAnyAsync<Exception>(async () =>
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, another.PublicKeySpki, dialer, "dlr_1", null, 1 << 20, TimeProvider.System, default));
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, another.PublicKeySpki, probe, "prb_1", null, 1 << 20, TimeProvider.System, default));
}
[Fact]
public async Task PeerSession_CarriesRpcBothWays_AfterTheHandshake()
{
using NodeIdentity dialer = NodeIdentity.Generate();
using NodeIdentity probe = NodeIdentity.Generate();
MethodTable table = TestTables.Of(TestTables.Req("agent.echo"));
var router = new RpcRouter<string>(RpcSide.Listener, table).On("agent.echo", LinkJson.Default.Number, LinkJson.Default.Number, (_, p, _) => ValueTask.FromResult(Result<Number>.Ok(new Number { Value = p.Value + 1 })));
await using var agent = new PeerFixture(
_ => dialer.PublicKeySpki,
_ => probe.PublicKeySpki,
async (info, transport, ct) =>
{
await using var session = new RpcSession<string>(transport, router, info.PeerId, TimeProvider.System);
await session.RunAsync(ct);
});
agent.KnockPeers.Add(dialer.PublicKeySpki);
agent.KnockPeers.Add(probe.PublicKeySpki);
await agent.Listener.Started.Task;
(_, TlsFrameTransport transport) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, dialer, "dlr_1", null, 1 << 20, TimeProvider.System, default);
await using var client = new RpcSession<string>(transport, new RpcRouter<string>(RpcSide.Dialer, table), "dialer", TimeProvider.System);
(_, TlsFrameTransport transport) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
await using var client = new RpcSession<string>(transport, new RpcRouter<string>(RpcSide.Dialer, table), "probe", TimeProvider.System);
Task run = client.RunAsync(default);
Result<Number> result = await client.RequestAsync("agent.echo", new Number { Value = 41 }, LinkJson.Default.Number, LinkJson.Default.Number, default);
Assert.Equal(42, result.Value.Value);
@@ -447,9 +416,9 @@ public sealed class LinkTests
[Fact]
public async Task PeerListener_LimitsConcurrentSessions_AndSurvivesGarbageConnections()
{
using NodeIdentity dialer = NodeIdentity.Generate();
await using var agent = new PeerFixture(_ => dialer.PublicKeySpki);
agent.KnockPeers.Add(dialer.PublicKeySpki);
using NodeIdentity probe = NodeIdentity.Generate();
await using var agent = new PeerFixture(_ => probe.PublicKeySpki);
agent.KnockPeers.Add(probe.PublicKeySpki);
await agent.Listener.Started.Task;
// 不说 TLS 的连接:握手失败,监听继续
@@ -459,75 +428,16 @@ public sealed class LinkTests
await garbage.GetStream().WriteAsync("GET / HTTP/1.1\r\n\r\n"u8.ToArray());
}
(_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, dialer, "dlr_1", null, 1 << 20, TimeProvider.System, default);
(_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
await ok.DisposeAsync();
}
/// <summary>名额只数通过敲门的会话:一堆还没敲门的连接占着,名册里的拨号方照样连得上(之前它们共用一个上限,节点多了之后后到的拨号方被永远拒绝)。</summary>
[Fact]
public async Task PeerListener_SessionLimitCountsOnlyAdmittedPeers_SoSilentConnectionsCannotLockOutTheRoster()
{
using NodeIdentity first = NodeIdentity.Generate();
using NodeIdentity second = NodeIdentity.Generate();
using NodeIdentity third = NodeIdentity.Generate();
await using var agent = new PeerFixture(id => id == "dlr_1" ? first.PublicKeySpki : id == "dlr_2" ? second.PublicKeySpki : third.PublicKeySpki, maxSessions: 2);
agent.KnockPeers.AddRange([first.PublicKeySpki, second.PublicKeySpki, third.PublicKeySpki]);
await agent.Listener.Started.Task;
// 20 个什么都不说的连接:占着待敲门的名额,但不占会话名额
var silent = new List<TcpClient>();
try
{
for (int i = 0; i < 20; i++)
{
var client = new TcpClient();
await client.ConnectAsync(IPAddress.Loopback, agent.Listener.Port);
silent.Add(client);
}
(_, TlsFrameTransport a) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, first, "dlr_1", null, 1 << 20, TimeProvider.System, default);
(_, TlsFrameTransport b) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, second, "dlr_2", null, 1 << 20, TimeProvider.System, default);
// 会话名额(2)满了:第三个在名册里的拨号方被拒绝,一个会话结束之后又能连上
await Assert.ThrowsAnyAsync<Exception>(async () =>
{
(_, TlsFrameTransport refused) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, third, "dlr_3", null, 1 << 20, TimeProvider.System, default);
await refused.DisposeAsync();
});
await a.DisposeAsync();
bool reconnected = false;
for (int attempt = 0; attempt < 50 && !reconnected; attempt++)
{
try
{
(_, TlsFrameTransport again) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, third, "dlr_3", null, 1 << 20, TimeProvider.System, default);
await again.DisposeAsync();
reconnected = true;
}
catch (Exception ex) when (ex is IOException or System.Security.Authentication.AuthenticationException or InvalidOperationException)
{
await Task.Delay(100);
}
}
Assert.True(reconnected);
await b.DisposeAsync();
}
finally
{
foreach (TcpClient client in silent)
{
client.Dispose();
}
}
}
[Fact]
public async Task PeerListener_WritesNothingToStrangers_NotEvenATlsServerHello()
{
using NodeIdentity dialer = NodeIdentity.Generate();
await using var agent = new PeerFixture(_ => dialer.PublicKeySpki);
agent.KnockPeers.Add(dialer.PublicKeySpki);
using NodeIdentity probe = NodeIdentity.Generate();
await using var agent = new PeerFixture(_ => probe.PublicKeySpki);
agent.KnockPeers.Add(probe.PublicKeySpki);
await agent.Listener.Started.Task;
// 一个像样的 TLS ClientHello 开头、随机数据、什么都不发:都拿不到任何字节(连接被直接关闭)
@@ -555,8 +465,8 @@ public sealed class LinkTests
Assert.Equal(0, read);
}
// 名册里的 拨号方 仍然进得来
(_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, dialer, "dlr_1", null, 1 << 20, TimeProvider.System, default);
// 名册里的 Probe 仍然进得来
(_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
await ok.DisposeAsync();
}
@@ -34,10 +34,10 @@ public sealed class MethodDirectionTests
// t.ping 声明成请求:不能按通知注册
Assert.Throws<InvalidOperationException>(() => server.OnNotify("t.ping", EchoContext.Default.AddParams, (_, _, _) => ValueTask.CompletedTask));
// 节点这一侧:t.note 只给 agent 角色,dialer 注册不了;t.push 是它该处理的
var dialer = new RpcRouter<string>(RpcSide.Node, s_table, "dialer");
dialer.On("t.push", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => throw new NotSupportedException());
Assert.Throws<InvalidOperationException>(() => new RpcRouter<string>(RpcSide.Server, s_table, "dialer").OnNotify("t.note", EchoContext.Default.AddParams, (_, _, _) => ValueTask.CompletedTask));
// 节点这一侧:t.note 只给 agent 角色,probe 注册不了;t.push 是它该处理的
var probe = new RpcRouter<string>(RpcSide.Node, s_table, "probe");
probe.On("t.push", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => throw new NotSupportedException());
Assert.Throws<InvalidOperationException>(() => new RpcRouter<string>(RpcSide.Server, s_table, "probe").OnNotify("t.note", EchoContext.Default.AddParams, (_, _, _) => ValueTask.CompletedTask));
server.On("t.ping", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => ValueTask.FromResult(Result<AddResult>.Ok(new AddResult())));
}
@@ -99,7 +99,7 @@ public sealed class MethodDirectionTests
{
RpcRouter<string> server = Server().OnNotify("t.note", EchoContext.Default.AddParams, (_, _, _) => ValueTask.CompletedTask);
Assert.Null((await server.DispatchCheckedAsync("agent", Notification("t.note"), default)).Violation);
Assert.NotNull((await server.DispatchCheckedAsync("dialer", Notification("t.note"), default)).Violation);
Assert.NotNull((await server.DispatchCheckedAsync("probe", Notification("t.note"), default)).Violation);
Assert.NotNull((await server.DispatchCheckedAsync(null!, Notification("t.note"), default)).Violation);
}
@@ -115,7 +115,7 @@ public sealed class MethodDirectionTests
// 对端的角色也要允许
var node = new RpcRouter<string>(RpcSide.Node, s_table, "agent");
node.CheckSend("t.note", MethodKind.Notification, "agent");
Assert.Throws<InvalidOperationException>(() => node.CheckSend("t.note", MethodKind.Notification, "dialer"));
Assert.Throws<InvalidOperationException>(() => node.CheckSend("t.note", MethodKind.Notification, "probe"));
}
[Fact]
@@ -126,7 +126,7 @@ public sealed class MethodDirectionTests
// 服务端要处理 t.ping 和 t.note;t.inline 在握手阶段直接处理,不算缺
Assert.Equal(["t.note", "t.ping"], s_table.MissingHandlers(RpcSide.Server, null, []));
Assert.Equal(["t.ping"], s_table.MissingHandlers(RpcSide.Server, "dialer", []));
Assert.Equal(["t.ping"], s_table.MissingHandlers(RpcSide.Server, "probe", []));
Assert.Equal(["t.note"], s_table.MissingHandlers(RpcSide.Server, "agent", ["t.ping"]));
Assert.Empty(s_table.MissingHandlers(RpcSide.Server, "agent", ["t.ping", "t.note"]));
// 同名方法在对等面的接收方是拨号方?不:t.ping 在对等面由监听方发起,拨号方处理
@@ -1,21 +0,0 @@
using Fleet.Core;
namespace Fleet.Tests;
public sealed class NodeVersionsTests
{
[Theory]
[InlineData("0.4.2-dev.73-5245f03", "0.4.2-dev.74", true)]
[InlineData("0.4.2-dev.73-5245f03", "0.4.2-dev.73-abcdef0", false)] // 哈希不参与比较
[InlineData("0.4.2-dev.100-aaaaaaa", "0.4.2-dev.99", false)] // 序号按数字比,不是按字符串
[InlineData("0.4.2-dev.999", "0.4.2", true)] // 正式版高于预发布版
[InlineData("0.4.2", "0.4.2-dev.5", false)]
[InlineData("0.4.1", "0.4.2-dev.1", true)]
[InlineData("v0.5.0", "0.4.9", false)]
[InlineData("0.4.2-dev.73-5245f03", "", false)] // 没有要求
[InlineData("", "0.4.2", false)] // 认不出来的版本号不拒绝
[InlineData("not-a-version", "0.4.2", false)]
[InlineData("0.4.2", "garbage", false)]
public void IsBelow_ComparesCoreThenReleaseThenTheDevCounter(string actual, string required, bool expected) =>
Assert.Equal(expected, NodeVersions.IsBelow(actual, required));
}
@@ -4,7 +4,7 @@ using Fleet.Identity;
namespace Fleet.Tests;
/// <summary>节点自己要求有属主(passkey):拨号方和监听方共用这一份,没有属主就不运行。</summary>
/// <summary>节点自己要求有属主(passkey):Agent 和 Probe 共用这一份,没有属主就不运行。</summary>
public sealed class OwnerGuardTests : IDisposable
{
private readonly TestAuthenticator _alice = new();
+1 -1
View File
@@ -2,7 +2,7 @@ using System.Text.Json.Nodes;
using Fleet.Core;
using Fleet.Enrollment.Client;
// fleet-enroll --server https://panel --join jt_xxx --name jp-1 --identity-dir /etc/moshiro [--meta '{"tcp_port":7443,...}'] [--version 1.0]
// fleet-enroll --server https://panel --join jt_xxx --name jp-1 --identity-dir /etc/moshiro [--meta '{"probe_tcp_port":7443,...}'] [--version 1.0]
// 一次性运行:生成身份、注册、写入身份目录,然后退出。
string? Arg(string name) => args.SkipWhile(a => a != name).Skip(1).FirstOrDefault();
@@ -33,6 +33,4 @@
<Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
<Import Project="Sdk.targets" Sdk="StuDev.AotAnywhere"
Condition="'$(UseAotAnywhere)' == 'true'" />
<Import Project="..\..\..\shared\MoShiro.Measurement\StaticMsQuic.targets"
Condition="'$(StaticMsQuic)' == 'true'" />
</Project>
+724 -1092
View File
File diff suppressed because it is too large. Load diff
+21
View File
@@ -0,0 +1,21 @@
<Project>
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" />
<PropertyGroup>
<OptimizationPreference>Size</OptimizationPreference>
<UseSystemResourceKeys>true</UseSystemResourceKeys>
<StackTraceSupport>false</StackTraceSupport>
<IlcFoldIdenticalMethodBodies>true</IlcFoldIdenticalMethodBodies>
<EventSourceSupport>false</EventSourceSupport>
<DebuggerSupport>false</DebuggerSupport>
<HttpActivityPropagationSupport>false</HttpActivityPropagationSupport>
<Http3Support>false</Http3Support>
<MetricsSupport>false</MetricsSupport>
<UseSizeOptimizedLinq>true</UseSizeOptimizedLinq>
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
<ServerGarbageCollection>false</ServerGarbageCollection>
<ConcurrentGarbageCollection>false</ConcurrentGarbageCollection>
</PropertyGroup>
<ItemGroup>
<AdditionalFiles Include="$(MSBuildThisFileDirectory)../BannedSymbols.Node.txt" />
</ItemGroup>
</Project>
+59
View File
@@ -0,0 +1,59 @@
# MoShiro Probe
Probe 是网络测量的发起端,通常运行在家宽或其他需要观测的网络出口。它连接 Backend(身份、Agent 列表、状态)和 Agent(测量),执行线路任务,并把本端采集的原始数据交给 Agent 汇总上报。Probe 不提供远程命令或文件管理功能。
## 工作流程
```text
Backend ──任务──> Agent <──测量会话──> Probe
│ ▲ │ │
│ └──汇总并上报数据 <───┘ │
└─────Agent 列表、状态(Fleet 长连接)───────────┘
```
Probe 用 `probe.json` 读取面板地址(`fleet.server`)、身份目录(`fleet.identity_dir`,由 `fleet-enroll` 注册时生成)和本地测试限制。连上面板之后,面板下发要连接的 Agent 列表;Probe 将列表缓存到同目录的 `agents.json`,面板暂时连不上时靠它继续测量。身份目录里有私钥,请限制访问权限,不要提交到版本控制。
容器节点的运行方式(podman + systemd)和宿主机更新器见 [`../../docs/deployment.md`](../../docs/deployment.md);Probe 也可以用 OCI 镜像 `registry.oeo.one/moshiro/moshiro-probe:<version>`,容器默认读取 `/etc/moshiro/probe.json`,命名 volume 保存身份配置和缓存,镜像以 UID/GID 1654 运行。
## 构建
需要 .NET SDK 10。为应用 根目录的 `global.json` 的 SDK 设置,请从 `backend/` 目录构建:
```sh
cd backend
dotnet build probe/src/MoShiro.Probe -c Release
```
发布 NativeAOT 程序需要对应平台的 C/C++ 工具链;`<RID>` 应与构建环境和目标平台匹配:
```sh
dotnet publish probe/src/MoShiro.Probe -c Release -r <RID> -p:PublishAot=true
```
也可在仓库根目录运行 `bash scripts/publish.sh probe <RID>`,再使用 `scripts/package.sh probe <RID>` 生成部署包。支持平台和安装流程见 [`../../docs/deployment.md`](../../docs/deployment.md)。
## 配置与运行
在管理端添加 Probe(生成 probe 角色的加入令牌),在目标机上注册(密钥由 Probe 自己生成):
```sh
fleet-enroll --server https://panel.example.com --join <加入令牌> --name home --identity-dir /etc/moshiro
```
写 `/etc/moshiro/probe.json`:`{"fleet":{"server":"https://panel.example.com","identity_dir":"/etc/moshiro"}}`(`install.sh probe --server … --join … --name …` 会一并完成)。随后运行:
```sh
moshiro-probe --config /etc/moshiro/probe.json
```
不传 `--config` 时默认读取 `/etc/moshiro/probe.json`。配置中的 `limits` 是本地硬上限,Backend 无法远程放宽;路由器等低性能设备可调低测试码率和包速率,避免 Probe 自身成为测量瓶颈。部署时可使用仓库的 `packaging/` 文件和安装脚本。
也可使用 OCI 镜像 `registry.oeo.one/moshiro/moshiro-probe:<version>`,自动选择 Linux amd64 或 arm64 镜像。容器默认读取 `/etc/moshiro/probe.json`,并在同一目录维护 `agents.json` 缓存,配置与缓存存放在命名 volume 中。建议使用宿主网络;当前独立 ICMP 目标任务由 Agent 执行。
## 设计说明
- Probe 只执行编译进程序的测量模块。它不落盘测量数据,也不能执行命令或自行更新(更新由宿主机上的更新器完成,Probe 只写清单和请求文件)。
- 测量模块由 `backend/shared/MoShiro.Measurement/` 与 Agent 共用;Probe 负责发起连接、调度任务和记录本端数据。
- 测量模型和协议细节见 [`../../docs/measurement.md`](../../docs/measurement.md) 与 [`../../docs/protocol.md`](../../docs/protocol.md);整体架构见 [`../../docs/architecture.md`](../../docs/architecture.md)。
Docker 与 rootless/rootful Podman 共用 `packaging/compose/probe/compose.yaml`,配置、身份和缓存放在命名 volume 中,无需宿主配置目录、keep-id 或手动 chown。首次导入和自动更新见部署文档。
@@ -0,0 +1,422 @@
using System.Collections.Concurrent;
using System.Net;
using System.Net.Sockets;
using System.Text.Json;
using Fleet.Core;
using Fleet.Identity;
using Fleet.Link;
using Fleet.Rpc;
using MoShiro.Measurement;
using MoShiro.Protocol;
namespace MoShiro.Probe;
/// <summary>所有连接共用的东西。</summary>
public sealed record ConnectionEnv(NodeIdentity Identity, string ProbeId, ProbeLimits Limits, AgentDirectory Directory, MonoClock Clock, TimeProvider Time, INodeLog Log);
/// <summary>
/// 与一个 Agent 的点对点会话:认证握手 → 同步时钟 → 按 Agent 下发的 <c>line_task_set</c> 运行角色 →
/// 每 10 秒把已完成的行切成列块放进发件箱 → 响应按需测试的阶段。会话断开时所有角色停止(凭据属于会话),按退避重连。
/// 会话内的所有状态(套接字、发件箱、角色)随会话一起创建和释放。
/// </summary>
public sealed class AgentConnection
{
private static readonly TimeSpan s_flushInterval = TimeSpan.FromSeconds(ProtocolLimits.ChunkSeconds);
private static readonly TimeSpan s_clockInterval = TimeSpan.FromSeconds(30);
private static readonly RpcRouter<ConnectionSession> s_router = BuildRouter();
/// <summary>对等面入站方法的路由器(只读,测试用)。</summary>
public static RpcRouter<ConnectionSession> Router => s_router;
private readonly ConnectionEnv _env;
private volatile ConnectionSession? _session;
public AgentConnection(KnownAgent endpoint, ConnectionEnv env)
{
Endpoint = endpoint;
_env = env;
}
public KnownAgent Endpoint { get; }
public bool IsConnected => _session is not null;
public int RoleCount => _session?.RoleCount ?? 0;
public long ClockOffsetUs => _session?.Clock.OffsetUs ?? 0;
public async Task RunAsync(CancellationToken ct)
{
var backoff = new Backoff(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(20));
while (!ct.IsCancellationRequested)
{
try
{
string reason = await RunOnceAsync(backoff, ct);
_env.Log.Warn("agent session ended", ("agent", Endpoint.AgentId), ("err", reason));
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
return;
}
catch (Exception ex) when (ex is IOException or System.Net.Sockets.SocketException or InvalidOperationException or System.Security.Authentication.AuthenticationException or OperationCanceledException or FormatException)
{
_env.Log.Warn("agent connection failed", ("agent", Endpoint.AgentId), ("err", ex.Message));
}
try
{
await Task.Delay(backoff.Next(), _env.Time, ct);
}
catch (OperationCanceledException)
{
return;
}
}
}
private async Task<string> RunOnceAsync(Backoff backoff, CancellationToken ct)
{
AddressFamily family = Endpoint.IpVersion == IpVersions.V6 ? AddressFamily.InterNetworkV6 : AddressFamily.InterNetwork;
using var resolveTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(10), _env.Time);
using CancellationTokenSource resolveCt = CancellationTokenSource.CreateLinkedTokenSource(ct, resolveTimeout.Token);
IPAddress[] addresses = IPAddress.TryParse(Endpoint.Host, out IPAddress? literal)
? [literal]
: await Dns.GetHostAddressesAsync(Endpoint.Host, family, resolveCt.Token);
IPAddress? address = addresses.FirstOrDefault(a => a.AddressFamily == family && !a.IsIPv4MappedToIPv6);
if (address is null)
{
throw new SocketException((int)SocketError.HostNotFound);
}
byte[] agentKey = Convert.FromBase64String(Endpoint.PublicKey);
(PeerSessionInfo info, TlsFrameTransport transport) = await PeerConnector.ConnectAsync(
address.ToString(), Endpoint.TcpPort, agentKey, _env.Identity, _env.ProbeId, ProbeApp.Version, 8 << 20, _env.Time, ct);
var remote = (IPEndPoint)info.Remote!;
var data = new DataSession(info.Credentials.SessionIdValue, info.Credentials.Key);
using var udp = new UdpMux(new SingleSession(data), null, _env.Clock, new DataPlaneCounters(), _env.Log, remote.AddressFamily == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any, []);
udp.Bind();
var outbox = new Outbox(8 << 20);
var clock = new ClockEstimator();
var shipper = new FragmentShipper(outbox, new NodeId(Endpoint.AgentId), new ProbeId(_env.ProbeId), clock, _env.Time);
var blockEnv = new BlockEnv
{
Clock = _env.Clock,
Log = _env.Log,
Session = () => data,
Udp = udp,
PeerAddress = remote.Address,
PeerSpkiSha256 = NodeIdentity.Fingerprint(agentKey),
KnockKey = Knock.DeriveKey(_env.Identity, agentKey),
Fragments = shipper,
MaxPacketsPerSecond = _env.Limits.MaxTotalPps,
MaxBulkMbps = _env.Limits.MaxTestMbps,
};
var session = new ConnectionSession(blockEnv, clock, outbox, _env);
var rpc = new RpcSession<ConnectionSession>(transport, s_router, session, _env.Time);
rpc.OnBinary = (_, _) => ValueTask.CompletedTask;
using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
Task run = rpc.RunAsync(cts.Token);
Task receive = udp.RunAsync(cts.Token);
Task pump = PumpAsync(rpc, outbox, cts.Token);
try
{
// 先同步时钟(块头里要带偏移);Agent 随后推送线路任务集
await SyncClockAsync(rpc, clock, rounds: 5, cts.Token);
backoff.Reset();
_session = session;
_env.Log.Info("agent session up", ("agent", Endpoint.AgentId), ("offset_us", clock.OffsetUs));
Task maintenance = MaintainAsync(rpc, session, clock, cts.Token);
Task finished = await Task.WhenAny(run, receive, pump, maintenance);
await cts.CancelAsync();
await Task.WhenAll(Quiet(run), Quiet(receive), Quiet(pump), Quiet(maintenance));
return finished == run ? "closed by the agent" : "session task failed";
}
finally
{
_session = null;
await cts.CancelAsync();
await session.StopAllAsync();
await Quiet(run);
await Quiet(receive);
await Quiet(pump);
await rpc.DisposeAsync();
}
}
private static async Task Quiet(Task task)
{
try
{
await task;
}
catch (Exception ex) when (ex is OperationCanceledException or IOException or ObjectDisposedException or System.Net.Sockets.SocketException or InvalidOperationException)
{
// 会话结束时的正常噪声
}
}
private static async Task PumpAsync(RpcSession<ConnectionSession> rpc, Outbox outbox, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
byte[] frame = await outbox.TakeNextAsync(2 << 20, ct);
await rpc.SendBinaryAsync(frame, ct);
}
}
private async Task SyncClockAsync(RpcSession<ConnectionSession> rpc, ClockEstimator clock, int rounds, CancellationToken ct)
{
for (int i = 0; i < rounds; i++)
{
long sent = _env.Clock.NowUs();
Result<ClockResult> reply = await rpc.RequestAsync(MoShiroMethods.Clock, new ClockParams { ProbeMonoUs = sent }, MoShiroJsonContext.Default.ClockParams, MoShiroJsonContext.Default.ClockResult, ct);
if (reply.TryGet(out ClockResult? value, out _))
{
clock.Add(sent, value.AgentMonoUs, _env.Clock.NowUs());
}
}
}
/// <summary>切块(每 10 秒)和时钟同步(每 30 秒)。</summary>
private async Task MaintainAsync(RpcSession<ConnectionSession> rpc, ConnectionSession session, ClockEstimator clock, CancellationToken ct)
{
using var flush = new PeriodicTimer(s_flushInterval, _env.Time);
long lastClockUs = _env.Clock.NowUs();
while (await flush.WaitForNextTickAsync(ct))
{
session.FlushLineBlocks();
if (_env.Clock.NowUs() - lastClockUs >= s_clockInterval.TotalMicroseconds)
{
lastClockUs = _env.Clock.NowUs();
await SyncClockAsync(rpc, clock, rounds: 1, ct);
}
}
}
private static RpcRouter<ConnectionSession> BuildRouter() =>
new RpcRouter<ConnectionSession>(RpcSide.Dialer, MoShiroMethodTable.Table)
.On(
FleetMethods.StateSync,
FleetRpcContext.Default.StateSyncParams,
FleetRpcContext.Default.StateSyncResult,
async (session, sync, _) =>
{
var result = new StateSyncResult { AppliedVersion = sync.Version };
if (sync.Type == LineTaskSet.StateType)
{
LineTaskSet? set = sync.Doc.Deserialize(MoShiroJsonContext.Default.LineTaskSet);
if (set is null)
{
return Result<StateSyncResult>.Fail(FleetErrors.InvalidParams, "line_task_set could not be parsed");
}
result.Rejected.AddRange(await session.ApplyAsync(set));
}
return Result<StateSyncResult>.Ok(result);
})
.On(
MoShiroMethods.TestPhase,
MoShiroJsonContext.Default.TestPhaseStartParams,
MoShiroJsonContext.Default.TestPhaseStartResult,
(session, start, _) => ValueTask.FromResult(Result<TestPhaseStartResult>.Ok(new TestPhaseStartResult { Accepted = session.StartPhase(start) })))
.On(
MoShiroMethods.TestPhaseEnd,
MoShiroJsonContext.Default.TestPhaseEndParams,
MoShiroJsonContext.Default.TestPhaseEndResult,
async (session, end, ct) => Result<TestPhaseEndResult>.Ok(new TestPhaseEndResult { Fragments = await session.EndPhaseAsync(end, ct) }))
.OnNotify(
MoShiroMethods.TestAbort,
MoShiroJsonContext.Default.TestAbortParams,
async (session, abort, _) => await session.AbortAsync(abort.TestId))
.OnNotify(
FleetMethods.OutboxAck,
FleetRpcContext.Default.OutboxAckParams,
(session, ack, _) =>
{
session.Outbox.Acknowledge(ack.Ids.Concat(ack.Duplicates).Select(Guid.Parse));
return ValueTask.CompletedTask;
});
}
/// <summary>一次会话内的运行状态:线路任务的积木、按需测试的阶段。方法由路由器在单个入站队列里顺序调用。</summary>
public sealed class ConnectionSession
{
private const int MaxBlocks = 16;
private readonly object _gate = new();
private readonly Dictionary<string, RunningBlock> _lineBlocks = [];
private readonly ConcurrentDictionary<uint, PhaseState> _phases = new();
public ConnectionSession(BlockEnv blockEnv, ClockEstimator clock, Outbox outbox, ConnectionEnv env)
{
BlockEnv = blockEnv;
Clock = clock;
Outbox = outbox;
Env = env;
}
public BlockEnv BlockEnv { get; }
public ClockEstimator Clock { get; }
public Outbox Outbox { get; }
public ConnectionEnv Env { get; }
public int RoleCount
{
get
{
lock (_gate)
{
return _lineBlocks.Count;
}
}
}
/// <summary>调和线路任务:参数没有变化的积木继续运行,其余启动或停止。返回被拒绝的任务(不认识的 Kind、参数非法、超过上限)。</summary>
public async Task<List<RejectedItem>> ApplyAsync(LineTaskSet set)
{
var rejected = new List<RejectedItem>();
var wanted = new Dictionary<string, BlockSpec>();
foreach (LineTask task in set.Tasks)
{
BlockSpec? spec = BlockCatalog.ProbeKinds.Contains(task.Kind)
? BlockSpec.Create(task.Kind, task.Params, task.StreamId, task.Port, new FragmentScope(task.Id, null, null))
: null;
if (spec is null)
{
rejected.Add(new RejectedItem { Id = task.Id.Value, Reason = BlockCatalog.ProbeKinds.Contains(task.Kind) ? "invalid_params" : "unsupported" });
}
else if (wanted.Count >= MaxBlocks)
{
rejected.Add(new RejectedItem { Id = task.Id.Value, Reason = "budget_exceeded" });
}
else
{
wanted[task.Id.Value] = spec;
}
}
var stopped = new List<RunningBlock>();
lock (_gate)
{
foreach ((string id, RunningBlock running) in _lineBlocks.ToList())
{
if (!wanted.TryGetValue(id, out BlockSpec? spec) || spec.Signature != running.Signature)
{
stopped.Add(running);
_lineBlocks.Remove(id);
}
}
foreach ((string id, BlockSpec spec) in wanted)
{
if (!_lineBlocks.ContainsKey(id))
{
_lineBlocks[id] = RunningBlock.Start(BlockCatalog.Find(spec.KindId)!.Dialer(BlockEnv, spec), spec.Signature);
}
}
}
// 被停止的积木把已经稳定的行交出去(刚发出、还在等回复的行不交:它们不是丢失,只是没有等到)
foreach (RunningBlock running in stopped)
{
await running.StopAsync();
running.Block.Flush(FlushMode.Settled);
}
return rejected;
}
public void FlushLineBlocks()
{
List<RunningBlock> blocks;
lock (_gate)
{
blocks = [.. _lineBlocks.Values];
}
foreach (RunningBlock running in blocks)
{
running.Block.Flush(FlushMode.Settled);
}
}
/// <summary>按需测试的一个阶段:起对应的拨号方积木(单向数据流是接收端)。超过本地上限(或不认识)则拒绝。</summary>
public bool StartPhase(TestPhaseStartParams start)
{
string? direction = start.Params.TryGetValue("direction", out System.Text.Json.JsonElement d) ? d.GetString() : null;
if (_phases.ContainsKey(start.StreamId) || !BlockCatalog.ProbeKinds.Contains(start.Kind) || BlockCatalog.Find(start.Kind)!.Supports(direction) is false)
{
return false;
}
if (start.Kind == "udp_train")
{
double mbps = start.Params["mbps"].GetDouble();
long size = start.Params["size"].GetInt64();
long pps = (long)(mbps * 1_000_000 / 8 / Math.Max(1, size));
if (mbps > Env.Limits.MaxTestMbps || pps > Env.Limits.MaxTotalPps)
{
return false;
}
}
BlockSpec? spec = BlockSpec.Create(start.Kind, start.Params, start.StreamId, start.Port, new FragmentScope(null, start.TestId, start.Phase));
if (spec is null)
{
return false;
}
_phases[start.StreamId] = new PhaseState(start.TestId, start.Kind, RunningBlock.Start(BlockCatalog.Find(start.Kind)!.Dialer(BlockEnv, spec), spec.Signature));
return true;
}
/// <summary>阶段结束:收尾、交出列块,返回块数。</summary>
public async Task<int> EndPhaseAsync(TestPhaseEndParams end, CancellationToken ct)
{
if (!_phases.TryRemove(end.StreamId, out PhaseState? phase))
{
return 0;
}
// 最后一批包(单向数据流)、最后几个回显的回复还在途中
await Task.Delay(TimeSpan.FromMilliseconds(phase.Kind == "udp_train" ? 200 : 300), BlockEnv.Time, ct);
await phase.Running.StopAsync();
return phase.Running.Block.Flush(FlushMode.All, end.SentCount);
}
public async Task AbortAsync(TestId test)
{
foreach ((uint stream, PhaseState phase) in _phases.ToList())
{
if (phase.Test == test && _phases.TryRemove(stream, out _))
{
await phase.Running.StopAsync();
}
}
}
public async Task StopAllAsync()
{
List<RunningBlock> blocks;
lock (_gate)
{
blocks = [.. _lineBlocks.Values];
_lineBlocks.Clear();
}
blocks.AddRange(_phases.Values.Select(p => p.Running));
_phases.Clear();
foreach (RunningBlock running in blocks)
{
await running.StopAsync();
}
}
private sealed record PhaseState(TestId Test, string Kind, RunningBlock Running);
}
@@ -0,0 +1,115 @@
using System.Text.Json;
using Fleet.Core;
using Fleet.Hosting;
using MoShiro.Protocol;
namespace MoShiro.Probe;
/// <summary>
/// Probe 要连接的 Agent 列表:由面板作为期望状态(<c>probe_agents</c>)直接下发;面板连不上时用上一次保存的 <c>agents.json</c>。
/// 列表变化时用 <see cref="ConfigStore"/> 写回 <c>agents.json</c>(这是配置,不是测量数据;只在变化时写,保护路由器的闪存)。
/// </summary>
public sealed class AgentDirectory
{
public const string FileName = "agents.json";
private readonly object _gate = new();
private readonly ConfigStore _store;
private readonly INodeLog _log;
private readonly TrustAnchor _trust;
private List<KnownAgent> _agents = [];
private long _version;
public AgentDirectory(ConfigStore store, string configDirectory, INodeLog log, TrustAnchor? trust = null)
{
_trust = trust ?? TrustAnchor.None;
_store = store;
_log = log;
string cache = Path.Combine(configDirectory, FileName);
if (File.Exists(cache))
{
try
{
AgentsCache saved = NodeConfig.Load(cache, ProbeConfigContext.Default.AgentsCache);
_agents = saved.Agents;
_version = saved.Version;
}
catch (Exception ex) when (ex is IOException or InvalidDataException or JsonException or InvalidOperationException)
{
_log.Warn("agents.json is unreadable, waiting for the panel", ("err", ex.Message));
}
}
}
/// <summary>列表变化(调和连接)。</summary>
public event Action? Changed;
public long Version
{
get
{
lock (_gate)
{
return _version;
}
}
}
public IReadOnlyList<KnownAgent> Agents
{
get
{
lock (_gate)
{
return [.. _agents];
}
}
}
/// <summary>应用面板下发的列表。版本不比当前新的忽略(乱序到达的旧推送)。</summary>
public void Apply(ProbeAgentList result)
{
var next = new List<KnownAgent>();
_trust.Update(result.TrustChain);
foreach (AgentEndpoint agent in result.Agents)
{
if (!string.IsNullOrWhiteSpace(agent.Host))
{
next.Add(new KnownAgent { AgentId = agent.AgentId.Value, Host = agent.Host, TcpPort = agent.TcpPort, PublicKey = agent.PublicKey });
}
if (!string.IsNullOrWhiteSpace(agent.Ipv6Host))
{
next.Add(new KnownAgent { AgentId = agent.AgentId.Value, Host = agent.Ipv6Host, TcpPort = agent.TcpPort, PublicKey = agent.PublicKey, IpVersion = IpVersions.V6 });
}
}
lock (_gate)
{
if (result.Version < _version || (result.Version == _version && Same(next, _agents)))
{
return;
}
_version = result.Version;
_agents = next;
}
try
{
_store.Write(FileName, JsonSerializer.SerializeToUtf8Bytes(new AgentsCache { Version = result.Version, Agents = next }, ProbeConfigContext.Default.AgentsCache));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_log.Warn("could not save agents.json", ("err", ex.Message));
}
_log.Info("agent list updated", ("version", result.Version), ("agents", next.Count));
Changed?.Invoke();
}
private static bool Same(List<KnownAgent> a, List<KnownAgent> b) =>
a.Count == b.Count && a.Zip(b).All(p => Equal(p.First, p.Second));
public static bool Equal(KnownAgent a, KnownAgent b) =>
a.AgentId == b.AgentId && a.IpVersion == b.IpVersion && a.Host == b.Host && a.TcpPort == b.TcpPort && a.PublicKey == b.PublicKey;
}
@@ -4,11 +4,11 @@ using Fleet.Link;
using MoShiro.Measurement;
using MoShiro.Protocol;
namespace MoShiro.Agent;
namespace MoShiro.Probe;
/// <summary>
/// 估计监听方单调时钟 − 拨号方单调时钟:每次 <c>moshiro.clock</c> 往返取中点,在最近 8 次里取往返最短的一次
/// (往返越短,对称性假设的误差越小)。拨号方的列块头里带着这个偏移,Backend 据此对齐两端的时间戳。
/// 估计 Agent 单调时钟 − Probe 单调时钟:每次 <c>moshiro.clock</c> 往返取中点,在最近 8 次里取往返最短的一次
/// (往返越短,对称性假设的误差越小)。Probe 的列块头里带着这个偏移,Backend 据此对齐两端的时间戳。
/// </summary>
public sealed class ClockEstimator
{
@@ -21,11 +21,11 @@ public sealed class ClockEstimator
public bool HasEstimate { get; private set; }
public void Add(long sendUs, long listenerUs, long receiveUs)
public void Add(long sendUs, long agentUs, long receiveUs)
{
lock (_gate)
{
_samples.Enqueue((receiveUs - sendUs, listenerUs - ((sendUs + receiveUs) / 2)));
_samples.Enqueue((receiveUs - sendUs, agentUs - ((sendUs + receiveUs) / 2)));
while (_samples.Count > Window)
{
_samples.Dequeue();
@@ -38,22 +38,22 @@ public sealed class ClockEstimator
}
/// <summary>
/// 把拨号方一侧的行交给监听方:补全块头 → 编码 → 放进会话的发件箱(通道 17,至少一次,有界)。
/// 把 Probe 一侧的行交给这个 Agent:补全块头 → 编码 → 放进会话的发件箱(通道 17,至少一次,有界)。
/// 发件箱的内容在连接断开重连之后重发;发件箱满了丢弃最旧的。
/// </summary>
public sealed class FragmentShipper : IFragmentSink
{
private readonly Outbox _outbox;
private readonly NodeId _listener;
private readonly NodeId _dialer;
private readonly NodeId _agent;
private readonly ProbeId _probe;
private readonly ClockEstimator _clock;
private readonly TimeProvider _time;
public FragmentShipper(Outbox outbox, NodeId listener, NodeId dialer, ClockEstimator clock, TimeProvider time)
public FragmentShipper(Outbox outbox, NodeId agent, ProbeId probe, ClockEstimator clock, TimeProvider time)
{
_outbox = outbox;
_listener = listener;
_dialer = dialer;
_agent = agent;
_probe = probe;
_clock = clock;
_time = time;
}
@@ -67,12 +67,12 @@ public sealed class FragmentShipper : IFragmentSink
TaskId = scope.Task,
TestId = scope.Test,
Phase = scope.Phase,
AgentId = _listener,
DialerId = _dialer,
AgentId = _agent,
ProbeId = _probe,
Port = port,
StartUtcMs = startUtcMs,
DurationMs = Math.Max(1, durationMs),
Clock = new ChunkClock { DialerOffsetUs = _clock.OffsetUs },
Clock = new ChunkClock { ProbeOffsetUs = _clock.OffsetUs },
};
if (parameters is not null)
{
@@ -0,0 +1,26 @@
<Project>
<Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" />
<Import Project="Sdk.props" Sdk="StuDev.AotAnywhere"
Condition="'$(UseAotAnywhere)' == 'true'" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<AssemblyName>moshiro-probe</AssemblyName>
<RootNamespace>MoShiro.Probe</RootNamespace>
<IsAotCompatible>true</IsAotCompatible>
<PublishAot>true</PublishAot>
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>full</TrimMode>
<StripSymbols>true</StripSymbols>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<MetadataUpdaterSupport>false</MetadataUpdaterSupport>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="MoShiro.Backend.Tests" />
<ProjectReference Include="..\..\..\fleet\src\Fleet.Link\Fleet.Link.csproj" />
<ProjectReference Include="..\..\..\fleet\src\Fleet.Hosting\Fleet.Hosting.csproj" />
<ProjectReference Include="..\..\..\shared\MoShiro.Measurement\MoShiro.Measurement.csproj" />
</ItemGroup>
<Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
<Import Project="Sdk.targets" Sdk="StuDev.AotAnywhere"
Condition="'$(UseAotAnywhere)' == 'true'" />
</Project>
Loaded 100 of 534 files, more files were not shown because too many files have changed in this diff. Show more