Compare commits
101
Commits
3364f7903e
...
v0.4.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf3481ebae | ||
|
|
b46ba69aa0 | ||
|
|
2fe8a498d2 | ||
|
|
15f159ab39 | ||
|
|
010576bc2a | ||
|
|
c2ab9eab3b | ||
|
|
a77f12545b | ||
|
|
9919de53d3 | ||
|
|
1975253beb | ||
|
|
2e1dbcf4ce | ||
|
|
a1101424d2 | ||
|
|
0d08e90dab | ||
|
|
047df83a84 | ||
|
|
145660f0a2 | ||
|
|
a1b8af8509 | ||
|
|
a3b9671d62 | ||
|
|
e49e701a73 | ||
|
|
7512ae0f00 | ||
|
|
da353d239a | ||
|
|
49812e94c0 | ||
|
|
376020fe34 | ||
|
|
d9938e4b96 | ||
|
|
8b3abd5b22 | ||
|
|
32507fcb4d | ||
|
|
85c2043f2f | ||
|
|
efb50d0efe | ||
|
|
97ab3cc740 | ||
|
|
238046d959 | ||
|
|
77d8404f59 | ||
|
|
29d998bc94 | ||
|
|
e2e5e437cb | ||
|
|
b25b111134 | ||
|
|
b964dc2bbc | ||
|
|
4609048865 | ||
|
|
900bbde46a | ||
|
|
2d31bd091e | ||
|
|
c9c21f8056 | ||
|
|
b2b63c065d | ||
|
|
0873acf66a | ||
|
|
be04045c8c | ||
|
|
006814823b | ||
|
|
7ff0d0f4e6 | ||
|
|
a9f78f99df | ||
|
|
a3338e1cf3 | ||
|
|
044ce0c339 | ||
|
|
725fe17a7d | ||
|
|
e49990776b | ||
|
|
f8e8bb8acb | ||
|
|
70f20d38d0 | ||
|
|
7ff03a9628 | ||
|
|
5dce387c87 | ||
|
|
535bb3517d | ||
|
|
88b1a0a9bb | ||
|
|
bac1c27e72 | ||
|
|
12be44645f | ||
|
|
e014a19554 | ||
|
|
636a784043 | ||
|
|
0f8f3f9a54 | ||
|
|
904ea2b0ae | ||
|
|
a4f3d1892a | ||
|
|
cab505b837 | ||
|
|
cd9e677331 | ||
|
|
879d1c8593 | ||
|
|
4a1e48db20 | ||
|
|
f22fdf8b68 | ||
|
|
2ae63653c4 | ||
|
|
8c37db1577 | ||
|
|
0b384a58f5 | ||
|
|
4841f7b15b | ||
|
|
117d34a2af | ||
|
|
4106f831dc | ||
|
|
f704c66dd9 | ||
|
|
fbda9487d5 | ||
|
|
4a3631f82b | ||
|
|
3b88a3419d | ||
|
|
a2f88a344b | ||
|
|
9bb6841dce | ||
|
|
fc2bbdcc6d | ||
|
|
321cacac32 | ||
|
|
36f9816f40 | ||
|
|
310b2b5b19 | ||
|
|
a19e6548d3 | ||
|
|
ff45ad086e | ||
|
|
1379b8c7cb | ||
|
|
359313a57b | ||
|
|
6385c1b9a3 | ||
|
|
ddb6608e32 | ||
|
|
55b80f0a97 | ||
|
|
af2962fc58 | ||
|
|
5528753d45 | ||
|
|
b99b95c738 | ||
|
|
f03988d68b | ||
|
|
9966ea5d63 | ||
|
|
b4b50c636f | ||
|
|
a9f562441f | ||
|
|
1ff847e45a | ||
|
|
ee22309e65 | ||
|
|
9bcef56497 | ||
|
|
388619b8b6 | ||
|
|
3b73fd7474 | ||
|
|
e423d9b506 |
No files matched your search
@@ -1,5 +1,18 @@
|
||||
/.gitattributes text eol=lf
|
||||
ci/build-main-amd64.sh text eol=lf
|
||||
ci/index-backend-image.sh text eol=lf
|
||||
ci/publish-release-offer.sh text eol=lf
|
||||
ci/release-linux.sh text eol=lf
|
||||
packaging/update/*.sh text eol=lf
|
||||
packaging/container/*.sh text eol=lf
|
||||
ci/release-node-images.sh text eol=lf
|
||||
packaging/install/*.sh text eol=lf
|
||||
packaging/install/*.ps1 text eol=lf
|
||||
*.sh text eol=lf
|
||||
*.init text eol=lf
|
||||
*.service text eol=lf
|
||||
*.container text eol=lf
|
||||
*.Containerfile text eol=lf
|
||||
*.ps1 text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.md text eol=lf
|
||||
@@ -0,0 +1,128 @@
|
||||
labels:
|
||||
ci: domain-linux-native
|
||||
platform: linux/amd64
|
||||
|
||||
clone:
|
||||
- name: git
|
||||
image: plugin-git
|
||||
settings:
|
||||
lfs: false
|
||||
# 要拿到 tag 才算得出开发版本号(ci/version.sh:自最近 tag 的提交数)
|
||||
tags: true
|
||||
|
||||
when:
|
||||
- event: push
|
||||
branch: main
|
||||
evaluate: 'CI_COMMIT_MESSAGE contains "[CI]"'
|
||||
|
||||
# 先等后端测试和前端构建都通过再出镜像(提交信息写 [SKIP TESTS] 只跳过后端测试)
|
||||
depends_on:
|
||||
- verify
|
||||
- web-build
|
||||
|
||||
concurrency: 1
|
||||
|
||||
steps:
|
||||
- name: build-linux-amd64-backend
|
||||
image: bash
|
||||
environment:
|
||||
NUGET_PACKAGES: /home/justme/woodpecker/cache/nuget/packages
|
||||
ZOT_REGISTRY: registry.oeo.one
|
||||
ZOT_USERNAME: justme
|
||||
ZOT_SECRET:
|
||||
from_secret: zot_secret
|
||||
RELEASE_API_URL: https://mo.oeo.one
|
||||
RELEASE_SIGNING_KEY:
|
||||
from_secret: release_signing_key
|
||||
commands:
|
||||
- bash ci/build-dev-image.sh backend
|
||||
depends_on: []
|
||||
|
||||
- name: build-linux-amd64-agent
|
||||
image: bash
|
||||
environment:
|
||||
ZOT_REGISTRY: registry.oeo.one
|
||||
ZOT_USERNAME: justme
|
||||
ZOT_SECRET:
|
||||
from_secret: zot_secret
|
||||
RELEASE_API_URL: https://mo.oeo.one
|
||||
RELEASE_SIGNING_KEY:
|
||||
from_secret: release_signing_key
|
||||
commands:
|
||||
- bash ci/build-dev-image.sh agent
|
||||
depends_on: []
|
||||
|
||||
- name: build-linux-amd64-probe
|
||||
image: bash
|
||||
environment:
|
||||
ZOT_REGISTRY: registry.oeo.one
|
||||
ZOT_USERNAME: justme
|
||||
ZOT_SECRET:
|
||||
from_secret: zot_secret
|
||||
RELEASE_API_URL: https://mo.oeo.one
|
||||
RELEASE_SIGNING_KEY:
|
||||
from_secret: release_signing_key
|
||||
commands:
|
||||
- bash ci/build-dev-image.sh probe
|
||||
depends_on: []
|
||||
|
||||
# Build both node architectures in parallel; each multi-architecture index waits for both of its images.
|
||||
- name: build-linux-arm64-agent
|
||||
image: bash
|
||||
environment:
|
||||
ZOT_REGISTRY: registry.oeo.one
|
||||
ZOT_USERNAME: justme
|
||||
ZOT_SECRET:
|
||||
from_secret: zot_secret
|
||||
RELEASE_API_URL: https://mo.oeo.one
|
||||
RELEASE_SIGNING_KEY:
|
||||
from_secret: release_signing_key
|
||||
commands:
|
||||
- bash ci/build-dev-image.sh agent arm64
|
||||
depends_on: []
|
||||
|
||||
- name: build-linux-arm64-probe
|
||||
image: bash
|
||||
environment:
|
||||
ZOT_REGISTRY: registry.oeo.one
|
||||
ZOT_USERNAME: justme
|
||||
ZOT_SECRET:
|
||||
from_secret: zot_secret
|
||||
RELEASE_API_URL: https://mo.oeo.one
|
||||
RELEASE_SIGNING_KEY:
|
||||
from_secret: release_signing_key
|
||||
commands:
|
||||
- bash ci/build-dev-image.sh probe arm64
|
||||
depends_on: []
|
||||
|
||||
- name: publish-linux-agent-multiarch
|
||||
image: bash
|
||||
environment:
|
||||
ZOT_REGISTRY: registry.oeo.one
|
||||
ZOT_USERNAME: justme
|
||||
ZOT_SECRET:
|
||||
from_secret: zot_secret
|
||||
RELEASE_API_URL: https://mo.oeo.one
|
||||
RELEASE_SIGNING_KEY:
|
||||
from_secret: release_signing_key
|
||||
commands:
|
||||
- bash ci/publish-dev-node-index.sh agent
|
||||
depends_on:
|
||||
- build-linux-amd64-agent
|
||||
- build-linux-arm64-agent
|
||||
|
||||
- name: publish-linux-probe-multiarch
|
||||
image: bash
|
||||
environment:
|
||||
ZOT_REGISTRY: registry.oeo.one
|
||||
ZOT_USERNAME: justme
|
||||
ZOT_SECRET:
|
||||
from_secret: zot_secret
|
||||
RELEASE_API_URL: https://mo.oeo.one
|
||||
RELEASE_SIGNING_KEY:
|
||||
from_secret: release_signing_key
|
||||
commands:
|
||||
- bash ci/publish-dev-node-index.sh probe
|
||||
depends_on:
|
||||
- build-linux-amd64-probe
|
||||
- build-linux-arm64-probe
|
||||
@@ -0,0 +1,32 @@
|
||||
labels:
|
||||
ci: domain-linux-native
|
||||
platform: linux/amd64
|
||||
|
||||
clone:
|
||||
- name: git
|
||||
image: plugin-git
|
||||
settings:
|
||||
lfs: false
|
||||
|
||||
# 带 [CI] 的推送会出镜像,慢测试放在 dev-image 里、等镜像构建完再跑(不和 AOT 编译抢内存);这个 workflow 管其余的:PR 和没带 [CI] 的推送
|
||||
when:
|
||||
- event: pull_request
|
||||
- event: push
|
||||
branch: main
|
||||
evaluate: 'not (CI_COMMIT_MESSAGE contains "[CI]")'
|
||||
|
||||
# 等 verify 编译完、快的测试过了再跑(共用它的增量编译缓存,不重新编译)
|
||||
depends_on:
|
||||
- verify
|
||||
|
||||
steps:
|
||||
- name: backend-e2e-tests
|
||||
image: bash
|
||||
# [SKIP TESTS] 和 verify 一样,跳过后端测试
|
||||
when:
|
||||
- evaluate: 'not (CI_COMMIT_MESSAGE contains "[SKIP TESTS]")'
|
||||
environment:
|
||||
NUGET_PACKAGES: /home/justme/woodpecker/cache/nuget/packages
|
||||
commands:
|
||||
# 标了 Category=Slow 的端到端测试,见 ci/verify-backend.sh
|
||||
- bash ci/verify-backend.sh slow
|
||||
@@ -27,6 +27,9 @@ steps:
|
||||
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/release-node-images.sh
|
||||
depends_on: []
|
||||
@@ -37,6 +40,8 @@ steps:
|
||||
GITEA_BASE: http://127.0.0.1:3100
|
||||
GITEA_TOKEN:
|
||||
from_secret: gitea_token
|
||||
RELEASE_SIGNING_KEY:
|
||||
from_secret: release_signing_key
|
||||
commands:
|
||||
- bash ci/publish-gitea-release.sh
|
||||
depends_on:
|
||||
|
||||
@@ -15,29 +15,44 @@ when:
|
||||
concurrency: 1
|
||||
|
||||
steps:
|
||||
# 前端先打包好(几十秒),两个架构的 Backend 构建再并行:它们把 web/*/dist 内嵌进程序,只读这份产物,不再各自 npm ci
|
||||
# (并行的两个 npm ci 会同时删 node_modules,互相搞坏)。步骤加了超时:卡住就几分钟内失败,不是耗着。
|
||||
- name: web-build
|
||||
image: bash
|
||||
environment:
|
||||
NPM_CONFIG_CACHE: /home/justme/.npm
|
||||
commands:
|
||||
# 版本取自 tag:先写进工作区(仓库里的版本号只是占位),前端才会带上对的版本
|
||||
- bash ci/version.sh apply
|
||||
- npm ci --prefix web --prefer-offline
|
||||
- timeout 600 npm run build --prefix web
|
||||
depends_on: []
|
||||
|
||||
- name: linux-amd64-backend
|
||||
image: bash
|
||||
environment:
|
||||
NUGET_PACKAGES: /home/justme/.nuget/packages
|
||||
NUGET_PACKAGES: /home/justme/woodpecker/cache/nuget/packages
|
||||
ZOT_REGISTRY: registry.oeo.one
|
||||
ZOT_USERNAME: justme
|
||||
ZOT_SECRET:
|
||||
from_secret: zot_secret
|
||||
commands:
|
||||
- bash ci/release-linux.sh amd64 backend
|
||||
depends_on: []
|
||||
depends_on:
|
||||
- web-build
|
||||
|
||||
- name: linux-arm64-backend
|
||||
image: bash
|
||||
environment:
|
||||
NUGET_PACKAGES: /home/justme/.nuget/packages
|
||||
NUGET_PACKAGES: /home/justme/woodpecker/cache/nuget/packages
|
||||
ZOT_REGISTRY: registry.oeo.one
|
||||
ZOT_USERNAME: justme
|
||||
ZOT_SECRET:
|
||||
from_secret: zot_secret
|
||||
commands:
|
||||
- bash ci/release-linux.sh arm64 backend
|
||||
depends_on: []
|
||||
depends_on:
|
||||
- web-build
|
||||
|
||||
- name: backend-multiarch-index
|
||||
image: bash
|
||||
@@ -50,8 +65,6 @@ steps:
|
||||
RELEASE_API_URL: https://mo.oeo.one
|
||||
RELEASE_SIGNING_KEY:
|
||||
from_secret: release_signing_key
|
||||
RELEASE_API_TOKEN:
|
||||
from_secret: release_api_token
|
||||
commands:
|
||||
- bash ci/index-backend-image.sh
|
||||
depends_on:
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
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
|
||||
RELEASE_API_TOKEN:
|
||||
from_secret: release_api_token
|
||||
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
|
||||
RELEASE_API_TOKEN:
|
||||
from_secret: release_api_token
|
||||
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
|
||||
RELEASE_API_TOKEN:
|
||||
from_secret: release_api_token
|
||||
commands:
|
||||
- bash ci/build-main-amd64.sh probe
|
||||
depends_on: []
|
||||
@@ -11,7 +11,7 @@ clone:
|
||||
when:
|
||||
- event: pull_request
|
||||
- event: push
|
||||
branch: dev
|
||||
branch: main
|
||||
|
||||
# On Gitea PR events, CI_COMMIT_MESSAGE is the PR title.
|
||||
steps:
|
||||
@@ -21,6 +21,7 @@ steps:
|
||||
when:
|
||||
- evaluate: 'not (CI_COMMIT_MESSAGE contains "[SKIP TESTS]")'
|
||||
environment:
|
||||
NUGET_PACKAGES: /home/justme/.nuget/packages
|
||||
NUGET_PACKAGES: /home/justme/woodpecker/cache/nuget/packages
|
||||
commands:
|
||||
- cd backend && dotnet test --solution MoShiro.slnx -c Release
|
||||
# 增量编译 + 快的那批测试(约 20 秒);慢的端到端测试在 e2e workflow 里和出镜像并行跑。见 ci/verify-backend.sh
|
||||
- bash ci/verify-backend.sh fast
|
||||
@@ -11,17 +11,14 @@ clone:
|
||||
when:
|
||||
- event: pull_request
|
||||
- event: push
|
||||
branch: dev
|
||||
|
||||
depends_on:
|
||||
- verify
|
||||
branch: main
|
||||
|
||||
# 和 verify 并行:前端构建不依赖后端测试,两个同时跑
|
||||
steps:
|
||||
- name: web-build
|
||||
image: bash
|
||||
environment:
|
||||
NPM_CONFIG_CACHE: /home/justme/.npm
|
||||
commands:
|
||||
- npm ci --prefix web --prefer-offline
|
||||
- npm run build --prefix web
|
||||
- npm run lint:css --prefix web
|
||||
# node_modules 按锁文件哈希缓存,展示端和管理端并行构建,再检查 CSS,见 ci/web-build.sh
|
||||
- bash ci/web-build.sh
|
||||
@@ -22,6 +22,7 @@ steps:
|
||||
GITEA_TOKEN:
|
||||
from_secret: gitea_token
|
||||
NUGET_PACKAGES: 'C:\Users\JustMe-NAS\.nuget\packages'
|
||||
MOSHIRO_QUIC_CACHE: 'C:\Users\JustMe-NAS\.nuget\moshiro-quic-runtime'
|
||||
RID: linux-x64
|
||||
COMPONENT: agent
|
||||
commands: &build-commands
|
||||
|
||||
@@ -6,10 +6,11 @@ MoShiro is a self-hosted network path quality monitor. Backend coordinates Agent
|
||||
|
||||
## Branch and workspace safety
|
||||
|
||||
- Treat `main` as the stable release branch and `dev` as the integration branch. Keep ordinary implementation work on `dev` or a feature branch; integrate into `main` through a pull request.
|
||||
- A push to `main` builds images only if its commit message contains `[CI]` (for a merged PR, put it in the PR or merge commit title). Without `[CI]` nothing is built; use `[CI SKIP]` to skip the pipeline entirely. When squashing or pushing to `main`, add `[CI]` unless a build is explicitly not wanted.
|
||||
- `main` is the only working branch (the old `dev` branch is retired and no pipeline uses it). Commit and push to `main` directly; use a pull request when a review is wanted.
|
||||
- A push to `main` publishes development images only if its commit message contains `[CI]` (for a merged PR, put it in the PR or merge commit title). Without `[CI]` nothing is built; use `[CI SKIP]` to skip the pipeline entirely. The fast Backend tests (`verify`) and the web build run on every `main` push and must pass first; the slow end-to-end tests (`[Trait("Category", "Slow")]`) run after the images are built and pushed (inside `dev-image` for `[CI]` pushes, workflow `e2e` otherwise) and never block them; `[SKIP TESTS]` skips only the Backend tests.
|
||||
- Versions are filled in by CI (`ci/version.sh`): a `vX.Y.Z` tag builds exactly `X.Y.Z`; a `[CI]` build on `main` is `vX.Y.(Z+1)-dev.<N>-<sha7>` (`N` = commits since the latest tag). The `<Version>` in `backend/Directory.Build.props` and the `version` in the `web` `package.json` files are placeholders (`0.0.0-dev`); never bump them by hand.
|
||||
- Before editing, inspect `git status`. Preserve pre-existing user changes: do not reset, clean, stash, or include unrelated files in a commit.
|
||||
- Do not commit credentials, API keys, registry passwords, or generated runtime data. CI credentials belong in Woodpecker secrets (`zot_secret`, `gitea_token`, `release_signing_key` for signing release manifests, `release_api_token` with the `release` scope). The release signing private key never goes into the repository or the panel; only the public key in `packaging/update/` does.
|
||||
- Do not commit credentials, API keys, registry passwords, or generated runtime data. CI credentials belong in Woodpecker secrets (`zot_secret`, `gitea_token`, `release_signing_key` for signing release manifests). CI registers the signed manifests at `/api/release/offer`, which needs no token because the panel verifies the signature. The release signing private key never goes into the repository or the panel; only the public key in `packaging/update/` does.
|
||||
- Keep development OCI images in the `moshiro-dev/` repository path and release images in `moshiro/`. Do not let a development build overwrite release tags such as `latest`.
|
||||
|
||||
## Project layout
|
||||
@@ -38,6 +39,6 @@ Follow the user's requested validation scope. Do not start a full tag/release bu
|
||||
|
||||
## CI and releases
|
||||
|
||||
CI triggers and image tags can change; inspect `.woodpecker/*.yml` and `docs/deployment.md` before changing or describing them. In particular, check the distinction between `[CI]` main builds, `[SKIP TESTS]`, and version-tag releases in the current branch.
|
||||
CI triggers and image tags can change; inspect `.woodpecker/*.yml` and `docs/ops/deployment.md` before changing or describing them. In particular, check the distinction between `[CI]` main builds, `[SKIP TESTS]`, and version-tag releases in the current branch.
|
||||
|
||||
A version tag `vMAJOR.MINOR.PATCH` runs the full cross-platform release pipeline. Development image builds and releases must remain isolated in their respective Zot repository paths.
|
||||
A version tag `vMAJOR.MINOR.PATCH` runs the full cross-platform release pipeline; it needs no version-bump commit, because CI takes the version from the tag. Development image builds and releases must remain isolated in their respective Zot repository paths.
|
||||
@@ -12,45 +12,54 @@ MoShiro 是一个自托管的**网络线路质量观测工具**。它在若干 P
|
||||
|
||||
**它不做什么**
|
||||
|
||||
- **节点是纯采集器**:Agent 和 Probe 没有终端、命令执行、文件读写和自更新能力,也不加载外部代码。更新由节点宿主机上的更新器完成,节点进程只放面板转来的发布清单。
|
||||
- **不是主机监控**:Agent 只上报 CPU、内存、磁盘、网卡流量和运行时长这类只读的汇总数字,不采集型号、主机名、进程和任何 IP;Probe 不上报主机资源。
|
||||
- **节点是纯采集器**:Agent 和 Probe 没有终端、命令执行、文件读写和自更新能力,也不加载外部代码。更新由节点外面的服务管理器完成:Podman Quadlet 单元每次启动都先拉最新镜像,管理端点「更新」时节点只是有序退出(没有 Quadlet 的环境用宿主机更新器),节点自己不下载、不替换任何东西。
|
||||
- **不是主机监控**:Agent 和 Probe 只上报 CPU、内存、磁盘、网卡流量和运行时长这类只读的汇总数字(Probe 可以在本地配置里关掉),不采集型号、主机名、进程和任何 IP。
|
||||
- **不暴露地址**:公开接口里没有 IP、城市、ASN;国家和运营商只由 Backend 查询(节点自己不访问任何外部服务)。
|
||||
- 暂不包含告警(见文末待办)。
|
||||
|
||||
## 信任关系
|
||||
|
||||
```text
|
||||
管理员 ── 账号密码(Cookie 会话 + CSRF)或 API 令牌(read · admin · release)──▶ Backend
|
||||
管理员 ── 密码或 passkey(Cookie 会话 + CSRF),或 API 令牌(按能力授权)──▶ Backend
|
||||
访客 ── 匿名只读,站点设为公开时才可用(没有 IP、城市、ASN)──────────────▶ Backend 公开接口
|
||||
|
||||
操作员 ── passkey 给「操作员集合」签名 ──▶ Backend 只转发 ──▶ 节点自己验链
|
||||
(创世指纹和属主在安装时固定在节点上;集合里还登记了 Backend 的服务器密钥,节点据此认 Backend、加密链路)
|
||||
节点 ── 加入令牌一次性注册,之后用自己的 ECDSA P-256 密钥签名认证 ───────▶ Backend(只存公钥)
|
||||
Probe ── 用同一把密钥签名建立测量会话 ───────────────────────────────────▶ Agent(只放行面板名单里的 Probe 公钥)
|
||||
CI ── 发布私钥对「镜像 digest 清单」签名 ──▶ Backend 原样转发 ──▶ 节点宿主机的更新器用本机公钥验签
|
||||
Probe ── 先敲门,再用自己的密钥签名建立测量会话 ───────────────────────────▶ Agent(只放行面板名单里的 Probe 公钥)
|
||||
CI ── 发布私钥对「镜像 digest 清单」签名 ──▶ 镜像仓库 / Backend ──▶ 节点宿主机的更新器用本机公钥验签
|
||||
```
|
||||
|
||||
- **Backend 不信任节点上报的内容**:数据按线路隔离存放;节点的身份靠签名,数据库泄露也无法冒充节点。
|
||||
- **节点不信任 Backend 的下发**:任务超出节点本机的硬上限(目标网段、码率、包速率、监听端口)就被拒绝;节点不执行任何「执行类」指令。
|
||||
- **更新不依赖面板的诚实**:清单必须由发布私钥签名;节点宿主机的更新器用本机公钥验签、只认本机配置的镜像仓库、按 digest 拉取、拒绝旧的 `seq`。面板被攻破也推不出恶意镜像。
|
||||
- **节点不信任 Backend 的下发**:任务超出节点本机的硬上限(目标网段、码率、包速率、监听端口)就被拒绝;节点不执行任何「执行类」指令;改信任(操作员集合)要操作员 passkey 签名,Backend 伪造不了。
|
||||
- **更新不依赖面板的诚实**:清单必须由发布私钥签名(面板登记时验一次,节点宿主机的更新器用本机公钥再验一次)、只认本机配置的镜像仓库、按 digest 拉取、拒绝旧的 `seq`。面板被攻破也推不出恶意镜像。可信的发布密钥由部署的人决定:官方 CI 密钥始终信任,也可以另外信任自己的公钥。
|
||||
- **没有默认账户**:第一次打开管理端进认领向导,用后端日志里的认领码创建第一个管理员、注册 passkey、建立信任根。每个节点都必须有属主(passkey),由节点自己强制。
|
||||
- 运行中的二进制不做校验(节点自报的哈希不可信);「资产是我们的」靠安装时的签名清单和签名的 `SHA256SUMS`。详见 [`docs/security/`](docs/security/security.md)。
|
||||
|
||||
## 架构
|
||||
|
||||
```text
|
||||
Web(展示端 · 管理端)
|
||||
▲ REST + SSE
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ Backend(面板) │
|
||||
│ Api · Registry · Planner · Links │
|
||||
│ Releases · Analysis · LiveHub │
|
||||
│ Store(段文件 + SQLite) │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
操作员 passkey ──签名「操作员集合」──┐ CI 发布私钥 ──签名「镜像 digest 清单」──▶ 镜像仓库(清单 + 镜像)
|
||||
▼ ▲
|
||||
Web(展示端 · 管理端) │ 定时取清单
|
||||
▲ REST + SSE │
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Backend(面板) │──────────────────────────────┘
|
||||
│ Api · Auth(会话 · passkey · API 令牌 · 审计) │
|
||||
│ Trust(操作员集合 · 服务器身份)· Registry · Planner · Links │
|
||||
│ Releases(发布清单 · 可信发布密钥) │
|
||||
│ Ingest · Analysis · LiveHub │
|
||||
│ Store(段文件 + SQLite) │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
▲ ▲
|
||||
│ wss 长连接(节点发起) │ wss 长连接(节点发起)
|
||||
│ wss 长连接(节点发起,应用层加密) │ wss 长连接(节点发起,应用层加密)
|
||||
│ 任务、数据、状态、更新通知 │ 身份、Agent 列表、状态、更新通知
|
||||
┌────────────────────┐ ┌──────────────────────┐
|
||||
│ Agent × 10+ │ ◀── 测量会话 ── │ Probe × N │
|
||||
│ 有公网 IP,监听 │ │ 家宽等出口,NAT 后 │
|
||||
└────────────────────┘ └──────────────────────┘
|
||||
测量会话:TLS 控制通道 + UDP/TCP 数据通道,由 Probe 主动拨出
|
||||
┌────────────────────┐ ┌──────────────────────┐
|
||||
│ Agent × 10+ │ ◀── 测量会话 ── │ Probe × N │
|
||||
│ 有公网 IP,监听 │ 先敲门再 TLS │ 家宽等出口,NAT 后 │
|
||||
└────────────────────┘ └──────────────────────┘
|
||||
测量会话:TLS 控制通道 + QUIC Datagram/TCP 数据通道,由 Probe 主动拨出
|
||||
宿主机更新器(容器节点):用本机公钥验签 → 按 digest 拉镜像 → 重启,失败回滚
|
||||
```
|
||||
|
||||
两种连接:**Probe 和 Agent 都用长连接连 Backend**(身份、任务、状态、更新通知),**测量只走 Probe → Agent**(Probe 主动拨出,Agent 不需要连 Probe,家宽也不需要入站端口)。哪些 Probe 连哪些 Agent 由 Backend 决定:默认自动全连,也可以在管理端手动选择。
|
||||
@@ -60,16 +69,16 @@ MoShiro 是一个自托管的**网络线路质量观测工具**。它在若干 P
|
||||
Probe / Agent 积木 ──▶ 原始列块(10 秒一块)──▶ Backend 段文件 ──▶ 指标与服务模型 ──▶ REST / SSE ──▶ Web
|
||||
```
|
||||
|
||||
Backend 用段文件保存原始数据,用 SQLite 保存索引、指标与配置。**完整的架构、数据链路、安全边界和设计决策见 [`docs/architecture.md`](docs/architecture.md)。**
|
||||
Backend 用段文件保存原始数据,用 SQLite 保存索引、指标与配置。**完整的架构、数据链路、安全边界和设计决策见 [`docs/design/architecture.md`](docs/design/architecture.md)。**
|
||||
|
||||
## 协议
|
||||
|
||||
- **控制链路(Fleet)**:节点到 Backend 的 `wss` 长连接(`/api/agent/v1/rpc`),JSON-RPC 2.0 信封加二进制通道;挑战加签名认证,`hello` 协商版本和能力,心跳;**期望状态同步**(Backend 下发完整快照,节点调和并回报已应用的版本和被拒绝的条目);**可靠发件箱**(原始块至少一次投递,按 id 去重)。Fleet 与业务无关,放在 `backend/fleet/`。
|
||||
- **业务方法**:`moshiro.*`(按需测试、Probe 断开、事件、主机汇总、更新通知等),状态文档有 `task_set`(Agent 的任务集)和 `probe_agents`(Probe 的 Agent 列表)。
|
||||
- **测量数据面**:Probe 到 Agent 的 TLS 控制通道、UDP 和 TCP 数据通道(默认 `7443/tcp`、`7443/udp`、`7444/tcp`);会话用 `session_id` 和 MAC 认证、不绑定五元组,NAT 映射变化后自动跟随。
|
||||
- **测量数据面**:Probe 到 Agent 的 TLS 控制通道、QUIC Datagram 和 TCP 数据通道(默认 `7443/tcp`、`7443/udp`、`7444/tcp`);测量包不走可靠重传,继续由会话 MAC、序号窗口和速率准入校验。
|
||||
- **REST + SSE**:`/api/public`(展示,可匿名)、`/api/admin`(管理);OpenAPI 在 `backend/openapi/v1.json`,前端据此生成类型。
|
||||
|
||||
细节见 [`docs/protocol.md`](docs/protocol.md)、[`backend/fleet/README.md`](backend/fleet/README.md)、[`docs/api.md`](docs/api.md)、[`docs/enrollment.md`](docs/enrollment.md)。
|
||||
细节见 [`docs/design/protocol.md`](docs/design/protocol.md)、[`backend/fleet/README.md`](backend/fleet/README.md)、[`docs/api/api.md`](docs/api/api.md)、[`docs/security/enrollment.md`](docs/security/enrollment.md)。
|
||||
|
||||
## 如何构建
|
||||
|
||||
@@ -80,7 +89,7 @@ dotnet build MoShiro.slnx -c Release # Backend、Agent、Probe、Fleet
|
||||
npm ci --prefix web && npm run build --prefix web # 展示端和管理端,产物在 web/display/dist、web/admin/dist
|
||||
```
|
||||
|
||||
本地运行:先在 `backend/` 里 `dotnet run --project src/MoShiro.Backend -- --urls http://localhost:5000`,再 `npm run dev --prefix web`,展示端是 <http://localhost:5173/>,管理端是 <http://localhost:5173/admin/>。
|
||||
本地运行:先在 `backend/` 里 `dotnet run --project src/MoShiro.Backend -- --urls http://localhost:5000`,再 `npm run dev --prefix web`,展示端是 <http://localhost:5173/>,管理端是 <http://localhost:5173/admin/>。第一次打开管理端会进认领向导,认领码在后端日志里(或用 `MoShiro__ClaimCode` 指定)。
|
||||
|
||||
测试和检查:
|
||||
|
||||
@@ -90,20 +99,19 @@ npm run lint:css --prefix web # 前端样式(颜色只能写
|
||||
npm run gen:api --prefix web # 后端改了接口之后重新生成前端类型
|
||||
```
|
||||
|
||||
Backend、Agent、Probe 以 OCI 镜像发布(Linux amd64、arm64),Agent 和 Probe 另有 Linux glibc/musl、macOS、Windows 的二进制包。NativeAOT 的构建细节、CI 触发规则(`[CI]`、`[SKIP TESTS]`、版本 tag)、镜像路径、容器节点的运行和更新见 [`docs/deployment.md`](docs/deployment.md);前端见 [`web/README.md`](web/README.md),后端见 [`backend/README.md`](backend/README.md)。
|
||||
Backend、Agent、Probe 以 OCI 镜像发布(Linux amd64、arm64),Agent 和 Probe 另有 Linux glibc/musl、macOS、Windows 的二进制包。NativeAOT 的构建细节、CI 触发规则(`[CI]`、`[SKIP TESTS]`、版本 tag)、镜像路径、容器节点的运行和更新见 [`docs/ops/deployment.md`](docs/ops/deployment.md);前端见 [`web/README.md`](web/README.md),后端见 [`backend/README.md`](backend/README.md)。
|
||||
|
||||
## 文档
|
||||
|
||||
| 文档 | 内容 |
|
||||
| --- | --- |
|
||||
| [`docs/architecture.md`](docs/architecture.md) | 架构、数据链路、安全边界、设计决策 |
|
||||
| [`docs/protocol.md`](docs/protocol.md)、[`backend/fleet/README.md`](backend/fleet/README.md) | 线协议、Fleet 节点框架(身份、注册、连接) |
|
||||
| [`docs/enrollment.md`](docs/enrollment.md) | 注册、身份、Probe 与 Agent 的接入 |
|
||||
| [`docs/measurement.md`](docs/measurement.md)、[`docs/analysis.md`](docs/analysis.md) | 采集引擎与任务模型、分析层与指标 |
|
||||
| [`docs/storage.md`](docs/storage.md)、[`docs/query.md`](docs/query.md) | 存储、展示数据查询 |
|
||||
| [`docs/api.md`](docs/api.md)、[`docs/api-guidelines.md`](docs/api-guidelines.md) | API 规范与约束 |
|
||||
| [`docs/deployment.md`](docs/deployment.md) | 构建、发布、CI、部署与更新 |
|
||||
| [`docs/project-layout.md`](docs/project-layout.md)、[`docs/coding-style.md`](docs/coding-style.md) | 仓库结构与依赖规则、代码风格 |
|
||||
| [`docs/README.md`](docs/README.md) | 文档索引,按类别组织 |
|
||||
| [`docs/design/`](docs/design/architecture.md) | 设计:架构与决策、线协议、存储与查询、采集与分析 |
|
||||
| [`docs/security/`](docs/security/security.md) | 安全:零信任与威胁模型、访问控制与入网信任链、注册与身份 |
|
||||
| [`docs/api/`](docs/api/api.md) | 接口:端点说明、API 规范 |
|
||||
| [`docs/ops/deployment.md`](docs/ops/deployment.md) | 运维:构建、发布、CI、部署与更新 |
|
||||
| [`docs/dev/`](docs/dev/project-layout.md) | 开发:仓库结构与依赖规则、代码风格 |
|
||||
| [`backend/fleet/README.md`](backend/fleet/README.md) | Fleet 节点框架(身份、注册、连接) |
|
||||
|
||||
代码目录:`backend/`(Backend、`fleet/` 节点框架、`shared/` 测量引擎和协议、`agent/`、`probe/`、`tests/`)、`web/`(`display` 展示端、`admin` 管理端、`shared`)、`packaging/` 和 `scripts/`(部署与维护)、`ci/` 和 `.woodpecker/`(流水线)。
|
||||
|
||||
|
||||
@@ -5,19 +5,14 @@
|
||||
## 测量
|
||||
|
||||
- 路由跳数、路径漂移和 trace
|
||||
- 握手耗时:TCP 连接耗时,TLS / QUIC 握手耗时(QUIC 目前没有真实握手积木)
|
||||
- QUIC 握手耗时:TCP 建连和 TLS 握手已经有了(`connect` 任务)。.NET 的 QUIC 依赖系统里的 msquic 原生库,和节点「只依赖 OpenSSL、NativeAOT 单文件」冲突,要做只能自己写最小的 QUIC Initial 握手探测;另外 .NET 还没有 QUIC datagram([dotnet/runtime#123418](https://github.com/dotnet/runtime/issues/123418) 只是提案),`System.Net.Quic` 只有可靠的流,重传会盖住丢包,测不了 UDP 丢包率
|
||||
- 网页长截图
|
||||
- pcap / eBPF 采集
|
||||
|
||||
## 任务与面板
|
||||
|
||||
- 单个 Agent 的任务(目前是线路模板加按 Agent 的覆盖,没有只属于某一个 Agent 的独立任务)
|
||||
- 原生 webhook 投递;告警(基于 webhook)
|
||||
- Agent 和 Probe 的定时重启(可选,例如早上四点)
|
||||
|
||||
## 部署
|
||||
|
||||
- 后端自动下载前端资源:用 Podman、Docker 部署时,默认拉取最新的前端资源(现在前端要自己构建,镜像里没有)。
|
||||
- 原生 webhook 投递;告警(基于 webhook)。实现之前先定约束(只允许 HTTPS、禁止私网和回环、不跟随重定向、限制超时和响应大小、解析后再检查地址),见 [`docs/security/security.md`](docs/security/security.md) 第 9.2 节
|
||||
|
||||
## 访客页面
|
||||
|
||||
@@ -35,11 +30,11 @@
|
||||
- 流程:下载 → 校验 → 解包到 `versions/<v>/` → 原子切换 `current`(Linux 符号链接,Windows 目录联结)→ 重启服务 → 等健康信标确认 → 失败切回旧版本并把该版本标记为坏版本。
|
||||
- 权限分离:更新进程只能写安装目录和重启被管理的服务,读不到节点身份私钥。
|
||||
- OpenWrt:`keep_versions = 1`,下载前检查闪存剩余空间。
|
||||
- 容器节点已经实现的那一套(清单签名、`seq` 防重放、按 digest 拉取、回滚)见 [`docs/deployment.md`](docs/deployment.md)。
|
||||
- 容器节点已经实现的那一套(清单签名、`seq` 防重放、按 digest 拉取、回滚)见 [`docs/ops/deployment.md`](docs/ops/deployment.md)。
|
||||
|
||||
## 安全
|
||||
|
||||
- **Agent、Backend、Probe 的零信任设计**:目前 Backend 是三者之间的信任中心,需要梳理哪些信任可以去掉。
|
||||
- **零信任**:设计和各缺口的状态在 [`docs/security/security.md`](docs/security/security.md)。已完成:协议方向声明表、Probe 自己生成密钥、Agent 预握手敲门、操作员集合与创世固定、服务器身份和安全链路、属主固定。还没做:测量数据的产生者签名和归属校验、入库配额、Probe 只拨公网地址、管理端 CSP 之外的输入校验、extension 框架(敏感能力都放进可选安装的独立 extension,核心保持 NativeAOT)、吊销表;可选的 UDP 单包授权、UDP 会话迁移的路径验证。
|
||||
- **extension**(Web SSH、TTY、更新遥测,敏感 operator):设计时必须保住「节点是纯采集器」这条边界,见 [`README.md`](README.md) 的项目边界。
|
||||
- 对 UDP 包、控制帧、RawChunk、JSON-RPC 做模糊测试;伪造签名和重放的专项测试。
|
||||
|
||||
|
||||
@@ -13,11 +13,17 @@
|
||||
<UseArtifactsOutput>true</UseArtifactsOutput>
|
||||
<ArtifactsPath>$(MSBuildThisFileDirectory)artifacts</ArtifactsPath>
|
||||
<Deterministic>true</Deterministic>
|
||||
<Version>0.1.1</Version>
|
||||
<Version>0.0.0-dev</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>
|
||||
+3
-3
@@ -36,7 +36,7 @@ dotnet test --solution MoShiro.slnx
|
||||
dotnet publish src/MoShiro.Backend -c Release -r <RID> -p:PublishAot=true
|
||||
```
|
||||
|
||||
例如 `<RID>` 可为 `win-x64`、`linux-x64` 或 `linux-arm64`。NativeAOT 发布需要对应平台的 C/C++ 工具链。仓库根目录的 `scripts/publish.sh backend <RID>` 可构建单个平台的 Backend 可执行文件;容器发布由 Woodpecker tag 流水线完成,详见 [`../docs/deployment.md`](../docs/deployment.md)。
|
||||
例如 `<RID>` 可为 `win-x64`、`linux-x64` 或 `linux-arm64`。NativeAOT 发布需要对应平台的 C/C++ 工具链。仓库根目录的 `scripts/publish.sh backend <RID>` 可构建单个平台的 Backend 可执行文件;容器发布由 Woodpecker tag 流水线完成,详见 [`../docs/ops/deployment.md`](../docs/ops/deployment.md)。
|
||||
|
||||
正式发布通过 Woodpecker tag 流水线生成 Linux/amd64 与 Linux/arm64 OCI 镜像,容器内监听 `8080`,数据写入挂载的 `/data` 卷。Agent 与 Probe 的多平台二进制包单独发布,不包含在 Backend 镜像中。
|
||||
|
||||
@@ -49,6 +49,6 @@ cd backend
|
||||
dotnet run --project src/MoShiro.Backend -- --urls http://localhost:5000
|
||||
```
|
||||
|
||||
首次启动时可用 `MoShiro__InitialAdminUser` 和 `MoShiro__InitialAdminPassword` 设置初始管理员。数据目录、公开地址、Cookie 和 CORS 等设置通过 `MoShiro` 配置节传入;完整配置说明见 [`../docs/deployment.md`](../docs/deployment.md)。站点默认需要登录,可在管理端修改公开设置。
|
||||
没有默认管理员:第一次打开面板时按页面引导自己创建管理员、注册 passkey、建立信任根,页面要的认领码写在后端日志里(或用 `MoShiro__ClaimCode` 指定)。`MoShiro__InitialAdminUser` / `MoShiro__InitialAdminPassword` 只给自动化用。数据目录、公开地址、Cookie 和 CORS 等设置通过 `MoShiro` 配置节传入;完整配置说明见 [`../docs/ops/deployment.md`](../docs/ops/deployment.md)。站点默认需要登录,可在管理端修改公开设置。
|
||||
|
||||
OpenAPI 文档由构建生成到 `openapi/v1.json`。API 设计和端点说明见 [`../docs/api.md`](../docs/api.md);项目总览见仓库根目录的 [`README.md`](../README.md)。
|
||||
OpenAPI 文档由构建生成到 `openapi/v1.json`。API 设计和端点说明见 [`../docs/api/api.md`](../docs/api/api.md);项目总览见仓库根目录的 [`README.md`](../README.md)。
|
||||
@@ -7,7 +7,7 @@ Agent 仅开放配置的控制和数据端口。它不提供远程终端、命
|
||||
## 工作流程
|
||||
|
||||
```text
|
||||
Probe ──TLS 控制 / UDP、TCP 测量──> Agent ──长连接与数据上报──> Backend
|
||||
Probe ──TLS 控制 / QUIC Datagram、TCP 测量──> Agent ──长连接与数据上报──> Backend
|
||||
▲
|
||||
fleet-enroll 注册
|
||||
```
|
||||
@@ -29,7 +29,7 @@ dotnet publish agent/src/MoShiro.Agent -c Release -r <RID> -p:PublishAot=true
|
||||
dotnet publish fleet/tools/fleet-enroll -c Release -r <RID> -p:PublishAot=true
|
||||
```
|
||||
|
||||
也可在仓库根目录运行 `bash scripts/publish.sh agent <RID>` 和 `bash scripts/publish.sh fleet-enroll <RID>`。部署包和平台说明见 [`../../docs/deployment.md`](../../docs/deployment.md)。
|
||||
也可在仓库根目录运行 `bash scripts/publish.sh agent <RID>` 和 `bash scripts/publish.sh fleet-enroll <RID>`。部署包和平台说明见 [`../../docs/ops/deployment.md`](../../docs/ops/deployment.md)。
|
||||
|
||||
## 注册与运行
|
||||
|
||||
@@ -47,7 +47,7 @@ fleet-enroll --server https://<backend> --join <token> --name <agent-name> --ide
|
||||
moshiro-agent --config /etc/moshiro/agent.json
|
||||
```
|
||||
|
||||
容器节点的运行方式(podman + systemd)和宿主机更新器见 [`../../docs/deployment.md`](../../docs/deployment.md)。也可使用 OCI 镜像 `registry.oeo.one/moshiro/moshiro-agent:<version>`,自动选择 Linux amd64 或 arm64 镜像。镜像同时包含 `fleet-enroll`;可通过 `--entrypoint /usr/local/bin/fleet-enroll` 执行首次注册。容器默认读取命名 volume 中的 `/etc/moshiro/agent.json` 与身份文件;建议使用宿主网络,Linux ICMP 优先使用普通用户 ping socket,内核 ping_group_range 需允许映射后的 GID。镜像以 UID/GID 1654 运行,新命名 volume 由引擎按镜像目录初始化权限。
|
||||
容器节点的运行方式(podman + systemd)和宿主机更新器见 [`../../docs/ops/deployment.md`](../../docs/ops/deployment.md)。也可使用 OCI 镜像 `registry.oeo.one/moshiro/moshiro-agent:<version>`,自动选择 Linux amd64 或 arm64 镜像。镜像同时包含 `fleet-enroll`;可通过 `--entrypoint /usr/local/bin/fleet-enroll` 执行首次注册。容器默认读取命名 volume 中的 `/etc/moshiro/agent.json` 与身份文件;建议使用宿主网络,Linux ICMP 优先使用普通用户 ping socket,内核 ping_group_range 需允许映射后的 GID。镜像以 UID/GID 1654 运行,新命名 volume 由引擎按镜像目录初始化权限。
|
||||
|
||||
不传 `--config` 时默认读取 `/etc/moshiro/agent.json`。Agent 默认监听控制端口 `7443/tcp`,数据端口 `7443/udp` 和 `7444/tcp`;防火墙和面板中的节点端口配置需与本地配置一致。`reporting.host_stats` 可关闭主机状态上报;启用时只上报资源使用等汇总数据,不包含主机名、进程或 IP。
|
||||
|
||||
@@ -58,6 +58,6 @@ IPv6 线路需要 Agent 实际绑定 IPv6。新配置的默认 `listen.bind` 为
|
||||
- 每个 Probe 使用独立的 TLS 会话和数据密钥;Probe 断开时对应的会话资源会释放。
|
||||
- 数据面会校验会话认证、重放窗口和速率限制;无效数据包会静默丢弃。
|
||||
- 测量引擎和协议由 `backend/shared/` 提供;节点身份、注册和连接由 `backend/fleet/` 提供。
|
||||
- 详细安全边界与拓扑见 [`../../docs/architecture.md`](../../docs/architecture.md),协议说明见 [`../../docs/protocol.md`](../../docs/protocol.md),部署见 [`../../docs/deployment.md`](../../docs/deployment.md)。
|
||||
- 详细安全边界与拓扑见 [`../../docs/design/architecture.md`](../../docs/design/architecture.md),协议说明见 [`../../docs/design/protocol.md`](../../docs/design/protocol.md),部署见 [`../../docs/ops/deployment.md`](../../docs/ops/deployment.md)。
|
||||
|
||||
Docker 与 rootless/rootful Podman 共用 `packaging/compose/agent/compose.yaml`,配置、身份和缓存放在命名 volume 中,无需宿主配置目录、keep-id 或手动 chown。首次导入和自动更新见部署文档。
|
||||
Podman 系用 Quadlet(`packaging/quadlet/moshiro-agent.container`,默认自动更新),Docker 用 `packaging/compose/agent/compose.yaml`(手动更新);配置、身份和缓存放在命名 volume 中,无需宿主配置目录、keep-id 或手动 chown。首次导入和自动更新见部署文档。
|
||||
@@ -1,3 +1,4 @@
|
||||
using Fleet.Identity;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Fleet.Core;
|
||||
@@ -10,12 +11,16 @@ using MoShiro.Protocol;
|
||||
namespace MoShiro.Agent;
|
||||
|
||||
/// <summary>Agent 的组装点:数据面、点对点监听、与 Backend 的链路都在这里接起来。没有全局状态,测试可以在同一进程里启动多个。</summary>
|
||||
public sealed class AgentApp : IDisposable
|
||||
public sealed class AgentApp : IDisposable, IAsyncDisposable
|
||||
{
|
||||
public const string Version = MoShiro.Protocol.BuildInfo.Version;
|
||||
|
||||
private readonly AgentConfig _config;
|
||||
private readonly bool _ipv6Supported;
|
||||
private readonly KnockGate _knock;
|
||||
private readonly TrustAnchor _trust;
|
||||
private readonly RestartController _restart;
|
||||
private readonly RestartController? _ownedRestart;
|
||||
private readonly LoadedNodeIdentity _identity;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly INodeLog _log;
|
||||
@@ -26,20 +31,23 @@ public sealed class AgentApp : IDisposable
|
||||
private readonly TaskCompletionSource _ready = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly AgentAdmission _admission;
|
||||
private readonly Outbox _outbox = new(64 << 20);
|
||||
private readonly UpdateBridge _update;
|
||||
private readonly RestartUpdater? _update;
|
||||
private readonly BackendUplink _uplink;
|
||||
private readonly UdpMux _udp;
|
||||
private readonly QuicDatagramTransport _datagrams;
|
||||
private readonly TcpAcceptor _tcp;
|
||||
private readonly PeerListener _peers;
|
||||
private readonly PeerHub _hub;
|
||||
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)
|
||||
public AgentApp(AgentConfig config, LoadedNodeIdentity identity, string dataDirectory, TimeProvider time, INodeLog log, RestartController? restart = null)
|
||||
{
|
||||
_update = new UpdateBridge(dataDirectory, new ConfigStore(dataDirectory).Write, log);
|
||||
_ownedRestart = restart is null ? new RestartController() : null;
|
||||
_restart = restart ?? _ownedRestart!;
|
||||
_update = RestartUpdater.FromEnvironment("agent", _restart.RequestNow, time, log);
|
||||
_config = config;
|
||||
_identity = identity;
|
||||
_time = time;
|
||||
@@ -55,8 +63,11 @@ public sealed class AgentApp : IDisposable
|
||||
|
||||
_ipv6Supported = bind.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6;
|
||||
_admission = new AgentAdmission(_streams);
|
||||
_udp = new UdpMux(_sessions, _admission, _clock, _counters.Data, log, bind, config.Listen.UdpPorts);
|
||||
_tcp = new TcpAcceptor(bind, config.Listen.TcpPorts, identity.Certificate, _sessions, _counters.Data);
|
||||
_datagrams = new QuicDatagramTransport(_sessions, _admission, _clock, _counters.Data, log, bind, config.Listen.EffectiveQuicPorts, identity.Certificate);
|
||||
// 预握手敲门:控制端口和数据端口共用一个关卡(限速和去重一起算);名册就是期望状态里的 peers
|
||||
_trust = TrustAnchor.Load(config.Fleet.IdentityDir, log);
|
||||
_knock = new KnockGate(identity.PeerIdentity, RosterKeys, time);
|
||||
_tcp = new TcpAcceptor(bind, config.Listen.TcpPorts, identity.Certificate, _sessions, _counters.Data, _knock);
|
||||
|
||||
// 监听方积木的环境:和 Probe 的拨号方共用同一套积木,区别只在这里装配的部分(准入策略、TCP 监听)
|
||||
var env = new BlockEnv
|
||||
@@ -64,7 +75,7 @@ public sealed class AgentApp : IDisposable
|
||||
Clock = _clock,
|
||||
Log = log,
|
||||
Session = () => null, // 每个 Probe 会话各用自己绑定的环境(BlockEnv.WithSession)
|
||||
Udp = _udp,
|
||||
Datagrams = _datagrams,
|
||||
TcpListen = _tcp,
|
||||
Admission = _admission,
|
||||
Counters = _counters.Data,
|
||||
@@ -73,14 +84,14 @@ public sealed class AgentApp : IDisposable
|
||||
};
|
||||
var chunks = new ChunkAssembler(_streams, _clock, _counters, _outbox, identity.NodeId, time, log);
|
||||
var targets = new TargetRunners(chunks, identity.NodeId, _clock, time, log);
|
||||
_tasks = new TaskManager(CreateGuard, _streams, env, targets, () => _udp.BoundPorts, () => _tcp.BoundPorts);
|
||||
_tasks = new TaskManager(CreateGuard, _streams, env, targets, () => _datagrams.BoundPorts, () => _tcp.BoundPorts, _trust);
|
||||
_hub = new PeerHub(_sessions, _clock, chunks, _tasks, _uplink, time, log);
|
||||
_tests = new TestRunner(_tasks, _sessions, _streams, env, _uplink, config.Limits.MaxConcurrentTests, transport => transport == "udp" ? _udp.BoundPorts : _tcp.BoundPorts, time, log);
|
||||
_tests = new TestRunner(_tasks, _sessions, _streams, env, _uplink, config.Limits.MaxConcurrentTests, transport => transport == "quic_datagram" ? _datagrams.BoundPorts : _tcp.BoundPorts, time, log);
|
||||
|
||||
_tasks.OnLineTasksChanged = (probe, set) => _ = _hub.PushToAsync(probe, set).AsTask();
|
||||
_tasks.OnPeersChanged = _hub.CloseNotIn;
|
||||
_hub.OnProbeLost = probe => _tests.ProbeLost(probe);
|
||||
_admission.OnEndpointChanged = probe => _ = _uplink.EmitEventAsync(AgentEventKinds.EndpointChanged, probe.UdpEndpoint?.ToString()).AsTask();
|
||||
_admission.OnEndpointChanged = probe => _ = _uplink.EmitEventAsync(AgentEventKinds.EndpointChanged, probe.DatagramRemoteEndPoint?.ToString()).AsTask();
|
||||
|
||||
_peers = new PeerListener(
|
||||
new PeerListenerOptions
|
||||
@@ -88,6 +99,7 @@ public sealed class AgentApp : IDisposable
|
||||
Listen = new IPEndPoint(bind, config.Listen.ControlPort),
|
||||
Certificate = identity.Certificate,
|
||||
LookupPeerKey = _tasks.LookupPeerKey,
|
||||
Knock = _knock,
|
||||
},
|
||||
_hub.OnSessionAsync,
|
||||
log,
|
||||
@@ -100,20 +112,46 @@ public sealed class AgentApp : IDisposable
|
||||
/// <summary>数据面和控制端口都开始监听之后完成(配置里的端口为 0 时,这里能读到实际端口)。</summary>
|
||||
public Task Ready => _ready.Task;
|
||||
|
||||
public void Dispose() => _udp.Dispose();
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_datagrams.Dispose();
|
||||
_ownedRestart?.Dispose();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _datagrams.DisposeAsync().ConfigureAwait(false);
|
||||
_ownedRestart?.Dispose();
|
||||
}
|
||||
|
||||
internal StreamRegistry Streams => _streams;
|
||||
|
||||
public IReadOnlyList<int> UdpPorts => _udp.BoundPorts;
|
||||
public IReadOnlyList<int> QuicPorts => _datagrams.BoundPorts;
|
||||
|
||||
public IReadOnlyList<int> TcpPorts => _tcp.BoundPorts;
|
||||
|
||||
public int ControlPort => _peers.Port;
|
||||
|
||||
/// <summary>节点当前采用的操作员集合版本(没有固定信任根时是 0)。</summary>
|
||||
public int TrustVersion => _trust.Version;
|
||||
|
||||
public AgentCounters Counters => _counters;
|
||||
|
||||
public FleetLink Link => _link;
|
||||
|
||||
/// <summary>对等面(Probe 连进来的会话)的路由器,测试用。</summary>
|
||||
public RpcRouter<PeerContext> PeerRouter => _hub.Router;
|
||||
|
||||
/// <summary>期望状态里是否已经有这个 Probe 的公钥(测试用:握手会按它校验)。</summary>
|
||||
public bool IsPeerKnown(string probeId, string publicKeyBase64) =>
|
||||
_tasks.LookupPeerKey(probeId) is { } key && Convert.ToBase64String(key) == publicKeyBase64;
|
||||
@@ -122,25 +160,26 @@ public sealed class AgentApp : IDisposable
|
||||
|
||||
public async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
_log.Info("moshiro agent starting", MoShiro.Protocol.BuildInfo.Fields);
|
||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
// 数据面和控制端口先开始监听,再连接 Backend:hello 里上报的是实际端口
|
||||
_udp.Bind();
|
||||
_datagrams.Bind();
|
||||
_tcp.Bind();
|
||||
Task udp = _udp.RunAsync(linked.Token);
|
||||
Task quic = _datagrams.RunAsync(linked.Token);
|
||||
Task tcp = _tcp.RunAsync(linked.Token);
|
||||
Task peers = _peers.RunAsync(linked.Token);
|
||||
await Task.WhenAny(_peers.Started.Task, peers);
|
||||
if (peers.IsFaulted)
|
||||
{
|
||||
await linked.CancelAsync();
|
||||
await Task.WhenAll(udp, tcp, peers); // 抛出监听失败的原因(端口被占用等)
|
||||
await Task.WhenAll(quic, tcp, peers); // 抛出监听失败的原因(端口被占用等)
|
||||
}
|
||||
|
||||
_ready.TrySetResult();
|
||||
_log.Info("agent listening", ("control", ControlPort), ("udp", string.Join(',', UdpPorts)), ("tcp", string.Join(',', TcpPorts)));
|
||||
_log.Info("agent listening", ("control", ControlPort), ("quic", string.Join(',', QuicPorts)), ("tcp", string.Join(',', TcpPorts)));
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(udp, tcp, peers, _link.RunAsync(linked.Token), ReportHostAsync(linked.Token));
|
||||
await Task.WhenAll(quic, tcp, peers, _link.RunAsync(linked.Token), ReportHostAsync(linked.Token));
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -181,14 +220,14 @@ public sealed class AgentApp : IDisposable
|
||||
MaxTotalPps = _config.Limits.MaxTotalPps,
|
||||
AllowedTargetCidrs = _config.Limits.AllowedTargetCidrs,
|
||||
MaxConcurrentTests = _config.Limits.MaxConcurrentTests,
|
||||
AllowedUdpPorts = _udp.BoundPorts,
|
||||
AllowedQuicPorts = _datagrams.BoundPorts,
|
||||
AllowedTcpPorts = _tcp.BoundPorts,
|
||||
},
|
||||
BlockCatalog.AgentKinds);
|
||||
|
||||
private FleetLink BuildLink()
|
||||
{
|
||||
var router = new RpcRouter<LinkContext>();
|
||||
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);
|
||||
|
||||
@@ -214,7 +253,20 @@ public sealed class AgentApp : IDisposable
|
||||
{
|
||||
router.OnNotify(MoShiroMethods.UpdateOffer, MoShiroJsonContext.Default.UpdateOfferParams, (_, offer, _) =>
|
||||
{
|
||||
_update.WriteOffer(offer);
|
||||
_update?.Offer(offer);
|
||||
return ValueTask.CompletedTask;
|
||||
});
|
||||
}
|
||||
|
||||
if (Allow(MoShiroMethods.Schedule))
|
||||
{
|
||||
router.OnNotify(MoShiroMethods.Schedule, MoShiroJsonContext.Default.ScheduleParams, (_, schedule, _) =>
|
||||
{
|
||||
if (!_restart.SetRemote(schedule.RestartAt))
|
||||
{
|
||||
_log.Warn("ignoring an invalid restart schedule from the panel");
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
});
|
||||
}
|
||||
@@ -223,7 +275,7 @@ public sealed class AgentApp : IDisposable
|
||||
{
|
||||
router.OnNotify(MoShiroMethods.UpdateApply, MoShiroJsonContext.Default.UpdateOfferParams, (_, _, _) =>
|
||||
{
|
||||
_update.RequestApply();
|
||||
_update?.RequestApply();
|
||||
return ValueTask.CompletedTask;
|
||||
});
|
||||
}
|
||||
@@ -241,11 +293,13 @@ public sealed class AgentApp : IDisposable
|
||||
{
|
||||
Server = new Uri(_config.Fleet.Server),
|
||||
Identity = _identity.Identity,
|
||||
ServerVerifier = _trust,
|
||||
NodeId = _identity.NodeId,
|
||||
NodeVersion = Version,
|
||||
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)], _outbox, _time, _log);
|
||||
|
||||
@@ -260,16 +314,22 @@ public sealed class AgentApp : IDisposable
|
||||
return link;
|
||||
}
|
||||
|
||||
/// <summary>敲门关卡的名册:期望状态里的 peers(任务管理器在构造之后才有)。</summary>
|
||||
private IReadOnlyList<byte[]> RosterKeys() => _tasks.PeerKeys();
|
||||
|
||||
private JsonElement? HelloMeta()
|
||||
{
|
||||
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(),
|
||||
TlsSpki = _identity.File.TlsSpki,
|
||||
TlsPublicKey = Convert.ToBase64String(_identity.Certificate.PublicKey.ExportSubjectPublicKeyInfo()),
|
||||
ProbeTcpPort = ControlPort,
|
||||
ProbeUdpPorts = [.. UdpPorts],
|
||||
ProbeQuicPorts = [.. QuicPorts],
|
||||
LegacyProbeUdpPorts = [.. QuicPorts],
|
||||
LocalLimits = new LocalLimits
|
||||
{
|
||||
MaxTotalMbps = _config.Limits.MaxTotalMbps,
|
||||
@@ -293,6 +353,7 @@ public sealed class AgentApp : IDisposable
|
||||
long packetsOut = _counters.PacketsOut;
|
||||
var extra = new AgentHeartbeatExtra
|
||||
{
|
||||
RuntimeStatus = _update?.Status().State == "applying" ? NodeRuntimeStatus.Updating : NodeRuntimeStatus.Running,
|
||||
Ipv6 = Ipv6Diagnostics.Sample(_ipv6Supported, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")),
|
||||
Probes = [.. _hub.ConnectedProbes()],
|
||||
Sessions = _sessions.All.Count,
|
||||
@@ -301,7 +362,7 @@ public sealed class AgentApp : IDisposable
|
||||
DroppedInvalid = _counters.DroppedInvalid,
|
||||
DroppedRateLimited = _counters.DroppedRateLimited,
|
||||
QueueBytes = _outbox.PendingBytes,
|
||||
Update = _update.ReadStatus(),
|
||||
Update = _update?.Status(),
|
||||
};
|
||||
_lastIn = packetsIn;
|
||||
_lastOut = packetsOut;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using MoShiro.Measurement;
|
||||
|
||||
namespace MoShiro.Agent;
|
||||
|
||||
@@ -22,16 +23,10 @@ public sealed class AgentConfig
|
||||
|
||||
[JsonPropertyName("reporting")]
|
||||
public ReportingSection Reporting { get; set; } = new();
|
||||
}
|
||||
|
||||
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;
|
||||
/// <summary>每天定时重启一次,例如 <c>04:00+08:00</c>(不写偏移按 UTC);不写就不重启。环境变量 <c>MOSHIRO_RESTART_AT</c> 优先。</summary>
|
||||
[JsonPropertyName("restart_at")]
|
||||
public string? RestartAt { get; set; }
|
||||
}
|
||||
|
||||
public sealed class FleetSection
|
||||
@@ -41,6 +36,10 @@ public sealed class FleetSection
|
||||
|
||||
[JsonPropertyName("identity_dir")]
|
||||
public string IdentityDir { get; set; } = "/etc/moshiro";
|
||||
|
||||
/// <summary>节点自己要求有属主(passkey):没有就不运行。见 <c>OwnerGuard</c>。</summary>
|
||||
[JsonPropertyName("require_owner")]
|
||||
public bool RequireOwner { get; set; } = true;
|
||||
}
|
||||
|
||||
public sealed class ListenSection
|
||||
@@ -52,8 +51,15 @@ public sealed class ListenSection
|
||||
[JsonPropertyName("control_port")]
|
||||
public int ControlPort { get; set; } = 7443;
|
||||
|
||||
[JsonPropertyName("quic_ports")]
|
||||
public List<int> QuicPorts { get; set; } = [7443];
|
||||
|
||||
/// <summary>Legacy config key accepted while deployments migrate to QUIC Datagram.</summary>
|
||||
[JsonPropertyName("udp_ports")]
|
||||
public List<int> UdpPorts { get; set; } = [7443];
|
||||
public List<int>? LegacyUdpPorts { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public IReadOnlyList<int> EffectiveQuicPorts => LegacyUdpPorts ?? QuicPorts;
|
||||
|
||||
[JsonPropertyName("tcp_ports")]
|
||||
public List<int> TcpPorts { get; set; } = [7444];
|
||||
|
||||
@@ -44,7 +44,7 @@ public sealed class ProbeSession : DataSession
|
||||
{
|
||||
private readonly ConcurrentDictionary<uint, ReplayWindow> _windows = new();
|
||||
private readonly ConcurrentDictionary<ulong, byte> _nonces = new();
|
||||
private SocketAddress? _udpSource;
|
||||
private SocketAddress? _datagramSource;
|
||||
|
||||
public ProbeSession(PeerSessionInfo info)
|
||||
: base(info.Credentials.SessionIdValue, info.Credentials.Key)
|
||||
@@ -71,11 +71,11 @@ public sealed class ProbeSession : DataSession
|
||||
public RpcSession<PeerContext>? Rpc { get; set; }
|
||||
|
||||
/// <summary>最近一次认证通过的 UDP 包的源地址(Agent 把单向数据发往这里)。</summary>
|
||||
public override IPEndPoint? UdpEndpoint
|
||||
public override IPEndPoint? DatagramRemoteEndPoint
|
||||
{
|
||||
get
|
||||
{
|
||||
SocketAddress? source = Volatile.Read(ref _udpSource);
|
||||
SocketAddress? source = Volatile.Read(ref _datagramSource);
|
||||
return source is null ? null : (IPEndPoint)new IPEndPoint(source.Family == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any, 0).Create(source);
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,7 @@ public sealed class ProbeSession : DataSession
|
||||
/// <summary>记录 UDP 源地址;NAT 映射变化后地址变了返回 true(上报 endpoint_changed)。第一次看到地址不算变化。</summary>
|
||||
public bool UpdateSource(SocketAddress source)
|
||||
{
|
||||
SocketAddress? previous = Volatile.Read(ref _udpSource);
|
||||
SocketAddress? previous = Volatile.Read(ref _datagramSource);
|
||||
if (previous is not null && previous.Equals(source))
|
||||
{
|
||||
return false;
|
||||
@@ -96,7 +96,7 @@ public sealed class ProbeSession : DataSession
|
||||
copy[i] = source[i];
|
||||
}
|
||||
|
||||
Volatile.Write(ref _udpSource, copy);
|
||||
Volatile.Write(ref _datagramSource, copy);
|
||||
return previous is not null;
|
||||
}
|
||||
|
||||
@@ -312,7 +312,7 @@ public sealed class AgentAdmission : IAdmission
|
||||
/// <summary>NAT 映射变化,源地址变了:上报 <c>endpoint_changed</c>。</summary>
|
||||
public Action<ProbeSession>? OnEndpointChanged { get; set; }
|
||||
|
||||
public Admission AdmitPacket(DataSession session, in UdpHeader header, SocketAddress source, long nowUs)
|
||||
public Admission AdmitDatagram(DataSession session, in MeasurementDatagramHeader header, SocketAddress source, long nowUs)
|
||||
{
|
||||
if (session is not ProbeSession probe || !_streams.TryGet(header.StreamId, out StreamInfo? stream) || !ReferenceEquals(stream.Owner, probe) || !probe.AcceptSequence(header.StreamId, header.Seq))
|
||||
{
|
||||
|
||||
@@ -78,8 +78,9 @@ public sealed class PeerHub
|
||||
{
|
||||
private const int DedupeWindow = 1024;
|
||||
|
||||
/// <summary>超过这么久没有收到 Probe 的任何帧就认为会话已死(Probe 每 30 秒至少发一次时钟同步)。</summary>
|
||||
private static readonly TimeSpan IdleLimit = TimeSpan.FromSeconds(75);
|
||||
/// <summary>超过这么久没有收到 Probe 的任何帧就认为会话已死(Probe 每 30 秒至少发一次时钟同步)。
|
||||
/// 要比线路可能的中断长得多:差的线路上几十秒甚至一两分钟的黑洞时有发生(TCP RTT 能到 30 秒),75 秒就关会话会把一次中断变成几分钟的数据空白(要重新握手,而握手在差线路上也慢)。</summary>
|
||||
private static readonly TimeSpan IdleLimit = TimeSpan.FromSeconds(180);
|
||||
|
||||
private readonly RpcRouter<PeerContext> _router;
|
||||
private readonly SessionRegistry _sessions;
|
||||
@@ -90,6 +91,9 @@ public sealed class PeerHub
|
||||
private readonly TimeProvider _time;
|
||||
private readonly INodeLog _log;
|
||||
|
||||
/// <summary>对等面入站方法的路由器(只读,测试用)。</summary>
|
||||
public RpcRouter<PeerContext> Router => _router;
|
||||
|
||||
public PeerHub(SessionRegistry sessions, MonoClock clock, ChunkAssembler chunks, TaskManager tasks, BackendUplink uplink, TimeProvider time, INodeLog log)
|
||||
{
|
||||
_router = BuildRouter();
|
||||
@@ -210,8 +214,6 @@ public sealed class PeerHub
|
||||
|
||||
private static ConnectedProbe Describe(ProbeSession p) => new() { Id = new ProbeId(p.ProbeId), Version = p.Version, Remote = p.RemoteAddress, SinceUtcMs = p.ConnectedAtUtcMs, IpVersion = p.IpVersion };
|
||||
|
||||
public IReadOnlyList<string> ConnectedProbeIds() => [.. _sessions.All.Select(p => p.ProbeId)];
|
||||
|
||||
/// <summary>向一个 Probe 下发它的线路任务集(Probe 连上时由 <see cref="OnSessionAsync"/> 先发一份)。</summary>
|
||||
public async ValueTask PushToAsync(ProbeSession probe, LineTaskSet set) => await PushAsync(probe, set, CancellationToken.None);
|
||||
|
||||
@@ -272,7 +274,7 @@ public sealed class PeerHub
|
||||
}
|
||||
|
||||
private static RpcRouter<PeerContext> BuildRouter() =>
|
||||
new RpcRouter<PeerContext>()
|
||||
new RpcRouter<PeerContext>(RpcSide.Listener, MoShiroMethodTable.Table)
|
||||
.On(
|
||||
MoShiroMethods.Clock,
|
||||
MoShiroJsonContext.Default.ClockParams,
|
||||
|
||||
@@ -22,10 +22,17 @@ for (int i = 0; i < args.Length - 1; i++)
|
||||
|
||||
AgentConfig config;
|
||||
LoadedNodeIdentity identity;
|
||||
RestartController restart;
|
||||
try
|
||||
{
|
||||
config = NodeConfig.Load(configPath, AgentConfigContext.Default.AgentConfig);
|
||||
restart = new RestartController(RestartSchedule.Resolve(config.RestartAt));
|
||||
identity = LoadedNodeIdentity.Load(config.Fleet.IdentityDir);
|
||||
if (!OwnerGuard.Check(config.Fleet.IdentityDir, config.Fleet.RequireOwner, log))
|
||||
{
|
||||
identity.Dispose();
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or System.Text.Json.JsonException or System.Security.Cryptography.CryptographicException)
|
||||
{
|
||||
@@ -33,10 +40,11 @@ catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidO
|
||||
return 1;
|
||||
}
|
||||
|
||||
using var restartScope = restart;
|
||||
using (identity)
|
||||
{
|
||||
using var app = new AgentApp(config, identity, Path.GetDirectoryName(Path.GetFullPath(configPath))!, time, log);
|
||||
await ServiceShell.RunAsync(app.RunAsync, log);
|
||||
await using var app = new AgentApp(config, identity, Path.GetDirectoryName(Path.GetFullPath(configPath))!, time, log, restart);
|
||||
await ServiceShell.RunAsync(app.RunAsync, log, restart, time);
|
||||
}
|
||||
|
||||
return 0;
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using Fleet.Hosting;
|
||||
using Fleet.Core;
|
||||
using Fleet.Rpc;
|
||||
using MoShiro.Measurement;
|
||||
@@ -23,21 +24,23 @@ public sealed class TaskManager
|
||||
private readonly StreamRegistry _streams;
|
||||
private readonly BlockEnv _env;
|
||||
private readonly TargetRunners _targets;
|
||||
private readonly Func<IReadOnlyList<int>> _udpPorts;
|
||||
private readonly Func<IReadOnlyList<int>> _quicPorts;
|
||||
private readonly Func<IReadOnlyList<int>> _tcpPorts;
|
||||
private readonly TrustAnchor _trust;
|
||||
private readonly Dictionary<ProbeSession, Dictionary<string, LineEntry>> _sessionLines = [];
|
||||
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>> udpPorts, Func<IReadOnlyList<int>> tcpPorts)
|
||||
public TaskManager(Func<BudgetGuard> guardFactory, StreamRegistry streams, BlockEnv env, TargetRunners targets, Func<IReadOnlyList<int>> quicPorts, Func<IReadOnlyList<int>> tcpPorts, TrustAnchor? trust = null)
|
||||
{
|
||||
_trust = trust ?? TrustAnchor.None;
|
||||
_guardFactory = guardFactory;
|
||||
_streams = streams;
|
||||
_env = env;
|
||||
_targets = targets;
|
||||
_udpPorts = udpPorts;
|
||||
_quicPorts = quicPorts;
|
||||
_tcpPorts = tcpPorts;
|
||||
}
|
||||
|
||||
@@ -52,6 +55,9 @@ public sealed class TaskManager
|
||||
/// <summary>点对点握手时查找 Probe 的公钥(SPKI)。</summary>
|
||||
public byte[]? LookupPeerKey(string peerId) => _peers.TryGetValue(peerId, out byte[]? key) ? key : null;
|
||||
|
||||
/// <summary>名册里全部 Probe 的公钥:敲门关卡用它们派生配对密钥。</summary>
|
||||
public IReadOnlyList<byte[]> PeerKeys() => [.. _peers.Values];
|
||||
|
||||
/// <summary>一个新的 Probe 会话开始:为它登记全部线路任务的流并启动监听方积木,返回要下发给它的线路任务集。</summary>
|
||||
public LineTaskSet OpenSession(ProbeSession probe)
|
||||
{
|
||||
@@ -95,6 +101,8 @@ public sealed class TaskManager
|
||||
_ = ct;
|
||||
var result = new StateSyncResult { AppliedVersion = version };
|
||||
|
||||
// 固定了操作员创世的节点:面板带来的新版本操作员集合,从固定的创世验过才采用(名册本身由面板决定)
|
||||
_trust.Update(set.TrustChain);
|
||||
var peers = new Dictionary<string, byte[]>(StringComparer.Ordinal);
|
||||
foreach (PeerSpec peer in set.Peers)
|
||||
{
|
||||
@@ -221,7 +229,8 @@ public sealed class TaskManager
|
||||
{
|
||||
Version = _version,
|
||||
Tasks = [.. entries.Values.Select(e => e.Task)],
|
||||
UdpPorts = [.. _udpPorts()],
|
||||
QuicPorts = [.. _quicPorts()],
|
||||
LegacyUdpPorts = [.. _quicPorts()],
|
||||
TcpPorts = [.. _tcpPorts()],
|
||||
};
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ public sealed class TestRunner
|
||||
|
||||
// 按需测试的数据端口由 Agent 决定:Backend 给的端口如果不是本机监听的端口(模板里是出厂默认值,每个 Agent 可以改,
|
||||
// 而 Backend 并不知道 TCP 数据端口),就用本机监听的第一个端口。块头里记录的是实际用的端口。
|
||||
IReadOnlyList<int> ports = _ports(kind.Blocks.Transport == "udp" ? "udp" : "tcp");
|
||||
IReadOnlyList<int> ports = _ports(kind.Blocks.Transport == "quic_datagram" ? "quic_datagram" : "tcp");
|
||||
bool listening = phase.Params.TryGetValue("port", out System.Text.Json.JsonElement requested) && requested.TryGetInt32(out int port) && ports.Contains(port);
|
||||
if (!listening)
|
||||
{
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
2. **安全默认值**:Fleet 永远不提供 exec、shell、文件读写、远程下发代码这类方法;节点只响应本地注册过的方法(本地策略 `allow_methods` 还能收窄);调用对方不支持的方法在本地直接返回 `fleet_unsupported`。
|
||||
3. **权限分离**:注册(`fleet-enroll`,一次性)和常驻业务进程是两个进程;常驻进程不含注册代码,也不使用 `HttpClient`。
|
||||
4. **NativeAOT**:节点侧只依赖 BCL,泛型扩展点通过产品传入的 `JsonTypeInfo<T>` 序列化,不使用反射。
|
||||
5. **更新不在 Fleet 里**:节点进程没有自更新能力。容器节点的签名更新由宿主机上的更新器完成,见 [`docs/deployment.md`](../../docs/deployment.md);纯二进制节点的更新是待办,见 [`TODO.md`](../../TODO.md)。
|
||||
5. **更新不在 Fleet 里**:节点进程没有自更新能力。Quadlet 装的节点由退出后 systemd 重新拉镜像、Backend 和脚本装的节点由宿主机上的更新器验签更新,见 [`docs/ops/deployment.md`](../../docs/ops/deployment.md);纯二进制节点的更新是待办,见 [`TODO.md`](../../TODO.md)。
|
||||
|
||||
## 身份
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
- JSON-RPC 2.0;方法名 `领域.动作`,`fleet.*` 保留给框架,产品用自己的前缀(MoShiro 是 `moshiro.*`)。
|
||||
- 二进制帧 `u8 channel | 正文`:`0`–`15` 框架保留,产品从 `16` 开始。
|
||||
- 错误:`error.data.code` 为 snake_case,框架的以 `fleet_` 开头,产品可以扩展。
|
||||
- **方法必须声明**:每个方法在声明表(`MethodTable`)里写明发起方、请求还是通知、哪些角色可以参与;不存在双向方法,同一个方法名在一个平面里只有一个发起方。`RpcRouter` 在注册、分发和发送三处核对,违规回 `fleet_protocol_violation`,超过上限断开连接。详见 [`docs/security/security.md`](../../docs/security/security.md) 第 8 节。
|
||||
- 与传输无关:同一套路由跑在 wss 上,也跑在 TLS 帧(`u32 长度 | u8 类型 | 正文`)上。
|
||||
|
||||
| 保留方法 | 方向 | 说明 |
|
||||
@@ -63,6 +64,7 @@
|
||||
| `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` | 服务端→节点 | 节点被禁用或接管,随后断开 |
|
||||
@@ -85,11 +87,13 @@ connect(wss) → fleet.challenge → fleet.auth → fleet.hello → 状态同步
|
||||
|
||||
```text
|
||||
节点 A(发起方,如 Probe)── TLS(按 SPKI 校验证书)──> 节点 B(监听方,如 Agent)
|
||||
<── fleet.challenge ──
|
||||
── fleet.auth(peer 用途,A 的私钥签名)──>
|
||||
<── fleet.peer.challenge ──
|
||||
── fleet.peer.auth(peer 用途,A 的私钥签名)──>
|
||||
B 用服务端下发的对端公钥列表验签(经 state.sync 下发),之后复用同一套 Rpc:路由、二进制通道、发件箱
|
||||
```
|
||||
|
||||
握手用两个方法:`fleet.peer.challenge`(监听方→拨号方,通知)和 `fleet.peer.auth`(拨号方→监听方,请求);握手完成后,两侧各自只能发起声明表里属于自己这一侧的方法。
|
||||
|
||||
对端公钥和允许连接的节点由产品通过期望状态下发给监听方,Fleet 只管认证和会话,拓扑由产品决定;UDP、原始 TCP 数据面由产品自己实现,Fleet 只提供会话密钥派生和首帧 MAC 的辅助函数。
|
||||
|
||||
## 宿主与服务端抽象
|
||||
|
||||
@@ -13,6 +13,8 @@ public static class FleetErrors
|
||||
public static readonly ErrorCode Unauthorized = new("fleet_unauthorized");
|
||||
public static readonly ErrorCode Forbidden = new("fleet_forbidden");
|
||||
public static readonly ErrorCode Unsupported = new("fleet_unsupported");
|
||||
/// <summary>方法没有声明、方向不对、类型不对或角色不允许:对方违反了协议。</summary>
|
||||
public static readonly ErrorCode ProtocolViolation = new("fleet_protocol_violation");
|
||||
public static readonly ErrorCode InvalidParams = new("fleet_invalid_params");
|
||||
public static readonly ErrorCode NotFound = new("fleet_not_found");
|
||||
public static readonly ErrorCode Internal = new("fleet_internal");
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Fleet.Core;
|
||||
|
||||
/// <summary>passkey 对一段语句签出的断言(字段都是 base64url)。信任根的格式和验证在 Fleet.Identity 的 <c>OperatorTrust</c>。</summary>
|
||||
public sealed class SignedStatementDto
|
||||
{
|
||||
[JsonPropertyName("statement")]
|
||||
public string Statement { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("credential_id")]
|
||||
public string CredentialId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("authenticator_data")]
|
||||
public string AuthenticatorData { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("client_data_json")]
|
||||
public string ClientDataJson { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("signature")]
|
||||
public string Signature { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>操作员集合的链(创世在前):节点注册时取来,从固定的创世指纹验过才采用。</summary>
|
||||
public sealed class TrustBundle
|
||||
{
|
||||
[JsonPropertyName("chain")]
|
||||
public List<SignedStatementDto> Chain { get; set; } = [];
|
||||
}
|
||||
@@ -51,16 +51,4 @@ public static class VarInt
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool TryReadUInt32LittleEndian(ReadOnlySpan<byte> source, out uint value)
|
||||
{
|
||||
if (source.Length < 4)
|
||||
{
|
||||
value = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
value = BinaryPrimitives.ReadUInt32LittleEndian(source);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,17 @@ using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json.Serialization;
|
||||
using Fleet.Core;
|
||||
using Fleet.Hosting;
|
||||
using Fleet.Identity;
|
||||
|
||||
namespace Fleet.Enrollment.Client;
|
||||
|
||||
public sealed record EnrollmentResult(string NodeId, string Status, string IdentityDirectory);
|
||||
[JsonSerializable(typeof(TrustBundle))]
|
||||
internal sealed partial class TrustBundleContext : JsonSerializerContext;
|
||||
|
||||
public sealed record EnrollmentResult(string NodeId, string Status, string IdentityDirectory, bool Pinned = false);
|
||||
|
||||
/// <summary>
|
||||
/// 用加入令牌注册(供 <c>fleet-enroll</c> 使用,也可以嵌入产品自己的安装工具):
|
||||
@@ -18,7 +22,8 @@ public sealed record EnrollmentResult(string NodeId, string Status, string Ident
|
||||
public static class EnrollmentClient
|
||||
{
|
||||
public static async Task<Result<EnrollmentResult>> EnrollAsync(
|
||||
HttpClient http, Uri server, string joinToken, string name, string identityDirectory, string nodeVersion, JsonNode? productMeta, TimeProvider time, CancellationToken ct)
|
||||
HttpClient http, Uri server, string joinToken, string name, string identityDirectory, string nodeVersion, JsonNode? productMeta, TimeProvider time, CancellationToken ct,
|
||||
string? genesis = null, IReadOnlyList<string>? controllers = null)
|
||||
{
|
||||
string nodeFile = Path.Combine(identityDirectory, LoadedNodeIdentity.NodeFileName);
|
||||
if (File.Exists(nodeFile))
|
||||
@@ -29,9 +34,29 @@ public static class EnrollmentClient
|
||||
using NodeIdentity identity = NodeIdentity.Generate();
|
||||
byte[] pfx = NodeCertificate.CreateSelfSignedPfx(name, TimeSpan.FromDays(3650), time);
|
||||
byte[] spki;
|
||||
byte[] tlsPublicKey;
|
||||
using (var certificate = NodeCertificate.LoadPfx(pfx))
|
||||
{
|
||||
spki = NodeCertificate.SpkiFingerprint(certificate);
|
||||
tlsPublicKey = certificate.PublicKey.ExportSubjectPublicKeyInfo();
|
||||
}
|
||||
|
||||
// 安装命令里带了创世指纹(--genesis)和属主(--controllers):先取操作员集合链,从固定的创世验起,
|
||||
// 属主必须是集合里的凭证;对不上就不注册,也不把自己的密钥交出去。通过之后固定到本地 trust.json,Backend 之后改不了。
|
||||
TrustBundle? bundle = null;
|
||||
if (genesis is not null)
|
||||
{
|
||||
if (controllers is not { Count: > 0 })
|
||||
{
|
||||
// 每个节点都必须有属主:没有属主的节点没有人能授权它的敏感操作
|
||||
return Result<EnrollmentResult>.Fail(FleetErrors.InvalidParams, "an owner is required: pass the passkey credential ids from the panel's command (--controllers)");
|
||||
}
|
||||
|
||||
Result<TrustBundle> trusted = await FetchTrustAsync(http, server, joinToken, genesis, controllers ?? [], ct);
|
||||
if (!trusted.TryGet(out bundle, out FleetError? trustError))
|
||||
{
|
||||
return Result<EnrollmentResult>.Fail(trustError);
|
||||
}
|
||||
}
|
||||
|
||||
string pubkey = Convert.ToBase64String(identity.PublicKeySpki);
|
||||
@@ -42,6 +67,7 @@ public static class EnrollmentClient
|
||||
["name"] = name,
|
||||
["pubkey"] = pubkey,
|
||||
["tls_spki"] = Convert.ToBase64String(spki),
|
||||
["tls_pubkey"] = Convert.ToBase64String(tlsPublicKey),
|
||||
["proof"] = Convert.ToBase64String(identity.Sign(SignaturePurpose.Enroll, tokenHash, pubkey)),
|
||||
["node_version"] = nodeVersion,
|
||||
["meta"] = productMeta,
|
||||
@@ -73,10 +99,46 @@ public static class EnrollmentClient
|
||||
var store = new ConfigStore(identityDirectory);
|
||||
store.Write(LoadedNodeIdentity.KeyFileName, identity.ExportPkcs8());
|
||||
store.Write(LoadedNodeIdentity.TlsFileName, pfx);
|
||||
if (bundle is not null)
|
||||
{
|
||||
TrustAnchor.Pin(identityDirectory, genesis!, bundle.Chain, controllers ?? []);
|
||||
}
|
||||
|
||||
// node.json 最后写:它存在就表示注册完整完成
|
||||
store.Write(
|
||||
LoadedNodeIdentity.NodeFileName,
|
||||
JsonSerializer.SerializeToUtf8Bytes(new NodeFile { NodeId = nodeId, Server = server.ToString().TrimEnd('/'), TlsSpki = Convert.ToBase64String(spki) }, NodeFileContext.Default.NodeFile));
|
||||
return Result<EnrollmentResult>.Ok(new EnrollmentResult(nodeId, result["status"]!.GetValue<string>(), identityDirectory));
|
||||
return Result<EnrollmentResult>.Ok(new EnrollmentResult(nodeId, result["status"]!.GetValue<string>(), identityDirectory, bundle is not null));
|
||||
}
|
||||
|
||||
private static async Task<Result<TrustBundle>> FetchTrustAsync(HttpClient http, Uri server, string joinToken, string genesis, IReadOnlyList<string> controllers, CancellationToken ct)
|
||||
{
|
||||
static Result<TrustBundle> Refuse(string reason) => Result<TrustBundle>.Fail(FleetErrors.ProofInvalid, "refusing to enroll: " + reason);
|
||||
|
||||
using var request = new StringContent(new JsonObject { ["join_token"] = joinToken }.ToJsonString(), Encoding.UTF8, "application/json");
|
||||
using HttpResponseMessage response = await http.PostAsync(new Uri(server, "/api/agent/v1/enroll/trust"), request, ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return Result<TrustBundle>.Fail(FleetErrors.JoinTokenInvalid, $"{(int)response.StatusCode}: the panel would not give the trust chain");
|
||||
}
|
||||
|
||||
TrustBundle? bundle = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync(ct), TrustBundleContext.Default.TrustBundle);
|
||||
if (bundle is null || bundle.Chain.Count == 0)
|
||||
{
|
||||
return Refuse("the panel has no operator trust root set up");
|
||||
}
|
||||
|
||||
OperatorSet? current = OperatorTrust.VerifyChain(genesis, [.. bundle.Chain.Select(c => c.ToSigned())]);
|
||||
if (current is null)
|
||||
{
|
||||
return Refuse("the panel's operator set does not match the pinned genesis fingerprint (wrong panel, or the panel was tampered with)");
|
||||
}
|
||||
|
||||
if (controllers.Any(c => current.Keys.All(k => k.CredentialId != c)))
|
||||
{
|
||||
return Refuse("an owner passkey in the install command is not in the operator set");
|
||||
}
|
||||
|
||||
return Result<TrustBundle>.Ok(bundle);
|
||||
}
|
||||
}
|
||||
@@ -126,7 +126,8 @@ public static class PlatformCheck
|
||||
/// <summary>处理 Ctrl+C 和 SIGTERM:取消令牌,让各个模块有序退出。</summary>
|
||||
public static class ServiceShell
|
||||
{
|
||||
public static async Task RunAsync(Func<CancellationToken, Task> run, INodeLog log)
|
||||
/// <param name="restart">每天定时有序退出一次(见 <see cref="RestartController"/>),重新拉起由服务管理器负责。</param>
|
||||
public static async Task RunAsync(Func<CancellationToken, Task> run, INodeLog log, RestartController? restart = null, TimeProvider? time = null)
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
using PosixSignalRegistration term = PosixSignalRegistration.Create(PosixSignal.SIGTERM, context =>
|
||||
@@ -156,6 +157,7 @@ public static class ServiceShell
|
||||
cts.Cancel();
|
||||
});
|
||||
Task trimmer = TrimHeapAsync(cts.Token);
|
||||
Task scheduled = restart is null ? Task.CompletedTask : WaitForRestartAsync(restart, time ?? TimeProvider.System, log, cts);
|
||||
try
|
||||
{
|
||||
await run(cts.Token);
|
||||
@@ -168,6 +170,45 @@ public static class ServiceShell
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
await trimmer;
|
||||
await scheduled;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WaitForRestartAsync(RestartController restart, TimeProvider time, INodeLog log, CancellationTokenSource cts)
|
||||
{
|
||||
while (!cts.IsCancellationRequested)
|
||||
{
|
||||
// 先取令牌、再取设置:中间被改动的话令牌会被取消,下一轮重新排
|
||||
CancellationToken changed = restart.ChangeToken;
|
||||
RestartSchedule? schedule = restart.Current;
|
||||
using CancellationTokenSource wait = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, changed, restart.NowToken);
|
||||
try
|
||||
{
|
||||
if (schedule is null)
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, wait.Token);
|
||||
continue;
|
||||
}
|
||||
|
||||
DateTimeOffset next = schedule.NextAfter(time.GetUtcNow() + RestartSchedule.MinimumUptime);
|
||||
log.Info("restart scheduled", ("at", next.ToString("O", System.Globalization.CultureInfo.InvariantCulture)), ("source", restart.IsLocal ? "local" : "panel"));
|
||||
await Task.Delay(next - time.GetUtcNow(), time, wait.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
if (restart.NowReason is { } reason)
|
||||
{
|
||||
log.Info("stopping", ("result", reason));
|
||||
await cts.CancelAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
continue; // 进程在退出,或者设置被改了:循环条件和下一轮会处理
|
||||
}
|
||||
|
||||
log.Info("stopping", ("result", "scheduled_restart"));
|
||||
await cts.CancelAsync();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,6 +338,15 @@ public sealed class LoadedNodeIdentity : IDisposable
|
||||
|
||||
public X509Certificate2 Certificate { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 对等面的签名身份:TLS 证书的那把密钥,和连服务端用的 <see cref="Identity"/> 是两把独立的密钥,
|
||||
/// 各自只用于自己的用途(一把泄露不影响另一把)。
|
||||
/// </summary>
|
||||
public NodeIdentity PeerIdentity => _peerIdentity ??= NodeIdentity.FromPkcs8(
|
||||
(Certificate.GetECDsaPrivateKey() ?? throw new InvalidDataException("the node certificate has no ECDSA private key")).ExportPkcs8PrivateKey());
|
||||
|
||||
private NodeIdentity? _peerIdentity;
|
||||
|
||||
public static LoadedNodeIdentity Load(string directory)
|
||||
{
|
||||
if (!System.IO.File.Exists(Path.Combine(directory, KeyFileName)) || !System.IO.File.Exists(Path.Combine(directory, TlsFileName)))
|
||||
@@ -313,6 +363,7 @@ public sealed class LoadedNodeIdentity : IDisposable
|
||||
public void Dispose()
|
||||
{
|
||||
Identity.Dispose();
|
||||
_peerIdentity?.Dispose();
|
||||
Certificate.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using Fleet.Core;
|
||||
|
||||
namespace Fleet.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// 节点自己要求有属主(passkey):Agent 和 Probe 共用这一份,启动时检查。
|
||||
/// <para>
|
||||
/// 属主是安装命令带来、注册时固定在 <c>trust.json</c> 里的 passkey 凭证 id(必须是操作员集合里的凭证)。节点据此知道自己绑定在哪个 passkey 上,
|
||||
/// 才敢把更多的接口和操作交给它(extension 会话、加密内容都只认属主的 passkey);没有属主的节点没有人能授权它的敏感能力,所以不运行。
|
||||
/// 这是节点本地的强制,不依赖面板:面板被攻破也改不了节点已经固定的属主。
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class OwnerGuard
|
||||
{
|
||||
/// <summary>设为 <c>1</c> 放行没有属主的节点(只给没有信任根的老面板)。</summary>
|
||||
public const string AllowEnvironmentVariable = "MOSHIRO_ALLOW_NO_OWNER";
|
||||
|
||||
/// <summary>没问题返回 null;否则是要告诉运维的原因。</summary>
|
||||
public static string? Problem(TrustAnchor trust, bool require)
|
||||
{
|
||||
if (!require || Environment.GetEnvironmentVariable(AllowEnvironmentVariable) == "1")
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!trust.Pinned)
|
||||
{
|
||||
return "this node has no owner: it was not enrolled with a trust root and owner passkeys; enroll it again with the command from the panel (or set " + AllowEnvironmentVariable + "=1 for a panel without a trust root)";
|
||||
}
|
||||
|
||||
return trust.Controllers.Count == 0
|
||||
? "none of this node's owner passkeys is in the operator set any more; enroll it again with a command from the panel"
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>读身份目录里固定的信任根,检查属主;不行就记一条错误并返回 false(调用方应当退出)。</summary>
|
||||
public static bool Check(string identityDirectory, bool require, INodeLog log)
|
||||
{
|
||||
TrustAnchor trust = TrustAnchor.Load(identityDirectory, log);
|
||||
if (Problem(trust, require) is { } problem)
|
||||
{
|
||||
log.Error("startup failed", ("err", problem));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
namespace Fleet.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// 当前生效的定时重启:节点本地的设置优先;本地没有写时用面板下发的,面板改了之后立即重新排下一次,不需要重启进程。
|
||||
/// </summary>
|
||||
public sealed class RestartController : IDisposable
|
||||
{
|
||||
private readonly RestartSchedule? _local;
|
||||
private readonly object _gate = new();
|
||||
private RestartSchedule? _remote;
|
||||
private readonly CancellationTokenSource _now = new();
|
||||
private CancellationTokenSource _changed = new();
|
||||
private string? _nowReason;
|
||||
|
||||
public RestartController(RestartSchedule? local = null)
|
||||
{
|
||||
_local = local;
|
||||
}
|
||||
|
||||
/// <summary>本地有设置时为 true:面板下发的不生效。</summary>
|
||||
public bool IsLocal => _local is not null;
|
||||
|
||||
public RestartSchedule? Current
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _local ?? _remote;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>当前设置被改动时会被取消的令牌;先取令牌、再取 <see cref="Current"/>,就不会漏掉中间的改动。</summary>
|
||||
public CancellationToken ChangeToken
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _changed.Token;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>应用要求现在就有序退出(比如管理员请求更新);退出的原因,没有就是 null。</summary>
|
||||
public string? NowReason
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _nowReason;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>应用要求现在就退出的令牌。</summary>
|
||||
public CancellationToken NowToken => _now.Token;
|
||||
|
||||
/// <summary>要求进程现在就有序退出,由服务管理器拉起;不管有没有设置定时重启。</summary>
|
||||
public void RequestNow(string reason)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_nowReason ??= reason;
|
||||
}
|
||||
|
||||
_now.Cancel();
|
||||
}
|
||||
|
||||
/// <summary>面板下发的设置;为空表示面板关闭了定时重启。返回 false 表示文本不合法(节点忽略它)。</summary>
|
||||
public bool SetRemote(string? text)
|
||||
{
|
||||
RestartSchedule? schedule = null;
|
||||
if (!string.IsNullOrWhiteSpace(text) && !RestartSchedule.TryParse(text, out schedule))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
CancellationTokenSource previous;
|
||||
lock (_gate)
|
||||
{
|
||||
_remote = schedule;
|
||||
previous = _changed;
|
||||
_changed = new CancellationTokenSource();
|
||||
}
|
||||
|
||||
previous.Cancel();
|
||||
previous.Dispose();
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_changed.Dispose();
|
||||
_now.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Fleet.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// 每天固定时刻有序退出一次,由 systemd、容器引擎或 procd 的「总是重启」拉起。写法 <c>HH:mm</c>(按 UTC)、
|
||||
/// <c>HH:mm±hh:mm</c> 或 <c>HH:mm</c> 加 <c>Z</c>:容器里没有时区数据库,所以偏移要写明,例如 <c>04:00+08:00</c>。
|
||||
/// 进程自己退出,不需要任何执行外部命令的能力。
|
||||
/// </summary>
|
||||
public sealed class RestartSchedule
|
||||
{
|
||||
/// <summary>环境变量,设置了就覆盖配置文件里的值。</summary>
|
||||
public const string EnvironmentVariable = "MOSHIRO_RESTART_AT";
|
||||
|
||||
/// <summary>刚启动的一段时间内不会触发:重启之后不会因为时刻还没过去而再重启一次,也避免重启风暴。</summary>
|
||||
public static readonly TimeSpan MinimumUptime = TimeSpan.FromMinutes(10);
|
||||
|
||||
private readonly TimeSpan _time;
|
||||
private readonly TimeSpan _offset;
|
||||
|
||||
private RestartSchedule(TimeSpan time, TimeSpan offset)
|
||||
{
|
||||
_time = time;
|
||||
_offset = offset;
|
||||
}
|
||||
|
||||
public static bool TryParse(string? text, out RestartSchedule? schedule)
|
||||
{
|
||||
schedule = null;
|
||||
string value = text?.Trim() ?? "";
|
||||
// HH:mm,后面可以跟 Z 或 ±hh:mm
|
||||
if (value.Length < 5 || value[2] != ':' ||
|
||||
!TryTwoDigits(value, 0, out int hour) || !TryTwoDigits(value, 3, out int minute) || hour > 23 || minute > 59)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
TimeSpan offset = TimeSpan.Zero;
|
||||
string rest = value[5..];
|
||||
if (rest.Length > 0 && rest != "Z")
|
||||
{
|
||||
if (rest.Length != 6 || rest[0] is not ('+' or '-') || rest[3] != ':' ||
|
||||
!TryTwoDigits(rest, 1, out int offsetHours) || !TryTwoDigits(rest, 4, out int offsetMinutes) || offsetMinutes > 59)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
offset = new TimeSpan(offsetHours, offsetMinutes, 0);
|
||||
if (offset > TimeSpan.FromHours(14))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (rest[0] == '-')
|
||||
{
|
||||
offset = -offset;
|
||||
}
|
||||
}
|
||||
|
||||
schedule = new RestartSchedule(new TimeSpan(hour, minute, 0), offset);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryTwoDigits(string text, int start, out int value)
|
||||
{
|
||||
value = 0;
|
||||
if (!char.IsAsciiDigit(text[start]) || !char.IsAsciiDigit(text[start + 1]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
value = (text[start] - '0') * 10 + (text[start + 1] - '0');
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 环境变量优先,其次配置文件的值;都没有就不定时重启。写错了抛 <see cref="InvalidOperationException"/>,
|
||||
/// 启动时就能看到,而不是悄悄不生效。
|
||||
/// </summary>
|
||||
public static RestartSchedule? Resolve(string? configured)
|
||||
{
|
||||
string? text = Environment.GetEnvironmentVariable(EnvironmentVariable);
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
text = configured;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return TryParse(text, out RestartSchedule? schedule)
|
||||
? schedule
|
||||
: throw new InvalidOperationException($"restart_at must look like 04:00, 04:00Z or 04:00+08:00 (got '{text}')");
|
||||
}
|
||||
|
||||
/// <summary>严格晚于 <paramref name="now"/> 的下一个触发时刻。</summary>
|
||||
public DateTimeOffset NextAfter(DateTimeOffset now)
|
||||
{
|
||||
DateTimeOffset local = now.ToOffset(_offset);
|
||||
DateTimeOffset candidate = new DateTimeOffset(local.Year, local.Month, local.Day, 0, 0, 0, _offset) + _time;
|
||||
return candidate <= local ? candidate.AddDays(1) : candidate;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Fleet.Core;
|
||||
using Fleet.Identity;
|
||||
|
||||
namespace Fleet.Hosting;
|
||||
|
||||
/// <summary><c>trust.json</c>:安装时固定的创世指纹和属主,加见过的最新操作员集合链。</summary>
|
||||
public sealed class TrustFile
|
||||
{
|
||||
[JsonPropertyName("genesis")]
|
||||
public string Genesis { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("chain")]
|
||||
public List<SignedStatementDto> Chain { get; set; } = [];
|
||||
|
||||
/// <summary>属主:允许授权本节点 extension 会话(web ssh 等)的 passkey 凭证 id,安装时固定,必须是操作员集合里的凭证。</summary>
|
||||
[JsonPropertyName("controllers")]
|
||||
public List<string> Controllers { get; set; } = [];
|
||||
}
|
||||
|
||||
[JsonSerializable(typeof(TrustFile))]
|
||||
public sealed partial class TrustFileContext : JsonSerializerContext;
|
||||
|
||||
/// <summary>
|
||||
/// 节点的信任根:安装命令里带来的创世指纹和属主,在注册时固定到本地,之后 Backend 改不了。
|
||||
/// 操作员集合的新版本随期望状态下发,节点只接受从固定的创世出发、签名完整、版本更新的链。
|
||||
/// 属主只是被固定和保存:节点现在没有 extension,等做 extension 时由节点本地强制(见 docs/security/access.md 第 9 节)。
|
||||
/// </summary>
|
||||
public sealed class TrustAnchor : IServerVerifier
|
||||
{
|
||||
public const string FileName = "trust.json";
|
||||
|
||||
private readonly ConfigStore? _store;
|
||||
private readonly INodeLog _log;
|
||||
private readonly object _gate = new();
|
||||
private readonly TrustFile? _file;
|
||||
private OperatorSet? _current;
|
||||
|
||||
private TrustAnchor(ConfigStore? store, TrustFile? file, OperatorSet? current, INodeLog log)
|
||||
{
|
||||
_store = store;
|
||||
_file = file;
|
||||
_current = current;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
/// <summary>没有固定的创世(旧节点,或没有指纹的安装)。</summary>
|
||||
public static TrustAnchor None { get; } = new(null, null, null, NullNodeLog.Instance);
|
||||
|
||||
public bool Pinned => _file is not null;
|
||||
|
||||
public string? Genesis => _file?.Genesis;
|
||||
|
||||
/// <summary>当前生效的操作员集合版本;没有固定时为 0。</summary>
|
||||
public int Version
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _current?.Version ?? 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>当前操作员集合里登记了服务器密钥:服务器必须在认证挑战里证明自己,链路用应用层加密。</summary>
|
||||
public bool Required
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _current?.Servers is { Count: > 0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>服务器的签名是不是当前操作员集合里登记的某把服务器密钥签的。</summary>
|
||||
public bool Verify(string nonce, ReadOnlySpan<byte> serverEphemeralSpki, ReadOnlySpan<byte> signature)
|
||||
{
|
||||
IReadOnlyList<byte[]> servers;
|
||||
lock (_gate)
|
||||
{
|
||||
servers = _current?.Servers ?? [];
|
||||
}
|
||||
|
||||
foreach (byte[] server in servers)
|
||||
{
|
||||
if (ServerHello.Verify(server, nonce, serverEphemeralSpki, signature))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>安装时固定的属主里,仍然在当前操作员集合里的那些(从集合里去掉的凭证不再有效)。</summary>
|
||||
public IReadOnlyList<string> Controllers
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _file is null || _current is null ? [] : [.. _file.Controllers.Where(c => _current.Keys.Any(k => k.CredentialId == c))];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static TrustAnchor Load(string identityDirectory, INodeLog log)
|
||||
{
|
||||
string path = Path.Combine(identityDirectory, FileName);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return new TrustAnchor(new ConfigStore(identityDirectory), null, null, log);
|
||||
}
|
||||
|
||||
TrustFile file = NodeConfig.Load(path, TrustFileContext.Default.TrustFile);
|
||||
OperatorSet? current = OperatorTrust.VerifyChain(file.Genesis, [.. file.Chain.Select(c => c.ToSigned())]);
|
||||
if (current is null)
|
||||
{
|
||||
// 固定的创世和存下来的链对不上:不能当成「没有固定」放过,直接让节点起不来比悄悄失去保护好
|
||||
throw new InvalidDataException($"{path} does not verify against its pinned genesis; remove the node's identity and enroll again");
|
||||
}
|
||||
|
||||
return new TrustAnchor(new ConfigStore(identityDirectory), file, current, log);
|
||||
}
|
||||
|
||||
/// <summary>注册时写下:固定的创世、验证过的链、属主。</summary>
|
||||
public static void Pin(string identityDirectory, string genesis, IReadOnlyList<SignedStatementDto> chain, IReadOnlyList<string> controllers) =>
|
||||
new ConfigStore(identityDirectory).Write(FileName, JsonSerializer.SerializeToUtf8Bytes(new TrustFile { Genesis = genesis, Chain = [.. chain], Controllers = [.. controllers] }, TrustFileContext.Default.TrustFile));
|
||||
|
||||
/// <summary>期望状态里带来的链:从固定的创世验起,比现有的新才采用并保存。返回当前链的版本(没有固定时为 0)。</summary>
|
||||
public int Update(IReadOnlyList<SignedStatementDto>? chain)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_file is null || chain is not { Count: > 0 })
|
||||
{
|
||||
return _current?.Version ?? 0;
|
||||
}
|
||||
|
||||
OperatorSet? candidate = OperatorTrust.VerifyChain(_file.Genesis, [.. chain.Select(c => c.ToSigned())]);
|
||||
if (candidate is null)
|
||||
{
|
||||
_log.Warn("the operator set from the panel does not verify against the pinned genesis; ignoring it");
|
||||
return _current!.Version;
|
||||
}
|
||||
|
||||
if (candidate.Version > _current!.Version)
|
||||
{
|
||||
_file.Chain = [.. chain];
|
||||
_current = candidate;
|
||||
try
|
||||
{
|
||||
_store?.Write(FileName, JsonSerializer.SerializeToUtf8Bytes(_file, TrustFileContext.Default.TrustFile));
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_log.Warn("could not save trust.json", ("err", ex.Message));
|
||||
}
|
||||
|
||||
_log.Info("operator set updated", ("version", candidate.Version));
|
||||
}
|
||||
|
||||
return _current.Version;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Fleet.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// 预握手敲门:TCP 连接建立之后、TLS 握手之前,拨号方先发 32 字节 <c>nonce(16) | tag(16)</c>,
|
||||
/// <c>tag = HMAC-SHA256(K_pair, 时间窗 | nonce)</c> 截断到 16 字节。监听方核对不过就什么都不写、直接断开,
|
||||
/// 所以扫描器拿不到证书、TLS 参数和 JARM 指纹。<c>K_pair</c> 只有名册里的成员算得出:双方身份密钥做静态 ECDH,再经 HKDF 加用途标签派生。
|
||||
/// TCP 的 SYN-ACK 由内核发出,端口在扫描器眼里仍是「开着但不说话」,不是「没监听」。
|
||||
/// </summary>
|
||||
public static class Knock
|
||||
{
|
||||
public const int PacketBytes = 32;
|
||||
public const int NonceBytes = 16;
|
||||
public const int TagBytes = 16;
|
||||
|
||||
/// <summary>时间窗长度;接受前后各一个窗,容忍时钟偏差。</summary>
|
||||
public const int WindowSeconds = 30;
|
||||
|
||||
private const string Label = "moshiro-knock-v1";
|
||||
|
||||
/// <summary>两把身份密钥的配对密钥。info 里的两个公钥按字节序排列,所以两边算出的是同一个值。</summary>
|
||||
public static byte[] DeriveKey(NodeIdentity own, ReadOnlySpan<byte> peerSpki)
|
||||
{
|
||||
byte[] secret = own.AgreeWith(peerSpki);
|
||||
try
|
||||
{
|
||||
ReadOnlySpan<byte> mine = own.PublicKeySpki;
|
||||
bool mineFirst = mine.SequenceCompareTo(peerSpki) <= 0;
|
||||
byte[] info = [.. Encoding.UTF8.GetBytes(Label), .. (mineFirst ? mine : peerSpki), .. (mineFirst ? peerSpki : mine)];
|
||||
return HKDF.DeriveKey(HashAlgorithmName.SHA256, secret, 32, null, info);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(secret);
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[] Build(ReadOnlySpan<byte> key, long nowMs)
|
||||
{
|
||||
byte[] packet = new byte[PacketBytes];
|
||||
RandomNumberGenerator.Fill(packet.AsSpan(0, NonceBytes));
|
||||
Tag(key, WindowOf(nowMs), packet.AsSpan(0, NonceBytes), packet.AsSpan(NonceBytes));
|
||||
return packet;
|
||||
}
|
||||
|
||||
/// <summary>tag 在前一个、当前、后一个时间窗里有一个对得上就通过。不管重放(重放由 <see cref="KnockGate"/> 按 nonce 去重)。</summary>
|
||||
public static bool Check(ReadOnlySpan<byte> key, ReadOnlySpan<byte> packet, long nowMs)
|
||||
{
|
||||
if (packet.Length != PacketBytes)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
long window = WindowOf(nowMs);
|
||||
Span<byte> expected = stackalloc byte[TagBytes];
|
||||
bool ok = false;
|
||||
for (long w = window - 1; w <= window + 1; w++)
|
||||
{
|
||||
Tag(key, w, packet[..NonceBytes], expected);
|
||||
ok |= CryptographicOperations.FixedTimeEquals(expected, packet[NonceBytes..]);
|
||||
}
|
||||
|
||||
return ok;
|
||||
}
|
||||
|
||||
private static long WindowOf(long nowMs) => nowMs / 1000 / WindowSeconds;
|
||||
|
||||
private static void Tag(ReadOnlySpan<byte> key, long window, ReadOnlySpan<byte> nonce, Span<byte> destination)
|
||||
{
|
||||
Span<byte> data = stackalloc byte[8 + NonceBytes];
|
||||
BinaryPrimitives.WriteInt64BigEndian(data, window);
|
||||
nonce.CopyTo(data[8..]);
|
||||
Span<byte> full = stackalloc byte[32];
|
||||
HMACSHA256.HashData(key, data, full);
|
||||
full[..TagBytes].CopyTo(destination);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace Fleet.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// 监听方的敲门关卡:新连接的前 32 字节必须是名册里某个成员的敲门包,否则不写任何字节、直接断开。
|
||||
/// 每个来源地址限速(陌生人用随机数据也要消耗 HMAC 计算),nonce 在时间窗内去重(防重放)。
|
||||
/// 名册由调用方提供(对端公钥列表);配对密钥按公钥缓存,名册变了自然只算新的。
|
||||
/// </summary>
|
||||
public sealed class KnockGate
|
||||
{
|
||||
private static readonly TimeSpan s_readTimeout = TimeSpan.FromSeconds(3);
|
||||
|
||||
private readonly NodeIdentity _own;
|
||||
private readonly Func<IReadOnlyList<byte[]>> _peers;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly int _attemptsPerPeriod;
|
||||
private readonly long _periodMs;
|
||||
private readonly ConcurrentDictionary<string, byte[]> _keys = new();
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<string, long> _seenNonces = [];
|
||||
private readonly Dictionary<string, (long WindowStartMs, int Count)> _sources = [];
|
||||
private long _lastSweepMs;
|
||||
|
||||
public KnockGate(NodeIdentity own, Func<IReadOnlyList<byte[]>> peers, TimeProvider time, int attemptsPerPeriod = 300, TimeSpan? period = null)
|
||||
{
|
||||
_own = own;
|
||||
_peers = peers;
|
||||
_time = time;
|
||||
_attemptsPerPeriod = attemptsPerPeriod;
|
||||
_periodMs = (long)(period ?? TimeSpan.FromSeconds(10)).TotalMilliseconds;
|
||||
}
|
||||
|
||||
/// <summary>读 32 字节并核对。通过返回 true,流从敲门包之后继续(TLS 或明文首帧);不通过返回 false,调用方直接关闭连接,不要写任何东西。</summary>
|
||||
public async ValueTask<bool> AdmitAsync(Stream stream, IPAddress? source, CancellationToken ct)
|
||||
{
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
if (!TryCountSource(source, now))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
byte[] packet = new byte[Knock.PacketBytes];
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(s_readTimeout);
|
||||
try
|
||||
{
|
||||
await stream.ReadExactlyAsync(packet, timeout.Token);
|
||||
}
|
||||
catch (Exception ex) when (ex is EndOfStreamException or OperationCanceledException or IOException or ObjectDisposedException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return Accept(packet, now);
|
||||
}
|
||||
|
||||
/// <summary>核对一个敲门包(不读流):名册里有成员的密钥对得上,并且 nonce 没用过。</summary>
|
||||
public bool Accept(ReadOnlySpan<byte> packet, long nowMs)
|
||||
{
|
||||
bool matched = false;
|
||||
foreach (byte[] peer in _peers())
|
||||
{
|
||||
byte[] key = _keys.GetOrAdd(Convert.ToBase64String(peer), _ => Knock.DeriveKey(_own, peer));
|
||||
matched |= Knock.Check(key, packet, nowMs);
|
||||
}
|
||||
|
||||
if (!matched)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string nonce = Convert.ToHexString(packet[..Knock.NonceBytes]);
|
||||
lock (_gate)
|
||||
{
|
||||
Sweep(nowMs);
|
||||
return _seenNonces.TryAdd(nonce, nowMs + (Knock.WindowSeconds * 3 * 1000L));
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryCountSource(IPAddress? source, long nowMs)
|
||||
{
|
||||
string key = source is null ? "" : source.IsIPv4MappedToIPv6 ? source.MapToIPv4().ToString() : source.ToString();
|
||||
lock (_gate)
|
||||
{
|
||||
Sweep(nowMs);
|
||||
(long start, int count) = _sources.TryGetValue(key, out var entry) && nowMs - entry.WindowStartMs < _periodMs ? entry : (nowMs, 0);
|
||||
_sources[key] = (start, count + 1);
|
||||
return count < _attemptsPerPeriod;
|
||||
}
|
||||
}
|
||||
|
||||
private void Sweep(long nowMs)
|
||||
{
|
||||
if (nowMs - _lastSweepMs < 5000 && _seenNonces.Count < 4096 && _sources.Count < 4096)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_lastSweepMs = nowMs;
|
||||
foreach (string nonce in _seenNonces.Where(p => p.Value <= nowMs).Select(p => p.Key).ToList())
|
||||
{
|
||||
_seenNonces.Remove(nonce);
|
||||
}
|
||||
|
||||
foreach (string source in _sources.Where(p => nowMs - p.Value.WindowStartMs >= _periodMs).Select(p => p.Key).ToList())
|
||||
{
|
||||
_sources.Remove(source);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ public static class NodeCertificate
|
||||
return certificate.Export(X509ContentType.Pfx);
|
||||
}
|
||||
|
||||
public static X509Certificate2 LoadPfx(byte[] pfx) => X509CertificateLoader.LoadPkcs12(pfx, null);
|
||||
public static X509Certificate2 LoadPfx(byte[] pfx) => X509CertificateLoader.LoadPkcs12(pfx, null, X509KeyStorageFlags.Exportable);
|
||||
|
||||
/// <summary>证书公钥(SPKI)的 SHA-256:对端用它来固定这张证书。</summary>
|
||||
public static byte[] SpkiFingerprint(X509Certificate2 certificate) =>
|
||||
|
||||
@@ -7,6 +7,9 @@ namespace Fleet.Identity;
|
||||
public static class SignaturePurpose
|
||||
{
|
||||
public const string Auth = "auth";
|
||||
|
||||
/// <summary>安全链路的认证:签名同时覆盖服务器和节点的临时公钥,把这次认证和这条加密链路绑在一起。</summary>
|
||||
public const string AuthSecure = "auth2";
|
||||
public const string Peer = "peer";
|
||||
public const string Enroll = "enroll";
|
||||
}
|
||||
@@ -41,6 +44,16 @@ public sealed class NodeIdentity : IDisposable
|
||||
|
||||
public byte[] ExportPkcs8() => _key.ExportPkcs8PrivateKey();
|
||||
|
||||
/// <summary>与对端公钥(SPKI)做静态 ECDH,返回原始共享秘密。只用于派生对称密钥(敲门),必须再经 KDF,不能直接当密钥用。</summary>
|
||||
public byte[] AgreeWith(ReadOnlySpan<byte> peerSpki)
|
||||
{
|
||||
using ECDiffieHellman own = ECDiffieHellman.Create();
|
||||
own.ImportPkcs8PrivateKey(_key.ExportPkcs8PrivateKey(), out _);
|
||||
using ECDiffieHellman peer = ECDiffieHellman.Create();
|
||||
peer.ImportSubjectPublicKeyInfo(peerSpki, out _);
|
||||
return own.DeriveRawSecretAgreement(peer.PublicKey);
|
||||
}
|
||||
|
||||
public byte[] Sign(string purpose, params string[] fields) =>
|
||||
_key.SignData(Encoding.UTF8.GetBytes(SignatureString.Build(purpose, fields)), HashAlgorithmName.SHA256,
|
||||
DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
using System.Buffers.Text;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Fleet.Identity;
|
||||
|
||||
/// <summary>passkey(WebAuthn)对一段文本签出的断言:<c>challenge = SHA-256(Statement 的 UTF-8)</c>。四个字段都是 base64url。</summary>
|
||||
public sealed record SignedStatement(string Statement, string CredentialId, string AuthenticatorData, string ClientDataJson, string Signature);
|
||||
|
||||
/// <summary>一个操作员凭证:passkey 的凭证 id 和公钥(SPKI)。</summary>
|
||||
public sealed record OperatorKey(string CredentialId, byte[] PublicKeySpki);
|
||||
|
||||
/// <summary>
|
||||
/// 操作员集合的一个版本。<c>RpId</c> 和 <c>Origins</c> 也在里面:节点验 WebAuthn 断言要用,而它们被操作员的签名覆盖,Backend 改不了。
|
||||
/// 创世版本(1)由它自己的某把凭证签名,指纹(<see cref="OperatorTrust.Fingerprint"/>)在安装时固定到节点;之后每个版本由上一版里的凭证签名。
|
||||
/// </summary>
|
||||
/// <param name="Servers">服务器密钥(SPKI):操作员授权的 Backend 签名密钥。集合里登记了服务器密钥之后,节点要求服务器在认证挑战里用它签名,
|
||||
/// 并在这条链路上用应用层加密,所以 DNS 或证书层面的冒充拿不出这把密钥就骗不了节点。空 = 还没有启用。</param>
|
||||
public sealed record OperatorSet(int Version, string Previous, string RpId, IReadOnlyList<string> Origins, IReadOnlyList<OperatorKey> Keys, IReadOnlyList<byte[]>? Servers = null);
|
||||
|
||||
/// <summary>
|
||||
/// 信任根:操作员(passkey)→ 操作员集合 → 节点本地固定的属主。设计见 docs/security/access.md 第 8 节。
|
||||
/// 安装命令里带着创世指纹和属主(passkey 的凭证 id):节点把它们固定在本地,之后只认从创世出发、由操作员 passkey 签过名的集合版本,
|
||||
/// 属主必须是集合里的凭证。Backend 能转发集合,但签不出新的版本,也改不了节点已经固定的东西。
|
||||
/// 全是纯函数,只用 BCL,节点和 Backend 共用。语句是 <c>key=value</c> 的文本,第一行是带版本的标题;签名覆盖整段文本。
|
||||
/// </summary>
|
||||
public static class OperatorTrust
|
||||
{
|
||||
public const string SetHeader = "moshiro-opset-v1";
|
||||
public const string Genesis = "0";
|
||||
|
||||
public static string Encode(OperatorSet set)
|
||||
{
|
||||
var text = new StringBuilder();
|
||||
text.Append(SetHeader).Append('\n')
|
||||
.Append("version=").Append(set.Version.ToString(CultureInfo.InvariantCulture)).Append('\n')
|
||||
.Append("prev=").Append(set.Previous).Append('\n')
|
||||
.Append("rp=").Append(set.RpId).Append('\n')
|
||||
.Append("origins=").Append(string.Join(',', set.Origins));
|
||||
foreach (OperatorKey key in set.Keys)
|
||||
{
|
||||
text.Append("\nkey=").Append(key.CredentialId).Append(':').Append(Convert.ToBase64String(key.PublicKeySpki));
|
||||
}
|
||||
|
||||
foreach (byte[] server in set.Servers ?? [])
|
||||
{
|
||||
text.Append("\nserver=").Append(Convert.ToBase64String(server));
|
||||
}
|
||||
|
||||
return text.ToString();
|
||||
}
|
||||
|
||||
/// <summary>严格解析:多一行、少一行、顺序不对都算坏。</summary>
|
||||
public static OperatorSet? ParseSet(string text)
|
||||
{
|
||||
string[] lines = text.Split('\n');
|
||||
if (lines.Length < 6 || lines[0] != SetHeader || !TryValue(lines[1], "version", out string version) || !int.TryParse(version, NumberStyles.None, CultureInfo.InvariantCulture, out int number) || number < 1
|
||||
|| !TryValue(lines[2], "prev", out string previous) || !TryValue(lines[3], "rp", out string rp) || rp.Length == 0 || !TryValue(lines[4], "origins", out string origins) || origins.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var keys = new List<OperatorKey>();
|
||||
var servers = new List<byte[]>();
|
||||
foreach (string line in lines.Skip(5))
|
||||
{
|
||||
if (servers.Count == 0 && TryValue(line, "key", out string value) && value.IndexOf(':', StringComparison.Ordinal) is > 0 and int split && TryBase64(value[(split + 1)..], out byte[] spki))
|
||||
{
|
||||
keys.Add(new OperatorKey(value[..split], spki));
|
||||
}
|
||||
else if (TryValue(line, "server", out string serverText) && TryBase64(serverText, out byte[] serverKey))
|
||||
{
|
||||
servers.Add(serverKey);
|
||||
}
|
||||
else
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return keys.Count == 0 || (number == 1) != (previous == Genesis) ? null : new OperatorSet(number, previous, rp, origins.Split(','), keys, servers);
|
||||
}
|
||||
|
||||
/// <summary>一段语句的指纹(SHA-256,小写十六进制)。创世版本的指纹就是安装命令里固定的那个。</summary>
|
||||
public static string Fingerprint(string statement) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(statement)));
|
||||
|
||||
/// <summary>
|
||||
/// 验证操作员集合的链:第一项是创世版本(指纹等于 <paramref name="pinnedGenesis"/>,自签),其后每一项由上一版里的凭证签名、版本加一、<c>prev</c> 指向上一版的指纹、域名不变。
|
||||
/// 返回最后一版;不对返回 null。
|
||||
/// </summary>
|
||||
public static OperatorSet? VerifyChain(string pinnedGenesis, IReadOnlyList<SignedStatement> chain)
|
||||
{
|
||||
if (chain.Count == 0 || !string.Equals(Fingerprint(chain[0].Statement), pinnedGenesis, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
OperatorSet? current = null;
|
||||
string? previousFingerprint = null;
|
||||
foreach (SignedStatement link in chain)
|
||||
{
|
||||
OperatorSet? next = ParseSet(link.Statement);
|
||||
if (next is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (current is null)
|
||||
{
|
||||
if (next.Version != 1 || !VerifyOperator(link, next))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
else if (next.Version != current.Version + 1 || next.Previous != previousFingerprint || next.RpId != current.RpId || !VerifyOperator(link, current))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
current = next;
|
||||
previousFingerprint = Fingerprint(link.Statement);
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
/// <summary>断言是不是这个集合里某把凭证对 <c>Statement</c> 签的(要求用户验证;挑战是语句的 SHA-256)。</summary>
|
||||
public static bool VerifyOperator(SignedStatement signed, OperatorSet set)
|
||||
{
|
||||
OperatorKey? key = set.Keys.FirstOrDefault(k => k.CredentialId == signed.CredentialId);
|
||||
if (key is null || !TryDecode(signed.AuthenticatorData, out byte[] authData) || !TryDecode(signed.ClientDataJson, out byte[] clientData) || !TryDecode(signed.Signature, out byte[] signature))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return WebAuthnAssertion.Verify(key.PublicKeySpki, authData, clientData, signature, set.RpId, SHA256.HashData(Encoding.UTF8.GetBytes(signed.Statement)), set.Origins.Contains, requireUserVerification: true, out _);
|
||||
}
|
||||
|
||||
private static bool TryValue(string line, string key, out string value)
|
||||
{
|
||||
value = "";
|
||||
if (!line.StartsWith(key + "=", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
value = line[(key.Length + 1)..];
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryBase64(string text, out byte[] data)
|
||||
{
|
||||
try
|
||||
{
|
||||
data = Convert.FromBase64String(text);
|
||||
return data.Length > 0;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
data = [];
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryDecode(string text, out byte[] data)
|
||||
{
|
||||
data = [];
|
||||
return Base64Url.IsValid(text) && (data = Base64Url.DecodeFromChars(text)).Length > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Fleet.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// 节点这一侧用来确认「对面确实是我的操作员授权过的 Backend」的校验器:<c>Required</c> 为真(当前操作员集合里登记了服务器密钥)
|
||||
/// 时,服务器的挑战必须带着对得上的签名,否则节点不继续认证、也不交出任何东西。
|
||||
/// </summary>
|
||||
public interface IServerVerifier
|
||||
{
|
||||
bool Required { get; }
|
||||
|
||||
/// <summary>服务器用它的服务器密钥对「挑战随机数加它这次连接的临时公钥」的签名。</summary>
|
||||
bool Verify(string nonce, ReadOnlySpan<byte> serverEphemeralSpki, ReadOnlySpan<byte> signature);
|
||||
}
|
||||
|
||||
/// <summary>服务器在挑战里给出的身份证明:服务器密钥(操作员签进集合)对「随机数加临时公钥」签名。纯函数。</summary>
|
||||
public static class ServerHello
|
||||
{
|
||||
/// <summary>被签名的内容;服务器那一侧只拿到这个、用自己的密钥签(服务器密钥不一定是 BCL 的 ECDsa,可以在别处)。</summary>
|
||||
public static byte[] Payload(string nonce, ReadOnlySpan<byte> serverEphemeralSpki) =>
|
||||
Encoding.UTF8.GetBytes("fleet|v1|server|" + nonce + "|" + Convert.ToBase64String(serverEphemeralSpki));
|
||||
|
||||
public static byte[] Sign(ECDsa serverKey, string nonce, ReadOnlySpan<byte> serverEphemeralSpki) =>
|
||||
serverKey.SignData(Payload(nonce, serverEphemeralSpki), HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
|
||||
|
||||
public static bool Verify(ReadOnlySpan<byte> serverKeySpki, string nonce, ReadOnlySpan<byte> serverEphemeralSpki, ReadOnlySpan<byte> signature)
|
||||
{
|
||||
try
|
||||
{
|
||||
using ECDsa key = ECDsa.Create();
|
||||
key.ImportSubjectPublicKeyInfo(serverKeySpki, out _);
|
||||
return key.VerifyData(Payload(nonce, serverEphemeralSpki), signature, HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 节点和 Backend 之间控制链路的应用层加密:两边的临时 ECDH 密钥协商出会话密钥,每个方向一把,AES-256-GCM,每帧带计数器。
|
||||
/// 所以即使 TLS 在中间被终止(Cloudflare、被冒充的证书),中间人没有服务器密钥,就拿不到明文,也改不了任何一帧。
|
||||
/// 帧格式:<c>计数器(8,大端) ‖ 密文 ‖ 标签(16)</c>;明文是 <c>帧类型(1) ‖ 内容</c>;计数器必须严格递增,重放、乱序、丢帧、篡改都会解密失败。
|
||||
/// </summary>
|
||||
public sealed class LinkCipher : IDisposable
|
||||
{
|
||||
public const int Overhead = 8 + 16;
|
||||
|
||||
private const byte NodeToServer = 0;
|
||||
private const byte ServerToNode = 1;
|
||||
|
||||
private readonly AesGcm _send;
|
||||
private readonly AesGcm _receive;
|
||||
private readonly byte _sendDirection;
|
||||
private readonly byte _receiveDirection;
|
||||
private readonly object _gate = new();
|
||||
private long _sendCounter;
|
||||
private long _receiveCounter;
|
||||
|
||||
private LinkCipher(byte[] sendKey, byte[] receiveKey, byte sendDirection, byte receiveDirection)
|
||||
{
|
||||
_send = new AesGcm(sendKey, 16);
|
||||
_receive = new AesGcm(receiveKey, 16);
|
||||
_sendDirection = sendDirection;
|
||||
_receiveDirection = receiveDirection;
|
||||
}
|
||||
|
||||
public static LinkCipher ForNode(EphemeralKey own, ReadOnlySpan<byte> serverEphemeralSpki, string nonce)
|
||||
{
|
||||
(byte[] n2s, byte[] s2n) = Derive(own, serverEphemeralSpki, own.PublicKeySpki, serverEphemeralSpki, nonce);
|
||||
return new LinkCipher(n2s, s2n, NodeToServer, ServerToNode);
|
||||
}
|
||||
|
||||
public static LinkCipher ForServer(EphemeralKey own, ReadOnlySpan<byte> nodeEphemeralSpki, string nonce)
|
||||
{
|
||||
(byte[] n2s, byte[] s2n) = Derive(own, nodeEphemeralSpki, nodeEphemeralSpki, own.PublicKeySpki, nonce);
|
||||
return new LinkCipher(s2n, n2s, ServerToNode, NodeToServer);
|
||||
}
|
||||
|
||||
private static (byte[] NodeToServerKey, byte[] ServerToNodeKey) Derive(EphemeralKey own, ReadOnlySpan<byte> remote, ReadOnlySpan<byte> nodeEphemeral, ReadOnlySpan<byte> serverEphemeral, string nonce)
|
||||
{
|
||||
byte[] secret = own.AgreeRaw(remote);
|
||||
try
|
||||
{
|
||||
byte[] info = [.. Encoding.UTF8.GetBytes("fleet|v1|link|"), .. SHA256.HashData(serverEphemeral), .. SHA256.HashData(nodeEphemeral)];
|
||||
byte[] material = HKDF.DeriveKey(HashAlgorithmName.SHA256, secret, 64, Encoding.UTF8.GetBytes(nonce), info);
|
||||
return (material[..32], material[32..]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(secret);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>加密一帧。</summary>
|
||||
public byte[] Seal(byte kind, ReadOnlySpan<byte> data)
|
||||
{
|
||||
long counter;
|
||||
lock (_gate)
|
||||
{
|
||||
counter = _sendCounter++;
|
||||
}
|
||||
|
||||
byte[] output = new byte[Overhead + 1 + data.Length];
|
||||
BinaryPrimitives.WriteInt64BigEndian(output, counter);
|
||||
byte[] plaintext = new byte[1 + data.Length];
|
||||
plaintext[0] = kind;
|
||||
data.CopyTo(plaintext.AsSpan(1));
|
||||
_send.Encrypt(Nonce(counter), plaintext, output.AsSpan(8, plaintext.Length), output.AsSpan(8 + plaintext.Length, 16), Aad(_sendDirection, counter));
|
||||
return output;
|
||||
}
|
||||
|
||||
/// <summary>解密一帧;计数器不是期望的下一个、或者标签不对,都返回 false。</summary>
|
||||
public bool TryOpen(ReadOnlySpan<byte> frame, out byte kind, out byte[] data)
|
||||
{
|
||||
kind = 0;
|
||||
data = [];
|
||||
if (frame.Length < Overhead + 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
long counter = BinaryPrimitives.ReadInt64BigEndian(frame);
|
||||
lock (_gate)
|
||||
{
|
||||
if (counter != _receiveCounter)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int length = frame.Length - Overhead;
|
||||
byte[] plaintext = new byte[length];
|
||||
try
|
||||
{
|
||||
_receive.Decrypt(Nonce(counter), frame.Slice(8, length), frame.Slice(8 + length, 16), plaintext, Aad(_receiveDirection, counter));
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_receiveCounter++;
|
||||
kind = plaintext[0];
|
||||
data = plaintext[1..];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] Nonce(long counter)
|
||||
{
|
||||
byte[] nonce = new byte[12];
|
||||
BinaryPrimitives.WriteInt64BigEndian(nonce.AsSpan(4), counter);
|
||||
return nonce;
|
||||
}
|
||||
|
||||
private static byte[] Aad(byte direction, long counter)
|
||||
{
|
||||
byte[] aad = new byte[9];
|
||||
aad[0] = direction;
|
||||
BinaryPrimitives.WriteInt64BigEndian(aad.AsSpan(1), counter);
|
||||
return aad;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_send.Dispose();
|
||||
_receive.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,14 @@ public sealed class EphemeralKey : IDisposable
|
||||
|
||||
public byte[] PublicKeySpki => _key.ExportSubjectPublicKeyInfo();
|
||||
|
||||
/// <summary>与对端的临时公钥做 ECDH,返回原始共享秘密;必须再经 KDF 才能当密钥用。</summary>
|
||||
public byte[] AgreeRaw(ReadOnlySpan<byte> remotePublicKeySpki)
|
||||
{
|
||||
using ECDiffieHellman remote = ECDiffieHellman.Create();
|
||||
remote.ImportSubjectPublicKeyInfo(remotePublicKeySpki, out _);
|
||||
return _key.DeriveRawSecretAgreement(remote.PublicKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 与对端的临时公钥协商,再经 HKDF-SHA256 派生会话凭据。
|
||||
/// 盐是服务端的随机数,info 绑定双方的临时公钥,防止凭据被挪用到别的握手上。
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
using Fleet.Core;
|
||||
|
||||
namespace Fleet.Identity;
|
||||
|
||||
public static class TrustMapping
|
||||
{
|
||||
public static SignedStatement ToSigned(this SignedStatementDto dto) => new(dto.Statement, dto.CredentialId, dto.AuthenticatorData, dto.ClientDataJson, dto.Signature);
|
||||
|
||||
public static SignedStatementDto ToDto(this SignedStatement signed) =>
|
||||
new() { Statement = signed.Statement, CredentialId = signed.CredentialId, AuthenticatorData = signed.AuthenticatorData, ClientDataJson = signed.ClientDataJson, Signature = signed.Signature };
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using System.Buffers.Text;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Fleet.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// 验证一次 WebAuthn 断言(<c>navigator.credentials.get</c> 的结果)。只支持 ES256(P-256 的 ECDSA),纯函数,只用 BCL:
|
||||
/// 面板用它验证 passkey 登录,节点验证操作员集合的签名也用它(docs/security/access.md 第 8 节)。
|
||||
/// 不解析 CBOR——注册时的 attestation 解析只在 Backend 里。
|
||||
/// </summary>
|
||||
public static class WebAuthnAssertion
|
||||
{
|
||||
public const byte FlagUserPresent = 0x01;
|
||||
public const byte FlagUserVerified = 0x04;
|
||||
|
||||
/// <param name="publicKeySpki">注册时保存的凭证公钥(SPKI)。</param>
|
||||
/// <param name="expectedChallenge">这次请求里服务端或签名人规定的挑战原文(不是 base64url)。</param>
|
||||
/// <param name="originAllowed">校验 <c>clientDataJSON.origin</c>。</param>
|
||||
/// <param name="signCount">authenticatorData 里的签名计数器。</param>
|
||||
public static bool Verify(
|
||||
ReadOnlySpan<byte> publicKeySpki, ReadOnlySpan<byte> authenticatorData, ReadOnlySpan<byte> clientDataJson, ReadOnlySpan<byte> signature,
|
||||
string rpId, ReadOnlySpan<byte> expectedChallenge, Func<string, bool> originAllowed, bool requireUserVerification, out uint signCount)
|
||||
{
|
||||
signCount = 0;
|
||||
if (authenticatorData.Length < 37 || !CryptographicOperations.FixedTimeEquals(authenticatorData[..32], SHA256.HashData(Encoding.UTF8.GetBytes(rpId))))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
byte flags = authenticatorData[32];
|
||||
if ((flags & FlagUserPresent) == 0 || (requireUserVerification && (flags & FlagUserVerified) == 0))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!ClientDataValid(clientDataJson, "webauthn.get", expectedChallenge, originAllowed))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using ECDsa key = ECDsa.Create();
|
||||
key.ImportSubjectPublicKeyInfo(publicKeySpki, out _);
|
||||
byte[] signed = [.. authenticatorData, .. SHA256.HashData(clientDataJson)];
|
||||
if (!key.VerifyData(signed, signature, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
signCount = (uint)((authenticatorData[33] << 24) | (authenticatorData[34] << 16) | (authenticatorData[35] << 8) | authenticatorData[36]);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary><c>clientDataJSON</c> 的 type、challenge、origin 都要对得上。注册和登录共用。</summary>
|
||||
public static bool ClientDataValid(ReadOnlySpan<byte> clientDataJson, string expectedType, ReadOnlySpan<byte> expectedChallenge, Func<string, bool> originAllowed)
|
||||
{
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(clientDataJson.ToArray());
|
||||
JsonElement root = document.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object
|
||||
|| root.GetProperty("type").GetString() != expectedType
|
||||
|| root.GetProperty("origin").GetString() is not { } origin || !originAllowed(origin)
|
||||
|| root.GetProperty("challenge").GetString() is not { } challenge)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
byte[] decoded = Base64Url.DecodeFromChars(challenge);
|
||||
return CryptographicOperations.FixedTimeEquals(decoded, expectedChallenge);
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or KeyNotFoundException or FormatException or InvalidOperationException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,9 @@ 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; }
|
||||
|
||||
public TimeSpan HeartbeatInterval { get; init; } = TimeSpan.FromSeconds(10);
|
||||
|
||||
public int MaxFrameBytes { get; init; } = 16 << 20;
|
||||
@@ -66,6 +69,12 @@ public sealed class FleetLinkOptions
|
||||
public long MaxInflightBytes { get; init; } = 4 << 20;
|
||||
|
||||
public Backoff Backoff { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 校验服务器身份:<see cref="IServerVerifier.Required"/> 为真(节点固定的操作员集合里登记了服务器密钥)时,
|
||||
/// 服务器必须在挑战里证明自己,之后这条链路用应用层加密;否则按旧的方式(没有固定信任根的节点)。
|
||||
/// </summary>
|
||||
public IServerVerifier? ServerVerifier { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -77,6 +86,7 @@ public sealed class FleetLink
|
||||
private const string RpcPath = "/api/agent/v1/rpc";
|
||||
|
||||
private readonly FleetLinkOptions _options;
|
||||
private volatile bool _secure;
|
||||
private readonly RpcRouter<LinkContext> _router;
|
||||
private readonly Dictionary<string, IStateHandler> _handlers;
|
||||
private readonly TimeProvider _time;
|
||||
@@ -105,6 +115,9 @@ public sealed class FleetLink
|
||||
|
||||
public RpcSession<LinkContext>? CurrentSession => _session;
|
||||
|
||||
/// <summary>入站方法的路由器(只读,测试用它核对声明表和实际注册的处理函数)。</summary>
|
||||
public RpcRouter<LinkContext> Router => _router;
|
||||
|
||||
/// <summary>被服务端撤销或禁用:不会再重连。</summary>
|
||||
public bool Revoked => _stopped;
|
||||
|
||||
@@ -150,6 +163,9 @@ public sealed class FleetLink
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>当前这条链路是不是应用层加密的(服务器证明了身份)。</summary>
|
||||
public bool IsSecure => _secure && IsConnected;
|
||||
|
||||
private async Task<string> RunOnceAsync(CancellationToken ct)
|
||||
{
|
||||
using var socket = new ClientWebSocket();
|
||||
@@ -157,7 +173,7 @@ public sealed class FleetLink
|
||||
socket.Options.KeepAliveTimeout = TimeSpan.FromSeconds(20); // 对端 20 秒没有回 Pong 就判定连接已死
|
||||
var builder = new UriBuilder(_options.Server) { Scheme = _options.Server.Scheme == "https" ? "wss" : "ws", Path = RpcPath };
|
||||
await socket.ConnectAsync(builder.Uri, ct);
|
||||
await using var transport = new WsTransport(socket, _options.MaxFrameBytes);
|
||||
await using var transport = new SecureTransport(new WsTransport(socket, _options.MaxFrameBytes));
|
||||
|
||||
string? failure = await AuthenticateAsync(transport, ct);
|
||||
if (failure is not null)
|
||||
@@ -165,8 +181,11 @@ public sealed class FleetLink
|
||||
return failure;
|
||||
}
|
||||
|
||||
_secure = transport.Secure;
|
||||
|
||||
var session = new RpcSession<LinkContext>(transport, _router, new LinkContext(this), _time);
|
||||
using CancellationTokenSource sessionCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
// Keep the active WebSocket receive alive until the planned shutdown marker and Close frame have been sent.
|
||||
using var sessionCts = new CancellationTokenSource();
|
||||
Task run = session.RunAsync(sessionCts.Token);
|
||||
try
|
||||
{
|
||||
@@ -177,7 +196,7 @@ 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, sessionCts.Token);
|
||||
Result<HelloResult> helloResult = await session.RequestAsync(FleetMethods.Hello, hello, FleetRpcContext.Default.HelloParams, FleetRpcContext.Default.HelloResult, ct);
|
||||
if (!helloResult.IsSuccess)
|
||||
{
|
||||
return "hello failed: " + helloResult.Error.Code;
|
||||
@@ -193,7 +212,23 @@ public sealed class FleetLink
|
||||
await OnConnected(session, sessionCts.Token);
|
||||
}
|
||||
|
||||
Task finished = await Task.WhenAny(run, pump, heartbeat);
|
||||
Task cancellation = Task.Delay(Timeout.Infinite, ct);
|
||||
Task finished = await Task.WhenAny(run, pump, heartbeat, cancellation);
|
||||
if (ct.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var closeTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(1), _time);
|
||||
string reason = _options.ShutdownReason?.Invoke() ?? "stopping";
|
||||
await session.NotifyAsync(FleetMethods.Stopping, new StoppingParams { Reason = reason }, FleetRpcContext.Default.StoppingParams, closeTimeout.Token);
|
||||
await session.CloseAsync(reason, closeTimeout.Token);
|
||||
}
|
||||
catch (Exception ex) when (ex is OperationCanceledException or WebSocketException or IOException or ObjectDisposedException or InvalidOperationException)
|
||||
{
|
||||
// The socket may already be gone; shutdown must still complete.
|
||||
}
|
||||
}
|
||||
|
||||
await sessionCts.CancelAsync();
|
||||
await Task.WhenAll(Quiet(run), Quiet(pump), Quiet(heartbeat));
|
||||
return finished == run ? "closed by server" : "link task failed";
|
||||
@@ -208,7 +243,7 @@ public sealed class FleetLink
|
||||
}
|
||||
|
||||
/// <summary>挑战 → 签名 → 认证结果。失败返回原因;服务端禁用该节点时不再重试。</summary>
|
||||
private async Task<string?> AuthenticateAsync(WsTransport transport, CancellationToken ct)
|
||||
private async Task<string?> AuthenticateAsync(SecureTransport transport, CancellationToken ct)
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15), _time);
|
||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
|
||||
@@ -219,9 +254,44 @@ public sealed class FleetLink
|
||||
}
|
||||
|
||||
ChallengeParams nonce = challenge.Params.Value.Deserialize(FleetRpcContext.Default.ChallengeParams)!;
|
||||
string signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.Auth, _options.NodeId.Value, nonce.Nonce));
|
||||
RpcMessage request = RpcMessage.Request(1, FleetMethods.Auth, new AuthParams { NodeId = _options.NodeId.Value, Signature = signature }, FleetRpcContext.Default.AuthParams);
|
||||
await transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(request, FleetRpcContext.Default.RpcMessage)), linked.Token);
|
||||
|
||||
// 固定了操作员集合、而集合里登记了服务器密钥:服务器必须证明自己,否则不继续,也不交出任何东西
|
||||
EphemeralKey? ephemeral = null;
|
||||
AuthParams authParams;
|
||||
try
|
||||
{
|
||||
if (_options.ServerVerifier is { Required: true } verifier)
|
||||
{
|
||||
if (!TryDecodeBase64(nonce.ServerEphemeral, out byte[] serverEphemeral) || !TryDecodeBase64(nonce.ServerSignature, out byte[] serverSignature)
|
||||
|| !verifier.Verify(nonce.Nonce, serverEphemeral, serverSignature))
|
||||
{
|
||||
return "the server could not prove its identity (it is not the server your operator set authorizes)";
|
||||
}
|
||||
|
||||
ephemeral = new EphemeralKey();
|
||||
string serverEphemeralText = Convert.ToBase64String(serverEphemeral);
|
||||
string nodeEphemeralText = Convert.ToBase64String(ephemeral.PublicKeySpki);
|
||||
authParams = new AuthParams
|
||||
{
|
||||
NodeId = _options.NodeId.Value,
|
||||
Signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.AuthSecure, _options.NodeId.Value, nonce.Nonce, serverEphemeralText, nodeEphemeralText)),
|
||||
NodeEphemeral = nodeEphemeralText,
|
||||
};
|
||||
RpcMessage secureRequest = RpcMessage.Request(1, FleetMethods.Auth, authParams, FleetRpcContext.Default.AuthParams);
|
||||
await transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(secureRequest, FleetRpcContext.Default.RpcMessage)), linked.Token);
|
||||
transport.Enable(LinkCipher.ForNode(ephemeral, serverEphemeral, nonce.Nonce));
|
||||
}
|
||||
else
|
||||
{
|
||||
authParams = new AuthParams { NodeId = _options.NodeId.Value, Signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.Auth, _options.NodeId.Value, nonce.Nonce)) };
|
||||
RpcMessage request = RpcMessage.Request(1, FleetMethods.Auth, authParams, FleetRpcContext.Default.AuthParams);
|
||||
await transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(request, FleetRpcContext.Default.RpcMessage)), linked.Token);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ephemeral?.Dispose();
|
||||
}
|
||||
|
||||
while (true)
|
||||
{
|
||||
@@ -256,7 +326,26 @@ public sealed class FleetLink
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<RpcMessage?> ReceiveMessageAsync(WsTransport transport, CancellationToken ct)
|
||||
private static bool TryDecodeBase64(string? text, out byte[] data)
|
||||
{
|
||||
data = [];
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
data = Convert.FromBase64String(text);
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<RpcMessage?> ReceiveMessageAsync(SecureTransport transport, CancellationToken ct)
|
||||
{
|
||||
RpcFrame? frame = await transport.ReceiveAsync(ct);
|
||||
return frame is { Kind: FrameKind.Text } f ? JsonSerializer.Deserialize(f.Data.Span, FleetRpcContext.Default.RpcMessage) : null;
|
||||
|
||||
@@ -22,11 +22,15 @@ public sealed class PeerListenerOptions
|
||||
/// <summary>按对端 ID 查找允许连接的公钥(SPKI)。不在列表里的对端一律拒绝(期望状态里的 peers)。</summary>
|
||||
public required Func<string, byte[]?> LookupPeerKey { get; init; }
|
||||
|
||||
/// <summary>预握手敲门:每个连接先过这一关(名册里的 Probe 才算得出敲门包),不通过就直接断开,什么都不写。</summary>
|
||||
public required KnockGate Knock { get; init; }
|
||||
|
||||
public int MaxFrameBytes { get; init; } = 8 << 20;
|
||||
|
||||
public int MaxSessions { get; init; } = 8;
|
||||
|
||||
public TimeSpan HandshakeTimeout { get; init; } = TimeSpan.FromSeconds(10);
|
||||
/// <summary>敲门、TLS、挑战应答整个握手的上限。线路差的时候(RTT 好几秒、还丢包)握手本来就要很久,10 秒做不完就会一直连不上;敲门已经挡住了不认识的连接,放宽不会放大被占连接的风险。</summary>
|
||||
public TimeSpan HandshakeTimeout { get; init; } = PeerTimings.Handshake;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -98,7 +102,13 @@ public sealed class PeerListener
|
||||
PeerSockets.EnableKeepAlive(client.Client);
|
||||
using var timeout = new CancellationTokenSource(_options.HandshakeTimeout, _time);
|
||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
|
||||
var tls = new SslStream(client.GetStream(), leaveInnerStreamOpen: false);
|
||||
Stream stream = client.GetStream();
|
||||
if (!await _options.Knock.AdmitAsync(stream, (client.Client.RemoteEndPoint as IPEndPoint)?.Address, linked.Token))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var tls = new SslStream(stream, leaveInnerStreamOpen: false);
|
||||
await tls.AuthenticateAsServerAsync(
|
||||
new SslServerAuthenticationOptions { ServerCertificate = _options.Certificate, ClientCertificateRequired = false, EnabledSslProtocols = System.Security.Authentication.SslProtocols.Tls12 | System.Security.Authentication.SslProtocols.Tls13 },
|
||||
linked.Token);
|
||||
@@ -200,7 +210,8 @@ public sealed class PeerListener
|
||||
}
|
||||
|
||||
/// <summary>发起点对点会话(Probe 一侧):按 SPKI 指纹固定对端证书,用身份私钥签名,派生同样的数据面凭据。</summary>
|
||||
/// <summary>点对点会话的套接字:开启 TCP keepalive(空闲 15 秒后每 5 秒探测,连续 3 次无响应就断开,约 30 秒),主机丢失或路径被切断时连接不会一直挂着。</summary>
|
||||
/// <summary>点对点会话的套接字:开启 TCP keepalive(空闲 30 秒后每 10 秒探测,连续 6 次无响应才断开,约 90 秒),主机真的没了的时候连接不会一直挂着;
|
||||
/// 但线路短暂中断(几十秒的黑洞)不能把会话杀掉——线路中断本来就是要测的东西,会话得扛过去,恢复之后接着测。</summary>
|
||||
internal static class PeerSockets
|
||||
{
|
||||
public static void EnableKeepAlive(Socket socket)
|
||||
@@ -208,9 +219,9 @@ internal static class PeerSockets
|
||||
try
|
||||
{
|
||||
socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true);
|
||||
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, 15);
|
||||
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, 5);
|
||||
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, 3);
|
||||
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, 30);
|
||||
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, 10);
|
||||
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, 6);
|
||||
}
|
||||
catch (Exception ex) when (ex is SocketException or ArgumentException or PlatformNotSupportedException)
|
||||
{
|
||||
@@ -219,21 +230,32 @@ internal static class PeerSockets
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>点对点会话的时间常数,Probe 和 Agent 两侧共用。</summary>
|
||||
public static class PeerTimings
|
||||
{
|
||||
/// <summary>建立会话(连接、敲门、TLS、挑战应答)的总时限。</summary>
|
||||
public static readonly TimeSpan Handshake = TimeSpan.FromSeconds(30);
|
||||
}
|
||||
|
||||
public static class PeerConnector
|
||||
{
|
||||
public static async Task<(PeerSessionInfo Info, TlsFrameTransport Transport)> ConnectAsync(
|
||||
string host, int port, byte[] expectedSpkiSha256, NodeIdentity identity, string peerId, string? version, int maxFrameBytes, TimeProvider time, CancellationToken ct)
|
||||
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);
|
||||
try
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10), time);
|
||||
using var timeout = new CancellationTokenSource(PeerTimings.Handshake, time);
|
||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
|
||||
await client.ConnectAsync(host, port, linked.Token);
|
||||
Stream stream = client.GetStream();
|
||||
await stream.WriteAsync(Knock.Build(Knock.DeriveKey(identity, agentPublicKey), time.GetUtcNow().ToUnixTimeMilliseconds()), linked.Token);
|
||||
var tls = new SslStream(
|
||||
client.GetStream(), leaveInnerStreamOpen: false,
|
||||
stream, leaveInnerStreamOpen: false,
|
||||
(_, certificate, _, _) => certificate is not null && SpkiMatches(certificate, expectedSpkiSha256));
|
||||
await tls.AuthenticateAsClientAsync(new SslClientAuthenticationOptions { TargetHost = host }, linked.Token);
|
||||
var transport = new TlsFrameTransport(tls, maxFrameBytes);
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
using Fleet.Identity;
|
||||
using Fleet.Rpc;
|
||||
|
||||
namespace Fleet.Link;
|
||||
|
||||
/// <summary>
|
||||
/// 认证阶段是明文,认证通过之后换成应用层加密(<see cref="LinkCipher"/>):此后每帧都是加密的二进制帧,明文帧一律拒绝。
|
||||
/// 认证阶段服务器的拒绝回复可能是明文(还没有协商好密钥),所以第一帧加密的回复到达之前允许一次明文文本帧。
|
||||
/// </summary>
|
||||
internal sealed class SecureTransport : IRpcTransport
|
||||
{
|
||||
private readonly IRpcTransport _inner;
|
||||
private LinkCipher? _cipher;
|
||||
private bool _allowPlaintext = true;
|
||||
|
||||
public SecureTransport(IRpcTransport inner)
|
||||
{
|
||||
_inner = inner;
|
||||
}
|
||||
|
||||
public bool Secure => _cipher is not null;
|
||||
|
||||
public void Enable(LinkCipher cipher) => _cipher = cipher;
|
||||
|
||||
public ValueTask SendAsync(RpcFrame frame, CancellationToken ct) =>
|
||||
_cipher is null ? _inner.SendAsync(frame, ct) : _inner.SendAsync(new RpcFrame(FrameKind.Binary, _cipher.Seal((byte)frame.Kind, frame.Data.Span)), ct);
|
||||
|
||||
public async ValueTask<RpcFrame?> ReceiveAsync(CancellationToken ct)
|
||||
{
|
||||
RpcFrame? frame = await _inner.ReceiveAsync(ct);
|
||||
if (frame is not { } received || _cipher is null)
|
||||
{
|
||||
return frame;
|
||||
}
|
||||
|
||||
if (received.Kind == FrameKind.Binary && _cipher.TryOpen(received.Data.Span, out byte kind, out byte[] data))
|
||||
{
|
||||
_allowPlaintext = false;
|
||||
return new RpcFrame((FrameKind)kind, data);
|
||||
}
|
||||
|
||||
if (_allowPlaintext && received.Kind == FrameKind.Text)
|
||||
{
|
||||
return received; // 认证被拒绝时服务器还没有协商好密钥,回复是明文
|
||||
}
|
||||
|
||||
throw new IOException("a frame on the secure link failed to authenticate");
|
||||
}
|
||||
|
||||
public ValueTask CloseAsync(string reason, CancellationToken ct) => _inner.CloseAsync(reason, ct);
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_cipher?.Dispose();
|
||||
await _inner.DisposeAsync();
|
||||
}
|
||||
}
|
||||
@@ -10,15 +10,36 @@ 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";
|
||||
}
|
||||
|
||||
/// <summary>二进制通道:0–15 保留给框架,产品从 16 开始。</summary>
|
||||
public static class FleetChannels
|
||||
/// <summary>
|
||||
/// 框架自己的方法声明,两个平面各一份。认证和握手阶段由连接代码直接处理的(<c>Inline</c>)不经过路由器。
|
||||
/// 同一个方法名在两个平面里可以有不同的发起方:<c>fleet.state.sync</c> 在节点面是服务端发给节点,在对等面是 Agent 发给 Probe。
|
||||
/// </summary>
|
||||
public static class FleetMethodTable
|
||||
{
|
||||
public const byte FirstProductChannel = 16;
|
||||
public static readonly MethodTable Table = new(
|
||||
[
|
||||
// 节点面
|
||||
MethodSpec.Notification(FleetMethods.Challenge, RpcSide.Server).AsInline(),
|
||||
MethodSpec.Request(FleetMethods.Auth, RpcSide.Node).AsInline(),
|
||||
MethodSpec.Request(FleetMethods.Hello, RpcSide.Node).AsInline(),
|
||||
MethodSpec.Notification(FleetMethods.Heartbeat, RpcSide.Node).AsInline(),
|
||||
MethodSpec.Notification(FleetMethods.Stopping, RpcSide.Node).AsInline(),
|
||||
MethodSpec.Request(FleetMethods.StateSync, RpcSide.Server),
|
||||
MethodSpec.Notification(FleetMethods.OutboxAck, RpcSide.Server),
|
||||
MethodSpec.Notification(FleetMethods.Revoked, RpcSide.Server),
|
||||
|
||||
// 对等面
|
||||
MethodSpec.Notification(PeerMethods.Challenge, RpcSide.Listener).AsInline(),
|
||||
MethodSpec.Request(PeerMethods.Auth, RpcSide.Dialer).AsInline(),
|
||||
MethodSpec.Request(FleetMethods.StateSync, RpcSide.Listener),
|
||||
MethodSpec.Notification(FleetMethods.OutboxAck, RpcSide.Listener),
|
||||
]);
|
||||
}
|
||||
|
||||
public sealed class ChallengeParams
|
||||
@@ -28,6 +49,14 @@ public sealed class ChallengeParams
|
||||
|
||||
[JsonPropertyName("expires_at_ms")]
|
||||
public long ExpiresAtMs { get; set; }
|
||||
|
||||
/// <summary>服务器这次连接的临时 ECDH 公钥(SPKI,base64);服务器有身份密钥时才有。</summary>
|
||||
[JsonPropertyName("server_eph")]
|
||||
public string? ServerEphemeral { get; set; }
|
||||
|
||||
/// <summary>服务器密钥对「随机数加临时公钥」的签名(base64):节点据此确认对面是操作员授权的服务器。</summary>
|
||||
[JsonPropertyName("server_sig")]
|
||||
public string? ServerSignature { get; set; }
|
||||
}
|
||||
|
||||
public sealed class AuthParams
|
||||
@@ -38,6 +67,10 @@ public sealed class AuthParams
|
||||
/// <summary>对 <c>fleet|v1|auth|{node_id}|{nonce}</c> 的签名(base64)。</summary>
|
||||
[JsonPropertyName("signature")]
|
||||
public string Signature { get; set; } = "";
|
||||
|
||||
/// <summary>节点的临时 ECDH 公钥(SPKI,base64):有它就是安全链路,签名覆盖双方的临时公钥,认证结果起所有帧都是加密的。</summary>
|
||||
[JsonPropertyName("node_eph")]
|
||||
public string? NodeEphemeral { get; set; }
|
||||
}
|
||||
|
||||
public sealed class AuthResult
|
||||
@@ -125,6 +158,12 @@ 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))]
|
||||
@@ -133,6 +172,7 @@ public sealed class RevokedParams
|
||||
[JsonSerializable(typeof(HelloParams))]
|
||||
[JsonSerializable(typeof(HelloResult))]
|
||||
[JsonSerializable(typeof(HeartbeatParams))]
|
||||
[JsonSerializable(typeof(StoppingParams))]
|
||||
[JsonSerializable(typeof(StateSyncParams))]
|
||||
[JsonSerializable(typeof(StateSyncResult))]
|
||||
[JsonSerializable(typeof(OutboxAckParams))]
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
namespace Fleet.Rpc;
|
||||
|
||||
/// <summary>连接的哪一侧。节点面:服务端和节点;对等面:拨号方(Probe)和监听方(Agent)。</summary>
|
||||
public enum RpcSide
|
||||
{
|
||||
Server,
|
||||
Node,
|
||||
Dialer,
|
||||
Listener,
|
||||
}
|
||||
|
||||
public enum RpcPlane
|
||||
{
|
||||
Node,
|
||||
Peer,
|
||||
}
|
||||
|
||||
public enum MethodKind
|
||||
{
|
||||
Request,
|
||||
Notification,
|
||||
}
|
||||
|
||||
public static class RpcSides
|
||||
{
|
||||
public static RpcPlane PlaneOf(RpcSide side) => side is RpcSide.Server or RpcSide.Node ? RpcPlane.Node : RpcPlane.Peer;
|
||||
|
||||
public static RpcSide Opposite(RpcSide side) => side switch
|
||||
{
|
||||
RpcSide.Server => RpcSide.Node,
|
||||
RpcSide.Node => RpcSide.Server,
|
||||
RpcSide.Dialer => RpcSide.Listener,
|
||||
_ => RpcSide.Dialer,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 一个方法的声明:<b>只有 <see cref="From"/> 能发起它</b>(不存在双向方法),类型是请求还是通知,哪些节点角色可以参与。
|
||||
/// <see cref="Inline"/> 表示它在认证或握手阶段由连接代码直接处理,不经过路由器。
|
||||
/// </summary>
|
||||
public sealed record MethodSpec(string Name, RpcSide From, MethodKind Kind, IReadOnlyList<string>? Roles = null, bool Inline = false)
|
||||
{
|
||||
public RpcPlane Plane => RpcSides.PlaneOf(From);
|
||||
|
||||
/// <summary>接收方。</summary>
|
||||
public RpcSide To => RpcSides.Opposite(From);
|
||||
|
||||
public bool AllowsRole(string? role) => Roles is null || (role is not null && Roles.Contains(role));
|
||||
|
||||
public static MethodSpec Request(string name, RpcSide from, params string[] roles) => new(name, from, MethodKind.Request, roles.Length == 0 ? null : roles);
|
||||
|
||||
public static MethodSpec Notification(string name, RpcSide from, params string[] roles) => new(name, from, MethodKind.Notification, roles.Length == 0 ? null : roles);
|
||||
|
||||
public MethodSpec AsInline() => this with { Inline = true };
|
||||
}
|
||||
|
||||
/// <summary>二进制通道的声明:谁能发、哪些角色可以发。通道 0–15 保留给框架,产品从 16 开始。</summary>
|
||||
public sealed record ChannelSpec(byte Channel, RpcSide From, IReadOnlyList<string>? Roles = null)
|
||||
{
|
||||
public RpcPlane Plane => RpcSides.PlaneOf(From);
|
||||
|
||||
public bool AllowsRole(string? role) => Roles is null || (role is not null && Roles.Contains(role));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 协议方法和二进制通道的声明表,单一真源。路由器用它在注册时拒绝方向、类型、角色不符的处理函数,
|
||||
/// 在收发时拒绝没有声明或不该由对方发起的方法。同一个方法名在不同平面里可以有不同的发起方,所以按(平面,名字)查找。
|
||||
/// </summary>
|
||||
public sealed class MethodTable
|
||||
{
|
||||
private readonly Dictionary<(RpcPlane, string), MethodSpec> _methods = [];
|
||||
private readonly Dictionary<(RpcPlane, byte), ChannelSpec> _channels = [];
|
||||
|
||||
public MethodTable(IEnumerable<MethodSpec> methods, IEnumerable<ChannelSpec>? channels = null)
|
||||
{
|
||||
foreach (MethodSpec spec in methods)
|
||||
{
|
||||
if (!_methods.TryAdd((spec.Plane, spec.Name), spec))
|
||||
{
|
||||
throw new ArgumentException($"method {spec.Name} is declared twice in the {spec.Plane} plane");
|
||||
}
|
||||
}
|
||||
|
||||
foreach (ChannelSpec spec in channels ?? [])
|
||||
{
|
||||
if (!_channels.TryAdd((spec.Plane, spec.Channel), spec))
|
||||
{
|
||||
throw new ArgumentException($"channel {spec.Channel} is declared twice in the {spec.Plane} plane");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public IEnumerable<MethodSpec> Methods => _methods.Values;
|
||||
|
||||
public IEnumerable<ChannelSpec> Channels => _channels.Values;
|
||||
|
||||
public MethodSpec? Find(RpcPlane plane, string method) => _methods.GetValueOrDefault((plane, method));
|
||||
|
||||
public ChannelSpec? FindChannel(RpcPlane plane, byte channel) => _channels.GetValueOrDefault((plane, channel));
|
||||
|
||||
/// <summary>合并多张表(框架的加产品的)。</summary>
|
||||
public static MethodTable Combine(params MethodTable[] tables) =>
|
||||
new(tables.SelectMany(t => t.Methods), tables.SelectMany(t => t.Channels));
|
||||
|
||||
/// <summary>
|
||||
/// 对账:<paramref name="receiver"/> 这一侧、<paramref name="role"/> 这个角色应该处理、但没有注册处理函数的方法
|
||||
/// (认证阶段直接处理的 <see cref="MethodSpec.Inline"/> 方法除外)。<paramref name="role"/> 为空表示不限角色(服务端要处理所有角色发来的)。
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> MissingHandlers(RpcSide receiver, string? role, IEnumerable<string> registered)
|
||||
{
|
||||
HashSet<string> have = [.. registered];
|
||||
return
|
||||
[
|
||||
.. Methods
|
||||
.Where(m => m.To == receiver && !m.Inline && (role is null || m.AllowsRole(role)) && !have.Contains(m.Name))
|
||||
.Select(m => m.Name)
|
||||
.Order(StringComparer.Ordinal),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,17 @@ using Fleet.Core;
|
||||
|
||||
namespace Fleet.Rpc;
|
||||
|
||||
/// <summary>方法路由,与传输方式无关。<typeparamref name="TContext"/> 由传输层提供(例如调用方节点)。</summary>
|
||||
/// <summary>对端违反了协议:方法没有声明、方向不对、请求和通知用反了、或者角色不允许。</summary>
|
||||
public readonly record struct RpcViolation(string Method, string Reason);
|
||||
|
||||
/// <summary>一次分发的结果:要回的响应(通知为空),以及对端有没有违规。</summary>
|
||||
public readonly record struct RpcDispatch(RpcMessage? Response, RpcViolation? Violation);
|
||||
|
||||
/// <summary>
|
||||
/// 方法路由,与传输方式无关,也是协议方向的<b>唯一卡口</b>:节点和服务端、Probe 和 Agent 的入站分发都经过这里。
|
||||
/// <typeparamref name="TContext"/> 由传输层提供(例如调用方节点)。路由器知道自己在连接的哪一侧(<see cref="Side"/>)和声明表,
|
||||
/// 注册处理函数时核对方向、类型和角色,分发时拒绝没有声明或不该由对方发起的方法,发送时核对自己有没有资格发起。
|
||||
/// </summary>
|
||||
public sealed class RpcRouter<TContext>
|
||||
{
|
||||
private delegate ValueTask<Result<JsonElement?>> Route(TContext context, JsonElement? parameters, CancellationToken ct);
|
||||
@@ -13,12 +23,28 @@ public sealed class RpcRouter<TContext>
|
||||
|
||||
private readonly Dictionary<string, Route> _routes = new(StringComparer.Ordinal);
|
||||
private readonly Action<string, Exception>? _onUnhandled;
|
||||
private readonly string? _localRole;
|
||||
private readonly Func<TContext, string?>? _callerRole;
|
||||
|
||||
public RpcRouter(Action<string, Exception>? onUnhandled = null)
|
||||
/// <param name="side">本路由器在连接的哪一侧。</param>
|
||||
/// <param name="table">方法声明表。</param>
|
||||
/// <param name="localRole">本节点的角色(节点侧和对等面用):声明限定了角色的方法,角色不符就不能注册。</param>
|
||||
/// <param name="callerRole">从调用上下文取出对端的角色(服务端用):声明限定了角色的方法,角色不符就拒绝。</param>
|
||||
public RpcRouter(RpcSide side, MethodTable table, string? localRole = null, Func<TContext, string?>? callerRole = null, Action<string, Exception>? onUnhandled = null)
|
||||
{
|
||||
Side = side;
|
||||
Table = table;
|
||||
_localRole = localRole;
|
||||
_callerRole = callerRole;
|
||||
_onUnhandled = onUnhandled;
|
||||
}
|
||||
|
||||
public RpcSide Side { get; }
|
||||
|
||||
public MethodTable Table { get; }
|
||||
|
||||
private RpcPlane Plane => RpcSides.PlaneOf(Side);
|
||||
|
||||
public bool Has(string method) => _routes.ContainsKey(method);
|
||||
|
||||
public IReadOnlyCollection<string> Methods => _routes.Keys;
|
||||
@@ -29,6 +55,7 @@ public sealed class RpcRouter<TContext>
|
||||
JsonTypeInfo<TResult> resultInfo,
|
||||
Func<TContext, TParams, CancellationToken, ValueTask<Result<TResult>>> handler)
|
||||
{
|
||||
Declare(method, MethodKind.Request);
|
||||
_routes[method] = async (context, raw, ct) =>
|
||||
{
|
||||
if (!TryDeserialize(raw, paramsInfo, out TParams? p))
|
||||
@@ -49,6 +76,7 @@ public sealed class RpcRouter<TContext>
|
||||
JsonTypeInfo<TParams> paramsInfo,
|
||||
Func<TContext, TParams, CancellationToken, ValueTask> handler)
|
||||
{
|
||||
Declare(method, MethodKind.Notification);
|
||||
_routes[method] = async (context, raw, ct) =>
|
||||
{
|
||||
if (!TryDeserialize(raw, paramsInfo, out TParams? p))
|
||||
@@ -66,12 +94,26 @@ public sealed class RpcRouter<TContext>
|
||||
/// 处理一条请求或通知。请求返回响应;通知返回 null(失败时只丢弃)。
|
||||
/// 处理函数里的异常属于程序缺陷:在这个边界捕获,回复 internal 错误,并交给 <c>onUnhandled</c> 记录。
|
||||
/// </summary>
|
||||
public async ValueTask<RpcMessage?> DispatchAsync(TContext context, RpcMessage message, CancellationToken ct)
|
||||
public async ValueTask<RpcMessage?> DispatchAsync(TContext context, RpcMessage message, CancellationToken ct) =>
|
||||
(await DispatchCheckedAsync(context, message, ct)).Response;
|
||||
|
||||
/// <summary>
|
||||
/// 先核对声明(方向、类型、角色),再分发。违规的消息不会到达处理函数,返回的结果里带 <see cref="RpcViolation"/>,
|
||||
/// 由会话决定怎么办(计数、超过上限就断开连接)。声明过、方向对、但这一侧没有处理函数的方法不算违规,只是不支持。
|
||||
/// </summary>
|
||||
public async ValueTask<RpcDispatch> DispatchCheckedAsync(TContext context, RpcMessage message, CancellationToken ct)
|
||||
{
|
||||
string method = message.Method ?? "";
|
||||
if (Check(context, message) is { } violation)
|
||||
{
|
||||
return new RpcDispatch(
|
||||
message.Id is null ? null : RpcMessage.Failure(message.Id, new FleetError(FleetErrors.ProtocolViolation, violation.Reason)),
|
||||
violation);
|
||||
}
|
||||
|
||||
if (!_routes.TryGetValue(method, out Route? route))
|
||||
{
|
||||
return message.Id is null ? null : RpcMessage.Failure(message.Id, new FleetError(FleetErrors.Unsupported, $"unknown method {method}"));
|
||||
return new RpcDispatch(message.Id is null ? null : RpcMessage.Failure(message.Id, new FleetError(FleetErrors.Unsupported, $"unknown method {method}")), null);
|
||||
}
|
||||
|
||||
Result<JsonElement?> result;
|
||||
@@ -91,12 +133,90 @@ public sealed class RpcRouter<TContext>
|
||||
|
||||
if (message.Id is null)
|
||||
{
|
||||
return null;
|
||||
return new RpcDispatch(null, null);
|
||||
}
|
||||
|
||||
return result.TryGet(out JsonElement? value, out FleetError? error)
|
||||
? new RpcMessage { Id = message.Id, Result = value ?? s_emptyObject }
|
||||
: RpcMessage.Failure(message.Id, error);
|
||||
return new RpcDispatch(
|
||||
result.TryGet(out JsonElement? value, out FleetError? error)
|
||||
? new RpcMessage { Id = message.Id, Result = value ?? s_emptyObject }
|
||||
: RpcMessage.Failure(message.Id, error),
|
||||
null);
|
||||
}
|
||||
|
||||
/// <summary>入站检查:这条消息是不是对方有资格发起的、类型对不对、对方的角色允不允许。</summary>
|
||||
public RpcViolation? Check(TContext context, RpcMessage message)
|
||||
{
|
||||
string method = message.Method ?? "";
|
||||
MethodSpec? spec = Table.Find(Plane, method);
|
||||
if (spec is null)
|
||||
{
|
||||
return new RpcViolation(method, $"method {method} is not declared");
|
||||
}
|
||||
|
||||
if (spec.To != Side)
|
||||
{
|
||||
return new RpcViolation(method, $"{method} cannot be initiated by the {RpcSides.Opposite(Side)} side");
|
||||
}
|
||||
|
||||
MethodKind kind = message.Id is null ? MethodKind.Notification : MethodKind.Request;
|
||||
if (spec.Kind != kind)
|
||||
{
|
||||
return new RpcViolation(method, $"{method} is a {spec.Kind.ToString().ToLowerInvariant()}");
|
||||
}
|
||||
|
||||
if (_callerRole is not null && !spec.AllowsRole(_callerRole(context)))
|
||||
{
|
||||
return new RpcViolation(method, $"{method} is not allowed for this node role");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发送前检查:本侧有没有资格发起这个方法、类型对不对、对端的角色允不允许(服务端发给节点时传入节点的角色)。
|
||||
/// 违反是程序缺陷,抛 <see cref="InvalidOperationException"/>。
|
||||
/// </summary>
|
||||
public void CheckSend(string method, MethodKind kind, string? peerRole = null)
|
||||
{
|
||||
MethodSpec? spec = Table.Find(Plane, method);
|
||||
if (spec is null)
|
||||
{
|
||||
throw new InvalidOperationException($"method {method} is not declared");
|
||||
}
|
||||
|
||||
if (spec.From != Side)
|
||||
{
|
||||
throw new InvalidOperationException($"{method} cannot be initiated by the {Side} side");
|
||||
}
|
||||
|
||||
if (spec.Kind != kind)
|
||||
{
|
||||
throw new InvalidOperationException($"{method} is a {spec.Kind.ToString().ToLowerInvariant()}");
|
||||
}
|
||||
|
||||
if (peerRole is not null && !spec.AllowsRole(peerRole))
|
||||
{
|
||||
throw new InvalidOperationException($"{method} cannot be sent to a {peerRole}");
|
||||
}
|
||||
}
|
||||
|
||||
private void Declare(string method, MethodKind kind)
|
||||
{
|
||||
MethodSpec spec = Table.Find(Plane, method) ?? throw new InvalidOperationException($"method {method} is not declared in the method table");
|
||||
if (spec.To != Side)
|
||||
{
|
||||
throw new InvalidOperationException($"{method} is initiated by the {spec.From} side and cannot be handled on the {Side} side");
|
||||
}
|
||||
|
||||
if (spec.Kind != kind)
|
||||
{
|
||||
throw new InvalidOperationException($"{method} is declared as a {spec.Kind.ToString().ToLowerInvariant()}");
|
||||
}
|
||||
|
||||
if (_localRole is not null && !spec.AllowsRole(_localRole))
|
||||
{
|
||||
throw new InvalidOperationException($"{method} is not for the {_localRole} role");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryDeserialize<T>(JsonElement? raw, JsonTypeInfo<T> info, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out T? value)
|
||||
|
||||
@@ -34,6 +34,9 @@ public sealed class RpcSessionOptions
|
||||
|
||||
/// <summary>待处理的入站请求上限;超过时接收循环等待(背压)。</summary>
|
||||
public int InboundQueue { get; set; } = 256;
|
||||
|
||||
/// <summary>对端违反协议(方法没有声明、方向、类型或角色不对)的次数上限;超过就断开会话。</summary>
|
||||
public int MaxViolations { get; set; } = 20;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -53,6 +56,8 @@ public sealed class RpcSession<TContext> : IAsyncDisposable
|
||||
private readonly CancellationTokenSource _stop = new();
|
||||
private long _nextId;
|
||||
private long _lastReceive;
|
||||
private int _violations;
|
||||
private bool _violated;
|
||||
|
||||
public RpcSession(IRpcTransport transport, RpcRouter<TContext> router, TContext context, TimeProvider time, RpcSessionOptions? options = null)
|
||||
{
|
||||
@@ -81,7 +86,7 @@ public sealed class RpcSession<TContext> : IAsyncDisposable
|
||||
while (!linked.IsCancellationRequested)
|
||||
{
|
||||
RpcFrame? frame = await _transport.ReceiveAsync(linked.Token);
|
||||
if (frame is null)
|
||||
if (frame is null || Volatile.Read(ref _violated))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -184,8 +189,16 @@ public sealed class RpcSession<TContext> : IAsyncDisposable
|
||||
public ValueTask NotifyAsync<TParams>(string method, TParams parameters, JsonTypeInfo<TParams> paramsInfo, CancellationToken ct) =>
|
||||
SendAsync(RpcMessage.Notification(method, parameters, paramsInfo), ct);
|
||||
|
||||
public ValueTask SendAsync(RpcMessage message, CancellationToken ct) =>
|
||||
_transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(message, FleetRpcContext.Default.RpcMessage)), ct);
|
||||
public ValueTask SendAsync(RpcMessage message, CancellationToken ct)
|
||||
{
|
||||
if (message.Method is not null)
|
||||
{
|
||||
// 请求和通知只能发本侧有资格发起的方法(响应没有方法名,不检查)
|
||||
_router.CheckSend(message.Method, message.Id is null ? MethodKind.Notification : MethodKind.Request);
|
||||
}
|
||||
|
||||
return _transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(message, FleetRpcContext.Default.RpcMessage)), ct);
|
||||
}
|
||||
|
||||
public ValueTask SendBinaryAsync(ReadOnlyMemory<byte> frame, CancellationToken ct) =>
|
||||
_transport.SendAsync(new RpcFrame(FrameKind.Binary, frame), ct);
|
||||
@@ -205,10 +218,18 @@ public sealed class RpcSession<TContext> : IAsyncDisposable
|
||||
{
|
||||
await foreach (RpcMessage message in _inbound.Reader.ReadAllAsync(ct))
|
||||
{
|
||||
RpcMessage? response = await _router.DispatchAsync(_context, message, ct);
|
||||
if (response is not null)
|
||||
RpcDispatch outcome = await _router.DispatchCheckedAsync(_context, message, ct);
|
||||
if (outcome.Response is not null)
|
||||
{
|
||||
await SendAsync(response, ct);
|
||||
await SendAsync(outcome.Response, ct);
|
||||
}
|
||||
|
||||
if (outcome.Violation is not null && ++_violations > _options.MaxViolations)
|
||||
{
|
||||
// 只关闭传输,不取消接收:接收循环在下一次读到帧或对端确认关闭时正常结束
|
||||
Volatile.Write(ref _violated, true);
|
||||
await _transport.CloseAsync("protocol violations", CancellationToken.None);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ public sealed class FleetServerOptions
|
||||
/// <summary>节点 ID 的类型前缀,MoShiro 使用 <c>agt</c>。</summary>
|
||||
public string NodeIdPrefix { get; set; } = "node";
|
||||
|
||||
/// <summary>加入令牌的「种类」对应的节点 ID 前缀(例如 <c>probe</c> → <c>prb</c>);没有列出的种类用 <see cref="NodeIdPrefix"/>。</summary>
|
||||
public Dictionary<string, string> NodeIdPrefixes { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
public int MaxFrameBytes { get; set; } = 16 << 20;
|
||||
|
||||
public TimeSpan ChallengeTtl { get; set; } = TimeSpan.FromSeconds(30);
|
||||
@@ -12,4 +15,7 @@ public sealed class FleetServerOptions
|
||||
public TimeSpan HeartbeatTimeout { get; set; } = TimeSpan.FromSeconds(45);
|
||||
|
||||
public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(15);
|
||||
|
||||
/// <summary>节点违反协议(方法没有声明、方向、类型或角色不对)的次数上限;超过就断开这条连接。</summary>
|
||||
public int MaxViolations { get; set; } = 20;
|
||||
}
|
||||
@@ -31,7 +31,10 @@ public sealed record NewNode(
|
||||
IReadOnlyList<string> Labels,
|
||||
string? MetaJson,
|
||||
string? NodeVersion,
|
||||
long NowMs);
|
||||
long NowMs,
|
||||
string Kind = "",
|
||||
byte[]? TlsPublicKey = null,
|
||||
string? AddedBy = null);
|
||||
|
||||
public sealed record JoinTokenRecord(
|
||||
string Id,
|
||||
@@ -43,6 +46,8 @@ public sealed record JoinTokenRecord(
|
||||
int Uses,
|
||||
long ExpiresAtMs,
|
||||
long? RevokedAtMs,
|
||||
long CreatedAtMs);
|
||||
long CreatedAtMs,
|
||||
string Kind = "",
|
||||
string? CreatedBy = null);
|
||||
|
||||
public sealed record Presence(bool Online, long AtMs, string? NodeVersion, IReadOnlyList<string> Capabilities, string? HelloMetaJson);
|
||||
@@ -6,6 +6,24 @@ namespace Fleet.Server;
|
||||
|
||||
public sealed record EnrollmentContext(string? RemoteIp, JsonElement? ProductMeta);
|
||||
|
||||
/// <summary>
|
||||
/// 服务器的身份密钥:操作员用 passkey 把它的公钥签进操作员集合。有它的时候,认证挑战会带上服务器的签名,节点据此确认对面是真的服务器,
|
||||
/// 并在这条链路上用应用层加密。没有注册就是旧的行为(没有服务器身份)。
|
||||
/// </summary>
|
||||
public interface IServerIdentity
|
||||
{
|
||||
byte[] PublicKeySpki { get; }
|
||||
|
||||
/// <summary>对一段内容签名(<c>Fleet.Identity.ServerHello.Payload</c> 给出的)。</summary>
|
||||
byte[] Sign(ReadOnlySpan<byte> payload);
|
||||
}
|
||||
|
||||
/// <summary>操作员集合的链(passkey 签的,创世在前)。没有初始化信任根时是空的。节点注册时用它确认「这个面板确实是我的操作员的」。</summary>
|
||||
public interface IOperatorTrustSource
|
||||
{
|
||||
ValueTask<IReadOnlyList<SignedStatementDto>> ChainAsync(CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>产品挂钩:节点注册成功之后调用。</summary>
|
||||
public interface IEnrollmentHook
|
||||
{
|
||||
@@ -20,7 +38,7 @@ public enum NodeEventKind
|
||||
Heartbeat,
|
||||
}
|
||||
|
||||
public sealed record NodeEvent(NodeEventKind Kind, NodeRecord Node, long AtMs, HelloParams? Hello = null, JsonElement? Extra = null);
|
||||
public sealed record NodeEvent(NodeEventKind Kind, NodeRecord Node, long AtMs, HelloParams? Hello = null, JsonElement? Extra = null, string? Reason = null);
|
||||
|
||||
public interface INodeEventSink
|
||||
{
|
||||
|
||||
@@ -19,6 +19,13 @@ public sealed class EnrollRequest
|
||||
[JsonPropertyName("tls_spki")]
|
||||
public string TlsSpki { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// 同一把 TLS 密钥的完整公钥(SPKI,base64),必须和 <see cref="TlsSpki"/> 对得上。节点用这把密钥做对等面的签名认证
|
||||
/// (和连服务端用的身份密钥是两把独立的密钥),对端要验签就需要完整的公钥,而不只是指纹。
|
||||
/// </summary>
|
||||
[JsonPropertyName("tls_pubkey")]
|
||||
public string TlsPublicKey { get; set; } = "";
|
||||
|
||||
/// <summary>持有证明:对 <c>fleet|v1|enroll|{sha256_hex(join_token)}|{pubkey}</c> 的签名(base64)。</summary>
|
||||
[JsonPropertyName("proof")]
|
||||
public string Proof { get; set; } = "";
|
||||
@@ -30,6 +37,12 @@ public sealed class EnrollRequest
|
||||
public JsonElement? Meta { get; set; }
|
||||
}
|
||||
|
||||
public sealed class TrustRequest
|
||||
{
|
||||
[JsonPropertyName("join_token")]
|
||||
public string JoinToken { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class EnrollResponse
|
||||
{
|
||||
[JsonPropertyName("node_id")]
|
||||
@@ -62,5 +75,7 @@ public sealed class FleetProblem
|
||||
[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
[JsonSerializable(typeof(EnrollRequest))]
|
||||
[JsonSerializable(typeof(EnrollResponse))]
|
||||
[JsonSerializable(typeof(TrustRequest))]
|
||||
[JsonSerializable(typeof(Fleet.Core.TrustBundle))]
|
||||
[JsonSerializable(typeof(FleetProblem))]
|
||||
internal sealed partial class EnrollmentJsonContext : JsonSerializerContext;
|
||||
@@ -20,9 +20,42 @@ public static class FleetEnrollmentEndpoints
|
||||
.WithTags("agent")
|
||||
.WithSummary("Enroll a node with a join token")
|
||||
.DisableAntiforgery();
|
||||
// 注册之前取信任链:节点先确认这个面板的操作员集合和委托确实是它固定的创世签出来的,再把自己的密钥交出去
|
||||
app.MapPost(path + "/trust", HandleTrustAsync)
|
||||
.WithName("fleet_enroll_trust")
|
||||
.WithTags("agent")
|
||||
.WithSummary("The operator set chain and the grant of a join token, for the node to verify before it enrolls")
|
||||
.DisableAntiforgery();
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleTrustAsync(HttpContext http, FleetEnrollmentService service)
|
||||
{
|
||||
TrustRequest? request;
|
||||
try
|
||||
{
|
||||
request = await JsonSerializer.DeserializeAsync(http.Request.Body, EnrollmentJsonContext.Default.TrustRequest, http.RequestAborted);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
request = null;
|
||||
}
|
||||
|
||||
if (request is null || string.IsNullOrEmpty(request.JoinToken))
|
||||
{
|
||||
return Problem(StatusCodes.Status400BadRequest, FleetErrors.InvalidParams, "join_token is required");
|
||||
}
|
||||
|
||||
Result<TrustBundle> result = await service.GetTrustAsync(request.JoinToken, http.RequestAborted);
|
||||
if (!result.TryGet(out TrustBundle? bundle, out FleetError? error))
|
||||
{
|
||||
return Problem(error.Code == FleetErrors.JoinTokenExpired ? StatusCodes.Status410Gone : StatusCodes.Status401Unauthorized, error.Code, error.Message);
|
||||
}
|
||||
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
return Results.Json(bundle, EnrollmentJsonContext.Default.TrustBundle);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleAsync(HttpContext http, FleetEnrollmentService service)
|
||||
{
|
||||
EnrollRequest? request;
|
||||
|
||||
@@ -9,13 +9,15 @@ public sealed partial class FleetEnrollmentService
|
||||
{
|
||||
private readonly IFleetStore _store;
|
||||
private readonly IEnumerable<IEnrollmentHook> _hooks;
|
||||
private readonly IEnumerable<IOperatorTrustSource> _trust;
|
||||
private readonly FleetServerOptions _options;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public FleetEnrollmentService(IFleetStore store, IEnumerable<IEnrollmentHook> hooks, FleetServerOptions options, TimeProvider time)
|
||||
public FleetEnrollmentService(IFleetStore store, IEnumerable<IEnrollmentHook> hooks, IEnumerable<IOperatorTrustSource> trust, FleetServerOptions options, TimeProvider time)
|
||||
{
|
||||
_store = store;
|
||||
_hooks = hooks;
|
||||
_trust = trust;
|
||||
_options = options;
|
||||
_time = time;
|
||||
}
|
||||
@@ -29,9 +31,16 @@ public sealed partial class FleetEnrollmentService
|
||||
|
||||
if (!TryDecode(request.PublicKey, out byte[] publicKey) || !NodeIdentity.IsValidPublicKey(publicKey)
|
||||
|| !TryDecode(request.TlsSpki, out byte[] tlsSpki) || tlsSpki.Length != 32
|
||||
|| !TryDecode(request.TlsPublicKey, out byte[] tlsPublicKey) || !NodeIdentity.IsValidPublicKey(tlsPublicKey)
|
||||
|| !TryDecode(request.Proof, out byte[] proof))
|
||||
{
|
||||
return Result<EnrollResponse>.Fail(FleetErrors.InvalidParams, "pubkey, tls_spki or proof is malformed");
|
||||
return Result<EnrollResponse>.Fail(FleetErrors.InvalidParams, "pubkey, tls_spki, tls_pubkey or proof is malformed");
|
||||
}
|
||||
|
||||
// 指纹和完整公钥必须是同一把密钥,否则对端按指纹固定证书、按公钥验签,会各认各的
|
||||
if (!System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(NodeIdentity.Fingerprint(tlsPublicKey), tlsSpki))
|
||||
{
|
||||
return Result<EnrollResponse>.Fail(FleetErrors.InvalidParams, "tls_spki does not match tls_pubkey");
|
||||
}
|
||||
|
||||
byte[] tokenHash = TokenHash.Sha256(request.JoinToken);
|
||||
@@ -61,8 +70,9 @@ public sealed partial class FleetEnrollmentService
|
||||
bool nameTaken = await _store.FindEstablishedNodeByNameAsync(request.Name, ct) is not null;
|
||||
NodeStatus status = token.AutoApprove && !nameTaken ? NodeStatus.Active : NodeStatus.Pending;
|
||||
|
||||
string prefix = token.Kind.Length > 0 && _options.NodeIdPrefixes.TryGetValue(token.Kind, out string? kindPrefix) ? kindPrefix : _options.NodeIdPrefix;
|
||||
var node = new NewNode(
|
||||
new NodeId(Ulid.NewPrefixed(_options.NodeIdPrefix, _time)),
|
||||
new NodeId(Ulid.NewPrefixed(prefix, _time)),
|
||||
request.Name,
|
||||
status,
|
||||
publicKey,
|
||||
@@ -70,7 +80,10 @@ public sealed partial class FleetEnrollmentService
|
||||
token.Labels,
|
||||
request.Meta?.GetRawText(),
|
||||
request.NodeVersion,
|
||||
nowMs);
|
||||
nowMs,
|
||||
token.Kind,
|
||||
tlsPublicKey,
|
||||
token.CreatedBy);
|
||||
|
||||
Result<NodeRecord> created = await _store.CreateNodeAsync(node, ct);
|
||||
if (!created.TryGet(out NodeRecord? record, out FleetError? error))
|
||||
@@ -91,6 +104,30 @@ public sealed partial class FleetEnrollmentService
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>节点注册之前取:操作员集合的链。码要是有效的;不能用的码不给。</summary>
|
||||
public async ValueTask<Result<TrustBundle>> GetTrustAsync(string joinToken, CancellationToken ct)
|
||||
{
|
||||
JoinTokenRecord? token = await _store.FindJoinTokenAsync(TokenHash.Sha256(joinToken), ct);
|
||||
long nowMs = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
if (token is null || token.RevokedAtMs is not null || token.Uses >= token.MaxUses)
|
||||
{
|
||||
return Result<TrustBundle>.Fail(FleetErrors.JoinTokenInvalid, "join token is invalid");
|
||||
}
|
||||
|
||||
if (token.ExpiresAtMs <= nowMs)
|
||||
{
|
||||
return Result<TrustBundle>.Fail(FleetErrors.JoinTokenExpired, "join token has expired");
|
||||
}
|
||||
|
||||
var bundle = new TrustBundle();
|
||||
foreach (IOperatorTrustSource source in _trust)
|
||||
{
|
||||
bundle.Chain.AddRange(await source.ChainAsync(ct));
|
||||
}
|
||||
|
||||
return Result<TrustBundle>.Ok(bundle);
|
||||
}
|
||||
|
||||
private static bool TryDecode(string text, out byte[] data)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -23,8 +23,10 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier
|
||||
IEnumerable<IStateProvider> stateProviders,
|
||||
IEnumerable<IInbox> inboxes,
|
||||
IEnumerable<INodeEventSink> sinks,
|
||||
FleetRouter router)
|
||||
FleetRouter router,
|
||||
IServerIdentity? serverIdentity = null)
|
||||
{
|
||||
ServerIdentity = serverIdentity;
|
||||
Store = store;
|
||||
Options = options;
|
||||
Time = time;
|
||||
@@ -33,10 +35,14 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier
|
||||
Inboxes = inboxes.ToDictionary(i => i.Channel);
|
||||
Sinks = [.. sinks];
|
||||
Router = router.Inner;
|
||||
RoleOf = router.RoleOf;
|
||||
}
|
||||
|
||||
internal IFleetStore Store { get; }
|
||||
|
||||
/// <summary>服务器的身份密钥;没有时节点连接用旧的明文认证。</summary>
|
||||
internal IServerIdentity? ServerIdentity { get; }
|
||||
|
||||
internal FleetServerOptions Options { get; }
|
||||
|
||||
internal TimeProvider Time { get; }
|
||||
@@ -51,6 +57,8 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier
|
||||
|
||||
internal RpcRouter<NodeCallContext> Router { get; }
|
||||
|
||||
internal Func<NodeRecord, string?>? RoleOf { get; }
|
||||
|
||||
public IReadOnlyCollection<NodeId> OnlineNodes => [.. _connections.Keys];
|
||||
|
||||
public bool IsOnline(NodeId node) => _connections.ContainsKey(node);
|
||||
@@ -161,10 +169,18 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier
|
||||
/// <summary>产品注册的 RPC 方法路由。</summary>
|
||||
public sealed class FleetRouter
|
||||
{
|
||||
public FleetRouter(RpcRouter<NodeCallContext> inner)
|
||||
public FleetRouter(RpcRouter<NodeCallContext> inner, Func<NodeRecord, string?>? roleOf)
|
||||
{
|
||||
Inner = inner;
|
||||
RoleOf = roleOf;
|
||||
}
|
||||
|
||||
internal RpcRouter<NodeCallContext> Inner { get; }
|
||||
|
||||
internal Func<NodeRecord, string?>? RoleOf { get; }
|
||||
|
||||
/// <summary>已注册的入站方法名(只读,测试用它核对声明表)。</summary>
|
||||
public IReadOnlyCollection<string> Methods => Inner.Methods;
|
||||
|
||||
public MethodTable Table => Inner.Table;
|
||||
}
|
||||
@@ -19,6 +19,9 @@ internal static partial class FleetLog
|
||||
[LoggerMessage(Level = LogLevel.Warning, Message = "state push failed node={Node} type={Type} err={Err}")]
|
||||
public static partial void StatePushFailed(ILogger logger, string node, string type, string err);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Warning, Message = "protocol violation node={Node} method={Method} reason={Reason}")]
|
||||
public static partial void ProtocolViolation(ILogger logger, string node, string method, string reason);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Error, Message = "unhandled error in handler method={Method}")]
|
||||
public static partial void HandlerFailed(ILogger logger, Exception exception, string method);
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ namespace Fleet.Server;
|
||||
public sealed class FleetServerBuilder
|
||||
{
|
||||
private readonly List<Action<RpcRouter<NodeCallContext>, IServiceProvider>> _routeConfigurators = [];
|
||||
private MethodTable? _methods;
|
||||
private Func<NodeRecord, string?>? _roleOf;
|
||||
|
||||
internal FleetServerBuilder(IServiceCollection services)
|
||||
{
|
||||
@@ -22,6 +24,21 @@ public sealed class FleetServerBuilder
|
||||
|
||||
internal IReadOnlyList<Action<RpcRouter<NodeCallContext>, IServiceProvider>> RouteConfigurators => _routeConfigurators;
|
||||
|
||||
internal MethodTable Methods => _methods ?? throw new InvalidOperationException("call WithMethods(...) when adding the Fleet server: the method table is required");
|
||||
|
||||
internal Func<NodeRecord, string?>? RoleOf => _roleOf;
|
||||
|
||||
/// <summary>
|
||||
/// 方法声明表(框架的加产品的,见 <see cref="MethodTable.Combine"/>)和节点的角色解析(例如按节点 ID 前缀区分 Agent 和 Probe)。必须设置:
|
||||
/// 路由器据此核对每个处理函数的方向、类型和角色,并拒绝没有声明或不该由节点发起的方法。
|
||||
/// </summary>
|
||||
public FleetServerBuilder WithMethods(MethodTable methods, Func<NodeRecord, string?> roleOf)
|
||||
{
|
||||
_methods = methods;
|
||||
_roleOf = roleOf;
|
||||
return this;
|
||||
}
|
||||
|
||||
public FleetServerBuilder AddState<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TProvider>()
|
||||
where TProvider : class, IStateProvider
|
||||
{
|
||||
@@ -71,13 +88,18 @@ public static class FleetServerExtensions
|
||||
services.AddSingleton(provider =>
|
||||
{
|
||||
ILogger logger = provider.GetRequiredService<ILoggerFactory>().CreateLogger("Fleet.Rpc");
|
||||
var router = new RpcRouter<NodeCallContext>((method, ex) => FleetLog.HandlerFailed(logger, ex, method));
|
||||
Func<NodeRecord, string?>? roleOf = builder.RoleOf;
|
||||
var router = new RpcRouter<NodeCallContext>(
|
||||
RpcSide.Server,
|
||||
builder.Methods,
|
||||
callerRole: roleOf is null ? null : context => roleOf(context.Node),
|
||||
onUnhandled: (method, ex) => FleetLog.HandlerFailed(logger, ex, method));
|
||||
foreach (Action<RpcRouter<NodeCallContext>, IServiceProvider> configurator in builder.RouteConfigurators)
|
||||
{
|
||||
configurator(router, provider);
|
||||
}
|
||||
|
||||
return new FleetRouter(router);
|
||||
return new FleetRouter(router, roleOf);
|
||||
});
|
||||
services.AddSingleton(provider => new FleetLinkHost(
|
||||
provider.GetRequiredService<IFleetStore>(),
|
||||
@@ -87,7 +109,8 @@ public static class FleetServerExtensions
|
||||
provider.GetServices<IStateProvider>(),
|
||||
provider.GetServices<IInbox>(),
|
||||
provider.GetServices<INodeEventSink>(),
|
||||
provider.GetRequiredService<FleetRouter>()));
|
||||
provider.GetRequiredService<FleetRouter>(),
|
||||
provider.GetService<IServerIdentity>()));
|
||||
services.AddSingleton<IFleetConnections>(provider => provider.GetRequiredService<FleetLinkHost>());
|
||||
services.AddSingleton<IStateNotifier>(provider => provider.GetRequiredService<FleetLinkHost>());
|
||||
return builder;
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
using System.Net.WebSockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Fleet.Core;
|
||||
using Fleet.Identity;
|
||||
using Fleet.Rpc;
|
||||
|
||||
namespace Fleet.Server;
|
||||
|
||||
/// <summary>认证:挑战、签名校验、拒绝。</summary>
|
||||
internal sealed partial class NodeConnection
|
||||
{
|
||||
private async Task<bool> AuthenticateAsync(CancellationToken ct)
|
||||
{
|
||||
string nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||
long expiresAtMs = _host.Time.GetUtcNow().Add(_host.Options.ChallengeTtl).ToUnixTimeMilliseconds();
|
||||
|
||||
// 有服务器身份密钥:挑战里带上这次连接的临时公钥和服务器的签名,节点据此确认服务器、协商加密链路
|
||||
using var serverEphemeral = _host.ServerIdentity is null ? null : new EphemeralKey();
|
||||
var challenge = new ChallengeParams { Nonce = nonce, ExpiresAtMs = expiresAtMs };
|
||||
string? serverEphemeralText = null;
|
||||
if (_host.ServerIdentity is { } identity && serverEphemeral is not null)
|
||||
{
|
||||
serverEphemeralText = Convert.ToBase64String(serverEphemeral.PublicKeySpki);
|
||||
challenge.ServerEphemeral = serverEphemeralText;
|
||||
challenge.ServerSignature = Convert.ToBase64String(identity.Sign(ServerHello.Payload(nonce, serverEphemeral.PublicKeySpki)));
|
||||
}
|
||||
|
||||
await SendAsync(RpcMessage.Notification(FleetMethods.Challenge, challenge, FleetRpcContext.Default.ChallengeParams), ct);
|
||||
|
||||
using var timeout = new CancellationTokenSource(s_authTimeout, _host.Time);
|
||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
|
||||
Frame? frame;
|
||||
try
|
||||
{
|
||||
frame = await ReceiveFrameAsync(linked.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
await RejectAsync(null, "auth timeout", ct);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (frame is not { IsText: true } || !TryParse(frame.Data, out RpcMessage? message) || message.Method != FleetMethods.Auth || message.Id is null)
|
||||
{
|
||||
await RejectAsync(null, "expected fleet.auth", ct);
|
||||
return false;
|
||||
}
|
||||
|
||||
AuthParams? auth = null;
|
||||
try
|
||||
{
|
||||
auth = message.Params?.Deserialize(FleetRpcContext.Default.AuthParams);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// 按缺失处理
|
||||
}
|
||||
|
||||
NodeRecord? node = auth is null ? null : await _host.Store.GetNodeAsync(new NodeId(auth.NodeId), ct);
|
||||
|
||||
// 安全链路:节点的签名覆盖双方的临时公钥(AuthSecure),验过之后这条连接上的所有帧都是加密的
|
||||
byte[] nodeEphemeral = [];
|
||||
bool secure = auth?.NodeEphemeral is not null && serverEphemeral is not null && serverEphemeralText is not null && TryDecode(auth.NodeEphemeral, out nodeEphemeral);
|
||||
bool signatureOk = node is not null
|
||||
&& _host.Time.GetUtcNow().ToUnixTimeMilliseconds() <= expiresAtMs
|
||||
&& TryDecode(auth!.Signature, out byte[] signature)
|
||||
&& (secure
|
||||
? NodeIdentity.Verify(node.PublicKey, signature, SignaturePurpose.AuthSecure, auth.NodeId, nonce, serverEphemeralText!, auth.NodeEphemeral!)
|
||||
: auth.NodeEphemeral is null && NodeIdentity.Verify(node.PublicKey, signature, SignaturePurpose.Auth, auth.NodeId, nonce));
|
||||
if (signatureOk && secure)
|
||||
{
|
||||
_cipher = LinkCipher.ForServer(serverEphemeral!, nodeEphemeral, nonce);
|
||||
}
|
||||
|
||||
if (!signatureOk)
|
||||
{
|
||||
// 节点不存在与签名错误返回同样的错误,不泄露节点是否存在。
|
||||
await RejectAsync(message.Id, "authentication failed", ct);
|
||||
return false;
|
||||
}
|
||||
|
||||
_node = node;
|
||||
if (node!.Status == NodeStatus.Disabled)
|
||||
{
|
||||
await SendAsync(RpcMessage.Failure(message.Id, new FleetError(FleetErrors.NodeDisabled, "node is disabled")), ct);
|
||||
await RevokeAsync("disabled", ct);
|
||||
return false;
|
||||
}
|
||||
|
||||
_session = new NodeSessionInfo(node.Id, _host.Time.GetUtcNow().ToUnixTimeMilliseconds(), node.NodeVersion, [], null);
|
||||
await SendAsync(
|
||||
RpcMessage.Success(message.Id.Value, new AuthResult { Status = StatusName(node.Status) }, FleetRpcContext.Default.AuthResult),
|
||||
ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task RejectAsync(long? id, string reason, CancellationToken ct)
|
||||
{
|
||||
FleetLog.AuthRejected(_host.Logger, reason);
|
||||
_closeReason ??= reason;
|
||||
if (id is not null)
|
||||
{
|
||||
await SendAsync(RpcMessage.Failure(id, new FleetError(FleetErrors.Unauthorized, "authentication failed")), ct);
|
||||
}
|
||||
|
||||
if (_socket.State == WebSocketState.Open)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _socket.CloseOutputAsync(WebSocketCloseStatus.PolicyViolation, "unauthorized", ct);
|
||||
}
|
||||
catch (WebSocketException)
|
||||
{
|
||||
// 对端已经断开
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
using System.Text.Json;
|
||||
using Fleet.Core;
|
||||
using Fleet.Rpc;
|
||||
|
||||
namespace Fleet.Server;
|
||||
|
||||
/// <summary>接收:读帧、分发消息、hello、心跳、二进制通道(收件箱)。</summary>
|
||||
internal sealed partial class NodeConnection
|
||||
{
|
||||
private async Task ReceiveLoopAsync(CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
using var idle = new CancellationTokenSource(_host.Options.HeartbeatTimeout, _host.Time);
|
||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, idle.Token);
|
||||
Frame? frame;
|
||||
try
|
||||
{
|
||||
frame = await ReceiveFrameAsync(linked.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (idle.IsCancellationRequested && !ct.IsCancellationRequested)
|
||||
{
|
||||
_closeReason ??= "heartbeat timeout";
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame is null)
|
||||
{
|
||||
_closeReason ??= "closed by peer";
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.IsText)
|
||||
{
|
||||
if (TryParse(frame.Data, out RpcMessage? message))
|
||||
{
|
||||
await HandleMessageAsync(message, ct);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await HandleBinaryAsync(frame.Data, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleMessageAsync(RpcMessage message, CancellationToken ct)
|
||||
{
|
||||
if (message.IsResponse)
|
||||
{
|
||||
if (_pending.TryGetValue(message.Id!.Value, out TaskCompletionSource<RpcMessage>? waiter))
|
||||
{
|
||||
waiter.TrySetResult(message);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
switch (message.Method)
|
||||
{
|
||||
case FleetMethods.Hello:
|
||||
await HandleHelloAsync(message, ct);
|
||||
return;
|
||||
case FleetMethods.Heartbeat:
|
||||
await HandleHeartbeatAsync(message, ct);
|
||||
return;
|
||||
case FleetMethods.Stopping:
|
||||
try
|
||||
{
|
||||
string? reason = message.Params?.Deserialize(FleetRpcContext.Default.StoppingParams)?.Reason;
|
||||
_closeReason = reason is "updating" ? "updating" : "stopping";
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
_closeReason = "stopping";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (_node!.Status != NodeStatus.Active)
|
||||
{
|
||||
if (message.Id is not null)
|
||||
{
|
||||
await SendAsync(RpcMessage.Failure(message.Id, new FleetError(FleetErrors.NodePending, "node is not approved yet")), ct);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var context = new NodeCallContext(_node, _session!, _host);
|
||||
RpcDispatch outcome;
|
||||
try
|
||||
{
|
||||
outcome = await _host.Router.DispatchCheckedAsync(context, message, ct);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
|
||||
if (outcome.Response is not null)
|
||||
{
|
||||
await SendAsync(outcome.Response, ct);
|
||||
}
|
||||
|
||||
await NoteViolationAsync(outcome.Violation);
|
||||
}
|
||||
|
||||
/// <summary>记录节点的违规;超过上限就断开这条连接。</summary>
|
||||
private async Task NoteViolationAsync(RpcViolation? violation)
|
||||
{
|
||||
if (violation is not { } v)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
FleetLog.ProtocolViolation(_host.Logger, _node!.Id.Value, v.Method, v.Reason);
|
||||
if (++_violations > _host.Options.MaxViolations)
|
||||
{
|
||||
await CloseAsync("protocol violations");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleHelloAsync(RpcMessage message, CancellationToken ct)
|
||||
{
|
||||
HelloParams? hello = null;
|
||||
try
|
||||
{
|
||||
hello = message.Params?.Deserialize(FleetRpcContext.Default.HelloParams);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// 按缺失处理
|
||||
}
|
||||
|
||||
if (hello is null)
|
||||
{
|
||||
if (message.Id is not null)
|
||||
{
|
||||
await SendAsync(RpcMessage.Failure(message.Id, new FleetError(FleetErrors.InvalidParams, "invalid hello")), ct);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
long nowMs = _host.Time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
_session = _session! with
|
||||
{
|
||||
NodeVersion = hello.NodeVersion,
|
||||
Capabilities = hello.Capabilities,
|
||||
HelloMetaJson = hello.Meta?.GetRawText(),
|
||||
};
|
||||
_node = _node! with { NodeVersion = hello.NodeVersion };
|
||||
_helloDone = true;
|
||||
|
||||
await UpdatePresenceAsync(online: true, ct);
|
||||
await FireAsync(NodeEventKind.Hello, ct, hello);
|
||||
|
||||
if (message.Id is not null)
|
||||
{
|
||||
await SendAsync(
|
||||
RpcMessage.Success(
|
||||
message.Id.Value,
|
||||
new HelloResult { ServerTimeMs = nowMs, ClockOffsetMs = hello.NowUtcMs - nowMs },
|
||||
FleetRpcContext.Default.HelloResult),
|
||||
ct);
|
||||
}
|
||||
|
||||
SignalStateChanged();
|
||||
}
|
||||
|
||||
private async Task HandleHeartbeatAsync(RpcMessage message, CancellationToken ct)
|
||||
{
|
||||
JsonElement? extra = null;
|
||||
try
|
||||
{
|
||||
extra = message.Params?.Deserialize(FleetRpcContext.Default.HeartbeatParams)?.Extra;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// 忽略格式不正确的附加字段
|
||||
}
|
||||
|
||||
long now = _host.Time.GetTimestamp();
|
||||
if (_host.Time.GetElapsedTime(Interlocked.Read(ref _lastPresenceTicks), now) >= s_presenceRefresh)
|
||||
{
|
||||
await UpdatePresenceAsync(online: true, ct);
|
||||
}
|
||||
|
||||
await FireAsync(NodeEventKind.Heartbeat, ct, hello: null, extra);
|
||||
if (message.Id is not null)
|
||||
{
|
||||
await SendAsync(new RpcMessage { Id = message.Id, Result = JsonSerializer.SerializeToElement(new EmptyObject(), FleetEmptyObject.Info) }, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleBinaryAsync(byte[] data, CancellationToken ct)
|
||||
{
|
||||
if (_node!.Status != NodeStatus.Active || !OutboxFrame.TryParse(data, out byte channel, out Guid msgId, out ReadOnlyMemory<byte> payload))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 通道也要声明:节点只能往声明过、并且允许它的角色使用的通道发
|
||||
ChannelSpec? spec = _host.Router.Table.FindChannel(RpcPlane.Node, channel);
|
||||
if (spec is null || spec.From != RpcSide.Node || !spec.AllowsRole(_host.RoleOf?.Invoke(_node)))
|
||||
{
|
||||
await NoteViolationAsync(new RpcViolation($"channel {channel}", $"channel {channel} is not declared for this node"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_host.Inboxes.TryGetValue(channel, out IInbox? inbox))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
InboxOutcome outcome;
|
||||
try
|
||||
{
|
||||
outcome = await inbox.HandleAsync(_node, msgId, payload, ct);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 没有持久化就不确认,节点会重发。
|
||||
FleetLog.InboxFailed(_host.Logger, ex, _node.Id.Value, channel);
|
||||
return;
|
||||
}
|
||||
|
||||
if (outcome == InboxOutcome.Rejected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var ack = new OutboxAckParams();
|
||||
string id = msgId.ToString();
|
||||
if (outcome == InboxOutcome.Duplicate)
|
||||
{
|
||||
ack.Duplicates.Add(id);
|
||||
}
|
||||
else
|
||||
{
|
||||
ack.Ids.Add(id);
|
||||
}
|
||||
|
||||
await SendAsync(RpcMessage.Notification(FleetMethods.OutboxAck, ack, FleetRpcContext.Default.OutboxAckParams), ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using System.Net.WebSockets;
|
||||
using System.Text.Json;
|
||||
using Fleet.Core;
|
||||
using Fleet.Rpc;
|
||||
|
||||
namespace Fleet.Server;
|
||||
|
||||
/// <summary>发给节点:关闭、吊销、发送一条消息、发起请求并等响应。</summary>
|
||||
internal sealed partial class NodeConnection
|
||||
{
|
||||
/// <summary>
|
||||
/// 服务端发起的关闭:发送 Close 帧,等对端确认之后接收循环自然结束。
|
||||
/// 注意不能直接取消接收的令牌——取消 <c>ReceiveAsync</c> 会中止整个 WebSocket,对端拿不到关闭原因;取消只作为超时兜底。
|
||||
/// </summary>
|
||||
public async ValueTask CloseAsync(string reason)
|
||||
{
|
||||
_closeReason ??= reason;
|
||||
try
|
||||
{
|
||||
await _sendLock.WaitAsync(CancellationToken.None);
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_socket.State == WebSocketState.Open)
|
||||
{
|
||||
await _socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, reason, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
catch (WebSocketException)
|
||||
{
|
||||
// 对端已经断开
|
||||
}
|
||||
finally
|
||||
{
|
||||
_sendLock.Release();
|
||||
}
|
||||
|
||||
_lifetime.CancelAfter(s_closeTimeout);
|
||||
}
|
||||
|
||||
public async ValueTask RevokeAsync(string reason, CancellationToken ct)
|
||||
{
|
||||
await SendAsync(RpcMessage.Notification(FleetMethods.Revoked, new RevokedParams { Reason = reason }, FleetRpcContext.Default.RevokedParams), ct);
|
||||
await CloseAsync(reason);
|
||||
}
|
||||
|
||||
public async ValueTask<bool> SendAsync(RpcMessage message, CancellationToken ct)
|
||||
{
|
||||
if (message.Method is not null)
|
||||
{
|
||||
// 服务端只能发自己有资格发起的方法,并且对端的角色要允许(响应没有方法名,不检查)
|
||||
string? peerRole = _node is null ? null : _host.RoleOf?.Invoke(_node);
|
||||
_host.Router.CheckSend(message.Method, message.Id is null ? MethodKind.Notification : MethodKind.Request, peerRole);
|
||||
}
|
||||
|
||||
byte[] bytes = JsonSerializer.SerializeToUtf8Bytes(message, FleetRpcContext.Default.RpcMessage);
|
||||
try
|
||||
{
|
||||
await _sendLock.WaitAsync(ct);
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_socket.State != WebSocketState.Open)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_cipher is { } cipher)
|
||||
{
|
||||
await _socket.SendAsync(cipher.Seal(0, bytes), WebSocketMessageType.Binary, endOfMessage: true, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _socket.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, ct);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (WebSocketException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_sendLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<Result<JsonElement>> RequestAsync(string method, JsonElement parameters, CancellationToken ct)
|
||||
{
|
||||
long id = Interlocked.Increment(ref _nextRequestId);
|
||||
var waiter = new TaskCompletionSource<RpcMessage>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_pending[id] = waiter;
|
||||
try
|
||||
{
|
||||
var request = new RpcMessage { Id = id, Method = method, Params = parameters };
|
||||
if (!await SendAsync(request, ct))
|
||||
{
|
||||
return Result<JsonElement>.Fail(FleetErrors.NodeOffline, "node is not connected");
|
||||
}
|
||||
|
||||
using var timeout = new CancellationTokenSource(_host.Options.RequestTimeout, _host.Time);
|
||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
|
||||
RpcMessage response = await waiter.Task.WaitAsync(linked.Token);
|
||||
return response.Error is not null
|
||||
? Result<JsonElement>.Fail(response.Error.ToFleetError())
|
||||
: Result<JsonElement>.Ok(response.Result ?? default);
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
return Result<JsonElement>.Fail(FleetErrors.Timeout, $"{method} timed out");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_pending.TryRemove(id, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using System.Text.Json;
|
||||
using Fleet.Core;
|
||||
using Fleet.Rpc;
|
||||
|
||||
namespace Fleet.Server;
|
||||
|
||||
/// <summary>状态:期望状态推送、在线状态、节点事件。</summary>
|
||||
internal sealed partial class NodeConnection
|
||||
{
|
||||
public void SignalStateChanged() => _stateSignal.Writer.TryWrite(true);
|
||||
|
||||
|
||||
private async Task StatePushLoopAsync(CancellationToken ct)
|
||||
{
|
||||
while (await _stateSignal.Reader.WaitToReadAsync(ct))
|
||||
{
|
||||
_stateSignal.Reader.TryRead(out _);
|
||||
bool complete = await PushStateAsync(ct);
|
||||
if (!complete)
|
||||
{
|
||||
await Task.Delay(s_statePushRetry, _host.Time, ct);
|
||||
SignalStateChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> PushStateAsync(CancellationToken ct)
|
||||
{
|
||||
NodeRecord? fresh = await _host.Store.GetNodeAsync(_node!.Id, ct);
|
||||
if (fresh is null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
_node = fresh with { NodeVersion = _node.NodeVersion ?? fresh.NodeVersion };
|
||||
if (fresh.Status == NodeStatus.Disabled)
|
||||
{
|
||||
await RevokeAsync("disabled", ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (fresh.Status != NodeStatus.Active || !_helloDone)
|
||||
{
|
||||
// pending 不下发状态;还没有 hello 的连接等 hello 之后再推送。
|
||||
return true;
|
||||
}
|
||||
|
||||
bool complete = true;
|
||||
foreach (IStateProvider provider in _host.StateProviders)
|
||||
{
|
||||
StateDocument? document = await provider.GetAsync(_node, ct);
|
||||
if (document is null || (_lastPushedVersion.TryGetValue(provider.Type, out long last) && last == document.Version))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Result<JsonElement> raw = await RequestAsync(
|
||||
FleetMethods.StateSync,
|
||||
JsonSerializer.SerializeToElement(
|
||||
new StateSyncParams { Type = provider.Type, Version = document.Version, Doc = document.Doc },
|
||||
FleetRpcContext.Default.StateSyncParams),
|
||||
ct);
|
||||
if (!raw.TryGet(out JsonElement value, out FleetError? error))
|
||||
{
|
||||
FleetLog.StatePushFailed(_host.Logger, _node.Id.Value, provider.Type, error.Code.Value);
|
||||
complete = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
StateSyncResult? result = null;
|
||||
try
|
||||
{
|
||||
result = value.Deserialize(FleetRpcContext.Default.StateSyncResult);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// 按缺失处理
|
||||
}
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
complete = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
_lastPushedVersion[provider.Type] = document.Version;
|
||||
await provider.OnAppliedAsync(_node, document.Version, result, ct);
|
||||
}
|
||||
|
||||
return complete;
|
||||
}
|
||||
|
||||
|
||||
private async Task UpdatePresenceAsync(bool online, CancellationToken ct)
|
||||
{
|
||||
Interlocked.Exchange(ref _lastPresenceTicks, _host.Time.GetTimestamp());
|
||||
await _host.Store.UpdatePresenceAsync(
|
||||
_node!.Id,
|
||||
new Presence(online, _host.Time.GetUtcNow().ToUnixTimeMilliseconds(), _session?.NodeVersion, _session?.Capabilities ?? [], _session?.HelloMetaJson),
|
||||
ct);
|
||||
}
|
||||
|
||||
private async Task FireAsync(NodeEventKind kind, CancellationToken ct, HelloParams? hello = null, JsonElement? extra = null)
|
||||
{
|
||||
var nodeEvent = new NodeEvent(kind, _node!, _host.Time.GetUtcNow().ToUnixTimeMilliseconds(), hello, extra, kind == NodeEventKind.Offline ? _closeReason : null);
|
||||
foreach (INodeEventSink sink in _host.Sinks)
|
||||
{
|
||||
try
|
||||
{
|
||||
await sink.OnEventAsync(nodeEvent, ct);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 产品的事件处理失败不能影响连接。
|
||||
FleetLog.SinkFailed(_host.Logger, ex, kind.ToString(), _node!.Id.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using System.Buffers;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text.Json;
|
||||
using Fleet.Rpc;
|
||||
|
||||
namespace Fleet.Server;
|
||||
|
||||
/// <summary>线上的帧:WebSocket 收帧、解析、状态名。</summary>
|
||||
internal sealed partial class NodeConnection
|
||||
{
|
||||
private async ValueTask<Frame?> ReceiveFrameAsync(CancellationToken ct)
|
||||
{
|
||||
var writer = new ArrayBufferWriter<byte>(1024);
|
||||
while (true)
|
||||
{
|
||||
Memory<byte> buffer = writer.GetMemory(4096);
|
||||
ValueWebSocketReceiveResult result = await _socket.ReceiveAsync(buffer, ct);
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
_closeReason ??= _socket.CloseStatusDescription;
|
||||
return null;
|
||||
}
|
||||
|
||||
writer.Advance(result.Count);
|
||||
if (writer.WrittenCount > _host.Options.MaxFrameBytes)
|
||||
{
|
||||
_closeReason ??= "frame too large";
|
||||
await _socket.CloseOutputAsync(WebSocketCloseStatus.MessageTooBig, "frame too large", CancellationToken.None);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (result.EndOfMessage)
|
||||
{
|
||||
if (_cipher is { } cipher)
|
||||
{
|
||||
// 加密链路上只接受通过认证的二进制帧;明文、被改过的、重放的、乱序的一律断开
|
||||
if (result.MessageType != WebSocketMessageType.Binary || !cipher.TryOpen(writer.WrittenSpan, out byte kind, out byte[] plain))
|
||||
{
|
||||
_closeReason ??= "frame failed authentication";
|
||||
await _socket.CloseOutputAsync(WebSocketCloseStatus.PolicyViolation, "frame failed authentication", CancellationToken.None);
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Frame(kind == 0, plain);
|
||||
}
|
||||
|
||||
return new Frame(result.MessageType == WebSocketMessageType.Text, writer.WrittenSpan.ToArray());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParse(byte[] data, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out RpcMessage? message)
|
||||
{
|
||||
try
|
||||
{
|
||||
message = JsonSerializer.Deserialize(data, FleetRpcContext.Default.RpcMessage);
|
||||
return message is not null;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
message = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryDecode(string text, out byte[] data)
|
||||
{
|
||||
try
|
||||
{
|
||||
data = Convert.FromBase64String(text);
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
data = [];
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string StatusName(NodeStatus status) => status switch
|
||||
{
|
||||
NodeStatus.Active => "active",
|
||||
NodeStatus.Pending => "pending",
|
||||
_ => "disabled",
|
||||
};
|
||||
|
||||
private sealed record Frame(bool IsText, byte[] Data);
|
||||
}
|
||||
|
||||
|
||||
[System.Text.Json.Serialization.JsonSerializable(typeof(EmptyObject))]
|
||||
internal sealed partial class FleetEmptyObjectContext : System.Text.Json.Serialization.JsonSerializerContext;
|
||||
|
||||
internal sealed class EmptyObject;
|
||||
|
||||
internal static class FleetEmptyObject
|
||||
{
|
||||
public static System.Text.Json.Serialization.Metadata.JsonTypeInfo<EmptyObject> Info => FleetEmptyObjectContext.Default.EmptyObject;
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
using System.Buffers;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.WebSockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading.Channels;
|
||||
using System.Text.Json;
|
||||
using Fleet.Core;
|
||||
using Fleet.Identity;
|
||||
using Fleet.Rpc;
|
||||
@@ -14,7 +11,7 @@ namespace Fleet.Server;
|
||||
/// 一个节点的 WebSocket 会话:挑战 → 签名认证 → hello → 状态推送 / 收件箱 / 产品 RPC。
|
||||
/// 收到的消息按顺序处理,所以产品的处理函数不能等待“向同一个节点发起的请求”,否则会死锁。
|
||||
/// </summary>
|
||||
internal sealed class NodeConnection : IDisposable
|
||||
internal sealed partial class NodeConnection : IDisposable
|
||||
{
|
||||
private static readonly TimeSpan s_authTimeout = TimeSpan.FromSeconds(15);
|
||||
private static readonly TimeSpan s_presenceRefresh = TimeSpan.FromSeconds(60);
|
||||
@@ -28,12 +25,14 @@ internal sealed class NodeConnection : IDisposable
|
||||
private readonly Channel<bool> _stateSignal = Channel.CreateBounded<bool>(new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropWrite });
|
||||
private readonly Dictionary<string, long> _lastPushedVersion = new(StringComparer.Ordinal);
|
||||
private readonly CancellationTokenSource _lifetime = new();
|
||||
private LinkCipher? _cipher;
|
||||
private long _nextRequestId;
|
||||
private long _lastPresenceTicks;
|
||||
private NodeRecord? _node;
|
||||
private NodeSessionInfo? _session;
|
||||
private string? _closeReason;
|
||||
private bool _helloDone;
|
||||
private int _violations;
|
||||
|
||||
public NodeConnection(FleetLinkHost host, WebSocket socket)
|
||||
{
|
||||
@@ -137,593 +136,8 @@ internal sealed class NodeConnection : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cipher?.Dispose();
|
||||
_lifetime.Dispose();
|
||||
_sendLock.Dispose();
|
||||
}
|
||||
|
||||
public void SignalStateChanged() => _stateSignal.Writer.TryWrite(true);
|
||||
|
||||
/// <summary>
|
||||
/// 服务端发起的关闭:发送 Close 帧,等对端确认之后接收循环自然结束。
|
||||
/// 注意不能直接取消接收的令牌——取消 <c>ReceiveAsync</c> 会中止整个 WebSocket,对端拿不到关闭原因;取消只作为超时兜底。
|
||||
/// </summary>
|
||||
public async ValueTask CloseAsync(string reason)
|
||||
{
|
||||
_closeReason ??= reason;
|
||||
try
|
||||
{
|
||||
await _sendLock.WaitAsync(CancellationToken.None);
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_socket.State == WebSocketState.Open)
|
||||
{
|
||||
await _socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, reason, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
catch (WebSocketException)
|
||||
{
|
||||
// 对端已经断开
|
||||
}
|
||||
finally
|
||||
{
|
||||
_sendLock.Release();
|
||||
}
|
||||
|
||||
_lifetime.CancelAfter(s_closeTimeout);
|
||||
}
|
||||
|
||||
public async ValueTask RevokeAsync(string reason, CancellationToken ct)
|
||||
{
|
||||
await SendAsync(RpcMessage.Notification(FleetMethods.Revoked, new RevokedParams { Reason = reason }, FleetRpcContext.Default.RevokedParams), ct);
|
||||
await CloseAsync(reason);
|
||||
}
|
||||
|
||||
public async ValueTask<bool> SendAsync(RpcMessage message, CancellationToken ct)
|
||||
{
|
||||
byte[] bytes = JsonSerializer.SerializeToUtf8Bytes(message, FleetRpcContext.Default.RpcMessage);
|
||||
try
|
||||
{
|
||||
await _sendLock.WaitAsync(ct);
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_socket.State != WebSocketState.Open)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
await _socket.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, ct);
|
||||
return true;
|
||||
}
|
||||
catch (WebSocketException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_sendLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<Result<JsonElement>> RequestAsync(string method, JsonElement parameters, CancellationToken ct)
|
||||
{
|
||||
long id = Interlocked.Increment(ref _nextRequestId);
|
||||
var waiter = new TaskCompletionSource<RpcMessage>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_pending[id] = waiter;
|
||||
try
|
||||
{
|
||||
var request = new RpcMessage { Id = id, Method = method, Params = parameters };
|
||||
if (!await SendAsync(request, ct))
|
||||
{
|
||||
return Result<JsonElement>.Fail(FleetErrors.NodeOffline, "node is not connected");
|
||||
}
|
||||
|
||||
using var timeout = new CancellationTokenSource(_host.Options.RequestTimeout, _host.Time);
|
||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
|
||||
RpcMessage response = await waiter.Task.WaitAsync(linked.Token);
|
||||
return response.Error is not null
|
||||
? Result<JsonElement>.Fail(response.Error.ToFleetError())
|
||||
: Result<JsonElement>.Ok(response.Result ?? default);
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
return Result<JsonElement>.Fail(FleetErrors.Timeout, $"{method} timed out");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_pending.TryRemove(id, out _);
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────── 认证 ─────────────────────────
|
||||
|
||||
private async Task<bool> AuthenticateAsync(CancellationToken ct)
|
||||
{
|
||||
string nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||
long expiresAtMs = _host.Time.GetUtcNow().Add(_host.Options.ChallengeTtl).ToUnixTimeMilliseconds();
|
||||
await SendAsync(
|
||||
RpcMessage.Notification(FleetMethods.Challenge, new ChallengeParams { Nonce = nonce, ExpiresAtMs = expiresAtMs }, FleetRpcContext.Default.ChallengeParams),
|
||||
ct);
|
||||
|
||||
using var timeout = new CancellationTokenSource(s_authTimeout, _host.Time);
|
||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
|
||||
Frame? frame;
|
||||
try
|
||||
{
|
||||
frame = await ReceiveFrameAsync(linked.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
await RejectAsync(null, "auth timeout", ct);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (frame is not { IsText: true } || !TryParse(frame.Data, out RpcMessage? message) || message.Method != FleetMethods.Auth || message.Id is null)
|
||||
{
|
||||
await RejectAsync(null, "expected fleet.auth", ct);
|
||||
return false;
|
||||
}
|
||||
|
||||
AuthParams? auth = null;
|
||||
try
|
||||
{
|
||||
auth = message.Params?.Deserialize(FleetRpcContext.Default.AuthParams);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// 按缺失处理
|
||||
}
|
||||
|
||||
NodeRecord? node = auth is null ? null : await _host.Store.GetNodeAsync(new NodeId(auth.NodeId), ct);
|
||||
bool signatureOk = node is not null
|
||||
&& _host.Time.GetUtcNow().ToUnixTimeMilliseconds() <= expiresAtMs
|
||||
&& TryDecode(auth!.Signature, out byte[] signature)
|
||||
&& NodeIdentity.Verify(node.PublicKey, signature, SignaturePurpose.Auth, auth.NodeId, nonce);
|
||||
if (!signatureOk)
|
||||
{
|
||||
// 节点不存在与签名错误返回同样的错误,不泄露节点是否存在。
|
||||
await RejectAsync(message.Id, "authentication failed", ct);
|
||||
return false;
|
||||
}
|
||||
|
||||
_node = node;
|
||||
if (node!.Status == NodeStatus.Disabled)
|
||||
{
|
||||
await SendAsync(RpcMessage.Failure(message.Id, new FleetError(FleetErrors.NodeDisabled, "node is disabled")), ct);
|
||||
await RevokeAsync("disabled", ct);
|
||||
return false;
|
||||
}
|
||||
|
||||
_session = new NodeSessionInfo(node.Id, _host.Time.GetUtcNow().ToUnixTimeMilliseconds(), node.NodeVersion, [], null);
|
||||
await SendAsync(
|
||||
RpcMessage.Success(message.Id.Value, new AuthResult { Status = StatusName(node.Status) }, FleetRpcContext.Default.AuthResult),
|
||||
ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task RejectAsync(long? id, string reason, CancellationToken ct)
|
||||
{
|
||||
FleetLog.AuthRejected(_host.Logger, reason);
|
||||
_closeReason ??= reason;
|
||||
if (id is not null)
|
||||
{
|
||||
await SendAsync(RpcMessage.Failure(id, new FleetError(FleetErrors.Unauthorized, "authentication failed")), ct);
|
||||
}
|
||||
|
||||
if (_socket.State == WebSocketState.Open)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _socket.CloseOutputAsync(WebSocketCloseStatus.PolicyViolation, "unauthorized", ct);
|
||||
}
|
||||
catch (WebSocketException)
|
||||
{
|
||||
// 对端已经断开
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────── 接收循环 ─────────────────────────
|
||||
|
||||
private async Task ReceiveLoopAsync(CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
using var idle = new CancellationTokenSource(_host.Options.HeartbeatTimeout, _host.Time);
|
||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, idle.Token);
|
||||
Frame? frame;
|
||||
try
|
||||
{
|
||||
frame = await ReceiveFrameAsync(linked.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (idle.IsCancellationRequested && !ct.IsCancellationRequested)
|
||||
{
|
||||
_closeReason ??= "heartbeat timeout";
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame is null)
|
||||
{
|
||||
_closeReason ??= "closed by peer";
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.IsText)
|
||||
{
|
||||
if (TryParse(frame.Data, out RpcMessage? message))
|
||||
{
|
||||
await HandleMessageAsync(message, ct);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await HandleBinaryAsync(frame.Data, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleMessageAsync(RpcMessage message, CancellationToken ct)
|
||||
{
|
||||
if (message.IsResponse)
|
||||
{
|
||||
if (_pending.TryGetValue(message.Id!.Value, out TaskCompletionSource<RpcMessage>? waiter))
|
||||
{
|
||||
waiter.TrySetResult(message);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
switch (message.Method)
|
||||
{
|
||||
case FleetMethods.Hello:
|
||||
await HandleHelloAsync(message, ct);
|
||||
return;
|
||||
case FleetMethods.Heartbeat:
|
||||
await HandleHeartbeatAsync(message, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_node!.Status != NodeStatus.Active)
|
||||
{
|
||||
if (message.Id is not null)
|
||||
{
|
||||
await SendAsync(RpcMessage.Failure(message.Id, new FleetError(FleetErrors.NodePending, "node is not approved yet")), ct);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var context = new NodeCallContext(_node, _session!, _host);
|
||||
RpcMessage? response;
|
||||
try
|
||||
{
|
||||
response = await _host.Router.DispatchAsync(context, message, ct);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
|
||||
if (response is not null)
|
||||
{
|
||||
await SendAsync(response, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleHelloAsync(RpcMessage message, CancellationToken ct)
|
||||
{
|
||||
HelloParams? hello = null;
|
||||
try
|
||||
{
|
||||
hello = message.Params?.Deserialize(FleetRpcContext.Default.HelloParams);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// 按缺失处理
|
||||
}
|
||||
|
||||
if (hello is null)
|
||||
{
|
||||
if (message.Id is not null)
|
||||
{
|
||||
await SendAsync(RpcMessage.Failure(message.Id, new FleetError(FleetErrors.InvalidParams, "invalid hello")), ct);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
long nowMs = _host.Time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
_session = _session! with
|
||||
{
|
||||
NodeVersion = hello.NodeVersion,
|
||||
Capabilities = hello.Capabilities,
|
||||
HelloMetaJson = hello.Meta?.GetRawText(),
|
||||
};
|
||||
_node = _node! with { NodeVersion = hello.NodeVersion };
|
||||
_helloDone = true;
|
||||
|
||||
await UpdatePresenceAsync(online: true, ct);
|
||||
await FireAsync(NodeEventKind.Hello, ct, hello);
|
||||
|
||||
if (message.Id is not null)
|
||||
{
|
||||
await SendAsync(
|
||||
RpcMessage.Success(
|
||||
message.Id.Value,
|
||||
new HelloResult { ServerTimeMs = nowMs, ClockOffsetMs = hello.NowUtcMs - nowMs },
|
||||
FleetRpcContext.Default.HelloResult),
|
||||
ct);
|
||||
}
|
||||
|
||||
SignalStateChanged();
|
||||
}
|
||||
|
||||
private async Task HandleHeartbeatAsync(RpcMessage message, CancellationToken ct)
|
||||
{
|
||||
JsonElement? extra = null;
|
||||
try
|
||||
{
|
||||
extra = message.Params?.Deserialize(FleetRpcContext.Default.HeartbeatParams)?.Extra;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// 忽略格式不正确的附加字段
|
||||
}
|
||||
|
||||
long now = _host.Time.GetTimestamp();
|
||||
if (_host.Time.GetElapsedTime(Interlocked.Read(ref _lastPresenceTicks), now) >= s_presenceRefresh)
|
||||
{
|
||||
await UpdatePresenceAsync(online: true, ct);
|
||||
}
|
||||
|
||||
await FireAsync(NodeEventKind.Heartbeat, ct, hello: null, extra);
|
||||
if (message.Id is not null)
|
||||
{
|
||||
await SendAsync(new RpcMessage { Id = message.Id, Result = JsonSerializer.SerializeToElement(new EmptyObject(), FleetEmptyObject.Info) }, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleBinaryAsync(byte[] data, CancellationToken ct)
|
||||
{
|
||||
if (_node!.Status != NodeStatus.Active
|
||||
|| !OutboxFrame.TryParse(data, out byte channel, out Guid msgId, out ReadOnlyMemory<byte> payload)
|
||||
|| !_host.Inboxes.TryGetValue(channel, out IInbox? inbox))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
InboxOutcome outcome;
|
||||
try
|
||||
{
|
||||
outcome = await inbox.HandleAsync(_node, msgId, payload, ct);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 没有持久化就不确认,节点会重发。
|
||||
FleetLog.InboxFailed(_host.Logger, ex, _node.Id.Value, channel);
|
||||
return;
|
||||
}
|
||||
|
||||
if (outcome == InboxOutcome.Rejected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var ack = new OutboxAckParams();
|
||||
string id = msgId.ToString();
|
||||
if (outcome == InboxOutcome.Duplicate)
|
||||
{
|
||||
ack.Duplicates.Add(id);
|
||||
}
|
||||
else
|
||||
{
|
||||
ack.Ids.Add(id);
|
||||
}
|
||||
|
||||
await SendAsync(RpcMessage.Notification(FleetMethods.OutboxAck, ack, FleetRpcContext.Default.OutboxAckParams), ct);
|
||||
}
|
||||
|
||||
// ───────────────────────── 状态推送 ─────────────────────────
|
||||
|
||||
private async Task StatePushLoopAsync(CancellationToken ct)
|
||||
{
|
||||
while (await _stateSignal.Reader.WaitToReadAsync(ct))
|
||||
{
|
||||
_stateSignal.Reader.TryRead(out _);
|
||||
bool complete = await PushStateAsync(ct);
|
||||
if (!complete)
|
||||
{
|
||||
await Task.Delay(s_statePushRetry, _host.Time, ct);
|
||||
SignalStateChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> PushStateAsync(CancellationToken ct)
|
||||
{
|
||||
NodeRecord? fresh = await _host.Store.GetNodeAsync(_node!.Id, ct);
|
||||
if (fresh is null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
_node = fresh with { NodeVersion = _node.NodeVersion ?? fresh.NodeVersion };
|
||||
if (fresh.Status == NodeStatus.Disabled)
|
||||
{
|
||||
await RevokeAsync("disabled", ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (fresh.Status != NodeStatus.Active || !_helloDone)
|
||||
{
|
||||
// pending 不下发状态;还没有 hello 的连接等 hello 之后再推送。
|
||||
return true;
|
||||
}
|
||||
|
||||
bool complete = true;
|
||||
foreach (IStateProvider provider in _host.StateProviders)
|
||||
{
|
||||
StateDocument? document = await provider.GetAsync(_node, ct);
|
||||
if (document is null || (_lastPushedVersion.TryGetValue(provider.Type, out long last) && last == document.Version))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Result<JsonElement> raw = await RequestAsync(
|
||||
FleetMethods.StateSync,
|
||||
JsonSerializer.SerializeToElement(
|
||||
new StateSyncParams { Type = provider.Type, Version = document.Version, Doc = document.Doc },
|
||||
FleetRpcContext.Default.StateSyncParams),
|
||||
ct);
|
||||
if (!raw.TryGet(out JsonElement value, out FleetError? error))
|
||||
{
|
||||
FleetLog.StatePushFailed(_host.Logger, _node.Id.Value, provider.Type, error.Code.Value);
|
||||
complete = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
StateSyncResult? result = null;
|
||||
try
|
||||
{
|
||||
result = value.Deserialize(FleetRpcContext.Default.StateSyncResult);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// 按缺失处理
|
||||
}
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
complete = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
_lastPushedVersion[provider.Type] = document.Version;
|
||||
await provider.OnAppliedAsync(_node, document.Version, result, ct);
|
||||
}
|
||||
|
||||
return complete;
|
||||
}
|
||||
|
||||
// ───────────────────────── 帮助方法 ─────────────────────────
|
||||
|
||||
private async Task UpdatePresenceAsync(bool online, CancellationToken ct)
|
||||
{
|
||||
Interlocked.Exchange(ref _lastPresenceTicks, _host.Time.GetTimestamp());
|
||||
await _host.Store.UpdatePresenceAsync(
|
||||
_node!.Id,
|
||||
new Presence(online, _host.Time.GetUtcNow().ToUnixTimeMilliseconds(), _session?.NodeVersion, _session?.Capabilities ?? [], _session?.HelloMetaJson),
|
||||
ct);
|
||||
}
|
||||
|
||||
private async Task FireAsync(NodeEventKind kind, CancellationToken ct, HelloParams? hello = null, JsonElement? extra = null)
|
||||
{
|
||||
var nodeEvent = new NodeEvent(kind, _node!, _host.Time.GetUtcNow().ToUnixTimeMilliseconds(), hello, extra);
|
||||
foreach (INodeEventSink sink in _host.Sinks)
|
||||
{
|
||||
try
|
||||
{
|
||||
await sink.OnEventAsync(nodeEvent, ct);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 产品的事件处理失败不能影响连接。
|
||||
FleetLog.SinkFailed(_host.Logger, ex, kind.ToString(), _node!.Id.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask<Frame?> ReceiveFrameAsync(CancellationToken ct)
|
||||
{
|
||||
var writer = new ArrayBufferWriter<byte>(1024);
|
||||
while (true)
|
||||
{
|
||||
Memory<byte> buffer = writer.GetMemory(4096);
|
||||
ValueWebSocketReceiveResult result = await _socket.ReceiveAsync(buffer, ct);
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
writer.Advance(result.Count);
|
||||
if (writer.WrittenCount > _host.Options.MaxFrameBytes)
|
||||
{
|
||||
_closeReason ??= "frame too large";
|
||||
await _socket.CloseOutputAsync(WebSocketCloseStatus.MessageTooBig, "frame too large", CancellationToken.None);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (result.EndOfMessage)
|
||||
{
|
||||
return new Frame(result.MessageType == WebSocketMessageType.Text, writer.WrittenSpan.ToArray());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParse(byte[] data, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out RpcMessage? message)
|
||||
{
|
||||
try
|
||||
{
|
||||
message = JsonSerializer.Deserialize(data, FleetRpcContext.Default.RpcMessage);
|
||||
return message is not null;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
message = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryDecode(string text, out byte[] data)
|
||||
{
|
||||
try
|
||||
{
|
||||
data = Convert.FromBase64String(text);
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
data = [];
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string StatusName(NodeStatus status) => status switch
|
||||
{
|
||||
NodeStatus.Active => "active",
|
||||
NodeStatus.Pending => "pending",
|
||||
_ => "disabled",
|
||||
};
|
||||
|
||||
private sealed record Frame(bool IsText, byte[] Data);
|
||||
}
|
||||
|
||||
[System.Text.Json.Serialization.JsonSerializable(typeof(EmptyObject))]
|
||||
internal sealed partial class FleetEmptyObjectContext : System.Text.Json.Serialization.JsonSerializerContext;
|
||||
|
||||
internal sealed class EmptyObject;
|
||||
|
||||
internal static class FleetEmptyObject
|
||||
{
|
||||
public static System.Text.Json.Serialization.Metadata.JsonTypeInfo<EmptyObject> Info => FleetEmptyObjectContext.Default.EmptyObject;
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
<ProjectReference Include="..\..\src\Fleet.Identity\Fleet.Identity.csproj" />
|
||||
<ProjectReference Include="..\..\src\Fleet.Rpc\Fleet.Rpc.csproj" />
|
||||
<ProjectReference Include="..\..\src\Fleet.Link\Fleet.Link.csproj" />
|
||||
<ProjectReference Include="..\..\src\Fleet.Hosting\Fleet.Hosting.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
|
||||
@@ -67,7 +67,7 @@ public sealed class IdentityAndRpcTests
|
||||
[Fact]
|
||||
public async Task Router_DispatchesTypedRequests_AndReportsStructuredErrors()
|
||||
{
|
||||
var router = new RpcRouter<string>()
|
||||
var router = new RpcRouter<string>(RpcSide.Listener, TestTables.Of(TestTables.Req("test.add"), TestTables.Req("test.boom"), TestTables.Req("test.nope")))
|
||||
.On("test.add", EchoContext.Default.AddParams, EchoContext.Default.AddResult,
|
||||
(caller, p, _) => ValueTask.FromResult(p.A < 0 ? Result<AddResult>.Fail(FleetErrors.Forbidden, "negative") : Result<AddResult>.Ok(new AddResult { Sum = p.A + p.B, Caller = caller })))
|
||||
.On<AddParams, AddResult>("test.boom", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => throw new InvalidOperationException("secret detail"));
|
||||
@@ -92,7 +92,7 @@ public sealed class IdentityAndRpcTests
|
||||
public async Task Router_HandlerExceptions_AreContained_AndNeverLeakDetails()
|
||||
{
|
||||
string? logged = null;
|
||||
var router = new RpcRouter<string>((method, ex) => logged = method + ":" + ex.Message)
|
||||
var router = new RpcRouter<string>(RpcSide.Listener, TestTables.Of(TestTables.Req("test.boom")), onUnhandled: (method, ex) => logged = method + ":" + ex.Message)
|
||||
.On<AddParams, AddResult>("test.boom", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => throw new InvalidOperationException("secret detail"));
|
||||
|
||||
RpcMessage reply = (await router.DispatchAsync("a", Request(1, "test.boom", """{"a":1,"b":1}"""), CancellationToken.None))!;
|
||||
@@ -105,7 +105,7 @@ public sealed class IdentityAndRpcTests
|
||||
public async Task Router_Notifications_ProduceNoResponse_EvenOnFailure()
|
||||
{
|
||||
int calls = 0;
|
||||
var router = new RpcRouter<string>().OnNotify("test.note", EchoContext.Default.AddParams, (_, _, _) =>
|
||||
var router = new RpcRouter<string>(RpcSide.Listener, TestTables.Of(TestTables.Note("test.note"), TestTables.Note("test.unknown"))).OnNotify("test.note", EchoContext.Default.AddParams, (_, _, _) =>
|
||||
{
|
||||
calls++;
|
||||
return ValueTask.CompletedTask;
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.Net;
|
||||
using Fleet.Identity;
|
||||
|
||||
namespace Fleet.Tests;
|
||||
|
||||
public sealed class KnockTests
|
||||
{
|
||||
private sealed class Clock : TimeProvider
|
||||
{
|
||||
public DateTimeOffset Now { get; set; } = new(2026, 10, 7, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => Now;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PairKey_IsTheSameOnBothSides_AndDiffersPerPair()
|
||||
{
|
||||
using NodeIdentity probe = NodeIdentity.Generate();
|
||||
using NodeIdentity agent = NodeIdentity.Generate();
|
||||
using NodeIdentity other = NodeIdentity.Generate();
|
||||
byte[] fromProbe = Knock.DeriveKey(probe, agent.PublicKeySpki);
|
||||
byte[] fromAgent = Knock.DeriveKey(agent, probe.PublicKeySpki);
|
||||
Assert.Equal(fromProbe, fromAgent);
|
||||
Assert.Equal(32, fromProbe.Length);
|
||||
Assert.NotEqual(fromProbe, Knock.DeriveKey(other, agent.PublicKeySpki));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Packet_IsAcceptedWithinTheWindowAndOneWindowOfSkew_ButNotBeyond()
|
||||
{
|
||||
byte[] key = new byte[32];
|
||||
long now = new DateTimeOffset(2026, 10, 7, 12, 0, 15, TimeSpan.Zero).ToUnixTimeMilliseconds();
|
||||
byte[] packet = Knock.Build(key, now);
|
||||
Assert.Equal(Knock.PacketBytes, packet.Length);
|
||||
Assert.True(Knock.Check(key, packet, now));
|
||||
Assert.True(Knock.Check(key, packet, now + (Knock.WindowSeconds * 1000)));
|
||||
Assert.True(Knock.Check(key, packet, now - (Knock.WindowSeconds * 1000)));
|
||||
Assert.False(Knock.Check(key, packet, now + (3 * Knock.WindowSeconds * 1000)));
|
||||
Assert.False(Knock.Check(key, packet, now - (3 * Knock.WindowSeconds * 1000)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Packet_WithAWrongKeyATamperedByteOrTheWrongLength_IsRejected()
|
||||
{
|
||||
byte[] key = new byte[32];
|
||||
long now = 1_700_000_000_000;
|
||||
byte[] packet = Knock.Build(key, now);
|
||||
Assert.False(Knock.Check(new byte[32] { 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, packet, now));
|
||||
foreach (int index in new[] { 0, 15, 16, 31 })
|
||||
{
|
||||
byte[] tampered = (byte[])packet.Clone();
|
||||
tampered[index] ^= 0x01;
|
||||
Assert.False(Knock.Check(key, tampered, now), $"byte {index}");
|
||||
}
|
||||
|
||||
Assert.False(Knock.Check(key, packet.AsSpan(0, 31), now));
|
||||
Assert.False(Knock.Check(key, [], now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate_AdmitsRosterMembersOnce_AndRejectsReplaysStrangersAndExpiredPackets()
|
||||
{
|
||||
using NodeIdentity agent = NodeIdentity.Generate();
|
||||
using NodeIdentity probe = NodeIdentity.Generate();
|
||||
using NodeIdentity stranger = NodeIdentity.Generate();
|
||||
var roster = new List<byte[]> { probe.PublicKeySpki };
|
||||
var gate = new KnockGate(agent, () => roster, new Clock());
|
||||
long now = 1_700_000_000_000;
|
||||
|
||||
byte[] packet = Knock.Build(Knock.DeriveKey(probe, agent.PublicKeySpki), now);
|
||||
Assert.True(gate.Accept(packet, now));
|
||||
Assert.False(gate.Accept(packet, now), "the same nonce cannot be used twice");
|
||||
Assert.False(gate.Accept(Knock.Build(Knock.DeriveKey(stranger, agent.PublicKeySpki), now), now), "not on the roster");
|
||||
Assert.False(gate.Accept(Knock.Build(Knock.DeriveKey(probe, agent.PublicKeySpki), now - 10 * 60_000), now), "expired");
|
||||
|
||||
// 名册变了:新成员马上能进,被移除的马上进不来
|
||||
using NodeIdentity second = NodeIdentity.Generate();
|
||||
roster.Add(second.PublicKeySpki);
|
||||
Assert.True(gate.Accept(Knock.Build(Knock.DeriveKey(second, agent.PublicKeySpki), now), now));
|
||||
roster.RemoveAt(0);
|
||||
Assert.False(gate.Accept(Knock.Build(Knock.DeriveKey(probe, agent.PublicKeySpki), now), now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Gate_RateLimitsEachSource_AndReadsAtMost32Bytes()
|
||||
{
|
||||
using NodeIdentity agent = NodeIdentity.Generate();
|
||||
using NodeIdentity probe = NodeIdentity.Generate();
|
||||
var clock = new Clock();
|
||||
var gate = new KnockGate(agent, () => [probe.PublicKeySpki], clock, attemptsPerPeriod: 3);
|
||||
IPAddress noisy = IPAddress.Parse("203.0.113.9");
|
||||
IPAddress quiet = IPAddress.Parse("203.0.113.10");
|
||||
byte[] key = Knock.DeriveKey(probe, agent.PublicKeySpki);
|
||||
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
Assert.False(await gate.AdmitAsync(new MemoryStream(new byte[Knock.PacketBytes]), noisy, default));
|
||||
}
|
||||
|
||||
// 超过限额:连有效的敲门包也不读、不算
|
||||
byte[] valid = Knock.Build(key, clock.Now.ToUnixTimeMilliseconds());
|
||||
Assert.False(await gate.AdmitAsync(new MemoryStream(valid), noisy, default));
|
||||
Assert.True(await gate.AdmitAsync(new MemoryStream(valid), quiet, default));
|
||||
|
||||
// 过了统计周期,恢复
|
||||
clock.Now += TimeSpan.FromSeconds(11);
|
||||
Assert.True(await gate.AdmitAsync(new MemoryStream(Knock.Build(key, clock.Now.ToUnixTimeMilliseconds())), noisy, default));
|
||||
|
||||
// 流在 32 字节之前就结束:拒绝,不抛异常
|
||||
Assert.False(await gate.AdmitAsync(new MemoryStream(new byte[10]), quiet, default));
|
||||
}
|
||||
}
|
||||
@@ -227,8 +227,9 @@ public sealed class LinkTests
|
||||
{
|
||||
(TlsFrameTransport left, TlsFrameTransport right) = await PairAsync();
|
||||
RpcSession<string>? rightSession = null;
|
||||
var leftRouter = new RpcRouter<string>().On("left.double", LinkJson.Default.Number, LinkJson.Default.Number, (_, p, _) => ValueTask.FromResult(Result<Number>.Ok(new Number { Value = p.Value * 2 })));
|
||||
var rightRouter = new RpcRouter<string>().On(
|
||||
MethodTable table = TestTables.Of(TestTables.Req("left.double", RpcSide.Listener), TestTables.Req("right.relay"), TestTables.Req("right.unhandled"));
|
||||
var leftRouter = new RpcRouter<string>(RpcSide.Dialer, table).On("left.double", LinkJson.Default.Number, LinkJson.Default.Number, (_, p, _) => ValueTask.FromResult(Result<Number>.Ok(new Number { Value = p.Value * 2 })));
|
||||
var rightRouter = new RpcRouter<string>(RpcSide.Listener, table).On(
|
||||
"right.relay", LinkJson.Default.Number, LinkJson.Default.Number,
|
||||
async (_, p, ct) =>
|
||||
{
|
||||
@@ -248,7 +249,7 @@ public sealed class LinkTests
|
||||
Result<Number> result = await leftSession.RequestAsync("right.relay", new Number { Value = 21 }, LinkJson.Default.Number, LinkJson.Default.Number, default);
|
||||
Assert.Equal(43, result.Value.Value);
|
||||
|
||||
Result<JsonElement> unknown = await leftSession.RequestAsync("nope", null, default);
|
||||
Result<JsonElement> unknown = await leftSession.RequestAsync("right.unhandled", null, default);
|
||||
Assert.Equal("fleet_unsupported", unknown.Error.Code.Value);
|
||||
|
||||
await leftSession.CloseAsync("bye", default);
|
||||
@@ -262,13 +263,13 @@ public sealed class LinkTests
|
||||
{
|
||||
(TlsFrameTransport left, TlsFrameTransport right) = await PairAsync();
|
||||
await using var silent = right; // 对端不读也不回
|
||||
await using var session = new RpcSession<string>(left, new RpcRouter<string>(), "x", TimeProvider.System, new RpcSessionOptions { RequestTimeout = TimeSpan.FromMilliseconds(200) });
|
||||
await using var session = new RpcSession<string>(left, new RpcRouter<string>(RpcSide.Dialer, TestTables.Of(TestTables.Req("test.any"))), "x", TimeProvider.System, new RpcSessionOptions { RequestTimeout = TimeSpan.FromMilliseconds(200) });
|
||||
Task run = session.RunAsync(default);
|
||||
|
||||
Result<JsonElement> timedOut = await session.RequestAsync("any", null, default);
|
||||
Result<JsonElement> timedOut = await session.RequestAsync("test.any", null, default);
|
||||
Assert.Equal(FleetErrors.Timeout, timedOut.Error.Code);
|
||||
|
||||
Task<Result<JsonElement>> pending = session.RequestAsync("any", null, default, TimeSpan.FromSeconds(30)).AsTask();
|
||||
Task<Result<JsonElement>> pending = session.RequestAsync("test.any", null, default, TimeSpan.FromSeconds(30)).AsTask();
|
||||
await silent.CloseAsync("gone", default);
|
||||
Assert.Equal(FleetErrors.Timeout, (await pending.WaitAsync(TimeSpan.FromSeconds(5))).Error.Code);
|
||||
await run.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
@@ -279,12 +280,20 @@ public sealed class LinkTests
|
||||
private sealed class PeerFixture : IAsyncDisposable
|
||||
{
|
||||
private readonly CancellationTokenSource _stop = new();
|
||||
private readonly NodeIdentity _identity;
|
||||
|
||||
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());
|
||||
Listener = new PeerListener(
|
||||
new PeerListenerOptions { Listen = new IPEndPoint(IPAddress.Loopback, 0), Certificate = Certificate, LookupPeerKey = lookup },
|
||||
new PeerListenerOptions
|
||||
{
|
||||
Listen = new IPEndPoint(IPAddress.Loopback, 0),
|
||||
Certificate = Certificate,
|
||||
LookupPeerKey = lookup,
|
||||
Knock = new KnockGate(_identity, () => KnockPeers, TimeProvider.System),
|
||||
},
|
||||
onSession ?? ((_, transport, ct) => KeepOpenAsync(transport, ct)),
|
||||
NullNodeLog.Instance, TimeProvider.System);
|
||||
Task = Listener.RunAsync(_stop.Token);
|
||||
@@ -298,10 +307,17 @@ public sealed class LinkTests
|
||||
|
||||
public byte[] Spki => NodeCertificate.SpkiFingerprint(Certificate);
|
||||
|
||||
/// <summary>Agent 的 TLS 证书公钥:Probe 用它固定证书、派生敲门密钥。</summary>
|
||||
public byte[] PublicKey => Certificate.PublicKey.ExportSubjectPublicKeyInfo();
|
||||
|
||||
/// <summary>名册:能敲开门的 Probe 公钥。</summary>
|
||||
public List<byte[]> KnockPeers { get; } = [];
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _stop.CancelAsync();
|
||||
await Task;
|
||||
_identity.Dispose();
|
||||
Certificate.Dispose();
|
||||
_stop.Dispose();
|
||||
}
|
||||
@@ -324,9 +340,10 @@ public sealed class LinkTests
|
||||
seen.TrySetResult(info);
|
||||
return Task.Delay(Timeout.Infinite, ct).ContinueWith(_ => { }, CancellationToken.None);
|
||||
});
|
||||
agent.KnockPeers.Add(probe.PublicKeySpki);
|
||||
await agent.Listener.Started.Task;
|
||||
|
||||
(PeerSessionInfo client, TlsFrameTransport transport) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
|
||||
(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));
|
||||
@@ -342,17 +359,18 @@ public sealed class LinkTests
|
||||
using NodeIdentity probe = NodeIdentity.Generate();
|
||||
using NodeIdentity impostor = NodeIdentity.Generate();
|
||||
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;
|
||||
|
||||
InvalidOperationException unknown = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
||||
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_unknown", null, 1 << 20, TimeProvider.System, default));
|
||||
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.Spki, impostor, "prb_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.Spki, probe, "prb_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();
|
||||
}
|
||||
|
||||
@@ -361,18 +379,21 @@ public sealed class LinkTests
|
||||
{
|
||||
using NodeIdentity probe = NodeIdentity.Generate();
|
||||
await using var agent = new PeerFixture(_ => probe.PublicKeySpki);
|
||||
agent.KnockPeers.Add(probe.PublicKeySpki);
|
||||
await agent.Listener.Started.Task;
|
||||
|
||||
byte[] wrong = SHA256.HashData("another certificate"u8);
|
||||
await Assert.ThrowsAnyAsync<AuthenticationException>(async () =>
|
||||
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, wrong, probe, "prb_1", null, 1 << 20, TimeProvider.System, default));
|
||||
// 公钥不对:敲门密钥对不上,Agent 直接断开(拿到的不是证书,连不是它的证书都看不到)
|
||||
using NodeIdentity another = NodeIdentity.Generate();
|
||||
await Assert.ThrowsAnyAsync<Exception>(async () =>
|
||||
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 probe = NodeIdentity.Generate();
|
||||
var router = new RpcRouter<string>().On("agent.echo", LinkJson.Default.Number, LinkJson.Default.Number, (_, p, _) => ValueTask.FromResult(Result<Number>.Ok(new Number { Value = p.Value + 1 })));
|
||||
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(
|
||||
_ => probe.PublicKeySpki,
|
||||
async (info, transport, ct) =>
|
||||
@@ -380,10 +401,11 @@ public sealed class LinkTests
|
||||
await using var session = new RpcSession<string>(transport, router, info.PeerId, TimeProvider.System);
|
||||
await session.RunAsync(ct);
|
||||
});
|
||||
agent.KnockPeers.Add(probe.PublicKeySpki);
|
||||
await agent.Listener.Started.Task;
|
||||
|
||||
(_, TlsFrameTransport transport) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
|
||||
await using var client = new RpcSession<string>(transport, new RpcRouter<string>(), "probe", 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);
|
||||
@@ -396,6 +418,7 @@ public sealed class LinkTests
|
||||
{
|
||||
using NodeIdentity probe = NodeIdentity.Generate();
|
||||
await using var agent = new PeerFixture(_ => probe.PublicKeySpki);
|
||||
agent.KnockPeers.Add(probe.PublicKeySpki);
|
||||
await agent.Listener.Started.Task;
|
||||
|
||||
// 不说 TLS 的连接:握手失败,监听继续
|
||||
@@ -405,7 +428,45 @@ 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.Spki, probe, "prb_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 PeerListener_WritesNothingToStrangers_NotEvenATlsServerHello()
|
||||
{
|
||||
using NodeIdentity probe = NodeIdentity.Generate();
|
||||
await using var agent = new PeerFixture(_ => probe.PublicKeySpki);
|
||||
agent.KnockPeers.Add(probe.PublicKeySpki);
|
||||
await agent.Listener.Started.Task;
|
||||
|
||||
// 一个像样的 TLS ClientHello 开头、随机数据、什么都不发:都拿不到任何字节(连接被直接关闭)
|
||||
foreach (byte[] payload in new[] { new byte[] { 0x16, 0x03, 0x01, 0x00, 0x05, 0x01, 0x00, 0x00, 0x01, 0x00 }.Concat(RandomNumberGenerator.GetBytes(64)).ToArray(), RandomNumberGenerator.GetBytes(32), [] })
|
||||
{
|
||||
using var scanner = new TcpClient();
|
||||
await scanner.ConnectAsync(IPAddress.Loopback, agent.Listener.Port);
|
||||
NetworkStream stream = scanner.GetStream();
|
||||
if (payload.Length > 0)
|
||||
{
|
||||
await stream.WriteAsync(payload);
|
||||
}
|
||||
|
||||
byte[] buffer = new byte[64];
|
||||
int read;
|
||||
try
|
||||
{
|
||||
read = await stream.ReadAsync(buffer).AsTask().WaitAsync(TimeSpan.FromSeconds(10));
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
read = 0; // 连接被重置也是「什么都没收到」
|
||||
}
|
||||
|
||||
Assert.Equal(0, read);
|
||||
}
|
||||
|
||||
// 名册里的 Probe 仍然进得来
|
||||
(_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
|
||||
await ok.DisposeAsync();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
using System.Text.Json;
|
||||
using System.Threading.Channels;
|
||||
using Fleet.Core;
|
||||
using Fleet.Rpc;
|
||||
|
||||
namespace Fleet.Tests;
|
||||
|
||||
/// <summary>协议方向声明:路由器是唯一卡口,注册、分发、发送三处都核对声明表。</summary>
|
||||
public sealed class MethodDirectionTests
|
||||
{
|
||||
private static readonly MethodTable s_table = TestTables.Of(
|
||||
MethodSpec.Request("t.ping", RpcSide.Node),
|
||||
MethodSpec.Notification("t.note", RpcSide.Node, "agent"),
|
||||
MethodSpec.Request("t.push", RpcSide.Server),
|
||||
MethodSpec.Request("t.inline", RpcSide.Node).AsInline(),
|
||||
// 同一个方法名在对等面里另有发起方:按(平面,名字)区分
|
||||
MethodSpec.Request("t.ping", RpcSide.Listener));
|
||||
|
||||
private static readonly JsonElement s_empty = JsonDocument.Parse("{}").RootElement.Clone();
|
||||
|
||||
private static RpcMessage Request(string method) => new() { Id = 1, Method = method, Params = s_empty };
|
||||
|
||||
private static RpcMessage Notification(string method) => new() { Method = method, Params = s_empty };
|
||||
|
||||
private static RpcRouter<string> Server(string? localRole = null) => new(RpcSide.Server, s_table, localRole, callerRole: role => role);
|
||||
|
||||
[Fact]
|
||||
public void Registration_RefusesUndeclaredMethods_WrongDirection_WrongKind_AndWrongRole()
|
||||
{
|
||||
RpcRouter<string> server = Server();
|
||||
Assert.Throws<InvalidOperationException>(() => server.On("t.unknown", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => throw new NotSupportedException()));
|
||||
// t.push 是服务端发给节点的:服务端这一侧不能处理它
|
||||
Assert.Throws<InvalidOperationException>(() => server.On("t.push", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => throw new NotSupportedException()));
|
||||
// t.ping 声明成请求:不能按通知注册
|
||||
Assert.Throws<InvalidOperationException>(() => server.OnNotify("t.ping", EchoContext.Default.AddParams, (_, _, _) => ValueTask.CompletedTask));
|
||||
|
||||
// 节点这一侧:t.note 只给 agent 角色,probe 注册不了;t.push 是它该处理的
|
||||
var probe = new RpcRouter<string>(RpcSide.Node, s_table, "probe");
|
||||
probe.On("t.push", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => throw new NotSupportedException());
|
||||
Assert.Throws<InvalidOperationException>(() => new RpcRouter<string>(RpcSide.Server, s_table, "probe").OnNotify("t.note", EchoContext.Default.AddParams, (_, _, _) => ValueTask.CompletedTask));
|
||||
|
||||
server.On("t.ping", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => ValueTask.FromResult(Result<AddResult>.Ok(new AddResult())));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dispatch_RefusesWhatThePeerMayNotInitiate_AndReportsAViolation()
|
||||
{
|
||||
int calls = 0;
|
||||
RpcRouter<string> server = Server()
|
||||
.On("t.ping", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) =>
|
||||
{
|
||||
calls++;
|
||||
return ValueTask.FromResult(Result<AddResult>.Ok(new AddResult()));
|
||||
})
|
||||
.OnNotify("t.note", EchoContext.Default.AddParams, (_, _, _) =>
|
||||
{
|
||||
calls++;
|
||||
return ValueTask.CompletedTask;
|
||||
});
|
||||
|
||||
// 正常
|
||||
RpcDispatch ok = await server.DispatchCheckedAsync("agent", Request("t.ping"), default);
|
||||
Assert.Null(ok.Violation);
|
||||
Assert.NotNull(ok.Response!.Result);
|
||||
|
||||
// 没有声明的方法
|
||||
RpcDispatch undeclared = await server.DispatchCheckedAsync("agent", Request("t.nope"), default);
|
||||
Assert.NotNull(undeclared.Violation);
|
||||
Assert.Equal("fleet_protocol_violation", undeclared.Response!.Error!.Data!.Code);
|
||||
|
||||
// 节点冒充服务端:t.push 只能由服务端发起
|
||||
RpcDispatch backwards = await server.DispatchCheckedAsync("agent", Request("t.push"), default);
|
||||
Assert.NotNull(backwards.Violation);
|
||||
Assert.Equal("fleet_protocol_violation", backwards.Response!.Error!.Data!.Code);
|
||||
|
||||
// 请求和通知用反了
|
||||
Assert.NotNull((await server.DispatchCheckedAsync("agent", Notification("t.ping"), default)).Violation);
|
||||
RpcDispatch asRequest = await server.DispatchCheckedAsync("agent", Request("t.note"), default);
|
||||
Assert.NotNull(asRequest.Violation);
|
||||
Assert.NotNull(asRequest.Response);
|
||||
|
||||
// 违规的消息到不了处理函数;通知违规不回响应
|
||||
Assert.Equal(1, calls);
|
||||
RpcDispatch silentViolation = await server.DispatchCheckedAsync("agent", Notification("t.nope"), default);
|
||||
Assert.NotNull(silentViolation.Violation);
|
||||
Assert.Null(silentViolation.Response);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dispatch_ADeclaredMethodWithoutAHandler_IsUnsupported_NotAViolation()
|
||||
{
|
||||
RpcDispatch outcome = await Server().DispatchCheckedAsync("agent", Request("t.ping"), default);
|
||||
Assert.Null(outcome.Violation);
|
||||
Assert.Equal("fleet_unsupported", outcome.Response!.Error!.Data!.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dispatch_ChecksTheCallersRole()
|
||||
{
|
||||
RpcRouter<string> server = Server().OnNotify("t.note", EchoContext.Default.AddParams, (_, _, _) => ValueTask.CompletedTask);
|
||||
Assert.Null((await server.DispatchCheckedAsync("agent", Notification("t.note"), default)).Violation);
|
||||
Assert.NotNull((await server.DispatchCheckedAsync("probe", Notification("t.note"), default)).Violation);
|
||||
Assert.NotNull((await server.DispatchCheckedAsync(null!, Notification("t.note"), default)).Violation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckSend_RefusesWhatThisSideMayNotInitiate()
|
||||
{
|
||||
RpcRouter<string> server = Server();
|
||||
server.CheckSend("t.push", MethodKind.Request);
|
||||
Assert.Throws<InvalidOperationException>(() => server.CheckSend("t.ping", MethodKind.Request)); // 那是节点发起的
|
||||
Assert.Throws<InvalidOperationException>(() => server.CheckSend("t.push", MethodKind.Notification)); // 类型不对
|
||||
Assert.Throws<InvalidOperationException>(() => server.CheckSend("t.unknown", MethodKind.Request));
|
||||
|
||||
// 对端的角色也要允许
|
||||
var node = new RpcRouter<string>(RpcSide.Node, s_table, "agent");
|
||||
node.CheckSend("t.note", MethodKind.Notification, "agent");
|
||||
Assert.Throws<InvalidOperationException>(() => node.CheckSend("t.note", MethodKind.Notification, "probe"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MethodTable_RefusesDuplicates_AndReportsMissingHandlers()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => TestTables.Of(TestTables.Req("a"), TestTables.Note("a")));
|
||||
Assert.Throws<ArgumentException>(() => new MethodTable([], [new ChannelSpec(16, RpcSide.Node), new ChannelSpec(16, RpcSide.Server)]));
|
||||
|
||||
// 服务端要处理 t.ping 和 t.note;t.inline 在握手阶段直接处理,不算缺
|
||||
Assert.Equal(["t.note", "t.ping"], s_table.MissingHandlers(RpcSide.Server, null, []));
|
||||
Assert.Equal(["t.ping"], s_table.MissingHandlers(RpcSide.Server, "probe", []));
|
||||
Assert.Equal(["t.note"], s_table.MissingHandlers(RpcSide.Server, "agent", ["t.ping"]));
|
||||
Assert.Empty(s_table.MissingHandlers(RpcSide.Server, "agent", ["t.ping", "t.note"]));
|
||||
// 同名方法在对等面的接收方是拨号方?不:t.ping 在对等面由监听方发起,拨号方处理
|
||||
Assert.Equal(["t.ping"], s_table.MissingHandlers(RpcSide.Dialer, null, []));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Session_ClosesTheConnection_AfterTooManyViolations()
|
||||
{
|
||||
(ChannelTransport a, ChannelTransport b) = ChannelTransport.Pair();
|
||||
RpcRouter<string> server = Server().On("t.ping", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => ValueTask.FromResult(Result<AddResult>.Ok(new AddResult())));
|
||||
await using var session = new RpcSession<string>(b, server, "agent", TimeProvider.System, new RpcSessionOptions { MaxViolations = 3 });
|
||||
Task run = session.RunAsync(default);
|
||||
|
||||
for (int i = 1; i <= 6; i++)
|
||||
{
|
||||
await a.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(new RpcMessage { Id = i, Method = "t.nope", Params = s_empty }, FleetRpcContext.Default.RpcMessage)), default);
|
||||
}
|
||||
|
||||
// 违规的请求先收到失败响应,超过上限之后连接被关闭
|
||||
int failures = 0;
|
||||
while (await a.ReceiveAsync(default).AsTask().WaitAsync(TimeSpan.FromSeconds(5)) is { } frame)
|
||||
{
|
||||
if (JsonSerializer.Deserialize(frame.Data.Span, FleetRpcContext.Default.RpcMessage)?.Error?.Data?.Code == "fleet_protocol_violation")
|
||||
{
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.InRange(failures, 3, 6);
|
||||
await a.CloseAsync("done", default); // 对端确认关闭之后,会话的接收循环正常结束
|
||||
await run.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
/// <summary>进程内的一对传输:一端发的帧从另一端收到;关闭时对端收到 null。</summary>
|
||||
private sealed class ChannelTransport : IRpcTransport
|
||||
{
|
||||
private readonly Channel<RpcFrame?> _in;
|
||||
private readonly Channel<RpcFrame?> _out;
|
||||
|
||||
private ChannelTransport(Channel<RpcFrame?> input, Channel<RpcFrame?> output)
|
||||
{
|
||||
_in = input;
|
||||
_out = output;
|
||||
}
|
||||
|
||||
public static (ChannelTransport A, ChannelTransport B) Pair()
|
||||
{
|
||||
Channel<RpcFrame?> ab = Channel.CreateUnbounded<RpcFrame?>();
|
||||
Channel<RpcFrame?> ba = Channel.CreateUnbounded<RpcFrame?>();
|
||||
return (new ChannelTransport(ba, ab), new ChannelTransport(ab, ba));
|
||||
}
|
||||
|
||||
public ValueTask SendAsync(RpcFrame frame, CancellationToken ct)
|
||||
{
|
||||
_out.Writer.TryWrite(frame);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public async ValueTask<RpcFrame?> ReceiveAsync(CancellationToken ct) =>
|
||||
await _in.Reader.WaitToReadAsync(ct) && _in.Reader.TryRead(out RpcFrame? frame) ? frame : null;
|
||||
|
||||
public ValueTask CloseAsync(string reason, CancellationToken ct)
|
||||
{
|
||||
_out.Writer.TryWrite(null);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using System.Buffers.Text;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Fleet.Identity;
|
||||
|
||||
namespace Fleet.Tests;
|
||||
|
||||
/// <summary>软件 passkey:只做断言(navigator.credentials.get 的结果),用来测信任链。</summary>
|
||||
internal sealed class TestAuthenticator : IDisposable
|
||||
{
|
||||
public const string RpId = "panel.example.com";
|
||||
public const string Origin = "https://panel.example.com";
|
||||
|
||||
private readonly ECDsa _key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
private uint _counter;
|
||||
|
||||
public string CredentialId { get; } = Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(32));
|
||||
|
||||
public OperatorKey Key => new(CredentialId, _key.ExportSubjectPublicKeyInfo());
|
||||
|
||||
public SignedStatement Sign(string statement, byte flags = 0x05, string origin = Origin)
|
||||
{
|
||||
byte[] challenge = SHA256.HashData(Encoding.UTF8.GetBytes(statement));
|
||||
_counter++;
|
||||
byte[] authData = [.. SHA256.HashData(Encoding.UTF8.GetBytes(RpId)), flags, (byte)(_counter >> 24), (byte)(_counter >> 16), (byte)(_counter >> 8), (byte)_counter];
|
||||
byte[] clientData = Encoding.UTF8.GetBytes($"{{\"type\":\"webauthn.get\",\"challenge\":\"{Base64Url.EncodeToString(challenge)}\",\"origin\":\"{origin}\",\"crossOrigin\":false}}");
|
||||
byte[] signature = _key.SignData([.. authData, .. SHA256.HashData(clientData)], HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence);
|
||||
return new SignedStatement(statement, CredentialId, Base64Url.EncodeToString(authData), Base64Url.EncodeToString(clientData), Base64Url.EncodeToString(signature));
|
||||
}
|
||||
|
||||
public void Dispose() => _key.Dispose();
|
||||
}
|
||||
|
||||
public sealed class OperatorTrustTests : IDisposable
|
||||
{
|
||||
private readonly TestAuthenticator _alice = new();
|
||||
private readonly TestAuthenticator _bob = new();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_alice.Dispose();
|
||||
_bob.Dispose();
|
||||
}
|
||||
|
||||
private OperatorSet Genesis(params TestAuthenticator[] keys) =>
|
||||
new(1, OperatorTrust.Genesis, TestAuthenticator.RpId, [TestAuthenticator.Origin], [.. keys.Select(k => k.Key)]);
|
||||
|
||||
private SignedStatement SignedGenesis(OperatorSet set, TestAuthenticator signer) => signer.Sign(OperatorTrust.Encode(set));
|
||||
|
||||
[Fact]
|
||||
public void TheGenesis_IsPinnedByItsFingerprint_AndMustBeSelfSigned()
|
||||
{
|
||||
OperatorSet set = Genesis(_alice);
|
||||
SignedStatement genesis = SignedGenesis(set, _alice);
|
||||
string pin = OperatorTrust.Fingerprint(genesis.Statement);
|
||||
|
||||
Assert.NotNull(OperatorTrust.VerifyChain(pin, [genesis]));
|
||||
Assert.Null(OperatorTrust.VerifyChain(new string('0', 64), [genesis])); // 指纹对不上:Backend 换了一份创世
|
||||
Assert.Null(OperatorTrust.VerifyChain(pin, [SignedGenesis(set, _bob)])); // 不是集合里的凭证签的(也改不了指纹)
|
||||
Assert.Null(OperatorTrust.VerifyChain(pin, []));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ABackendThatSwapsTheKeys_CannotMakeAChainTheNodeAccepts()
|
||||
{
|
||||
SignedStatement real = SignedGenesis(Genesis(_alice), _alice);
|
||||
string pin = OperatorTrust.Fingerprint(real.Statement);
|
||||
|
||||
using var attacker = new TestAuthenticator();
|
||||
SignedStatement forged = SignedGenesis(Genesis(attacker), attacker); // 自洽的假创世,但指纹不同
|
||||
Assert.Null(OperatorTrust.VerifyChain(pin, [forged]));
|
||||
|
||||
// 在真创世后面接一个攻击者签的更新:上一版里没有他的凭证
|
||||
var update = new OperatorSet(2, OperatorTrust.Fingerprint(real.Statement), TestAuthenticator.RpId, [TestAuthenticator.Origin], [_alice.Key, attacker.Key]);
|
||||
Assert.Null(OperatorTrust.VerifyChain(pin, [real, attacker.Sign(OperatorTrust.Encode(update))]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnUpdate_NeedsAKeyOfThePreviousVersion_AndFollowsTheChain()
|
||||
{
|
||||
SignedStatement genesis = SignedGenesis(Genesis(_alice), _alice);
|
||||
string pin = OperatorTrust.Fingerprint(genesis.Statement);
|
||||
var second = new OperatorSet(2, OperatorTrust.Fingerprint(genesis.Statement), TestAuthenticator.RpId, [TestAuthenticator.Origin], [_alice.Key, _bob.Key]);
|
||||
SignedStatement signedSecond = _alice.Sign(OperatorTrust.Encode(second));
|
||||
|
||||
OperatorSet? current = OperatorTrust.VerifyChain(pin, [genesis, signedSecond]);
|
||||
Assert.Equal(2, current?.Version);
|
||||
Assert.Equal(2, current?.Keys.Count);
|
||||
|
||||
// 新加的 bob 可以签下一版(上一版里有他)
|
||||
var third = new OperatorSet(3, OperatorTrust.Fingerprint(signedSecond.Statement), TestAuthenticator.RpId, [TestAuthenticator.Origin], [_bob.Key]);
|
||||
Assert.Equal(3, OperatorTrust.VerifyChain(pin, [genesis, signedSecond, _bob.Sign(OperatorTrust.Encode(third))])?.Version);
|
||||
|
||||
// 版本号跳了、prev 不对、域名换了:都不行
|
||||
var skipped = second with { Version = 3 };
|
||||
Assert.Null(OperatorTrust.VerifyChain(pin, [genesis, _alice.Sign(OperatorTrust.Encode(skipped))]));
|
||||
var wrongPrev = second with { Previous = new string('a', 64) };
|
||||
Assert.Null(OperatorTrust.VerifyChain(pin, [genesis, _alice.Sign(OperatorTrust.Encode(wrongPrev))]));
|
||||
var otherRp = second with { RpId = "evil.example" };
|
||||
Assert.Null(OperatorTrust.VerifyChain(pin, [genesis, _alice.Sign(OperatorTrust.Encode(otherRp))]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnAssertion_NeedsUserVerification_TheRightOrigin_AndTheExactStatement()
|
||||
{
|
||||
OperatorSet set = Genesis(_alice);
|
||||
Assert.True(OperatorTrust.VerifyOperator(_alice.Sign("hello"), set));
|
||||
Assert.False(OperatorTrust.VerifyOperator(_alice.Sign("hello", flags: 0x01), set)); // 没有用户验证
|
||||
Assert.False(OperatorTrust.VerifyOperator(_alice.Sign("hello", origin: "https://evil.example"), set));
|
||||
Assert.False(OperatorTrust.VerifyOperator(_alice.Sign("hello") with { Statement = "hellO" }, set)); // 语句被改
|
||||
Assert.False(OperatorTrust.VerifyOperator(_bob.Sign("hello"), set)); // 不在集合里
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parsing_IsStrict()
|
||||
{
|
||||
Assert.Null(OperatorTrust.ParseSet("moshiro-opset-v2\nversion=1"));
|
||||
OperatorSet set = Genesis(_alice);
|
||||
Assert.Equal(set.Keys[0].CredentialId, OperatorTrust.ParseSet(OperatorTrust.Encode(set))!.Keys[0].CredentialId);
|
||||
Assert.Null(OperatorTrust.ParseSet(OperatorTrust.Encode(set with { Previous = "abc" }))); // 创世的 prev 必须是 0
|
||||
Assert.Null(OperatorTrust.ParseSet(OperatorTrust.Encode(set) + "\nnot-a-key"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using Fleet.Core;
|
||||
using Fleet.Hosting;
|
||||
using Fleet.Identity;
|
||||
|
||||
namespace Fleet.Tests;
|
||||
|
||||
/// <summary>节点自己要求有属主(passkey):Agent 和 Probe 共用这一份,没有属主就不运行。</summary>
|
||||
public sealed class OwnerGuardTests : IDisposable
|
||||
{
|
||||
private readonly TestAuthenticator _alice = new();
|
||||
private readonly string _directory = Path.Combine(Path.GetTempPath(), "moshiro-owner-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
public OwnerGuardTests()
|
||||
{
|
||||
Directory.CreateDirectory(_directory);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_alice.Dispose();
|
||||
Directory.Delete(_directory, recursive: true);
|
||||
}
|
||||
|
||||
private void Pin(params string[] controllers)
|
||||
{
|
||||
var set = new OperatorSet(1, OperatorTrust.Genesis, TestAuthenticator.RpId, [TestAuthenticator.Origin], [_alice.Key]);
|
||||
SignedStatement genesis = _alice.Sign(OperatorTrust.Encode(set));
|
||||
TrustAnchor.Pin(_directory, OperatorTrust.Fingerprint(genesis.Statement), [genesis.ToDto()], controllers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ANodeEnrolledWithoutATrustRoot_HasNoOwner_AndDoesNotRun()
|
||||
{
|
||||
TrustAnchor none = TrustAnchor.Load(_directory, NullNodeLog.Instance);
|
||||
|
||||
Assert.NotNull(OwnerGuard.Problem(none, require: true));
|
||||
Assert.False(OwnerGuard.Check(_directory, require: true, NullNodeLog.Instance));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ANodePinnedToAnOwnerPasskey_Runs()
|
||||
{
|
||||
Pin(_alice.CredentialId);
|
||||
|
||||
TrustAnchor pinned = TrustAnchor.Load(_directory, NullNodeLog.Instance);
|
||||
|
||||
Assert.Null(OwnerGuard.Problem(pinned, require: true));
|
||||
Assert.True(OwnerGuard.Check(_directory, require: true, NullNodeLog.Instance));
|
||||
Assert.Equal([_alice.CredentialId], pinned.Controllers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void APinnedNodeWhoseOwnerIsNoLongerInTheOperatorSet_DoesNotRun()
|
||||
{
|
||||
Pin("some-passkey-that-is-not-in-the-set");
|
||||
|
||||
TrustAnchor pinned = TrustAnchor.Load(_directory, NullNodeLog.Instance);
|
||||
|
||||
Assert.Empty(pinned.Controllers);
|
||||
Assert.NotNull(OwnerGuard.Problem(pinned, require: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheRequirementCanBeTurnedOff_ForAPanelWithoutATrustRoot()
|
||||
{
|
||||
TrustAnchor none = TrustAnchor.Load(_directory, NullNodeLog.Instance);
|
||||
|
||||
Assert.Null(OwnerGuard.Problem(none, require: false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
using Fleet.Core;
|
||||
using Fleet.Hosting;
|
||||
|
||||
namespace Fleet.Tests;
|
||||
|
||||
public sealed class RestartScheduleTests
|
||||
{
|
||||
private static RestartSchedule Parse(string text)
|
||||
{
|
||||
Assert.True(RestartSchedule.TryParse(text, out RestartSchedule? schedule), text);
|
||||
return schedule!;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("04:00")]
|
||||
[InlineData("04:00Z")]
|
||||
[InlineData("04:00+08:00")]
|
||||
[InlineData("23:59-05:30")]
|
||||
[InlineData(" 00:00 ")]
|
||||
public void Parse_AcceptsTimeWithOptionalOffset(string text) => Parse(text);
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("4:00")]
|
||||
[InlineData("24:00")]
|
||||
[InlineData("04:60")]
|
||||
[InlineData("04:00+8:00")]
|
||||
[InlineData("04:00+15:00")]
|
||||
[InlineData("04:00 UTC")]
|
||||
[InlineData("0400")]
|
||||
[InlineData("04:00+08:60")]
|
||||
public void Parse_RejectsEverythingElse(string text) => Assert.False(RestartSchedule.TryParse(text, out _));
|
||||
|
||||
[Fact]
|
||||
public void NextAfter_IsStrictlyLater_AndRollsToTomorrow()
|
||||
{
|
||||
RestartSchedule utc = Parse("04:00");
|
||||
Assert.Equal(new DateTimeOffset(2026, 10, 7, 4, 0, 0, TimeSpan.Zero), utc.NextAfter(new DateTimeOffset(2026, 10, 7, 3, 59, 59, TimeSpan.Zero)));
|
||||
Assert.Equal(new DateTimeOffset(2026, 10, 8, 4, 0, 0, TimeSpan.Zero), utc.NextAfter(new DateTimeOffset(2026, 10, 7, 4, 0, 0, TimeSpan.Zero)));
|
||||
Assert.Equal(new DateTimeOffset(2026, 10, 8, 4, 0, 0, TimeSpan.Zero), utc.NextAfter(new DateTimeOffset(2026, 10, 7, 20, 0, 0, TimeSpan.Zero)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NextAfter_UsesTheWrittenOffset()
|
||||
{
|
||||
// 04:00+08:00 就是 UTC 前一天 20:00
|
||||
RestartSchedule china = Parse("04:00+08:00");
|
||||
Assert.Equal(new DateTimeOffset(2026, 10, 6, 20, 0, 0, TimeSpan.Zero), china.NextAfter(new DateTimeOffset(2026, 10, 6, 19, 0, 0, TimeSpan.Zero)));
|
||||
Assert.Equal(new DateTimeOffset(2026, 10, 7, 20, 0, 0, TimeSpan.Zero), china.NextAfter(new DateTimeOffset(2026, 10, 6, 20, 0, 0, TimeSpan.Zero)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NextAfter_WithMinimumUptime_DoesNotRestartAgainRightAfterARestart()
|
||||
{
|
||||
RestartSchedule schedule = Parse("04:00");
|
||||
// 04:00:03 重启后启动:加上最短运行时间再算,下一次是明天,而不是几秒后
|
||||
var started = new DateTimeOffset(2026, 10, 7, 4, 0, 3, TimeSpan.Zero);
|
||||
Assert.Equal(new DateTimeOffset(2026, 10, 8, 4, 0, 0, TimeSpan.Zero), schedule.NextAfter(started + RestartSchedule.MinimumUptime));
|
||||
// 03:55 启动:离 04:00 只有 5 分钟,不够最短运行时间,顺延到明天
|
||||
var early = new DateTimeOffset(2026, 10, 7, 3, 55, 0, TimeSpan.Zero);
|
||||
Assert.Equal(new DateTimeOffset(2026, 10, 8, 4, 0, 0, TimeSpan.Zero), schedule.NextAfter(early + RestartSchedule.MinimumUptime));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_ReportsABadValueInsteadOfIgnoringIt()
|
||||
{
|
||||
Assert.Null(RestartSchedule.Resolve(null));
|
||||
Assert.Null(RestartSchedule.Resolve(" "));
|
||||
InvalidOperationException error = Assert.Throws<InvalidOperationException>(() => RestartSchedule.Resolve("tomorrow"));
|
||||
Assert.Contains("restart_at", error.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ServiceShell_StopsAtTheScheduledTime()
|
||||
{
|
||||
var time = new ImmediateTimeProvider(new DateTimeOffset(2026, 10, 7, 12, 0, 0, TimeSpan.Zero));
|
||||
var log = new ListLog();
|
||||
bool stoppedByToken = false;
|
||||
|
||||
// 业务循环只在被取消时返回;定时重启到点以后令牌被取消,RunAsync 正常返回
|
||||
await ServiceShell.RunAsync(async ct =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
stoppedByToken = true;
|
||||
throw;
|
||||
}
|
||||
}, log, new RestartController(Parse("04:00")), time).WaitAsync(TimeSpan.FromSeconds(10));
|
||||
|
||||
Assert.True(stoppedByToken);
|
||||
Assert.Contains(log.Lines, l => l.Contains("scheduled_restart", StringComparison.Ordinal));
|
||||
Assert.Contains(log.Lines, l => l.Contains("restart scheduled", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ServiceShell_PicksUpAScheduleThePanelSendsLater()
|
||||
{
|
||||
var time = new ImmediateTimeProvider(new DateTimeOffset(2026, 10, 7, 12, 0, 0, TimeSpan.Zero));
|
||||
var log = new ListLog();
|
||||
using var controller = new RestartController();
|
||||
|
||||
Task shell = ServiceShell.RunAsync(ct => Task.Delay(Timeout.Infinite, ct), log, controller, time);
|
||||
await Task.Delay(300);
|
||||
Assert.False(shell.IsCompleted, "no schedule yet: the node keeps running");
|
||||
|
||||
Assert.True(controller.SetRemote("04:30+08:00"));
|
||||
await shell.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
Assert.Contains(log.Lines, l => l.Contains("scheduled_restart", StringComparison.Ordinal));
|
||||
Assert.Contains(log.Lines, l => l.Contains("source=panel", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ServiceShell_ClearingTheScheduleCancelsTheRestart()
|
||||
{
|
||||
var time = new ManualTimeProvider(new DateTimeOffset(2026, 10, 7, 12, 0, 0, TimeSpan.Zero));
|
||||
var log = new ListLog();
|
||||
using var controller = new RestartController();
|
||||
|
||||
controller.SetRemote("04:30+08:00");
|
||||
|
||||
Task shell = ServiceShell.RunAsync(ct => Task.Delay(Timeout.Infinite, ct), log, controller, time);
|
||||
await Task.Delay(300);
|
||||
controller.SetRemote(null); // 面板关掉了
|
||||
await Task.Delay(300);
|
||||
Assert.False(shell.IsCompleted);
|
||||
Assert.Contains(log.Lines, l => l.Contains("restart scheduled", StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(log.Lines, l => l.Contains("scheduled_restart", StringComparison.Ordinal));
|
||||
// 清理:把进程停掉
|
||||
Assert.True(controller.SetRemote("04:30+08:00"));
|
||||
// 新的计时器是异步注册的:反复推进,直到进程退出
|
||||
for (int i = 0; i < 100 && !shell.IsCompleted; i++)
|
||||
{
|
||||
time.Advance();
|
||||
await Task.Delay(50);
|
||||
}
|
||||
|
||||
await shell.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Controller_LocalSettingWinsOverThePanel_AndBadTextIsRejected()
|
||||
{
|
||||
using var controller = new RestartController(Parse("04:00"));
|
||||
Assert.True(controller.IsLocal);
|
||||
Assert.True(controller.SetRemote("05:00"));
|
||||
Assert.Equal(new DateTimeOffset(2026, 10, 7, 4, 0, 0, TimeSpan.Zero), controller.Current!.NextAfter(new DateTimeOffset(2026, 10, 7, 0, 0, 0, TimeSpan.Zero)));
|
||||
|
||||
using var remoteOnly = new RestartController();
|
||||
Assert.Null(remoteOnly.Current);
|
||||
Assert.False(remoteOnly.SetRemote("whenever"));
|
||||
Assert.Null(remoteOnly.Current);
|
||||
Assert.True(remoteOnly.SetRemote("05:00"));
|
||||
Assert.NotNull(remoteOnly.Current);
|
||||
Assert.True(remoteOnly.SetRemote(null));
|
||||
Assert.Null(remoteOnly.Current);
|
||||
}
|
||||
|
||||
/// <summary>计时器马上触发:用来验证「到点」之后的行为,不用真的等。</summary>
|
||||
private sealed class ImmediateTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _now;
|
||||
|
||||
public ImmediateTimeProvider(DateTimeOffset now) => _now = now;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
|
||||
public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
|
||||
{
|
||||
// TrimHeapAsync 这类每 30 秒一次的循环也会走到这里;只让「很长」的延迟(定时重启,至少 10 分钟)马上触发
|
||||
var timer = new ImmediateTimer(callback, state);
|
||||
if (dueTime >= RestartSchedule.MinimumUptime)
|
||||
{
|
||||
ThreadPool.QueueUserWorkItem(_ => timer.Fire());
|
||||
}
|
||||
|
||||
return timer;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>计时器只在 <see cref="Advance"/> 时触发:用来验证「还没到点」时不会退出。</summary>
|
||||
private sealed class ManualTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _now;
|
||||
private readonly List<ImmediateTimer> _timers = [];
|
||||
|
||||
public ManualTimeProvider(DateTimeOffset now) => _now = now;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
|
||||
public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
|
||||
{
|
||||
var timer = new ImmediateTimer(callback, state);
|
||||
lock (_timers)
|
||||
{
|
||||
_timers.Add(timer);
|
||||
}
|
||||
|
||||
return timer;
|
||||
}
|
||||
|
||||
public void Advance()
|
||||
{
|
||||
ImmediateTimer[] due;
|
||||
lock (_timers)
|
||||
{
|
||||
due = [.. _timers];
|
||||
_timers.Clear();
|
||||
}
|
||||
|
||||
foreach (ImmediateTimer timer in due)
|
||||
{
|
||||
timer.Fire();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ImmediateTimer : ITimer
|
||||
{
|
||||
private readonly TimerCallback _callback;
|
||||
private readonly object? _state;
|
||||
|
||||
public ImmediateTimer(TimerCallback callback, object? state)
|
||||
{
|
||||
_callback = callback;
|
||||
_state = state;
|
||||
}
|
||||
|
||||
public void Fire() => _callback(_state);
|
||||
|
||||
public bool Change(TimeSpan dueTime, TimeSpan period) => true;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class ListLog : INodeLog
|
||||
{
|
||||
private readonly List<string> _lines = [];
|
||||
|
||||
public IReadOnlyList<string> Lines
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lines)
|
||||
{
|
||||
return [.. _lines];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Write(NodeLogLevel level, string message, params (string Key, object? Value)[] fields)
|
||||
{
|
||||
lock (_lines)
|
||||
{
|
||||
_lines.Add(message + " " + string.Join(' ', fields.Select(f => f.Key + "=" + f.Value)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using System.Security.Cryptography;
|
||||
using Fleet.Identity;
|
||||
|
||||
namespace Fleet.Tests;
|
||||
|
||||
public sealed class SecureChannelTests
|
||||
{
|
||||
private const string Nonce = "n0nce-from-the-server";
|
||||
|
||||
private static (LinkCipher Node, LinkCipher Server) Pair(string nodeNonce = Nonce, string serverNonce = Nonce)
|
||||
{
|
||||
using var node = new EphemeralKey();
|
||||
using var server = new EphemeralKey();
|
||||
return (LinkCipher.ForNode(node, server.PublicKeySpki, nodeNonce), LinkCipher.ForServer(server, node.PublicKeySpki, serverNonce));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheServerProof_VerifiesOnlyForTheRightKey_Nonce_AndEphemeralKey()
|
||||
{
|
||||
using ECDsa serverKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
using ECDsa other = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
using var eph = new EphemeralKey();
|
||||
using var otherEph = new EphemeralKey();
|
||||
byte[] spki = serverKey.ExportSubjectPublicKeyInfo();
|
||||
byte[] proof = ServerHello.Sign(serverKey, Nonce, eph.PublicKeySpki);
|
||||
|
||||
Assert.True(ServerHello.Verify(spki, Nonce, eph.PublicKeySpki, proof));
|
||||
Assert.False(ServerHello.Verify(other.ExportSubjectPublicKeyInfo(), Nonce, eph.PublicKeySpki, proof)); // 别人的服务器密钥
|
||||
Assert.False(ServerHello.Verify(spki, "another-nonce", eph.PublicKeySpki, proof)); // 挪到别的挑战上
|
||||
Assert.False(ServerHello.Verify(spki, Nonce, otherEph.PublicKeySpki, proof)); // 中间人换了自己的临时公钥
|
||||
Assert.False(ServerHello.Verify(spki, Nonce, eph.PublicKeySpki, proof.AsSpan(0, proof.Length - 1)));
|
||||
Assert.False(ServerHello.Verify([1, 2, 3], Nonce, eph.PublicKeySpki, proof)); // 公钥不合法也不抛异常
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BothDirections_RoundTrip_AndKeepTheFrameKind()
|
||||
{
|
||||
(LinkCipher node, LinkCipher server) = Pair();
|
||||
using LinkCipher n = node;
|
||||
using LinkCipher s = server;
|
||||
|
||||
byte[] toServer = n.Seal(0, "hello server"u8);
|
||||
Assert.True(s.TryOpen(toServer, out byte kind, out byte[] data));
|
||||
Assert.Equal(0, kind);
|
||||
Assert.Equal("hello server"u8.ToArray(), data);
|
||||
|
||||
byte[] big = RandomNumberGenerator.GetBytes(300_000);
|
||||
byte[] toNode = s.Seal(1, big);
|
||||
Assert.True(n.TryOpen(toNode, out kind, out data));
|
||||
Assert.Equal(1, kind);
|
||||
Assert.Equal(big, data);
|
||||
Assert.Equal(big.Length + LinkCipher.Overhead + 1, toNode.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AReplayedReorderedTamperedOrReflectedFrame_IsRefused()
|
||||
{
|
||||
(LinkCipher node, LinkCipher server) = Pair();
|
||||
using LinkCipher n = node;
|
||||
using LinkCipher s = server;
|
||||
|
||||
byte[] first = n.Seal(0, "one"u8);
|
||||
byte[] second = n.Seal(0, "two"u8);
|
||||
Assert.False(s.TryOpen(second, out _, out _)); // 乱序(丢了第一帧)
|
||||
Assert.True(s.TryOpen(first, out _, out _));
|
||||
Assert.False(s.TryOpen(first, out _, out _)); // 重放
|
||||
Assert.True(s.TryOpen(second, out _, out _));
|
||||
|
||||
byte[] tampered = n.Seal(0, "three"u8);
|
||||
tampered[12] ^= 0x01;
|
||||
Assert.False(s.TryOpen(tampered, out _, out _)); // 改了一位
|
||||
|
||||
byte[] fromNode = n.Seal(0, "four"u8);
|
||||
(LinkCipher otherNode, LinkCipher otherServer) = Pair();
|
||||
using LinkCipher on = otherNode;
|
||||
using LinkCipher os = otherServer;
|
||||
Assert.False(os.TryOpen(fromNode, out _, out _)); // 另一条连接的密钥打不开
|
||||
|
||||
byte[] fromServer = s.Seal(0, "reflected"u8);
|
||||
Assert.False(s.TryOpen(fromServer, out _, out _)); // 方向不同:服务器发的东西不能原样塞回给服务器
|
||||
Assert.False(n.TryOpen([1, 2, 3], out _, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ADifferentNonce_GivesDifferentKeys()
|
||||
{
|
||||
(LinkCipher node, LinkCipher server) = Pair("nonce-a", "nonce-b"); // 两边对不上:没人能解开对方的东西
|
||||
using LinkCipher n = node;
|
||||
using LinkCipher s = server;
|
||||
Assert.False(s.TryOpen(n.Seal(0, "x"u8), out _, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnOperatorSet_CarriesTheServerKeys_ThroughTheChain()
|
||||
{
|
||||
using var alice = new TestAuthenticator();
|
||||
using ECDsa serverKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
byte[] spki = serverKey.ExportSubjectPublicKeyInfo();
|
||||
var set = new OperatorSet(1, OperatorTrust.Genesis, TestAuthenticator.RpId, [TestAuthenticator.Origin], [alice.Key], [spki]);
|
||||
string text = OperatorTrust.Encode(set);
|
||||
|
||||
OperatorSet parsed = OperatorTrust.ParseSet(text)!;
|
||||
Assert.Equal(spki, Assert.Single(parsed.Servers!));
|
||||
Assert.Equal(text, OperatorTrust.Encode(parsed)); // 编码和解析互逆,指纹稳定
|
||||
|
||||
SignedStatement genesis = alice.Sign(text);
|
||||
OperatorSet verified = OperatorTrust.VerifyChain(OperatorTrust.Fingerprint(text), [genesis])!;
|
||||
Assert.Equal(spki, Assert.Single(verified.Servers!));
|
||||
|
||||
// 服务器密钥只能写在凭证后面;顺序不对、内容不是 base64 都算坏
|
||||
Assert.Null(OperatorTrust.ParseSet(text + "\nkey=late:AAAA"));
|
||||
Assert.Null(OperatorTrust.ParseSet(text + "\nserver=***"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using Fleet.Rpc;
|
||||
|
||||
namespace Fleet.Tests;
|
||||
|
||||
/// <summary>测试里用的方法声明:除非另有说明,方法都由拨号方发给监听方。</summary>
|
||||
internal static class TestTables
|
||||
{
|
||||
public static MethodTable Of(params MethodSpec[] specs) => new(specs);
|
||||
|
||||
public static MethodSpec Req(string name, RpcSide from = RpcSide.Dialer) => MethodSpec.Request(name, from);
|
||||
|
||||
public static MethodSpec Note(string name, RpcSide from = RpcSide.Dialer) => MethodSpec.Notification(name, from);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ string? name = Arg("--name");
|
||||
string? directory = Arg("--identity-dir");
|
||||
if (server is null || join is null || name is null || directory is null)
|
||||
{
|
||||
Console.Error.WriteLine("usage: fleet-enroll --server <url> --join <token> --name <node name> --identity-dir <dir> [--meta <json>] [--version <v>]");
|
||||
Console.Error.WriteLine("usage: fleet-enroll --server <url> --join <token> --name <node name> --identity-dir <dir> [--meta <json>] [--version <v>] [--genesis <trust root fingerprint> --controllers <passkey credential ids, comma separated>]");
|
||||
return 2;
|
||||
}
|
||||
|
||||
@@ -30,14 +30,23 @@ if (Arg("--meta") is { } metaText)
|
||||
}
|
||||
}
|
||||
|
||||
string? genesis = Arg("--genesis");
|
||||
string[] controllers = (Arg("--controllers") ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
if (genesis is not null && controllers.Length == 0)
|
||||
{
|
||||
Console.Error.WriteLine("--genesis needs --controllers: every node has an owner (the passkey credential ids from the panel's command)");
|
||||
return 2;
|
||||
}
|
||||
|
||||
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
|
||||
Result<EnrollmentResult> result = await EnrollmentClient.EnrollAsync(
|
||||
http, new Uri(server), join, name, directory, Arg("--version") ?? "0.0.0", meta, TimeProvider.System, CancellationToken.None);
|
||||
http, new Uri(server), join, name, directory, Arg("--version") ?? "0.0.0", meta, TimeProvider.System, CancellationToken.None, genesis, controllers);
|
||||
if (!result.TryGet(out EnrollmentResult? enrolled, out FleetError? error))
|
||||
{
|
||||
Console.Error.WriteLine($"enrollment failed ({error.Code}): {error.Message}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
Console.WriteLine($"enrolled as {enrolled.NodeId} ({enrolled.Status}); identity written to {enrolled.IdentityDirectory}");
|
||||
Console.WriteLine($"enrolled as {enrolled.NodeId} ({enrolled.Status}); identity written to {enrolled.IdentityDirectory}" + (enrolled.Pinned ? "; operator trust pinned" : "; operator trust NOT pinned (no --genesis)"));
|
||||
return 0;
|
||||
+2203
-297
File diff suppressed because it is too large.
Load diff
+14
-6
@@ -11,9 +11,11 @@ Backend ──任务──> Agent <──测量会话──> Probe
|
||||
└─────Agent 列表、状态(Fleet 长连接)───────────┘
|
||||
```
|
||||
|
||||
Probe 使用面板生成的 `probe.json` 读取自身身份、面板地址(`server`)和本地测试限制。连上面板之后,面板下发要连接的 Agent 列表;Probe 将列表缓存到同目录的 `agents.json`,面板暂时连不上时靠它继续测量。`probe.json` 含私钥,请限制文件访问权限,不要提交到版本控制。
|
||||
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 运行。
|
||||
Agent 测量连接失败时,Probe 按 1、2、4、8、16 秒递增等待,再以 32 秒间隔重试 5 次,之后最多每 60 秒重试一次;连接恢复后退避重新从 1 秒开始。管理端把没有任何已启用 Agent 目标的 Probe 标为「暂停探测」,不会将链路断开误显示为已连接。
|
||||
|
||||
容器节点的运行方式(podman + systemd)和宿主机更新器见 [`../../docs/ops/deployment.md`](../../docs/ops/deployment.md);Probe 也可以用 OCI 镜像 `registry.oeo.one/moshiro/moshiro-probe:<version>`,容器默认读取 `/etc/moshiro/probe.json`,命名 volume 保存身份配置和缓存,镜像以 UID/GID 1654 运行。
|
||||
|
||||
## 构建
|
||||
|
||||
@@ -30,11 +32,17 @@ dotnet build probe/src/MoShiro.Probe -c Release
|
||||
dotnet publish probe/src/MoShiro.Probe -c Release -r <RID> -p:PublishAot=true
|
||||
```
|
||||
|
||||
也可在仓库根目录运行 `bash scripts/publish.sh probe <RID>`,再使用 `scripts/package.sh probe <RID>` 生成部署包。支持平台和安装流程见 [`../../docs/deployment.md`](../../docs/deployment.md)。
|
||||
也可在仓库根目录运行 `bash scripts/publish.sh probe <RID>`,再使用 `scripts/package.sh probe <RID>` 生成部署包。支持平台和安装流程见 [`../../docs/ops/deployment.md`](../../docs/ops/deployment.md)。
|
||||
|
||||
## 配置与运行
|
||||
|
||||
在管理端创建 Probe 并下载一次性身份配置,将文件保存为目标机上的 `/etc/moshiro/probe.json`。随后运行:
|
||||
在管理端添加 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
|
||||
@@ -48,6 +56,6 @@ moshiro-probe --config /etc/moshiro/probe.json
|
||||
|
||||
- Probe 只执行编译进程序的测量模块。它不落盘测量数据,也不能执行命令或自行更新(更新由宿主机上的更新器完成,Probe 只写清单和请求文件)。
|
||||
- 测量模块由 `backend/shared/MoShiro.Measurement/` 与 Agent 共用;Probe 负责发起连接、调度任务和记录本端数据。
|
||||
- 测量模型和协议细节见 [`../../docs/measurement.md`](../../docs/measurement.md) 与 [`../../docs/protocol.md`](../../docs/protocol.md);整体架构见 [`../../docs/architecture.md`](../../docs/architecture.md)。
|
||||
- 测量模型和协议细节见 [`../../docs/design/measurement.md`](../../docs/design/measurement.md) 与 [`../../docs/design/protocol.md`](../../docs/design/protocol.md);整体架构见 [`../../docs/design/architecture.md`](../../docs/design/architecture.md)。
|
||||
|
||||
Docker 与 rootless/rootful Podman 共用 `packaging/compose/probe/compose.yaml`,配置、身份和缓存放在命名 volume 中,无需宿主配置目录、keep-id 或手动 chown。首次导入和自动更新见部署文档。
|
||||
Podman 系用 Quadlet(`packaging/quadlet/moshiro-probe.container`,默认自动更新),Docker 用 `packaging/compose/probe/compose.yaml`(手动更新);配置、身份和缓存放在命名 volume 中,无需宿主配置目录、keep-id 或手动 chown。首次导入和自动更新见部署文档。
|
||||
@@ -19,14 +19,29 @@ public sealed record ConnectionEnv(NodeIdentity Identity, string ProbeId, ProbeL
|
||||
/// 每 10 秒把已完成的行切成列块放进发件箱 → 响应按需测试的阶段。会话断开时所有角色停止(凭据属于会话),按退避重连。
|
||||
/// 会话内的所有状态(套接字、发件箱、角色)随会话一起创建和释放。
|
||||
/// </summary>
|
||||
public sealed class AgentConnection
|
||||
public sealed class AgentConnection : IDisposable
|
||||
{
|
||||
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;
|
||||
private int _previousQuicSourcePort;
|
||||
private QuicDatagramTransport? _activeDatagrams;
|
||||
private readonly object _handshakeGate = new();
|
||||
private readonly Queue<double> _handshakeSamplesMs = new();
|
||||
private readonly SemaphoreSlim _retrySignal = new(0, 1);
|
||||
private ProbeLinkAttemptState _attemptState = ProbeLinkAttemptState.Connecting;
|
||||
private string? _lastError;
|
||||
private DateTimeOffset? _retryAt;
|
||||
private long _handshakeAttempts;
|
||||
private long _handshakeSuccesses;
|
||||
private long _handshakeFailures;
|
||||
private long _handshakeTimeouts;
|
||||
|
||||
public AgentConnection(KnownAgent endpoint, ConnectionEnv env)
|
||||
{
|
||||
@@ -42,28 +57,116 @@ public sealed class AgentConnection
|
||||
|
||||
public long ClockOffsetUs => _session?.Clock.OffsetUs ?? 0;
|
||||
|
||||
/// <summary>管理端手动配对时,跳过当前退避等待,马上再拨一次。</summary>
|
||||
public void RetryNow()
|
||||
{
|
||||
if (IsConnected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
_retrySignal.Release();
|
||||
}
|
||||
catch (SemaphoreFullException)
|
||||
{
|
||||
// 已经有一次手动重试排队。
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// 对应线路正在被移除。
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => _retrySignal.Dispose();
|
||||
|
||||
public ProbeQuicLinkHealth QuicHealth()
|
||||
{
|
||||
lock (_handshakeGate)
|
||||
{
|
||||
double? p95 = null;
|
||||
if (_handshakeSamplesMs.Count > 0)
|
||||
{
|
||||
double[] sorted = [.. _handshakeSamplesMs.Order()];
|
||||
p95 = sorted[(int)Math.Ceiling(sorted.Length * 0.95) - 1];
|
||||
}
|
||||
|
||||
QuicTransportSnapshot? transport = Volatile.Read(ref _activeDatagrams)?.GetTransportSnapshot();
|
||||
return new ProbeQuicLinkHealth
|
||||
{
|
||||
AgentId = Endpoint.AgentId,
|
||||
IpVersion = Endpoint.IpVersion,
|
||||
Connected = IsConnected,
|
||||
State = _attemptState,
|
||||
LastError = _lastError,
|
||||
RetryAttempt = _lastRetryAttempt,
|
||||
RetryInMs = _attemptState == ProbeLinkAttemptState.RetryWait && _retryAt is { } retryAt
|
||||
? Math.Max(0, (long)(retryAt - _env.Time.GetUtcNow()).TotalMilliseconds)
|
||||
: null,
|
||||
HandshakeAttempts = _handshakeAttempts,
|
||||
HandshakeSuccesses = _handshakeSuccesses,
|
||||
HandshakeFailures = _handshakeFailures,
|
||||
HandshakeTimeouts = _handshakeTimeouts,
|
||||
HandshakeP95Ms = p95,
|
||||
HandshakeSampleCount = _handshakeSamplesMs.Count,
|
||||
TransportPacketLossRate = transport?.PacketLossRate,
|
||||
TransportPacketsSent = transport?.PacketsSent,
|
||||
TransportCongestionEvents = transport?.CongestionEvents,
|
||||
TransportEcnCongestionEvents = transport?.EcnCongestionEvents,
|
||||
PathMtu = transport?.PathMtu,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private int _lastRetryAttempt;
|
||||
|
||||
public async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
var backoff = new Backoff(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(20));
|
||||
var backoff = new ProbeReconnectBackoff();
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
SetAttempt(ProbeLinkAttemptState.Connecting, null, null, 0);
|
||||
try
|
||||
{
|
||||
string reason = await RunOnceAsync(backoff, ct);
|
||||
SetAttempt(ProbeLinkAttemptState.RetryWait, reason, null, backoff.Attempts);
|
||||
_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)
|
||||
catch (Exception ex)
|
||||
{
|
||||
_env.Log.Warn("agent connection failed", ("agent", Endpoint.AgentId), ("err", ex.Message));
|
||||
SetAttempt(ProbeLinkAttemptState.RetryWait, $"{ex.GetType().Name}: {ex.Message}", null, backoff.Attempts + 1);
|
||||
_env.Log.Error("agent connection attempt failed; retry will continue", ("agent", Endpoint.AgentId), ("exception", ex.GetType().FullName), ("err", ex.Message));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(backoff.Next(), _env.Time, ct);
|
||||
TimeSpan delay = backoff.Next();
|
||||
lock (_handshakeGate)
|
||||
{
|
||||
_attemptState = ProbeLinkAttemptState.RetryWait;
|
||||
_lastRetryAttempt = backoff.Attempts;
|
||||
_retryAt = _env.Time.GetUtcNow() + delay;
|
||||
}
|
||||
using var waitCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
Task delayTask = Task.Delay(delay, _env.Time, waitCts.Token);
|
||||
Task retryTask = _retrySignal.WaitAsync(waitCts.Token);
|
||||
Task completed = await Task.WhenAny(delayTask, retryTask);
|
||||
await waitCts.CancelAsync();
|
||||
if (completed == retryTask)
|
||||
{
|
||||
await retryTask;
|
||||
_env.Log.Info("manual link retry requested", ("agent", Endpoint.AgentId));
|
||||
}
|
||||
else
|
||||
{
|
||||
await delayTask;
|
||||
try { await retryTask; }
|
||||
catch (OperationCanceledException) { }
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -72,7 +175,7 @@ public sealed class AgentConnection
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> RunOnceAsync(Backoff backoff, CancellationToken ct)
|
||||
private async Task<string> RunOnceAsync(ProbeReconnectBackoff backoff, CancellationToken ct)
|
||||
{
|
||||
AddressFamily family = Endpoint.IpVersion == IpVersions.V6 ? AddressFamily.InterNetworkV6 : AddressFamily.InterNetwork;
|
||||
using var resolveTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(10), _env.Time);
|
||||
@@ -86,12 +189,25 @@ public sealed class AgentConnection
|
||||
throw new SocketException((int)SocketError.HostNotFound);
|
||||
}
|
||||
|
||||
byte[] agentKey = Convert.FromBase64String(Endpoint.PublicKey);
|
||||
(PeerSessionInfo info, TlsFrameTransport transport) = await PeerConnector.ConnectAsync(
|
||||
address.ToString(), Endpoint.TcpPort, Convert.FromBase64String(Endpoint.Spki), _env.Identity, _env.ProbeId, ProbeApp.Version, 8 << 20, _env.Time, ct);
|
||||
var remote = (IPEndPoint)info.Remote!;
|
||||
address.ToString(), Endpoint.TcpPort, agentKey, _env.Identity, _env.ProbeId, ProbeApp.Version, 8 << 20, _env.Time, ct);
|
||||
var data = new DataSession(info.Credentials.SessionIdValue, info.Credentials.Key);
|
||||
using var udp = new UdpMux(new SingleSession(data), null, _env.Clock, new DataPlaneCounters(), _env.Log, remote.AddressFamily == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any, []);
|
||||
udp.Bind();
|
||||
var remote = (IPEndPoint)info.Remote!;
|
||||
IReadOnlyList<int> quicPorts = Endpoint.EffectiveQuicPorts;
|
||||
int quicPort = quicPorts.Count == 0 ? 7443 : quicPorts[0];
|
||||
await using var datagrams = new QuicDatagramTransport(
|
||||
data,
|
||||
_env.Clock,
|
||||
new DataPlaneCounters(),
|
||||
_env.Log,
|
||||
new IPEndPoint(remote.Address, quicPort),
|
||||
agentKey,
|
||||
Volatile.Read(ref _previousQuicSourcePort));
|
||||
Volatile.Write(ref _activeDatagrams, datagrams);
|
||||
datagrams.Bind();
|
||||
using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
|
||||
var outbox = new Outbox(8 << 20);
|
||||
var clock = new ClockEstimator();
|
||||
var shipper = new FragmentShipper(outbox, new NodeId(Endpoint.AgentId), new ProbeId(_env.ProbeId), clock, _env.Time);
|
||||
@@ -100,9 +216,10 @@ public sealed class AgentConnection
|
||||
Clock = _env.Clock,
|
||||
Log = _env.Log,
|
||||
Session = () => data,
|
||||
Udp = udp,
|
||||
Datagrams = datagrams,
|
||||
PeerAddress = remote.Address,
|
||||
PeerSpkiSha256 = Convert.FromBase64String(Endpoint.Spki),
|
||||
PeerSpkiSha256 = NodeIdentity.Fingerprint(agentKey),
|
||||
KnockKey = Knock.DeriveKey(_env.Identity, agentKey),
|
||||
Fragments = shipper,
|
||||
MaxPacketsPerSecond = _env.Limits.MaxTotalPps,
|
||||
MaxBulkMbps = _env.Limits.MaxTestMbps,
|
||||
@@ -111,16 +228,57 @@ public sealed class AgentConnection
|
||||
var rpc = new RpcSession<ConnectionSession>(transport, s_router, session, _env.Time);
|
||||
rpc.OnBinary = (_, _) => ValueTask.CompletedTask;
|
||||
|
||||
using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
Task run = rpc.RunAsync(cts.Token);
|
||||
Task receive = udp.RunAsync(cts.Token);
|
||||
Task receive = datagrams.RunAsync(cts.Token);
|
||||
Task pump = PumpAsync(rpc, outbox, cts.Token);
|
||||
try
|
||||
{
|
||||
// 先同步时钟(块头里要带偏移);Agent 随后推送线路任务集
|
||||
await SyncClockAsync(rpc, clock, rounds: 5, cts.Token);
|
||||
lock (_handshakeGate)
|
||||
{
|
||||
_handshakeAttempts++;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
SetAttempt(ProbeLinkAttemptState.Connecting, null, null, 0);
|
||||
await datagrams.ConnectAsync(cts.Token);
|
||||
lock (_handshakeGate)
|
||||
{
|
||||
_handshakeSuccesses++;
|
||||
if (datagrams.HandshakeDurationMs is { } elapsed)
|
||||
{
|
||||
_handshakeSamplesMs.Enqueue(elapsed);
|
||||
while (_handshakeSamplesMs.Count > 128)
|
||||
{
|
||||
_handshakeSamplesMs.Dequeue();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (!(ex is OperationCanceledException && cts.IsCancellationRequested))
|
||||
{
|
||||
lock (_handshakeGate)
|
||||
{
|
||||
_handshakeFailures++;
|
||||
if (ex is TimeoutException)
|
||||
{
|
||||
_handshakeTimeouts++;
|
||||
}
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
if (datagrams.BoundPorts.Count > 0)
|
||||
{
|
||||
Volatile.Write(ref _previousQuicSourcePort, datagrams.BoundPorts[0]);
|
||||
}
|
||||
|
||||
backoff.Reset();
|
||||
_retrySignal.Wait(0, CancellationToken.None); // 若“立即重试”恰好赶在本次拨号成功前到达,本次成功已满足请求。
|
||||
_session = session;
|
||||
SetAttempt(ProbeLinkAttemptState.Connected, null, null, 0);
|
||||
_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);
|
||||
@@ -131,6 +289,7 @@ public sealed class AgentConnection
|
||||
finally
|
||||
{
|
||||
_session = null;
|
||||
Volatile.Write(ref _activeDatagrams, null);
|
||||
await cts.CancelAsync();
|
||||
await session.StopAllAsync();
|
||||
await Quiet(run);
|
||||
@@ -140,6 +299,20 @@ public sealed class AgentConnection
|
||||
}
|
||||
}
|
||||
|
||||
private void SetAttempt(ProbeLinkAttemptState state, string? error, DateTimeOffset? retryAt, int attempt)
|
||||
{
|
||||
lock (_handshakeGate)
|
||||
{
|
||||
_attemptState = state;
|
||||
if (error is not null || state == ProbeLinkAttemptState.Connected)
|
||||
{
|
||||
_lastError = error;
|
||||
}
|
||||
_retryAt = retryAt;
|
||||
_lastRetryAttempt = attempt;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task Quiet(Task task)
|
||||
{
|
||||
try
|
||||
@@ -191,7 +364,7 @@ public sealed class AgentConnection
|
||||
}
|
||||
|
||||
private static RpcRouter<ConnectionSession> BuildRouter() =>
|
||||
new RpcRouter<ConnectionSession>()
|
||||
new RpcRouter<ConnectionSession>(RpcSide.Dialer, MoShiroMethodTable.Table)
|
||||
.On(
|
||||
FleetMethods.StateSync,
|
||||
FleetRpcContext.Default.StateSyncParams,
|
||||
|
||||
@@ -16,11 +16,13 @@ public sealed class AgentDirectory
|
||||
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)
|
||||
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);
|
||||
@@ -68,16 +70,18 @@ public sealed class AgentDirectory
|
||||
public void Apply(ProbeAgentList result)
|
||||
{
|
||||
var next = new List<KnownAgent>();
|
||||
_trust.Update(result.TrustChain);
|
||||
foreach (AgentEndpoint agent in result.Agents)
|
||||
{
|
||||
List<int> ports = agent.QuicPorts.Count > 0 ? agent.QuicPorts : agent.LegacyUdpPorts ?? [];
|
||||
if (!string.IsNullOrWhiteSpace(agent.Host))
|
||||
{
|
||||
next.Add(new KnownAgent { AgentId = agent.AgentId.Value, Host = agent.Host, TcpPort = agent.TcpPort, Spki = agent.Spki });
|
||||
next.Add(new KnownAgent { AgentId = agent.AgentId.Value, Host = agent.Host, TcpPort = agent.TcpPort, QuicPorts = [.. ports], PublicKey = agent.PublicKey });
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(agent.Ipv6Host))
|
||||
{
|
||||
next.Add(new KnownAgent { AgentId = agent.AgentId.Value, Host = agent.Ipv6Host, TcpPort = agent.TcpPort, Spki = agent.Spki, IpVersion = IpVersions.V6 });
|
||||
next.Add(new KnownAgent { AgentId = agent.AgentId.Value, Host = agent.Ipv6Host, TcpPort = agent.TcpPort, QuicPorts = [.. ports], PublicKey = agent.PublicKey, IpVersion = IpVersions.V6 });
|
||||
}
|
||||
}
|
||||
lock (_gate)
|
||||
@@ -108,5 +112,5 @@ public sealed class AgentDirectory
|
||||
a.Count == b.Count && a.Zip(b).All(p => Equal(p.First, p.Second));
|
||||
|
||||
public static bool Equal(KnownAgent a, KnownAgent b) =>
|
||||
a.AgentId == b.AgentId && a.IpVersion == b.IpVersion && a.Host == b.Host && a.TcpPort == b.TcpPort && a.Spki == b.Spki;
|
||||
a.AgentId == b.AgentId && a.IpVersion == b.IpVersion && a.Host == b.Host && a.TcpPort == b.TcpPort && a.QuicPorts.SequenceEqual(b.QuicPorts) && a.PublicKey == b.PublicKey;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace MoShiro.Probe;
|
||||
|
||||
/// <summary>Reconnect cadence for a single Probe-to-Agent path: 1, 2, 4, 8, 16, then five 32-second waits, then once per minute.</summary>
|
||||
public sealed class ProbeReconnectBackoff
|
||||
{
|
||||
private static readonly TimeSpan[] s_delays =
|
||||
[
|
||||
TimeSpan.FromSeconds(1),
|
||||
TimeSpan.FromSeconds(2),
|
||||
TimeSpan.FromSeconds(4),
|
||||
TimeSpan.FromSeconds(8),
|
||||
TimeSpan.FromSeconds(16),
|
||||
TimeSpan.FromSeconds(32),
|
||||
TimeSpan.FromSeconds(32),
|
||||
TimeSpan.FromSeconds(32),
|
||||
TimeSpan.FromSeconds(32),
|
||||
TimeSpan.FromSeconds(32),
|
||||
];
|
||||
|
||||
private int _attempt;
|
||||
|
||||
public int Attempts => Volatile.Read(ref _attempt);
|
||||
|
||||
public TimeSpan Next()
|
||||
{
|
||||
int attempt = Interlocked.Increment(ref _attempt) - 1;
|
||||
return attempt < s_delays.Length ? s_delays[attempt] : TimeSpan.FromMinutes(1);
|
||||
}
|
||||
|
||||
public void Reset() => Interlocked.Exchange(ref _attempt, 0);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using Fleet.Core;
|
||||
using Fleet.Hosting;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using Fleet.Identity;
|
||||
using Fleet.Link;
|
||||
@@ -19,28 +20,41 @@ public sealed class ProbeApp : IDisposable
|
||||
|
||||
private readonly ConnectionEnv _env;
|
||||
private readonly Dictionary<string, Running> _connections = [];
|
||||
private readonly ConcurrentDictionary<string, long> _pendingRetries = new(StringComparer.Ordinal);
|
||||
private readonly SemaphoreSlim _changed = new(0);
|
||||
private readonly INodeLog _log;
|
||||
private readonly FleetLink _link;
|
||||
private readonly ReportingSection _reporting;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public ProbeApp(ProbeFile file, string configDirectory, TimeProvider time, INodeLog log)
|
||||
/// <param name="identity">注册时由节点自己生成:<c>Identity</c> 用来连面板,<c>PeerIdentity</c> 是另一把独立的密钥,只用来连 Agent。</param>
|
||||
public ProbeApp(ProbeFile file, LoadedNodeIdentity identity, string configDirectory, TimeProvider time, INodeLog log, RestartController? restart = null)
|
||||
{
|
||||
_log = log;
|
||||
var directory = new AgentDirectory(new ConfigStore(configDirectory), configDirectory, log);
|
||||
NodeIdentity identity = NodeIdentity.FromPkcs8(Convert.FromBase64String(file.PrivateKey));
|
||||
_env = new ConnectionEnv(identity, file.ProbeId, file.Limits, directory, new MonoClock(time), time, log);
|
||||
_time = time;
|
||||
_reporting = file.Reporting;
|
||||
var trust = TrustAnchor.Load(file.Fleet.IdentityDir, log);
|
||||
var directory = new AgentDirectory(new ConfigStore(configDirectory), configDirectory, log, trust);
|
||||
_env = new ConnectionEnv(identity.PeerIdentity, identity.NodeId.Value, file.Limits, directory, new MonoClock(time), time, log);
|
||||
directory.Changed += () => _changed.Release();
|
||||
|
||||
var update = new UpdateBridge(configDirectory, new ConfigStore(configDirectory).Write, log);
|
||||
RestartUpdater? update = RestartUpdater.FromEnvironment("probe", reason => restart?.RequestNow(reason), time, log);
|
||||
var options = new FleetLinkOptions
|
||||
{
|
||||
Server = new Uri(file.Server),
|
||||
Identity = identity,
|
||||
NodeId = new NodeId(file.ProbeId),
|
||||
Server = new Uri(file.Fleet.Server),
|
||||
Identity = identity.Identity,
|
||||
ServerVerifier = trust,
|
||||
NodeId = identity.NodeId,
|
||||
NodeVersion = Version,
|
||||
Capabilities = [MoShiroCapabilities.Base],
|
||||
HeartbeatExtra = () => JsonSerializer.SerializeToElement(new ProbeHeartbeatExtra { Update = update.ReadStatus(), Ipv6 = Ipv6Diagnostics.Sample(null, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")) }, MoShiroJsonContext.Default.ProbeHeartbeatExtra),
|
||||
HelloMeta = () => JsonSerializer.SerializeToElement(new ProbeHelloMeta { Platform = PlatformDetector.Detect(), Ipv6 = Ipv6Diagnostics.Sample(null, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")) }, MoShiroJsonContext.Default.ProbeHelloMeta),
|
||||
ShutdownReason = () => restart?.NowReason == "update" ? "updating" : "stopping",
|
||||
HeartbeatExtra = () =>
|
||||
{
|
||||
UpdateStatus? updateStatus = update?.Status();
|
||||
NodeRuntimeStatus runtimeStatus = updateStatus?.State == "applying" ? NodeRuntimeStatus.Updating : _env.Directory.Agents.Count == 0 ? NodeRuntimeStatus.ProbePaused : NodeRuntimeStatus.Running;
|
||||
return JsonSerializer.SerializeToElement(new ProbeHeartbeatExtra { RuntimeStatus = runtimeStatus, Update = updateStatus, Ipv6 = Ipv6Diagnostics.Sample(null, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")), QuicLinks = SnapshotQuicLinks() }, MoShiroJsonContext.Default.ProbeHeartbeatExtra);
|
||||
},
|
||||
HelloMeta = () => JsonSerializer.SerializeToElement(new ProbeHelloMeta { Owners = [.. trust.Controllers], Platform = PlatformDetector.Detect(), Ipv6 = Ipv6Diagnostics.Sample(null, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")) }, MoShiroJsonContext.Default.ProbeHelloMeta),
|
||||
};
|
||||
var list = new StateHandler<ProbeAgentList>(
|
||||
ProbeAgentList.StateType,
|
||||
@@ -50,15 +64,29 @@ public sealed class ProbeApp : IDisposable
|
||||
directory.Apply(doc);
|
||||
return ValueTask.FromResult(new StateSyncResult { AppliedVersion = version });
|
||||
});
|
||||
var router = new RpcRouter<LinkContext>()
|
||||
var router = new RpcRouter<LinkContext>(RpcSide.Node, MoShiroMethodTable.Table, NodeRoles.Probe)
|
||||
.OnNotify(MoShiroMethods.ProbeLinkRetry, MoShiroJsonContext.Default.ProbeLinkRetryParams, (_, retry, _) =>
|
||||
{
|
||||
RetryAgent(retry.AgentId.Value);
|
||||
return ValueTask.CompletedTask;
|
||||
})
|
||||
.OnNotify(MoShiroMethods.UpdateOffer, MoShiroJsonContext.Default.UpdateOfferParams, (_, offer, _) =>
|
||||
{
|
||||
update.WriteOffer(offer);
|
||||
update?.Offer(offer);
|
||||
return ValueTask.CompletedTask;
|
||||
})
|
||||
.OnNotify(MoShiroMethods.UpdateApply, MoShiroJsonContext.Default.UpdateOfferParams, (_, _, _) =>
|
||||
{
|
||||
update.RequestApply();
|
||||
update?.RequestApply();
|
||||
return ValueTask.CompletedTask;
|
||||
})
|
||||
.OnNotify(MoShiroMethods.Schedule, MoShiroJsonContext.Default.ScheduleParams, (_, schedule, _) =>
|
||||
{
|
||||
if (restart is not null && !restart.SetRemote(schedule.RestartAt))
|
||||
{
|
||||
log.Warn("ignoring an invalid restart schedule from the panel");
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
});
|
||||
_link = new FleetLink(options, router, [list], new Outbox(1 << 20), time, log);
|
||||
@@ -86,19 +114,71 @@ public sealed class ProbeApp : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private void RetryAgent(string agentId, bool queueIfMissing = true)
|
||||
{
|
||||
AgentConnection[] matches;
|
||||
lock (_connections)
|
||||
{
|
||||
matches = [.. _connections.Values.Select(r => r.Connection).Where(c => c.Endpoint.AgentId == agentId)];
|
||||
}
|
||||
|
||||
if (matches.Length == 0)
|
||||
{
|
||||
if (queueIfMissing)
|
||||
{
|
||||
_pendingRetries[agentId] = _time.GetUtcNow().AddSeconds(30).ToUnixTimeMilliseconds();
|
||||
}
|
||||
_log.Warn("manual link retry requested but no matching Agent path is configured", ("agent", agentId));
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingRetries.TryRemove(agentId, out _);
|
||||
foreach (AgentConnection connection in matches)
|
||||
{
|
||||
connection.RetryNow();
|
||||
}
|
||||
}
|
||||
|
||||
private void RetryPending()
|
||||
{
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
foreach ((string agentId, long expiresAt) in _pendingRetries.ToArray())
|
||||
{
|
||||
if (expiresAt <= now)
|
||||
{
|
||||
_pendingRetries.TryRemove(agentId, out _);
|
||||
}
|
||||
else
|
||||
{
|
||||
RetryAgent(agentId, queueIfMissing: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<ProbeQuicLinkHealth> SnapshotQuicLinks()
|
||||
{
|
||||
lock (_connections)
|
||||
{
|
||||
return [.. _connections.Values.Select(r => r.Connection.QuicHealth())];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
_log.Info("moshiro probe starting", MoShiro.Protocol.BuildInfo.Fields);
|
||||
if (_env.Directory.Agents.Count == 0)
|
||||
{
|
||||
_log.Info("no agents known yet, waiting for the panel to send the list");
|
||||
}
|
||||
|
||||
Task link = _link.RunAsync(ct);
|
||||
Task host = ReportHostAsync(ct);
|
||||
try
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
await ReconcileAsync(_env.Directory.Agents, ct);
|
||||
RetryPending();
|
||||
await _changed.WaitAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -109,7 +189,41 @@ public sealed class ProbeApp : IDisposable
|
||||
finally
|
||||
{
|
||||
await ReconcileAsync([], CancellationToken.None);
|
||||
await Task.WhenAny(link, Task.Delay(TimeSpan.FromSeconds(5), CancellationToken.None));
|
||||
await Task.WhenAny(Task.WhenAll(link, host), Task.Delay(TimeSpan.FromSeconds(5), CancellationToken.None));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>每隔几秒把主机资源占用的汇总数字发给面板,和 Agent 一样(没有型号、主机名、网卡名和 IP);链路断开时丢弃,重连之后下一次就补上。</summary>
|
||||
private async Task ReportHostAsync(CancellationToken ct)
|
||||
{
|
||||
if (!_reporting.HostStats)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var sampler = new HostSampler(_time);
|
||||
sampler.Sample(); // 第一次只用来建立 CPU 和网速的基线
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(Math.Clamp(_reporting.HostIntervalSeconds, 1, 60)), _time);
|
||||
try
|
||||
{
|
||||
while (await timer.WaitForNextTickAsync(ct))
|
||||
{
|
||||
if (_link.CurrentSession is { } session)
|
||||
{
|
||||
try
|
||||
{
|
||||
await session.NotifyAsync(MoShiroMethods.Host, sampler.Sample(), MoShiroJsonContext.Default.HostSnapshot, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_log.Warn($"could not report host status: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 停止
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +305,10 @@ public sealed class ProbeApp : IDisposable
|
||||
Dispose();
|
||||
}
|
||||
|
||||
public void Dispose() => _cts.Dispose();
|
||||
public void Dispose()
|
||||
{
|
||||
Connection.Dispose();
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,43 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using MoShiro.Measurement;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Probe;
|
||||
|
||||
/// <summary>
|
||||
/// <c>probe.json</c>:面板 <c>POST /api/admin/probe/identity</c> 生成的文件(只能下载一次)。
|
||||
/// <c>probe.json</c>:本地配置,和 Agent 一样由 <c>fleet-enroll</c> 注册之后写出身份目录(节点自己生成的两把密钥),这里只放面板地址和本地硬上限。
|
||||
/// <c>limits</c> 是本地硬上限,面板无法修改;在路由器上建议调低,避免 Probe 自己的 CPU 成为瓶颈、反过来影响测量。
|
||||
/// </summary>
|
||||
public sealed class ProbeFile
|
||||
{
|
||||
[JsonPropertyName("probe_id")]
|
||||
public string ProbeId { get; set; } = "";
|
||||
[JsonPropertyName("fleet")]
|
||||
public ProbeFleetSection Fleet { get; set; } = new();
|
||||
|
||||
/// <summary>PKCS#8,base64。</summary>
|
||||
[JsonPropertyName("private_key")]
|
||||
public string PrivateKey { get; set; } = "";
|
||||
/// <summary>每天定时重启一次,例如 <c>04:00+08:00</c>(不写偏移按 UTC);不写就不重启。环境变量 <c>MOSHIRO_RESTART_AT</c> 优先;这里是本地配置,从面板重新下载 probe.json 会丢掉它,所以容器里推荐用环境变量。</summary>
|
||||
[JsonPropertyName("restart_at")]
|
||||
public string? RestartAt { get; set; }
|
||||
|
||||
[JsonPropertyName("limits")]
|
||||
public ProbeLimits Limits { get; set; } = new();
|
||||
|
||||
/// <summary>上报主机资源占用,和 Agent 一样(只有汇总数字,没有型号和 IP);默认开,设 <c>host_stats: false</c> 就一个字也不上报。</summary>
|
||||
[JsonPropertyName("reporting")]
|
||||
public ReportingSection Reporting { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class ProbeFleetSection
|
||||
{
|
||||
/// <summary>面板的地址(http 或 https)。Probe 直接连它:身份、Agent 列表、更新都从这里来。</summary>
|
||||
[JsonPropertyName("server")]
|
||||
public string Server { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("limits")]
|
||||
public ProbeLimits Limits { get; set; } = new();
|
||||
/// <summary><c>fleet-enroll</c> 写出的身份目录:<c>node.json</c>、<c>node.key</c>(连面板的密钥)、<c>tls.pfx</c>(对等面的密钥)。</summary>
|
||||
[JsonPropertyName("identity_dir")]
|
||||
public string IdentityDir { get; set; } = "/etc/moshiro/probe-id";
|
||||
|
||||
/// <summary>节点自己要求有属主(passkey):没有就不运行。见 <c>OwnerGuard</c>(Agent 和 Probe 共用)。</summary>
|
||||
[JsonPropertyName("require_owner")]
|
||||
public bool RequireOwner { get; set; } = true;
|
||||
}
|
||||
|
||||
public sealed class KnownAgent
|
||||
@@ -41,9 +57,18 @@ public sealed class KnownAgent
|
||||
[JsonPropertyName("tcp_port")]
|
||||
public int TcpPort { get; set; }
|
||||
|
||||
/// <summary>Agent 的 TLS 证书指纹(SHA-256,base64)。</summary>
|
||||
[JsonPropertyName("spki")]
|
||||
public string Spki { get; set; } = "";
|
||||
[JsonPropertyName("quic_ports")]
|
||||
public List<int> QuicPorts { get; set; } = [7443];
|
||||
|
||||
[JsonPropertyName("udp_ports")]
|
||||
public List<int>? LegacyUdpPorts { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public IReadOnlyList<int> EffectiveQuicPorts => LegacyUdpPorts ?? QuicPorts;
|
||||
|
||||
/// <summary>Agent 的 TLS 证书公钥(SPKI,base64):证书按它的 SHA-256 指纹固定,敲门密钥由它派生。</summary>
|
||||
[JsonPropertyName("public_key")]
|
||||
public string PublicKey { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class ProbeLimits
|
||||
|
||||
@@ -21,30 +21,42 @@ for (int i = 0; i < args.Length - 1; i++)
|
||||
}
|
||||
|
||||
ProbeFile file;
|
||||
LoadedNodeIdentity identity;
|
||||
RestartController restart;
|
||||
try
|
||||
{
|
||||
file = NodeConfig.Load(configPath, ProbeConfigContext.Default.ProbeFile);
|
||||
if (file.ProbeId.Length == 0 || file.PrivateKey.Length == 0 || !Uri.TryCreate(file.Server, UriKind.Absolute, out _))
|
||||
restart = new RestartController(RestartSchedule.Resolve(file.RestartAt));
|
||||
if (!Uri.TryCreate(file.Fleet.Server, UriKind.Absolute, out _))
|
||||
{
|
||||
throw new InvalidOperationException("probe.json needs probe_id, private_key and server (download it from the panel: Probes → Create)");
|
||||
throw new InvalidOperationException("probe.json needs fleet.server (the panel address)");
|
||||
}
|
||||
|
||||
identity = LoadedNodeIdentity.Load(file.Fleet.IdentityDir);
|
||||
if (!identity.NodeId.Value.StartsWith(MoShiro.Protocol.ProbeId.Prefix, StringComparison.Ordinal))
|
||||
{
|
||||
identity.Dispose();
|
||||
throw new InvalidOperationException($"the identity in {file.Fleet.IdentityDir} is not a probe's: enroll with a probe join token");
|
||||
}
|
||||
|
||||
if (!OwnerGuard.Check(file.Fleet.IdentityDir, file.Fleet.RequireOwner, log))
|
||||
{
|
||||
identity.Dispose();
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or System.Text.Json.JsonException)
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or System.Text.Json.JsonException or System.Security.Cryptography.CryptographicException)
|
||||
{
|
||||
log.Error("startup failed", ("err", ex.Message));
|
||||
return 1;
|
||||
}
|
||||
|
||||
using var restartScope = restart;
|
||||
string directory = Path.GetDirectoryName(Path.GetFullPath(configPath))!;
|
||||
try
|
||||
using (identity)
|
||||
{
|
||||
using var app = new ProbeApp(file, directory, time, log);
|
||||
await ServiceShell.RunAsync(app.RunAsync, log);
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or System.Security.Cryptography.CryptographicException)
|
||||
{
|
||||
log.Error("startup failed", ("err", "private_key is not a valid PKCS#8 key: " + ex.Message));
|
||||
return 1;
|
||||
using var app = new ProbeApp(file, identity, directory, time, log, restart);
|
||||
await ServiceShell.RunAsync(app.RunAsync, log, restart, time);
|
||||
}
|
||||
|
||||
return 0;
|
||||
@@ -0,0 +1,34 @@
|
||||
using MoShiro.Probe;
|
||||
|
||||
namespace MoShiro.Probe.Tests;
|
||||
|
||||
public sealed class ProbeReconnectBackoffTests
|
||||
{
|
||||
[Fact]
|
||||
public void Next_UsesRequestedEscalationThenCapsAtOneMinute()
|
||||
{
|
||||
var backoff = new ProbeReconnectBackoff();
|
||||
TimeSpan[] expected =
|
||||
[
|
||||
TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(4),
|
||||
TimeSpan.FromSeconds(8), TimeSpan.FromSeconds(16),
|
||||
TimeSpan.FromSeconds(32), TimeSpan.FromSeconds(32), TimeSpan.FromSeconds(32),
|
||||
TimeSpan.FromSeconds(32), TimeSpan.FromSeconds(32),
|
||||
TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1),
|
||||
];
|
||||
|
||||
Assert.Equal(expected, expected.Select(_ => backoff.Next()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_RestartsAtOneSecond()
|
||||
{
|
||||
var backoff = new ProbeReconnectBackoff();
|
||||
_ = backoff.Next();
|
||||
_ = backoff.Next();
|
||||
|
||||
backoff.Reset();
|
||||
|
||||
Assert.Equal(TimeSpan.FromSeconds(1), backoff.Next());
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,7 @@ public sealed class ProbeTests
|
||||
private static ProbeAgentList List(long version, params string[] ids) => new()
|
||||
{
|
||||
Version = version,
|
||||
Agents = [.. ids.Select(id => new AgentEndpoint { AgentId = new NodeId(id), Host = id + ".example", TcpPort = 7443, Spki = "c3BraQ==" })],
|
||||
Agents = [.. ids.Select(id => new AgentEndpoint { AgentId = new NodeId(id), Host = id + ".example", TcpPort = 7443, PublicKey = "c3BraQ==" })],
|
||||
};
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -26,7 +26,7 @@ public static class BlockCatalog
|
||||
{
|
||||
public static readonly IReadOnlyList<BlockPair> All =
|
||||
[
|
||||
new("udp_echo", (e, s) => new UdpEchoDialer(e, s), (e, s) => new UdpEchoListener(e, s)),
|
||||
new("udp_echo", (e, s) => new DatagramEchoDialer(e, s), (e, s) => new DatagramEchoListener(e, s)),
|
||||
new("tcp_echo", (e, s) => new TcpEchoDialer(e, s), (e, s) => new TcpEchoListener(e, s)),
|
||||
new("udp_train", (e, s) => new TrainDialer(e, s), (e, s) => new TrainListener(e, s), OnDemandOnly: true, Directions: ["rev"]),
|
||||
new("tcp_bulk", (e, s) => new TcpBulkDialer(e, s), (e, s) => new TcpBulkListener(e, s), OnDemandOnly: true, Directions: ["fwd", "rev"]),
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace MoShiro.Measurement;
|
||||
internal sealed class EchoRows
|
||||
{
|
||||
/// <summary>UDP:没有在这段时间内回来的包就是丢了。</summary>
|
||||
public const long UdpGraceUs = 2_000_000;
|
||||
public const long DatagramGraceUs = 2_000_000;
|
||||
|
||||
/// <summary>
|
||||
/// TCP 不会丢包,只会延迟(重传、队头阻塞):一次几秒的中断之后,积压的回显会陆续回来,RTT 就是那几秒。
|
||||
@@ -65,26 +65,27 @@ internal sealed class EchoRows
|
||||
/// <summary>
|
||||
/// UDP 回显,拨号方(Probe):按声明的速率发包,t1 在发出时记录,回复到达时回填 t4。回复丢失的行 t4 缺失(Agent 一侧再补 t2/t3)。
|
||||
/// </summary>
|
||||
public sealed class UdpEchoDialer : BlockBase
|
||||
public sealed class DatagramEchoDialer : BlockBase
|
||||
{
|
||||
private readonly EchoRows _rows;
|
||||
|
||||
public UdpEchoDialer(BlockEnv env, BlockSpec spec)
|
||||
public DatagramEchoDialer(BlockEnv env, BlockSpec spec)
|
||||
: base(env, spec)
|
||||
{
|
||||
_rows = new EchoRows(spec.Kind, 16384, EchoRows.UdpGraceUs, settleOnReply: false);
|
||||
_rows = new EchoRows(spec.Kind, 16384, EchoRows.DatagramGraceUs, settleOnReply: false);
|
||||
}
|
||||
|
||||
public override long Produced => _rows.Count;
|
||||
|
||||
public override async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
UdpMux udp = Env.Udp!;
|
||||
QuicDatagramTransport datagrams = Env.Datagrams!;
|
||||
DataSession session = Env.Session() ?? throw new BlockFaultException("no data session");
|
||||
await datagrams.WaitUntilConnectedAsync(ct);
|
||||
IPEndPoint target = PeerEndpoint(Spec.Port);
|
||||
int size = (int)Spec.Int("size");
|
||||
byte[] packet = new byte[size];
|
||||
udp.Register(Spec.StreamId, OnPacket);
|
||||
datagrams.Register(Spec.StreamId, OnPacket);
|
||||
try
|
||||
{
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1.0 / Spec.Int("rate_hz")), Env.Time);
|
||||
@@ -92,8 +93,8 @@ public sealed class UdpEchoDialer : BlockBase
|
||||
{
|
||||
long t1 = Env.Clock.NowUs();
|
||||
uint seq = _rows.Add(size, t1);
|
||||
UdpPacket.Write(packet, new UdpHeader(UdpPacketType.EchoRequest, session.SessionId, Spec.StreamId, seq, t1), session.Key);
|
||||
udp.Send(target, packet);
|
||||
MeasurementDatagram.Write(packet, new MeasurementDatagramHeader(MeasurementDatagramType.EchoRequest, session.SessionId, Spec.StreamId, seq, t1), session.Key);
|
||||
datagrams.Send(session, target, packet);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
@@ -102,7 +103,7 @@ public sealed class UdpEchoDialer : BlockBase
|
||||
}
|
||||
finally
|
||||
{
|
||||
udp.Unregister(Spec.StreamId);
|
||||
datagrams.Unregister(Spec.StreamId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,9 +119,9 @@ public sealed class UdpEchoDialer : BlockBase
|
||||
return 1;
|
||||
}
|
||||
|
||||
private void OnPacket(in UdpDelivery delivery)
|
||||
private void OnPacket(in QuicDatagramDelivery delivery)
|
||||
{
|
||||
if (delivery.Header.Type == UdpPacketType.EchoReply)
|
||||
if (delivery.Header.Type == MeasurementDatagramType.EchoReply)
|
||||
{
|
||||
_rows.Reply(delivery.Header.Seq, delivery.ReceiveUs);
|
||||
}
|
||||
@@ -128,11 +129,11 @@ public sealed class UdpEchoDialer : BlockBase
|
||||
}
|
||||
|
||||
/// <summary>UDP 回显,监听方(Agent):校验后立即回复一个等长的包,记录 t2(到达)和 t3(回复发出),按序号留给合并。</summary>
|
||||
public sealed class UdpEchoListener : BlockBase
|
||||
public sealed class DatagramEchoListener : BlockBase
|
||||
{
|
||||
private readonly RowStore _records;
|
||||
|
||||
public UdpEchoListener(BlockEnv env, BlockSpec spec)
|
||||
public DatagramEchoListener(BlockEnv env, BlockSpec spec)
|
||||
: base(env, spec)
|
||||
{
|
||||
_records = new RowStore(spec.Kind.FindStream("msg")!, Side.Agent, 32768);
|
||||
@@ -142,26 +143,26 @@ public sealed class UdpEchoListener : BlockBase
|
||||
|
||||
public override async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
Env.Udp!.Register(Spec.StreamId, OnPacket);
|
||||
Env.Datagrams!.Register(Spec.StreamId, OnPacket);
|
||||
try
|
||||
{
|
||||
await WaitUntilCancelledAsync(ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Env.Udp.Unregister(Spec.StreamId);
|
||||
Env.Datagrams.Unregister(Spec.StreamId);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPacket(in UdpDelivery delivery)
|
||||
private void OnPacket(in QuicDatagramDelivery delivery)
|
||||
{
|
||||
if (delivery.Header.Type != UdpPacketType.EchoRequest)
|
||||
if (delivery.Header.Type != MeasurementDatagramType.EchoRequest)
|
||||
{
|
||||
return; // Data:探活包,只用来保持 NAT 映射和让准入策略学到源地址
|
||||
}
|
||||
|
||||
long t2 = delivery.ReceiveUs;
|
||||
delivery.ReplyAs(UdpPacketType.EchoReply);
|
||||
delivery.ReplyAs(MeasurementDatagramType.EchoReply);
|
||||
Span<long> row = [t2, Env.Clock.NowUs()];
|
||||
_records.SetRow(delivery.Header.Seq, row);
|
||||
}
|
||||
|
||||
@@ -30,11 +30,11 @@ public sealed class TrainListener : BlockBase
|
||||
public override async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
// Probe 开始前发来的探活包(Data)要被接受,准入策略才能据此学到它当前的源地址(NAT 映射);它们本身没有内容
|
||||
Env.Udp!.Register(Spec.StreamId, static (in UdpDelivery _) => { });
|
||||
Env.Datagrams!.Register(Spec.StreamId, static (in QuicDatagramDelivery _) => { });
|
||||
try
|
||||
{
|
||||
DataSession session = await WaitForPeerAsync(ct);
|
||||
IPEndPoint target = session.UdpEndpoint!;
|
||||
IPEndPoint target = session.DatagramRemoteEndPoint!;
|
||||
TimeSpan duration = TimeSpan.FromSeconds(Spec.Int("duration_s"));
|
||||
var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var thread = new Thread(() =>
|
||||
@@ -58,7 +58,7 @@ public sealed class TrainListener : BlockBase
|
||||
}
|
||||
finally
|
||||
{
|
||||
Env.Udp.Unregister(Spec.StreamId);
|
||||
Env.Datagrams.Unregister(Spec.StreamId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ public sealed class TrainListener : BlockBase
|
||||
while (true)
|
||||
{
|
||||
DataSession? session = Env.Session();
|
||||
if (session?.UdpEndpoint is not null)
|
||||
if (session?.DatagramRemoteEndPoint is not null)
|
||||
{
|
||||
return session;
|
||||
}
|
||||
@@ -120,8 +120,8 @@ public sealed class TrainListener : BlockBase
|
||||
for (; seq < due; seq++)
|
||||
{
|
||||
long sendUs = Env.Clock.NowUs();
|
||||
UdpPacket.Write(packet, new UdpHeader(UdpPacketType.Data, session.SessionId, Spec.StreamId, seq, sendUs), session.Key);
|
||||
Env.Udp!.Send(target, packet);
|
||||
MeasurementDatagram.Write(packet, new MeasurementDatagramHeader(MeasurementDatagramType.Data, session.SessionId, Spec.StreamId, seq, sendUs), session.Key);
|
||||
Env.Datagrams!.Send(session, target, packet);
|
||||
Span<long> row = [size, sendUs];
|
||||
_records.SetRow(seq, row);
|
||||
Interlocked.Exchange(ref _sent, seq + 1L);
|
||||
@@ -159,18 +159,19 @@ public sealed class TrainDialer : BlockBase
|
||||
|
||||
public override async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
UdpMux udp = Env.Udp!;
|
||||
QuicDatagramTransport datagrams = Env.Datagrams!;
|
||||
DataSession session = Env.Session() ?? throw new BlockFaultException("no data session");
|
||||
await datagrams.WaitUntilConnectedAsync(ct);
|
||||
_startUs = Env.Clock.NowUs();
|
||||
udp.Register(Spec.StreamId, OnPacket);
|
||||
datagrams.Register(Spec.StreamId, OnPacket);
|
||||
try
|
||||
{
|
||||
byte[] hello = new byte[UdpPacket.MinBytes + 20];
|
||||
byte[] hello = new byte[MeasurementDatagram.MinBytes + 20];
|
||||
IPEndPoint target = PeerEndpoint(Spec.Port);
|
||||
for (uint seq = 0; seq < 3; seq++)
|
||||
{
|
||||
UdpPacket.Write(hello, new UdpHeader(UdpPacketType.Data, session.SessionId, Spec.StreamId, seq, Env.Clock.NowUs()), session.Key);
|
||||
udp.Send(target, hello);
|
||||
MeasurementDatagram.Write(hello, new MeasurementDatagramHeader(MeasurementDatagramType.Data, session.SessionId, Spec.StreamId, seq, Env.Clock.NowUs()), session.Key);
|
||||
datagrams.Send(session, target, hello);
|
||||
}
|
||||
|
||||
await WaitUntilCancelledAsync(ct);
|
||||
@@ -178,7 +179,7 @@ public sealed class TrainDialer : BlockBase
|
||||
finally
|
||||
{
|
||||
_endUs = Env.Clock.NowUs();
|
||||
udp.Unregister(Spec.StreamId);
|
||||
datagrams.Unregister(Spec.StreamId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,10 +229,10 @@ public sealed class TrainDialer : BlockBase
|
||||
private static System.Text.Json.JsonElement Number(long value) =>
|
||||
System.Text.Json.JsonDocument.Parse(value.ToString(System.Globalization.CultureInfo.InvariantCulture)).RootElement.Clone();
|
||||
|
||||
private void OnPacket(in UdpDelivery delivery)
|
||||
private void OnPacket(in QuicDatagramDelivery delivery)
|
||||
{
|
||||
long seq = delivery.Header.Seq;
|
||||
if (delivery.Header.Type == UdpPacketType.Data && seq < _maxSeq && !_received.IsSet(seq, _tRecv))
|
||||
if (delivery.Header.Type == MeasurementDatagramType.Data && seq < _maxSeq && !_received.IsSet(seq, _tRecv))
|
||||
{
|
||||
_received.Set(seq, _tRecv, delivery.ReceiveUs); // 只记第一次到达(重复的包不覆盖)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ namespace MoShiro.Measurement;
|
||||
/// </summary>
|
||||
public class DataSession
|
||||
{
|
||||
private IDatagramConnection? _datagramConnection;
|
||||
|
||||
public DataSession(long sessionId, byte[] key)
|
||||
{
|
||||
SessionId = sessionId;
|
||||
@@ -21,13 +23,26 @@ public class DataSession
|
||||
|
||||
public byte[] Key { get; }
|
||||
|
||||
public IDatagramConnection? DatagramConnection => Volatile.Read(ref _datagramConnection);
|
||||
|
||||
public void BindDatagramConnection(IDatagramConnection connection) => Volatile.Write(ref _datagramConnection, connection);
|
||||
|
||||
/// <summary>对端最近一次通过认证的 UDP 源地址(监听方把单向数据发往这里;拨号方不需要)。</summary>
|
||||
public virtual IPEndPoint? UdpEndpoint => null;
|
||||
public virtual IPEndPoint? DatagramRemoteEndPoint => null;
|
||||
|
||||
/// <summary>TCP 首帧的随机数只能用一次。拨号方不接受连接,所以默认放行。</summary>
|
||||
public virtual bool UseNonce(ReadOnlySpan<byte> nonce) => true;
|
||||
}
|
||||
|
||||
/// <summary>Unreliable QUIC datagram sender bound to one authenticated measurement session.</summary>
|
||||
public interface IDatagramConnection
|
||||
{
|
||||
bool Send(ReadOnlySpan<byte> datagram);
|
||||
}
|
||||
|
||||
/// <summary>Transport-layer QUIC counters. These are separate from application Datagram delivery/loss.</summary>
|
||||
public sealed record QuicTransportSnapshot(double? PacketLossRate, ulong PacketsSent, uint CongestionEvents, uint EcnCongestionEvents, ushort PathMtu);
|
||||
|
||||
public interface IDataSessions
|
||||
{
|
||||
DataSession? Find(long sessionId);
|
||||
@@ -60,7 +75,7 @@ public enum Admission
|
||||
public interface IAdmission
|
||||
{
|
||||
/// <summary>UDP 包:<paramref name="source"/> 是认证通过的源地址(实现可以据此学习 NAT 重绑定)。</summary>
|
||||
Admission AdmitPacket(DataSession session, in UdpHeader header, SocketAddress source, long nowUs);
|
||||
Admission AdmitDatagram(DataSession session, in MeasurementDatagramHeader header, SocketAddress source, long nowUs);
|
||||
|
||||
/// <summary>TCP 回显的一条消息(只限速)。</summary>
|
||||
bool AdmitMessage(DataSession session, uint streamId, long nowUs);
|
||||
@@ -201,7 +216,7 @@ public sealed class BlockEnv
|
||||
/// <summary>当前的数据面会话(拨号方固定;监听方是 Agent 当前的 Probe 会话,可能没有)。</summary>
|
||||
public required Func<DataSession?> Session { get; init; }
|
||||
|
||||
public UdpMux? Udp { get; init; }
|
||||
public QuicDatagramTransport? Datagrams { get; init; }
|
||||
|
||||
/// <summary>监听方的准入策略(限速等);拨号方为 null。</summary>
|
||||
public IAdmission? Admission { get; init; }
|
||||
@@ -217,6 +232,9 @@ public sealed class BlockEnv
|
||||
/// <summary>拨号方:对端 TLS 证书的 SPKI 指纹;null 表示不校验证书(目标任务,目标是任意地址)。</summary>
|
||||
public byte[]? PeerSpkiSha256 { get; init; }
|
||||
|
||||
/// <summary>拨号方:和对端(Agent)的配对敲门密钥;每条 TCP 连接建立后先发敲门包。null 表示对端不要求敲门(目标任务的任意地址)。</summary>
|
||||
public byte[]? KnockKey { get; init; }
|
||||
|
||||
/// <summary>拨号方:列块出口。</summary>
|
||||
public IFragmentSink? Fragments { get; init; }
|
||||
|
||||
@@ -235,12 +253,13 @@ public sealed class BlockEnv
|
||||
Clock = Clock,
|
||||
Log = Log,
|
||||
Session = () => session,
|
||||
Udp = Udp,
|
||||
Datagrams = Datagrams,
|
||||
Admission = Admission,
|
||||
Counters = Counters,
|
||||
TcpListen = TcpListen,
|
||||
PeerAddress = PeerAddress,
|
||||
PeerSpkiSha256 = PeerSpkiSha256,
|
||||
KnockKey = KnockKey,
|
||||
Fragments = Fragments,
|
||||
MaxBulkMbps = MaxBulkMbps,
|
||||
MaxPacketsPerSecond = MaxPacketsPerSecond,
|
||||
|
||||
@@ -17,7 +17,7 @@ public sealed class NodeLimits
|
||||
public int MaxConcurrentTests { get; init; } = 1;
|
||||
|
||||
/// <summary>任务参数里的 <c>port</c> 只能取这些端口(本机配置的 listen 列表)。</summary>
|
||||
public IReadOnlyList<int> AllowedUdpPorts { get; init; } = [7443];
|
||||
public IReadOnlyList<int> AllowedQuicPorts { get; init; } = [7443];
|
||||
|
||||
public IReadOnlyList<int> AllowedTcpPorts { get; init; } = [7444];
|
||||
}
|
||||
@@ -143,7 +143,7 @@ public sealed class BudgetGuard
|
||||
}
|
||||
|
||||
int port = (int)values.GetInt("port");
|
||||
return kind.Blocks.Transport == "udp" ? _limits.AllowedUdpPorts.Contains(port) : _limits.AllowedTcpPorts.Contains(port);
|
||||
return kind.Blocks.Transport == "quic_datagram" ? _limits.AllowedQuicPorts.Contains(port) : _limits.AllowedTcpPorts.Contains(port);
|
||||
}
|
||||
|
||||
private static long EstimatePps(string kindId, ParamValues values) => kindId switch
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsAotCompatible>true</IsAotCompatible>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="MoShiro.Measurement.Tests" />
|
||||
<AdditionalFiles Include="$(MSBuildThisFileDirectory)../../BannedSymbols.Node.txt" />
|
||||
<ProjectReference Include="..\MoShiro.Protocol\MoShiro.Protocol.csproj" />
|
||||
<ProjectReference Include="..\..\fleet\src\Fleet.Identity\Fleet.Identity.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,110 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Measurement;
|
||||
|
||||
/// <summary>An authenticated, per-stream QUIC Datagram delivered to a measurement block.</summary>
|
||||
public readonly ref struct QuicDatagramDelivery
|
||||
{
|
||||
private readonly Span<byte> _payload;
|
||||
private readonly MsQuicConnection _connection;
|
||||
private readonly DataPlaneCounters _counters;
|
||||
|
||||
internal QuicDatagramDelivery(MeasurementDatagramHeader header, Span<byte> payload, long receiveUs, DataSession session, MsQuicConnection connection, DataPlaneCounters counters)
|
||||
{
|
||||
Header = header;
|
||||
_payload = payload;
|
||||
ReceiveUs = receiveUs;
|
||||
Session = session;
|
||||
_connection = connection;
|
||||
_counters = counters;
|
||||
}
|
||||
|
||||
public MeasurementDatagramHeader Header { get; }
|
||||
public ReadOnlySpan<byte> Payload => _payload;
|
||||
public long ReceiveUs { get; }
|
||||
public DataSession Session { get; }
|
||||
|
||||
public void ReplyAs(MeasurementDatagramType type)
|
||||
{
|
||||
_payload[3] = (byte)type;
|
||||
MeasurementDatagram.Seal(_payload, Session.Key);
|
||||
if (_connection.Send(_payload))
|
||||
{
|
||||
_counters.PacketOut();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public delegate void QuicDatagramHandler(in QuicDatagramDelivery delivery);
|
||||
|
||||
/// <summary>Authenticates datagrams, applies Agent admission policy, and dispatches by stream.</summary>
|
||||
internal sealed class MeasurementDatagramRouter
|
||||
{
|
||||
private readonly IDataSessions _sessions;
|
||||
private readonly IAdmission? _admission;
|
||||
private readonly MonoClock _clock;
|
||||
private readonly ConcurrentDictionary<uint, QuicDatagramHandler> _handlers = new();
|
||||
|
||||
public MeasurementDatagramRouter(IDataSessions sessions, IAdmission? admission, MonoClock clock, DataPlaneCounters counters)
|
||||
{
|
||||
_sessions = sessions;
|
||||
_admission = admission;
|
||||
_clock = clock;
|
||||
Counters = counters;
|
||||
}
|
||||
|
||||
public DataPlaneCounters Counters { get; }
|
||||
|
||||
public void Register(uint streamId, QuicDatagramHandler handler) => _handlers[streamId] = handler;
|
||||
|
||||
public void Unregister(uint streamId) => _handlers.TryRemove(streamId, out _);
|
||||
|
||||
public void Dispatch(MsQuicConnection connection, ReadOnlySpan<byte> datagram)
|
||||
{
|
||||
Counters.PacketIn();
|
||||
Span<byte> payload = stackalloc byte[MeasurementDatagram.MaxBytes];
|
||||
if (datagram.Length > payload.Length)
|
||||
{
|
||||
Counters.DropInvalid();
|
||||
return;
|
||||
}
|
||||
|
||||
datagram.CopyTo(payload);
|
||||
payload = payload[..datagram.Length];
|
||||
if (!MeasurementDatagram.TryParseHeader(payload, out MeasurementDatagramHeader header)
|
||||
|| !_handlers.TryGetValue(header.StreamId, out QuicDatagramHandler? handler)
|
||||
|| _sessions.Find(header.SessionId) is not { } session
|
||||
|| !MeasurementDatagram.VerifyMac(payload, session.Key))
|
||||
{
|
||||
Counters.DropInvalid();
|
||||
return;
|
||||
}
|
||||
|
||||
IPEndPoint? remote = connection.RefreshRemoteEndPoint();
|
||||
if (remote is null)
|
||||
{
|
||||
Counters.DropInvalid();
|
||||
return;
|
||||
}
|
||||
|
||||
long receiveUs = _clock.NowUs();
|
||||
if (_admission is not null)
|
||||
{
|
||||
switch (_admission.AdmitDatagram(session, header, remote.Serialize(), receiveUs))
|
||||
{
|
||||
case Admission.Invalid:
|
||||
Counters.DropInvalid();
|
||||
return;
|
||||
case Admission.RateLimited:
|
||||
Counters.DropRateLimited();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
session.BindDatagramConnection(connection);
|
||||
handler(new QuicDatagramDelivery(header, payload, receiveUs, session, connection, Counters));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace MoShiro.Measurement;
|
||||
|
||||
/// <summary>Small, versioned view of the MsQuic v2 C API table used by this transport.</summary>
|
||||
internal static unsafe class MsQuicApi
|
||||
{
|
||||
private const int SetContextIndex = 0;
|
||||
private const int SetCallbackHandlerIndex = 2;
|
||||
private const int SetParamIndex = 3;
|
||||
private const int GetParamIndex = 4;
|
||||
private const int RegistrationOpenIndex = 5;
|
||||
private const int RegistrationCloseIndex = 6;
|
||||
private const int ConfigurationOpenIndex = 8;
|
||||
private const int ConfigurationCloseIndex = 9;
|
||||
private const int ConfigurationLoadCredentialIndex = 10;
|
||||
private const int ListenerOpenIndex = 11;
|
||||
private const int ListenerCloseIndex = 12;
|
||||
private const int ListenerStartIndex = 13;
|
||||
private const int ListenerStopIndex = 14;
|
||||
private const int ConnectionOpenIndex = 15;
|
||||
private const int ConnectionCloseIndex = 16;
|
||||
private const int ConnectionShutdownIndex = 17;
|
||||
private const int ConnectionStartIndex = 18;
|
||||
private const int ConnectionSetConfigurationIndex = 19;
|
||||
private const int DatagramSendIndex = 28;
|
||||
private const uint ConnectionStatisticsV2Parameter = 0x05000016;
|
||||
|
||||
private static readonly SetContextDelegate s_setContext = MsQuicRuntime.Function<SetContextDelegate>(SetContextIndex);
|
||||
private static readonly SetCallbackHandlerDelegate s_setCallbackHandler = MsQuicRuntime.Function<SetCallbackHandlerDelegate>(SetCallbackHandlerIndex);
|
||||
private static readonly SetParamDelegate s_setParam = MsQuicRuntime.Function<SetParamDelegate>(SetParamIndex);
|
||||
private static readonly GetParamDelegate s_getParam = MsQuicRuntime.Function<GetParamDelegate>(GetParamIndex);
|
||||
private static readonly RegistrationOpenDelegate s_registrationOpen = MsQuicRuntime.Function<RegistrationOpenDelegate>(RegistrationOpenIndex);
|
||||
private static readonly HandleCloseDelegate s_registrationClose = MsQuicRuntime.Function<HandleCloseDelegate>(RegistrationCloseIndex);
|
||||
private static readonly ConfigurationOpenDelegate s_configurationOpen = MsQuicRuntime.Function<ConfigurationOpenDelegate>(ConfigurationOpenIndex);
|
||||
private static readonly HandleCloseDelegate s_configurationClose = MsQuicRuntime.Function<HandleCloseDelegate>(ConfigurationCloseIndex);
|
||||
private static readonly ConfigurationLoadCredentialDelegate s_loadCredential = MsQuicRuntime.Function<ConfigurationLoadCredentialDelegate>(ConfigurationLoadCredentialIndex);
|
||||
private static readonly ListenerOpenDelegate s_listenerOpen = MsQuicRuntime.Function<ListenerOpenDelegate>(ListenerOpenIndex);
|
||||
private static readonly HandleCloseDelegate s_listenerClose = MsQuicRuntime.Function<HandleCloseDelegate>(ListenerCloseIndex);
|
||||
private static readonly ListenerStartDelegate s_listenerStart = MsQuicRuntime.Function<ListenerStartDelegate>(ListenerStartIndex);
|
||||
private static readonly ListenerStopDelegate s_listenerStop = MsQuicRuntime.Function<ListenerStopDelegate>(ListenerStopIndex);
|
||||
private static readonly ConnectionOpenDelegate s_connectionOpen = MsQuicRuntime.Function<ConnectionOpenDelegate>(ConnectionOpenIndex);
|
||||
private static readonly HandleCloseDelegate s_connectionClose = MsQuicRuntime.Function<HandleCloseDelegate>(ConnectionCloseIndex);
|
||||
private static readonly ConnectionShutdownDelegate s_connectionShutdown = MsQuicRuntime.Function<ConnectionShutdownDelegate>(ConnectionShutdownIndex);
|
||||
private static readonly ConnectionStartDelegate s_connectionStart = MsQuicRuntime.Function<ConnectionStartDelegate>(ConnectionStartIndex);
|
||||
private static readonly ConnectionSetConfigurationDelegate s_connectionSetConfiguration = MsQuicRuntime.Function<ConnectionSetConfigurationDelegate>(ConnectionSetConfigurationIndex);
|
||||
private static readonly DatagramSendDelegate s_datagramSend = MsQuicRuntime.Function<DatagramSendDelegate>(DatagramSendIndex);
|
||||
|
||||
public static void SetContext(nint handle, nint context) => s_setContext(handle, context);
|
||||
|
||||
public static void SetCallbackHandler<T>(nint handle, T callback, nint context)
|
||||
where T : Delegate => s_setCallbackHandler(handle, Marshal.GetFunctionPointerForDelegate(callback), context);
|
||||
|
||||
public static void SetParam(nint handle, uint parameter, byte value)
|
||||
{
|
||||
unsafe
|
||||
{
|
||||
uint status;
|
||||
status = s_setParam(handle, parameter, sizeof(byte), &value);
|
||||
Check(status, "SetParam");
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[] GetAddress(nint handle, uint parameter)
|
||||
{
|
||||
byte[] address = new byte[128];
|
||||
unsafe
|
||||
{
|
||||
fixed (byte* buffer = address)
|
||||
{
|
||||
uint length = (uint)address.Length;
|
||||
uint status = s_getParam(handle, parameter, &length, buffer);
|
||||
Check(status, "GetParam(address)");
|
||||
Array.Resize(ref address, checked((int)length));
|
||||
}
|
||||
}
|
||||
|
||||
return address;
|
||||
}
|
||||
|
||||
public static QuicStatisticsV2 GetStatisticsV2(nint connection)
|
||||
{
|
||||
QuicStatisticsV2 statistics = default;
|
||||
unsafe
|
||||
{
|
||||
uint length = (uint)sizeof(QuicStatisticsV2);
|
||||
uint status = s_getParam(connection, ConnectionStatisticsV2Parameter, &length, &statistics);
|
||||
Check(status, "GetParam(connection statistics v2)");
|
||||
}
|
||||
|
||||
return statistics;
|
||||
}
|
||||
|
||||
public static nint RegistrationOpen(string appName)
|
||||
{
|
||||
nint utf8 = Marshal.StringToCoTaskMemUTF8(appName);
|
||||
try
|
||||
{
|
||||
var config = new RegistrationConfig { AppName = utf8, ExecutionProfile = 0 };
|
||||
uint status = s_registrationOpen(ref config, out nint registration);
|
||||
Check(status, "RegistrationOpen");
|
||||
return registration;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeCoTaskMem(utf8);
|
||||
}
|
||||
}
|
||||
|
||||
public static void RegistrationClose(nint handle) => s_registrationClose(handle);
|
||||
|
||||
public static nint ConfigurationOpen(nint registration, byte[] alpn)
|
||||
{
|
||||
unsafe
|
||||
{
|
||||
fixed (byte* alpnBytes = alpn)
|
||||
{
|
||||
var buffer = new QuicBuffer { Length = (uint)alpn.Length, Buffer = (nint)alpnBytes };
|
||||
uint status = s_configurationOpen(registration, ref buffer, 1, 0, 0, 0, out nint configuration);
|
||||
Check(status, "ConfigurationOpen");
|
||||
return configuration;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void ConfigurationClose(nint handle) => s_configurationClose(handle);
|
||||
|
||||
public static void ConfigurationLoadCredential(nint configuration, ref CredentialConfig credential)
|
||||
{
|
||||
uint status = s_loadCredential(configuration, ref credential);
|
||||
Check(status, "ConfigurationLoadCredential");
|
||||
}
|
||||
|
||||
public static nint ListenerOpen<T>(nint registration, T callback, nint context)
|
||||
where T : Delegate
|
||||
{
|
||||
uint status = s_listenerOpen(registration, Marshal.GetFunctionPointerForDelegate(callback), context, out nint listener);
|
||||
Check(status, "ListenerOpen");
|
||||
return listener;
|
||||
}
|
||||
|
||||
public static void ListenerClose(nint handle) => s_listenerClose(handle);
|
||||
|
||||
public static void ListenerStart(nint listener, byte[] alpn, byte[] address)
|
||||
{
|
||||
unsafe
|
||||
{
|
||||
fixed (byte* alpnBytes = alpn)
|
||||
fixed (byte* addressBytes = address)
|
||||
{
|
||||
var buffer = new QuicBuffer { Length = (uint)alpn.Length, Buffer = (nint)alpnBytes };
|
||||
uint status = s_listenerStart(listener, ref buffer, 1, (nint)addressBytes);
|
||||
Check(status, "ListenerStart");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void ListenerStop(nint handle) => s_listenerStop(handle);
|
||||
|
||||
public static nint ConnectionOpen<T>(nint registration, T callback, nint context)
|
||||
where T : Delegate
|
||||
{
|
||||
uint status = s_connectionOpen(registration, Marshal.GetFunctionPointerForDelegate(callback), context, out nint connection);
|
||||
Check(status, "ConnectionOpen");
|
||||
return connection;
|
||||
}
|
||||
|
||||
public static void ConnectionClose(nint handle) => s_connectionClose(handle);
|
||||
|
||||
public static void ConnectionShutdown(nint handle, ulong errorCode = 0) => s_connectionShutdown(handle, 0x00000001, errorCode);
|
||||
|
||||
public static void ConnectionStart(nint handle, nint configuration, ushort addressFamily, nint serverName, ushort port)
|
||||
{
|
||||
uint status = s_connectionStart(handle, configuration, addressFamily, serverName, port);
|
||||
Check(status, "ConnectionStart");
|
||||
}
|
||||
|
||||
public static void ConnectionBind(nint handle, byte[] address)
|
||||
{
|
||||
unsafe
|
||||
{
|
||||
fixed (byte* addressBytes = address)
|
||||
{
|
||||
uint status = s_setParam(handle, 0x05000001, (uint)address.Length, addressBytes);
|
||||
Check(status, "SetParam(local address)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void ConnectionSetConfiguration(nint handle, nint configuration)
|
||||
{
|
||||
uint status = s_connectionSetConfiguration(handle, configuration);
|
||||
Check(status, "ConnectionSetConfiguration");
|
||||
}
|
||||
|
||||
public static uint DatagramSend(nint connection, nint buffers, nint clientContext) => s_datagramSend(connection, buffers, 1, 0, clientContext);
|
||||
|
||||
|
||||
public static void Check(uint status, string operation)
|
||||
{
|
||||
if (!Succeeded(status))
|
||||
{
|
||||
throw new InvalidOperationException($"MsQuic {operation} failed with status 0x{status:X8}.");
|
||||
}
|
||||
}
|
||||
|
||||
// QUIC_STATUS_PENDING (0xFFFFFFFE) is a successful asynchronous result. Other
|
||||
// negative HRESULT/NTSTATUS values are failures on both Windows and POSIX.
|
||||
public static bool Succeeded(uint status) => status == 0 || status == 0xFFFFFFFE;
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct QuicBuffer
|
||||
{
|
||||
public uint Length;
|
||||
public nint Buffer;
|
||||
}
|
||||
|
||||
// MsQuic QUIC_STATISTICS_V2 through v2.5. All fields are fixed-width on
|
||||
// the supported 64-bit RIDs; keeping the field order aligned with msquic.h.
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct QuicStatisticsV2
|
||||
{
|
||||
public ulong CorrelationId;
|
||||
public uint Flags;
|
||||
public uint RttUs;
|
||||
public uint MinRttUs;
|
||||
public uint MaxRttUs;
|
||||
public ulong TimingStart;
|
||||
public ulong TimingInitialFlightEnd;
|
||||
public ulong TimingHandshakeFlightEnd;
|
||||
public uint HandshakeClientFlight1Bytes;
|
||||
public uint HandshakeServerFlight1Bytes;
|
||||
public uint HandshakeClientFlight2Bytes;
|
||||
public ushort SendPathMtu;
|
||||
public ulong SendTotalPackets;
|
||||
public ulong SendRetransmittablePackets;
|
||||
public ulong SendSuspectedLostPackets;
|
||||
public ulong SendSpuriousLostPackets;
|
||||
public ulong SendTotalBytes;
|
||||
public ulong SendTotalStreamBytes;
|
||||
public uint SendCongestionCount;
|
||||
public uint SendPersistentCongestionCount;
|
||||
public ulong RecvTotalPackets;
|
||||
public ulong RecvReorderedPackets;
|
||||
public ulong RecvDroppedPackets;
|
||||
public ulong RecvDuplicatePackets;
|
||||
public ulong RecvTotalBytes;
|
||||
public ulong RecvTotalStreamBytes;
|
||||
public ulong RecvDecryptionFailures;
|
||||
public ulong RecvValidAckFrames;
|
||||
public uint KeyUpdateCount;
|
||||
public uint SendCongestionWindow;
|
||||
public uint DestCidUpdateCount;
|
||||
public uint SendEcnCongestionCount;
|
||||
public byte HandshakeHopLimitTtl;
|
||||
public uint RttVarianceUs;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct RegistrationConfig
|
||||
{
|
||||
public nint AppName;
|
||||
public int ExecutionProfile;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct CredentialConfig
|
||||
{
|
||||
public uint Type;
|
||||
public uint Flags;
|
||||
public nint Certificate;
|
||||
public nint Principal;
|
||||
public nint Reserved;
|
||||
public nint AsyncHandler;
|
||||
public uint AllowedCipherSuites;
|
||||
public nint CaCertificateFile;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct Pkcs12Certificate
|
||||
{
|
||||
public nint Asn1Blob;
|
||||
public uint Asn1BlobLength;
|
||||
public nint PrivateKeyPassword;
|
||||
}
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
internal delegate uint ListenerCallback(nint listener, nint context, nint @event);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
internal delegate uint ConnectionCallback(nint connection, nint context, nint @event);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate void SetContextDelegate(nint handle, nint context);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate void SetCallbackHandlerDelegate(nint handle, nint handler, nint context);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate uint SetParamDelegate(nint handle, uint parameter, uint bufferLength, void* buffer);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate uint GetParamDelegate(nint handle, uint parameter, uint* bufferLength, void* buffer);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate uint RegistrationOpenDelegate(ref RegistrationConfig config, out nint registration);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate void HandleCloseDelegate(nint handle);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate uint ConfigurationOpenDelegate(nint registration, ref QuicBuffer alpn, uint alpnCount, nint settings, uint settingsLength, nint context, out nint configuration);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate uint ConfigurationLoadCredentialDelegate(nint configuration, ref CredentialConfig credential);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate uint ListenerOpenDelegate(nint registration, nint callback, nint context, out nint listener);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate uint ListenerStartDelegate(nint listener, ref QuicBuffer alpn, uint alpnCount, nint address);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate void ListenerStopDelegate(nint listener);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate uint ConnectionOpenDelegate(nint registration, nint callback, nint context, out nint connection);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate void ConnectionShutdownDelegate(nint connection, uint flags, ulong errorCode);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate uint ConnectionStartDelegate(nint connection, nint configuration, ushort family, nint serverName, ushort serverPort);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate uint ConnectionSetConfigurationDelegate(nint connection, nint configuration);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate uint DatagramSendDelegate(nint connection, nint buffers, uint bufferCount, uint flags, nint clientContext);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,610 @@
|
||||
using System.Buffers;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Fleet.Core;
|
||||
|
||||
namespace MoShiro.Measurement;
|
||||
|
||||
internal delegate void MsQuicDatagramCallback(MsQuicConnection connection, ReadOnlySpan<byte> datagram);
|
||||
|
||||
/// <summary>A single MsQuic connection with connection-bound datagram delivery.</summary>
|
||||
internal sealed class MsQuicConnection : IAsyncDisposable, IDatagramConnection
|
||||
{
|
||||
private const uint DatagramReceiveEnabled = 0x0500000D;
|
||||
private const uint ClientCredentialFlags = 0x00000001 | 0x00000004 | 0x00000010 | 0x00004000;
|
||||
private const uint CertificateCredentialNone = 0;
|
||||
private const uint CertificateCredentialContext = 3;
|
||||
private const uint CertificateCredentialPkcs12 = 6;
|
||||
private const uint ConnectionEventConnected = 0;
|
||||
private const uint ConnectionEventShutdownByTransport = 1;
|
||||
private const uint ConnectionEventShutdownByPeer = 2;
|
||||
private const uint ConnectionEventShutdownComplete = 3;
|
||||
private const uint ConnectionEventPeerAddressChanged = 5;
|
||||
private const uint ConnectionEventDatagramStateChanged = 10;
|
||||
private const uint ConnectionEventDatagramReceived = 11;
|
||||
private const uint ConnectionEventDatagramSendStateChanged = 12;
|
||||
private const uint ConnectionEventPeerCertificateReceived = 15;
|
||||
private const int DatagramStateFinal = 3;
|
||||
private const int QuicAddressBytes = 28;
|
||||
private static readonly MsQuicApi.ConnectionCallback s_callback = OnEvent;
|
||||
|
||||
private readonly object _sendGate = new();
|
||||
private readonly TaskCompletionSource _connected = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly TaskCompletionSource _shutdown = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly GCHandle _self;
|
||||
private readonly byte[]? _expectedPeerSpki;
|
||||
private readonly Action<MsQuicConnection>? _closed;
|
||||
private readonly Action<IPEndPoint>? _peerAddressChanged;
|
||||
private readonly INodeLog _log;
|
||||
private readonly Dictionary<nint, SendState> _sends = [];
|
||||
private nint _registration;
|
||||
private nint _configuration;
|
||||
private nint _handle;
|
||||
private int _maxDatagramBytes;
|
||||
private int _sendEnabled;
|
||||
private int _disposeStarted;
|
||||
private double? _handshakeDurationMs;
|
||||
|
||||
private MsQuicConnection(
|
||||
nint handle,
|
||||
nint registration,
|
||||
nint configuration,
|
||||
byte[]? expectedPeerSpki,
|
||||
MsQuicDatagramCallback? datagramHandler,
|
||||
INodeLog log,
|
||||
Action<MsQuicConnection>? closed,
|
||||
Action<IPEndPoint>? peerAddressChanged)
|
||||
{
|
||||
_handle = handle;
|
||||
_registration = registration;
|
||||
_configuration = configuration;
|
||||
_expectedPeerSpki = expectedPeerSpki;
|
||||
DatagramHandler = datagramHandler;
|
||||
_log = log;
|
||||
_closed = closed;
|
||||
_peerAddressChanged = peerAddressChanged;
|
||||
_self = GCHandle.Alloc(this);
|
||||
}
|
||||
|
||||
public MsQuicDatagramCallback? DatagramHandler { get; set; }
|
||||
|
||||
public IPEndPoint? RemoteEndPoint { get; private set; }
|
||||
|
||||
public IPEndPoint? LocalEndPoint => Volatile.Read(ref _handle) is nint handle && handle != 0
|
||||
? ReadEndpoint(MsQuicApi.GetAddress(handle, 0x05000001))
|
||||
: null;
|
||||
|
||||
public nint Handle => Volatile.Read(ref _handle);
|
||||
|
||||
public IPEndPoint? RefreshRemoteEndPoint()
|
||||
{
|
||||
if (RemoteEndPoint is not null || Handle == 0)
|
||||
{
|
||||
return RemoteEndPoint;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
RemoteEndPoint = ReadEndpoint(MsQuicApi.GetAddress(Handle, 0x05000002));
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// Address isn't available until the handshake finishes.
|
||||
}
|
||||
|
||||
return RemoteEndPoint;
|
||||
}
|
||||
|
||||
public bool IsReady => _connected.Task.IsCompletedSuccessfully && Volatile.Read(ref _sendEnabled) != 0;
|
||||
|
||||
public int MaxDatagramBytes => Volatile.Read(ref _maxDatagramBytes);
|
||||
|
||||
public double? HandshakeDurationMs => _handshakeDurationMs;
|
||||
|
||||
public bool TryGetStatistics(out MsQuicApi.QuicStatisticsV2 statistics)
|
||||
{
|
||||
nint handle = Volatile.Read(ref _handle);
|
||||
if (handle == 0)
|
||||
{
|
||||
statistics = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
statistics = MsQuicApi.GetStatisticsV2(handle);
|
||||
return true;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
statistics = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<MsQuicConnection> ConnectAsync(
|
||||
IPAddress address,
|
||||
int port,
|
||||
int localPort,
|
||||
byte[] expectedPeerSpki,
|
||||
MsQuicDatagramCallback datagramHandler,
|
||||
INodeLog log,
|
||||
CancellationToken ct)
|
||||
{
|
||||
nint registration = 0;
|
||||
nint configuration = 0;
|
||||
MsQuicConnection? connection = null;
|
||||
long handshakeStarted = 0;
|
||||
try
|
||||
{
|
||||
log.Info("opening MsQuic client registration");
|
||||
registration = MsQuicApi.RegistrationOpen("moshiro-probe");
|
||||
log.Info("opening MsQuic client configuration");
|
||||
configuration = MsQuicApi.ConfigurationOpen(registration, QuicAlpn.Value);
|
||||
log.Info("loading MsQuic client credentials");
|
||||
LoadClientCredentials(configuration);
|
||||
var endpoint = new IPEndPoint(address.AddressFamily == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any, localPort);
|
||||
SocketAddress serialized = endpoint.Serialize();
|
||||
byte[] localAddress = new byte[QuicAddressBytes];
|
||||
for (int i = 0; i < serialized.Size; i++)
|
||||
{
|
||||
localAddress[i] = serialized[i];
|
||||
}
|
||||
ushort nativeFamily = ReadAddressFamily(localAddress);
|
||||
nint name = Marshal.StringToCoTaskMemUTF8(address.ToString());
|
||||
try
|
||||
{
|
||||
nint handle = MsQuicApi.ConnectionOpen(registration, s_callback, 0);
|
||||
log.Info("opened MsQuic client connection");
|
||||
connection = new MsQuicConnection(handle, registration, configuration, expectedPeerSpki, datagramHandler, log, null, null);
|
||||
registration = 0;
|
||||
configuration = 0;
|
||||
MsQuicApi.SetContext(handle, GCHandle.ToIntPtr(connection._self));
|
||||
log.Info("enabling MsQuic datagram receive");
|
||||
MsQuicApi.SetParam(handle, DatagramReceiveEnabled, 1);
|
||||
log.Info("binding MsQuic local address", ("local_port", localPort));
|
||||
MsQuicApi.ConnectionBind(handle, localAddress);
|
||||
log.Info("starting MsQuic QUIC handshake", ("remote", address), ("port", port), ("local_port", localPort));
|
||||
handshakeStarted = Stopwatch.GetTimestamp();
|
||||
MsQuicApi.ConnectionStart(handle, connection._configuration, nativeFamily, name, checked((ushort)port));
|
||||
log.Info("MsQuic connection start returned");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeCoTaskMem(name);
|
||||
}
|
||||
|
||||
await connection._connected.Task.WaitAsync(TimeSpan.FromSeconds(10), ct);
|
||||
connection._handshakeDurationMs = Stopwatch.GetElapsedTime(handshakeStarted).TotalMilliseconds;
|
||||
return connection;
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (connection is not null)
|
||||
{
|
||||
await connection.DisposeAsync();
|
||||
}
|
||||
|
||||
if (configuration != 0)
|
||||
{
|
||||
MsQuicApi.ConfigurationClose(configuration);
|
||||
}
|
||||
|
||||
if (registration != 0)
|
||||
{
|
||||
MsQuicApi.RegistrationClose(registration);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public static MsQuicConnection Accept(
|
||||
nint handle,
|
||||
nint configuration,
|
||||
MsQuicDatagramCallback datagramHandler,
|
||||
INodeLog log,
|
||||
Action<MsQuicConnection> closed,
|
||||
Action<IPEndPoint>? peerAddressChanged)
|
||||
{
|
||||
var connection = new MsQuicConnection(handle, 0, 0, null, datagramHandler, log, closed, peerAddressChanged);
|
||||
MsQuicApi.SetContext(handle, GCHandle.ToIntPtr(connection._self));
|
||||
MsQuicApi.SetCallbackHandler(handle, s_callback, GCHandle.ToIntPtr(connection._self));
|
||||
MsQuicApi.ConnectionSetConfiguration(handle, configuration);
|
||||
MsQuicApi.SetParam(handle, DatagramReceiveEnabled, 1);
|
||||
return connection;
|
||||
}
|
||||
|
||||
public bool Send(ReadOnlySpan<byte> datagram)
|
||||
{
|
||||
nint handle = Volatile.Read(ref _handle);
|
||||
if (handle == 0 || !IsReady || datagram.Length > MaxDatagramBytes)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
byte[] buffer = ArrayPool<byte>.Shared.Rent(datagram.Length);
|
||||
datagram.CopyTo(buffer);
|
||||
GCHandle pinned = GCHandle.Alloc(buffer, GCHandleType.Pinned);
|
||||
nint nativeBuffer = Marshal.AllocHGlobal(Marshal.SizeOf<MsQuicApi.QuicBuffer>());
|
||||
var state = new SendState(buffer, pinned, nativeBuffer);
|
||||
GCHandle stateHandle = GCHandle.Alloc(state);
|
||||
nint context = GCHandle.ToIntPtr(stateHandle);
|
||||
lock (_sendGate)
|
||||
{
|
||||
if (_handle == 0)
|
||||
{
|
||||
stateHandle.Free();
|
||||
state.Release();
|
||||
return false;
|
||||
}
|
||||
|
||||
_sends.Add(context, state);
|
||||
}
|
||||
|
||||
var nativeBufferValue = new MsQuicApi.QuicBuffer
|
||||
{
|
||||
Length = (uint)datagram.Length,
|
||||
Buffer = pinned.AddrOfPinnedObject(),
|
||||
};
|
||||
Marshal.StructureToPtr(nativeBufferValue, nativeBuffer, false);
|
||||
uint status = MsQuicApi.DatagramSend(handle, nativeBuffer, context);
|
||||
if (MsQuicApi.Succeeded(status))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
ReleaseSend(context);
|
||||
return false;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposeStarted, 1) == 0 && Volatile.Read(ref _handle) is nint handle && handle != 0)
|
||||
{
|
||||
MsQuicApi.ConnectionShutdown(handle);
|
||||
}
|
||||
|
||||
if (Volatile.Read(ref _handle) != 0)
|
||||
{
|
||||
await _shutdown.Task.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public Task WaitForShutdownAsync() => _shutdown.Task;
|
||||
|
||||
private static void LoadClientCredentials(nint configuration)
|
||||
{
|
||||
var credential = new MsQuicApi.CredentialConfig { Type = CertificateCredentialNone, Flags = ClientCredentialFlags };
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
credential.Flags &= ~0x00004000u;
|
||||
}
|
||||
|
||||
MsQuicApi.ConfigurationLoadCredential(configuration, ref credential);
|
||||
}
|
||||
|
||||
internal static void LoadServerCredentials(nint configuration, X509Certificate2 certificate)
|
||||
{
|
||||
var credential = new MsQuicApi.CredentialConfig();
|
||||
GCHandle pfxPin = default;
|
||||
nint pkcs12 = 0;
|
||||
try
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
credential.Type = CertificateCredentialContext;
|
||||
credential.Certificate = certificate.Handle;
|
||||
}
|
||||
else
|
||||
{
|
||||
byte[] pfxBytes = certificate.Export(X509ContentType.Pkcs12);
|
||||
pfxPin = GCHandle.Alloc(pfxBytes, GCHandleType.Pinned);
|
||||
var pfxCertificate = new MsQuicApi.Pkcs12Certificate
|
||||
{
|
||||
Asn1Blob = pfxPin.AddrOfPinnedObject(),
|
||||
Asn1BlobLength = (uint)pfxBytes.Length,
|
||||
};
|
||||
pkcs12 = Marshal.AllocHGlobal(Marshal.SizeOf<MsQuicApi.Pkcs12Certificate>());
|
||||
Marshal.StructureToPtr(pfxCertificate, pkcs12, false);
|
||||
credential.Type = CertificateCredentialPkcs12;
|
||||
credential.Flags = 0x00004000;
|
||||
credential.Certificate = pkcs12;
|
||||
}
|
||||
|
||||
MsQuicApi.ConfigurationLoadCredential(configuration, ref credential);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (pkcs12 != 0)
|
||||
{
|
||||
Marshal.FreeHGlobal(pkcs12);
|
||||
}
|
||||
|
||||
if (pfxPin.IsAllocated)
|
||||
{
|
||||
pfxPin.Free();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static uint OnEvent(nint handle, nint context, nint eventPointer)
|
||||
{
|
||||
try
|
||||
{
|
||||
var connection = (MsQuicConnection)GCHandle.FromIntPtr(context).Target!;
|
||||
uint eventType = unchecked((uint)Marshal.ReadInt32(eventPointer));
|
||||
switch (eventType)
|
||||
{
|
||||
case ConnectionEventConnected:
|
||||
connection._log.Info("MsQuic QUIC handshake completed");
|
||||
connection.RefreshRemoteEndPoint();
|
||||
connection._connected.TrySetResult();
|
||||
break;
|
||||
case ConnectionEventShutdownByTransport:
|
||||
case ConnectionEventShutdownByPeer:
|
||||
connection._connected.TrySetException(new IOException("QUIC connection closed during handshake."));
|
||||
break;
|
||||
case ConnectionEventShutdownComplete:
|
||||
connection.OnShutdownComplete(handle);
|
||||
break;
|
||||
case ConnectionEventPeerAddressChanged:
|
||||
{
|
||||
nint address = Marshal.ReadIntPtr(eventPointer, IntPtr.Size);
|
||||
IPEndPoint? endpoint = ReadEndpoint(address);
|
||||
if (endpoint is not null)
|
||||
{
|
||||
connection.RemoteEndPoint = endpoint;
|
||||
connection._peerAddressChanged?.Invoke(endpoint);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case ConnectionEventDatagramStateChanged:
|
||||
connection.OnDatagramStateChanged(eventPointer);
|
||||
break;
|
||||
case ConnectionEventDatagramReceived:
|
||||
connection.OnDatagramReceived(eventPointer);
|
||||
break;
|
||||
case ConnectionEventDatagramSendStateChanged:
|
||||
connection.OnDatagramSendStateChanged(eventPointer);
|
||||
break;
|
||||
case ConnectionEventPeerCertificateReceived:
|
||||
return connection.ValidatePeerCertificate(eventPointer);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (context != 0 && GCHandle.FromIntPtr(context).Target is MsQuicConnection connection)
|
||||
{
|
||||
connection._log.Error("MsQuic connection callback failed", ("err", ex.Message));
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep exceptions inside the native callback boundary.
|
||||
}
|
||||
|
||||
return OperatingSystem.IsWindows() ? 0x80004005u : 1u;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDatagramStateChanged(nint eventPointer)
|
||||
{
|
||||
byte sendEnabled = Marshal.ReadByte(eventPointer, IntPtr.Size);
|
||||
int maxSendLength = (ushort)Marshal.ReadInt16(eventPointer, IntPtr.Size + 1);
|
||||
Volatile.Write(ref _maxDatagramBytes, maxSendLength);
|
||||
Volatile.Write(ref _sendEnabled, sendEnabled == 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
private void OnDatagramReceived(nint eventPointer)
|
||||
{
|
||||
nint bufferPointer = Marshal.ReadIntPtr(eventPointer, IntPtr.Size);
|
||||
if (bufferPointer == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var buffer = Marshal.PtrToStructure<MsQuicApi.QuicBuffer>(bufferPointer);
|
||||
if (buffer.Buffer != 0 && buffer.Length > 0 && buffer.Length <= 65535)
|
||||
{
|
||||
unsafe
|
||||
{
|
||||
DatagramHandler?.Invoke(this, new ReadOnlySpan<byte>((void*)buffer.Buffer, checked((int)buffer.Length)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDatagramSendStateChanged(nint eventPointer)
|
||||
{
|
||||
nint context = Marshal.ReadIntPtr(eventPointer, IntPtr.Size);
|
||||
int state = Marshal.ReadInt32(eventPointer, IntPtr.Size * 2);
|
||||
if (state >= DatagramStateFinal)
|
||||
{
|
||||
ReleaseSend(context);
|
||||
}
|
||||
}
|
||||
|
||||
private uint ValidatePeerCertificate(nint eventPointer)
|
||||
{
|
||||
nint certificate = Marshal.ReadIntPtr(eventPointer, IntPtr.Size);
|
||||
if (certificate == 0 || _expectedPeerSpki is null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
byte[] der;
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
nint encoded = Marshal.ReadIntPtr(certificate, IntPtr.Size);
|
||||
int length = Marshal.ReadInt32(certificate, IntPtr.Size * 2);
|
||||
if (encoded == 0 || length <= 0 || length > 64 * 1024)
|
||||
{
|
||||
return 0x80004005u;
|
||||
}
|
||||
|
||||
der = new byte[length];
|
||||
Marshal.Copy(encoded, der, 0, length);
|
||||
}
|
||||
else
|
||||
{
|
||||
var nativeBuffer = Marshal.PtrToStructure<MsQuicApi.QuicBuffer>(certificate);
|
||||
if (nativeBuffer.Buffer == 0 || nativeBuffer.Length == 0 || nativeBuffer.Length > 64 * 1024)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
der = new byte[checked((int)nativeBuffer.Length)];
|
||||
Marshal.Copy(nativeBuffer.Buffer, der, 0, der.Length);
|
||||
}
|
||||
|
||||
using X509Certificate2 peerCertificate = X509CertificateLoader.LoadCertificate(der);
|
||||
bool valid = ExportSubjectPublicKeyInfo(peerCertificate).AsSpan().SequenceEqual(_expectedPeerSpki);
|
||||
return valid ? 0 : OperatingSystem.IsWindows() ? 0x80004005u : 1u;
|
||||
}
|
||||
|
||||
private static byte[] ExportSubjectPublicKeyInfo(X509Certificate2 certificate)
|
||||
{
|
||||
using ECDsa? ecdsa = certificate.GetECDsaPublicKey();
|
||||
if (ecdsa is not null)
|
||||
{
|
||||
return ecdsa.ExportSubjectPublicKeyInfo();
|
||||
}
|
||||
|
||||
using RSA? rsa = certificate.GetRSAPublicKey();
|
||||
if (rsa is not null)
|
||||
{
|
||||
return rsa.ExportSubjectPublicKeyInfo();
|
||||
}
|
||||
|
||||
using DSA? dsa = certificate.GetDSAPublicKey();
|
||||
if (dsa is not null)
|
||||
{
|
||||
return dsa.ExportSubjectPublicKeyInfo();
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private void ReleaseSend(nint context)
|
||||
{
|
||||
SendState? state;
|
||||
lock (_sendGate)
|
||||
{
|
||||
if (!_sends.Remove(context, out state))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (GCHandle.FromIntPtr(context).IsAllocated)
|
||||
{
|
||||
GCHandle.FromIntPtr(context).Free();
|
||||
}
|
||||
|
||||
state.Release();
|
||||
}
|
||||
|
||||
private void OnShutdownComplete(nint handle)
|
||||
{
|
||||
if (Interlocked.Exchange(ref _handle, 0) == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
MsQuicApi.ConnectionClose(handle);
|
||||
lock (_sendGate)
|
||||
{
|
||||
foreach ((nint context, SendState state) in _sends)
|
||||
{
|
||||
GCHandle.FromIntPtr(context).Free();
|
||||
state.Release();
|
||||
}
|
||||
|
||||
_sends.Clear();
|
||||
}
|
||||
|
||||
if (_configuration != 0)
|
||||
{
|
||||
MsQuicApi.ConfigurationClose(_configuration);
|
||||
}
|
||||
|
||||
if (_registration != 0)
|
||||
{
|
||||
MsQuicApi.RegistrationClose(_registration);
|
||||
}
|
||||
|
||||
_self.Free();
|
||||
_shutdown.TrySetResult();
|
||||
_closed?.Invoke(this);
|
||||
}
|
||||
|
||||
private static IPEndPoint? ReadEndpoint(nint address)
|
||||
{
|
||||
if (address == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
ushort family = unchecked((ushort)Marshal.ReadInt16(address));
|
||||
if (family == 2)
|
||||
{
|
||||
byte[] bytes = new byte[4];
|
||||
Marshal.Copy(address + 4, bytes, 0, bytes.Length);
|
||||
int port = (Marshal.ReadByte(address, 2) << 8) | Marshal.ReadByte(address, 3);
|
||||
return new IPEndPoint(new IPAddress(bytes), port);
|
||||
}
|
||||
|
||||
ushort ipv6Family = OperatingSystem.IsWindows() ? (ushort)23 : OperatingSystem.IsMacOS() ? (ushort)30 : (ushort)10;
|
||||
if (family == ipv6Family)
|
||||
{
|
||||
byte[] bytes = new byte[16];
|
||||
Marshal.Copy(address + 8, bytes, 0, bytes.Length);
|
||||
int port = (Marshal.ReadByte(address, 2) << 8) | Marshal.ReadByte(address, 3);
|
||||
long scope = unchecked((uint)Marshal.ReadInt32(address, 24));
|
||||
return new IPEndPoint(new IPAddress(bytes, scope), port);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IPEndPoint? ReadEndpoint(byte[] address)
|
||||
{
|
||||
unsafe
|
||||
{
|
||||
fixed (byte* addressBytes = address)
|
||||
{
|
||||
return ReadEndpoint((nint)addressBytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static ushort ReadAddressFamily(byte[] address) => (ushort)(address[0] | (address[1] << 8));
|
||||
|
||||
private sealed class SendState(byte[] buffer, GCHandle pin, nint nativeBuffer)
|
||||
{
|
||||
public void Release()
|
||||
{
|
||||
if (pin.IsAllocated)
|
||||
{
|
||||
pin.Free();
|
||||
}
|
||||
|
||||
if (nativeBuffer != 0)
|
||||
{
|
||||
Marshal.FreeHGlobal(nativeBuffer);
|
||||
}
|
||||
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Fleet.Core;
|
||||
|
||||
namespace MoShiro.Measurement;
|
||||
|
||||
/// <summary>Agent-side MsQuic listener. Measurement frames arrive only as QUIC DATAGRAM frames.</summary>
|
||||
internal sealed class MsQuicListener : IAsyncDisposable
|
||||
{
|
||||
private const int QuicAddressBytes = 28;
|
||||
private const uint ListenerEventNewConnection = 0;
|
||||
private const uint ListenerLocalAddress = 0x04000000;
|
||||
private static readonly MsQuicApi.ListenerCallback s_callback = OnListenerEvent;
|
||||
|
||||
private readonly IPAddress _bindAddress;
|
||||
private readonly IReadOnlyList<int> _ports;
|
||||
private readonly MsQuicDatagramCallback _datagramHandler;
|
||||
private readonly INodeLog _log;
|
||||
private readonly Action<IPEndPoint>? _peerAddressChanged;
|
||||
private readonly X509Certificate2 _certificate;
|
||||
private readonly GCHandle _self;
|
||||
private readonly ConcurrentDictionary<nint, MsQuicConnection> _connections = new();
|
||||
private readonly List<nint> _listeners = [];
|
||||
private nint _registration;
|
||||
private nint _configuration;
|
||||
private int _disposed;
|
||||
|
||||
public MsQuicListener(
|
||||
IPAddress bindAddress,
|
||||
IReadOnlyList<int> ports,
|
||||
X509Certificate2 certificate,
|
||||
MsQuicDatagramCallback datagramHandler,
|
||||
INodeLog log,
|
||||
Action<IPEndPoint>? peerAddressChanged)
|
||||
{
|
||||
_bindAddress = bindAddress;
|
||||
_ports = ports.Count == 0 ? [7443] : ports;
|
||||
_certificate = certificate;
|
||||
_datagramHandler = datagramHandler;
|
||||
_log = log;
|
||||
_peerAddressChanged = peerAddressChanged;
|
||||
_self = GCHandle.Alloc(this);
|
||||
}
|
||||
|
||||
public IReadOnlyList<int> BoundPorts { get; private set; } = [];
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (_registration != 0)
|
||||
{
|
||||
throw new InvalidOperationException("MsQuic listener is already started.");
|
||||
}
|
||||
|
||||
_registration = MsQuicApi.RegistrationOpen("moshiro-agent");
|
||||
try
|
||||
{
|
||||
_configuration = MsQuicApi.ConfigurationOpen(_registration, QuicAlpn.Value);
|
||||
MsQuicConnection.LoadServerCredentials(_configuration, _certificate);
|
||||
var bound = new List<int>(_ports.Count);
|
||||
foreach (int port in _ports)
|
||||
{
|
||||
nint listener = MsQuicApi.ListenerOpen(_registration, s_callback, GCHandle.ToIntPtr(_self));
|
||||
_listeners.Add(listener);
|
||||
byte[] address = Serialize(new IPEndPoint(_bindAddress, port));
|
||||
MsQuicApi.ListenerStart(listener, QuicAlpn.Value, address);
|
||||
bound.Add(ReadPort(MsQuicApi.GetAddress(listener, ListenerLocalAddress)));
|
||||
}
|
||||
|
||||
BoundPorts = bound;
|
||||
}
|
||||
catch
|
||||
{
|
||||
foreach (nint listener in _listeners)
|
||||
{
|
||||
MsQuicApi.ListenerStop(listener);
|
||||
MsQuicApi.ListenerClose(listener);
|
||||
}
|
||||
|
||||
_listeners.Clear();
|
||||
DisposeHandles();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (nint listener in _listeners)
|
||||
{
|
||||
MsQuicApi.ListenerStop(listener);
|
||||
}
|
||||
|
||||
foreach (nint listener in _listeners)
|
||||
{
|
||||
MsQuicApi.ListenerClose(listener);
|
||||
}
|
||||
|
||||
_listeners.Clear();
|
||||
MsQuicConnection[] connections = [.. _connections.Values];
|
||||
await Task.WhenAll(connections.Select(c => c.DisposeAsync().AsTask())).ConfigureAwait(false);
|
||||
DisposeHandles();
|
||||
_self.Free();
|
||||
}
|
||||
|
||||
private static uint OnListenerEvent(nint listener, nint context, nint @event)
|
||||
{
|
||||
try
|
||||
{
|
||||
var owner = (MsQuicListener)GCHandle.FromIntPtr(context).Target!;
|
||||
uint eventType = unchecked((uint)Marshal.ReadInt32(@event));
|
||||
if (eventType != ListenerEventNewConnection || Volatile.Read(ref owner._disposed) != 0)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
nint connectionHandle = Marshal.ReadIntPtr(@event, IntPtr.Size * 2);
|
||||
owner._log.Info("accepted MsQuic QUIC connection");
|
||||
MsQuicConnection? connection = null;
|
||||
connection = MsQuicConnection.Accept(
|
||||
connectionHandle,
|
||||
owner._configuration,
|
||||
owner._datagramHandler,
|
||||
owner._log,
|
||||
closed => owner._connections.TryRemove(connectionHandle, out _),
|
||||
owner._peerAddressChanged);
|
||||
if (!owner._connections.TryAdd(connectionHandle, connection))
|
||||
{
|
||||
_ = connection.DisposeAsync().AsTask();
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (context != 0 && GCHandle.FromIntPtr(context).Target is MsQuicListener owner)
|
||||
{
|
||||
owner._log.Error("MsQuic listener callback failed", ("err", ex.Message));
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep exceptions inside the native callback boundary.
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private void DisposeHandles()
|
||||
{
|
||||
if (_configuration != 0)
|
||||
{
|
||||
MsQuicApi.ConfigurationClose(_configuration);
|
||||
_configuration = 0;
|
||||
}
|
||||
|
||||
if (_registration != 0)
|
||||
{
|
||||
MsQuicApi.RegistrationClose(_registration);
|
||||
_registration = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] Serialize(IPEndPoint endpoint)
|
||||
{
|
||||
SocketAddress source = endpoint.Serialize();
|
||||
byte[] address = new byte[QuicAddressBytes];
|
||||
for (int i = 0; i < source.Size; i++)
|
||||
{
|
||||
address[i] = source[i];
|
||||
}
|
||||
|
||||
return address;
|
||||
}
|
||||
|
||||
private static int ReadPort(byte[] address)
|
||||
{
|
||||
if (address.Length < 4)
|
||||
{
|
||||
throw new InvalidOperationException("MsQuic returned a truncated bound address.");
|
||||
}
|
||||
|
||||
return (address[2] << 8) | address[3];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace MoShiro.Measurement;
|
||||
|
||||
/// <summary>Loads the MsQuic v2 ABI used by the QUIC datagram transport.</summary>
|
||||
internal static class MsQuicRuntime
|
||||
{
|
||||
private const uint ApiVersion = 2;
|
||||
private static readonly object s_gate = new();
|
||||
private static nint s_library;
|
||||
private static nint s_api;
|
||||
private static OpenVersionDelegate? s_open;
|
||||
private static CloseDelegate? s_close;
|
||||
private static readonly List<nint> s_supportLibraries = [];
|
||||
|
||||
public static nint Api
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (s_gate)
|
||||
{
|
||||
if (s_api != 0)
|
||||
{
|
||||
return s_api;
|
||||
}
|
||||
|
||||
string[] candidates = OperatingSystem.IsWindows()
|
||||
? ["msquic.dll"]
|
||||
: OperatingSystem.IsMacOS()
|
||||
? ["libmsquic.dylib"]
|
||||
: ["libmsquic.so.2", "libmsquic.so"];
|
||||
LoadBundledSupportLibraries();
|
||||
string appDirectory = AppContext.BaseDirectory;
|
||||
foreach (string candidate in candidates)
|
||||
{
|
||||
if (NativeLibrary.TryLoad(Path.Combine(appDirectory, candidate), out s_library)
|
||||
|| NativeLibrary.TryLoad(candidate, out s_library))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (s_library == 0)
|
||||
{
|
||||
throw new DllNotFoundException($"MsQuic runtime was not found. Expected one of: {string.Join(", ", candidates)}.");
|
||||
}
|
||||
|
||||
s_open = Marshal.GetDelegateForFunctionPointer<OpenVersionDelegate>(NativeLibrary.GetExport(s_library, "MsQuicOpenVersion"));
|
||||
s_close = Marshal.GetDelegateForFunctionPointer<CloseDelegate>(NativeLibrary.GetExport(s_library, "MsQuicClose"));
|
||||
int status = s_open(ApiVersion, out s_api);
|
||||
if (status != 0 || s_api == 0)
|
||||
{
|
||||
NativeLibrary.Free(s_library);
|
||||
s_library = 0;
|
||||
s_api = 0;
|
||||
throw new InvalidOperationException($"MsQuic API v{ApiVersion} could not be opened (status {status}).");
|
||||
}
|
||||
|
||||
return s_api;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void Close()
|
||||
{
|
||||
lock (s_gate)
|
||||
{
|
||||
if (s_api != 0)
|
||||
{
|
||||
s_close!(s_api);
|
||||
s_api = 0;
|
||||
}
|
||||
|
||||
if (s_library != 0)
|
||||
{
|
||||
NativeLibrary.Free(s_library);
|
||||
s_library = 0;
|
||||
}
|
||||
|
||||
for (int i = s_supportLibraries.Count - 1; i >= 0; i--)
|
||||
{
|
||||
NativeLibrary.Free(s_supportLibraries[i]);
|
||||
}
|
||||
|
||||
s_supportLibraries.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private static void LoadBundledSupportLibraries()
|
||||
{
|
||||
if (OperatingSystem.IsLinux())
|
||||
{
|
||||
TryLoadSupport("libnuma.so.1");
|
||||
}
|
||||
else if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
TryLoadSupport("libcrypto.3.dylib");
|
||||
TryLoadSupport("libssl.3.dylib");
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryLoadSupport(string library)
|
||||
{
|
||||
if (NativeLibrary.TryLoad(Path.Combine(AppContext.BaseDirectory, library), out nint handle))
|
||||
{
|
||||
s_supportLibraries.Add(handle);
|
||||
}
|
||||
}
|
||||
|
||||
public static T Function<T>(int index)
|
||||
where T : Delegate => Marshal.GetDelegateForFunctionPointer<T>(Marshal.ReadIntPtr(Api, checked(index * IntPtr.Size)));
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate int OpenVersionDelegate(uint version, out nint api);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate void CloseDelegate(nint api);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace MoShiro.Measurement;
|
||||
|
||||
/// <summary>The application protocol negotiated by the Agent and Probe QUIC endpoints.</summary>
|
||||
internal static class QuicAlpn
|
||||
{
|
||||
internal static readonly byte[] Value = "h3"u8.ToArray();
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Fleet.Core;
|
||||
|
||||
namespace MoShiro.Measurement;
|
||||
|
||||
/// <summary>
|
||||
/// Measurement data transport. Agent instances listen on configured QUIC UDP ports; Probe instances
|
||||
/// open one QUIC connection from a newly selected ephemeral source port. Measurement frames are sent
|
||||
/// only with MsQuic DATAGRAM calls; the existing TLS peer connection continues to carry session control.
|
||||
/// </summary>
|
||||
public sealed class QuicDatagramTransport : IDisposable, IAsyncDisposable
|
||||
{
|
||||
private readonly MeasurementDatagramRouter _router;
|
||||
private readonly INodeLog _log;
|
||||
private readonly MsQuicDatagramCallback _onDatagram;
|
||||
private readonly IPAddress _bind;
|
||||
private readonly IReadOnlyList<int> _ports;
|
||||
private readonly X509Certificate2? _serverCertificate;
|
||||
private readonly DataSession? _clientSession;
|
||||
private readonly IPEndPoint? _remote;
|
||||
private readonly byte[]? _peerSpki;
|
||||
private readonly int _previousSourcePort;
|
||||
private readonly TaskCompletionSource _clientReady = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private MsQuicListener? _listener;
|
||||
private MsQuicConnection? _client;
|
||||
private int _disposed;
|
||||
|
||||
/// <summary>Constructs an Agent-side QUIC listener.</summary>
|
||||
public QuicDatagramTransport(IDataSessions sessions, IAdmission? admission, MonoClock clock, DataPlaneCounters counters, INodeLog log, IPAddress bind, IReadOnlyList<int> ports, X509Certificate2 serverCertificate)
|
||||
{
|
||||
_router = new MeasurementDatagramRouter(sessions, admission, clock, counters);
|
||||
_log = log;
|
||||
_onDatagram = _router.Dispatch;
|
||||
_bind = bind;
|
||||
_ports = ports;
|
||||
_serverCertificate = serverCertificate;
|
||||
}
|
||||
|
||||
/// <summary>Constructs a Probe-side QUIC dialer for one Agent session.</summary>
|
||||
public QuicDatagramTransport(DataSession session, MonoClock clock, DataPlaneCounters counters, INodeLog log, IPEndPoint remote, byte[] peerSpki, int previousSourcePort)
|
||||
{
|
||||
_router = new MeasurementDatagramRouter(new SingleSession(session), null, clock, counters);
|
||||
_log = log;
|
||||
_onDatagram = _router.Dispatch;
|
||||
_bind = remote.AddressFamily == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any;
|
||||
_ports = [];
|
||||
_clientSession = session;
|
||||
_remote = remote;
|
||||
_peerSpki = peerSpki;
|
||||
_previousSourcePort = previousSourcePort;
|
||||
}
|
||||
|
||||
public IReadOnlyList<int> BoundPorts { get; private set; } = [];
|
||||
|
||||
public DataPlaneCounters Counters => _router.Counters;
|
||||
|
||||
public bool IsConnected => _client?.IsReady == true;
|
||||
|
||||
public double? HandshakeDurationMs => _client?.HandshakeDurationMs;
|
||||
|
||||
public QuicTransportSnapshot? GetTransportSnapshot()
|
||||
{
|
||||
if (_client is null || !_client.TryGetStatistics(out MsQuicApi.QuicStatisticsV2 stats))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
ulong lost = stats.SendSuspectedLostPackets >= stats.SendSpuriousLostPackets
|
||||
? stats.SendSuspectedLostPackets - stats.SendSpuriousLostPackets
|
||||
: 0;
|
||||
double? packetLossRate = stats.SendTotalPackets > 0 ? lost * 100.0 / stats.SendTotalPackets : null;
|
||||
return new QuicTransportSnapshot(packetLossRate, stats.SendTotalPackets, stats.SendCongestionCount, stats.SendEcnCongestionCount, stats.SendPathMtu);
|
||||
}
|
||||
|
||||
public void Bind()
|
||||
{
|
||||
if (_remote is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_listener is not null)
|
||||
{
|
||||
throw new InvalidOperationException("QUIC listener is already bound.");
|
||||
}
|
||||
|
||||
_listener = new MsQuicListener(_bind, _ports, _serverCertificate!, _onDatagram, _log, null);
|
||||
_listener.Start();
|
||||
BoundPorts = _listener.BoundPorts;
|
||||
}
|
||||
|
||||
public async Task ConnectAsync(CancellationToken ct)
|
||||
{
|
||||
if (_remote is null || _clientSession is null || _peerSpki is null)
|
||||
{
|
||||
throw new InvalidOperationException("Only Probe-side QUIC transports can connect.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
int sourcePort = SelectSourcePort(_remote.AddressFamily, _previousSourcePort);
|
||||
_client = await MsQuicConnection.ConnectAsync(
|
||||
_remote.Address,
|
||||
_remote.Port,
|
||||
sourcePort,
|
||||
_peerSpki,
|
||||
_onDatagram,
|
||||
_log,
|
||||
ct);
|
||||
_clientSession.BindDatagramConnection(_client);
|
||||
BoundPorts = _client.LocalEndPoint is { } local ? [local.Port] : [sourcePort];
|
||||
_clientReady.TrySetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Error("QUIC client connection failed", ("err", ex.Message));
|
||||
_clientReady.TrySetException(ex);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public Task WaitUntilConnectedAsync(CancellationToken ct) => _remote is null ? Task.CompletedTask : _clientReady.Task.WaitAsync(ct);
|
||||
|
||||
public void Register(uint streamId, QuicDatagramHandler handler) => _router.Register(streamId, handler);
|
||||
|
||||
public void Unregister(uint streamId) => _router.Unregister(streamId);
|
||||
|
||||
/// <summary>Sends one measurement datagram on the connection for the supplied session.</summary>
|
||||
public void Send(DataSession session, EndPoint target, ReadOnlySpan<byte> packet)
|
||||
{
|
||||
_ = target; // A QUIC connection owns its path; the Agent learns it from authenticated datagrams.
|
||||
if (session.DatagramConnection?.Send(packet) == true)
|
||||
{
|
||||
_router.Counters.PacketOut();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
if (_remote is not null)
|
||||
{
|
||||
await _clientReady.Task.WaitAsync(ct);
|
||||
await _client!.WaitForShutdownAsync().WaitAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, ct);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
// Listener lifetime is controlled by AgentApp.
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_ = DisposeNativeResourcesAsync();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await DisposeNativeResourcesAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task DisposeNativeResourcesAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_client is not null)
|
||||
{
|
||||
await _client.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (_listener is not null)
|
||||
{
|
||||
await _listener.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidOperationException or ObjectDisposedException)
|
||||
{
|
||||
_log.Error("QUIC datagram transport shutdown failed", ("err", ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
private static int SelectSourcePort(AddressFamily family, int previousPort)
|
||||
{
|
||||
IPAddress local = family == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any;
|
||||
for (int attempt = 0; attempt < 8; attempt++)
|
||||
{
|
||||
using var socket = new Socket(family, SocketType.Dgram, ProtocolType.Udp);
|
||||
socket.Bind(new IPEndPoint(local, 0));
|
||||
int port = ((IPEndPoint)socket.LocalEndPoint!).Port;
|
||||
if (port != previousPort)
|
||||
{
|
||||
return port;
|
||||
}
|
||||
}
|
||||
|
||||
throw new SocketException((int)SocketError.AddressAlreadyInUse);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ using System.Net.Sockets;
|
||||
using System.Security.Authentication;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Fleet.Identity;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Measurement;
|
||||
@@ -46,6 +47,12 @@ public sealed class TcpLink : IDisposable
|
||||
await client.ConnectAsync(env.PeerAddress!, port, ct);
|
||||
long tcpUs = env.Clock.NowUs() - start;
|
||||
Stream stream = client.GetStream();
|
||||
if (env.KnockKey is { } knockKey)
|
||||
{
|
||||
// 预握手敲门:监听方不认就不会回任何字节
|
||||
await stream.WriteAsync(Knock.Build(knockKey, env.Time.GetUtcNow().ToUnixTimeMilliseconds()), ct);
|
||||
}
|
||||
|
||||
long? tlsUs = null;
|
||||
if (tls)
|
||||
{
|
||||
@@ -115,7 +122,7 @@ public sealed class TcpConnection
|
||||
public delegate Task TcpResponder(TcpConnection connection, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// 监听方的 TCP 数据面:每个端口一个监听器。连接的第一个字节是 0x16 就先做 TLS(节点自己的证书),否则直接是明文。
|
||||
/// 监听方的 TCP 数据面:每个端口一个监听器。连接先过预握手敲门,然后第一个字节是 0x16 就先做 TLS(节点自己的证书),否则直接是明文。
|
||||
/// 随后是认证首帧(<c>"MN" | ver | session_id | stream_id | nonce | mac</c>,随机数只能用一次),通过之后交给按 <c>stream_id</c> 登记的处理函数。
|
||||
/// 首帧在限定时间内没有到达或者校验失败,直接关闭连接并计数。
|
||||
/// </summary>
|
||||
@@ -128,11 +135,13 @@ public sealed class TcpAcceptor
|
||||
private readonly X509Certificate2 _certificate;
|
||||
private readonly IDataSessions _sessions;
|
||||
private readonly DataPlaneCounters _counters;
|
||||
private readonly KnockGate _knock;
|
||||
private readonly ConcurrentDictionary<uint, TcpResponder> _handlers = new();
|
||||
private readonly List<TcpListener> _listeners = [];
|
||||
|
||||
public TcpAcceptor(IPAddress bind, IReadOnlyList<int> ports, X509Certificate2 certificate, IDataSessions sessions, DataPlaneCounters counters)
|
||||
public TcpAcceptor(IPAddress bind, IReadOnlyList<int> ports, X509Certificate2 certificate, IDataSessions sessions, DataPlaneCounters counters, KnockGate knock)
|
||||
{
|
||||
_knock = knock;
|
||||
_bind = bind;
|
||||
_ports = ports;
|
||||
_certificate = certificate;
|
||||
@@ -214,6 +223,13 @@ public sealed class TcpAcceptor
|
||||
using var authCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
authCts.CancelAfter(s_authTimeout);
|
||||
|
||||
// 预握手敲门:名册里的 Probe 才算得出敲门包;不通过就不写任何字节、直接断开
|
||||
if (!await _knock.AdmitAsync(stream, (client.Client.RemoteEndPoint as IPEndPoint)?.Address, authCts.Token))
|
||||
{
|
||||
_counters.DropInvalid();
|
||||
return;
|
||||
}
|
||||
|
||||
byte[] first = new byte[1];
|
||||
if (await stream.ReadAsync(first, authCts.Token) != 1)
|
||||
{
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Fleet.Core;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Measurement;
|
||||
|
||||
/// <summary>一个通过校验的 UDP 包,交给按 <c>stream_id</c> 登记的处理函数。处理函数在接收循环里同步执行,不能阻塞。</summary>
|
||||
public readonly ref struct UdpDelivery
|
||||
{
|
||||
private readonly Span<byte> _packet;
|
||||
private readonly Socket _socket;
|
||||
private readonly SocketAddress _source;
|
||||
private readonly DataPlaneCounters _counters;
|
||||
|
||||
internal UdpDelivery(UdpHeader header, Span<byte> packet, long receiveUs, DataSession session, Socket socket, SocketAddress source, DataPlaneCounters counters)
|
||||
{
|
||||
Header = header;
|
||||
_packet = packet;
|
||||
ReceiveUs = receiveUs;
|
||||
Session = session;
|
||||
_socket = socket;
|
||||
_source = source;
|
||||
_counters = counters;
|
||||
}
|
||||
|
||||
public UdpHeader Header { get; }
|
||||
|
||||
public ReadOnlySpan<byte> Packet => _packet;
|
||||
|
||||
/// <summary>包到达的时刻(本节点的单调时钟,微秒):从接收返回的那一刻取,不含校验的耗时。</summary>
|
||||
public long ReceiveUs { get; }
|
||||
|
||||
public DataSession Session { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 原地回复:同样的长度,只把 type 改成 <paramref name="type"/> 并重新计算 MAC,发回请求的实际源地址。
|
||||
/// 回复与请求等长,所以监听方不能被当成放大器。发送失败(源地址已经不可达)不抛出。
|
||||
/// </summary>
|
||||
public void ReplyAs(UdpPacketType type)
|
||||
{
|
||||
_packet[3] = (byte)type;
|
||||
UdpPacket.Seal(_packet, Session.Key);
|
||||
try
|
||||
{
|
||||
_socket.SendTo(_packet, SocketFlags.None, _source);
|
||||
_counters.PacketOut();
|
||||
}
|
||||
catch (SocketException)
|
||||
{
|
||||
// 回复丢失由对端的缺失体现
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public delegate void UdpHandler(in UdpDelivery delivery);
|
||||
|
||||
/// <summary>
|
||||
/// UDP 数据面:一个或多个套接字,按 <c>stream_id</c> 把包分发给积木。校验顺序:长度、magic、version、type →
|
||||
/// 流和会话都存在 → MAC → (监听方)<see cref="IAdmission"/>(重放窗口、限速)。任何一步失败都静默丢弃并计数,不回复。
|
||||
/// 接收循环只做校验和分发,不分配对象。两端共用这一份代码:Agent 监听多个端口并带准入策略,Probe 每个 Agent 一个临时端口。
|
||||
/// </summary>
|
||||
public sealed class UdpMux : IDisposable
|
||||
{
|
||||
private const int WindowsUdpConnReset = -1744830452; // SIO_UDP_CONNRESET
|
||||
|
||||
private readonly IDataSessions _sessions;
|
||||
private readonly IAdmission? _admission;
|
||||
private readonly MonoClock _clock;
|
||||
private readonly DataPlaneCounters _counters;
|
||||
private readonly INodeLog _log;
|
||||
private readonly IPAddress _bind;
|
||||
private readonly IReadOnlyList<int> _ports;
|
||||
private readonly List<Socket> _sockets = [];
|
||||
private readonly ConcurrentDictionary<uint, UdpHandler> _handlers = new();
|
||||
|
||||
/// <param name="ports">要监听的端口(0 表示由系统分配);空表示一个临时端口。</param>
|
||||
public UdpMux(IDataSessions sessions, IAdmission? admission, MonoClock clock, DataPlaneCounters counters, INodeLog log, IPAddress bind, IReadOnlyList<int> ports)
|
||||
{
|
||||
_sessions = sessions;
|
||||
_admission = admission;
|
||||
_clock = clock;
|
||||
_counters = counters;
|
||||
_log = log;
|
||||
_bind = bind;
|
||||
_ports = ports.Count == 0 ? [0] : ports;
|
||||
}
|
||||
|
||||
public IReadOnlyList<int> BoundPorts { get; private set; } = [];
|
||||
|
||||
public DataPlaneCounters Counters => _counters;
|
||||
|
||||
/// <summary>绑定套接字(端口被占用等错误在这里抛出)。</summary>
|
||||
public void Bind()
|
||||
{
|
||||
var bound = new List<int>();
|
||||
foreach (int port in _ports)
|
||||
{
|
||||
var socket = new Socket(_bind.AddressFamily, SocketType.Dgram, ProtocolType.Udp) { ReceiveBufferSize = 4 << 20, SendBufferSize = 4 << 20 };
|
||||
if (_bind.AddressFamily == AddressFamily.InterNetworkV6)
|
||||
{
|
||||
socket.DualMode = true;
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
// Windows 上对一个已关闭的端口发送之后,下一次接收会抛 ConnectionReset:关掉这个行为
|
||||
socket.IOControl(WindowsUdpConnReset, [0], null);
|
||||
}
|
||||
|
||||
socket.Bind(new IPEndPoint(_bind, port));
|
||||
bound.Add(((IPEndPoint)socket.LocalEndPoint!).Port);
|
||||
_sockets.Add(socket);
|
||||
}
|
||||
|
||||
BoundPorts = bound;
|
||||
}
|
||||
|
||||
public void Register(uint streamId, UdpHandler handler) => _handlers[streamId] = handler;
|
||||
|
||||
public void Unregister(uint streamId) => _handlers.TryRemove(streamId, out _);
|
||||
|
||||
/// <summary>从第一个套接字发出一个已经封好的包。发送失败(没有路由、缓冲区满)不抛出:丢包本身就是测量结果。</summary>
|
||||
public void Send(EndPoint to, ReadOnlySpan<byte> packet)
|
||||
{
|
||||
try
|
||||
{
|
||||
_sockets[0].SendTo(packet, SocketFlags.None, to);
|
||||
_counters.PacketOut();
|
||||
}
|
||||
catch (SocketException)
|
||||
{
|
||||
// 记为丢失
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(_sockets.Select(s => ReceiveLoopAsync(s, ct)));
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// 套接字已释放:停止
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (Socket socket in _sockets)
|
||||
{
|
||||
socket.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReceiveLoopAsync(Socket socket, CancellationToken ct)
|
||||
{
|
||||
byte[] buffer = new byte[UdpPacket.MaxBytes + 64];
|
||||
var source = new SocketAddress(socket.AddressFamily);
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
int length;
|
||||
try
|
||||
{
|
||||
length = await socket.ReceiveFromAsync(buffer.AsMemory(), SocketFlags.None, source, ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (SocketException ex)
|
||||
{
|
||||
// 个别错误(例如对端的 ICMP 不可达)不能让接收循环退出
|
||||
_log.Warn("udp receive failed", ("err", ex.SocketErrorCode));
|
||||
continue;
|
||||
}
|
||||
|
||||
Dispatch(socket, buffer, length, source, _clock.NowUs());
|
||||
}
|
||||
}
|
||||
|
||||
private void Dispatch(Socket socket, byte[] buffer, int length, SocketAddress source, long receiveUs)
|
||||
{
|
||||
_counters.PacketIn();
|
||||
Span<byte> packet = buffer.AsSpan(0, length);
|
||||
if (!UdpPacket.TryParseHeader(packet, out UdpHeader header)
|
||||
|| !_handlers.TryGetValue(header.StreamId, out UdpHandler? handler)
|
||||
|| _sessions.Find(header.SessionId) is not { } session
|
||||
|| !UdpPacket.VerifyMac(packet, session.Key))
|
||||
{
|
||||
_counters.DropInvalid();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_admission is not null)
|
||||
{
|
||||
switch (_admission.AdmitPacket(session, header, source, receiveUs))
|
||||
{
|
||||
case Admission.Invalid:
|
||||
_counters.DropInvalid();
|
||||
return;
|
||||
case Admission.RateLimited:
|
||||
_counters.DropRateLimited();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
handler(new UdpDelivery(header, packet, receiveUs, session, socket, source, _counters));
|
||||
}
|
||||
}
|
||||
+27
-7
@@ -1,13 +1,13 @@
|
||||
using System.Globalization;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Agent;
|
||||
namespace MoShiro.Measurement;
|
||||
|
||||
/// <summary>
|
||||
/// 主机资源占用的只读快照:CPU 占用、内存、根分区、网卡累计流量与瞬时网速、系统运行时长。
|
||||
/// Agent 和 Probe 共用。主机资源占用的只读快照:CPU 占用、内存、根分区、网卡累计流量与瞬时网速、系统运行时长。
|
||||
/// 只有汇总的数字:<b>不采集 CPU 型号、主机名、网卡名和任何 IP 地址</b>,所以面板和展示页想泄漏也泄漏不出去。
|
||||
/// Linux 读 <c>/proc</c> 和 <c>/sys</c>;容器通过 HOST_PROC、HOST_SYS、HOST_ROOT 指定宿主路径。
|
||||
/// 读不到的项是 null(例如 Windows 上的 CPU)。不执行任何命令,不写文件。
|
||||
/// Windows 用 GetSystemTimes、GlobalMemoryStatusEx 和 NetworkInterface(见 WindowsHost);macOS 等其他系统只有磁盘和运行时长。读不到的项是 null。不执行任何命令,不写文件。
|
||||
/// </summary>
|
||||
public sealed class HostSampler
|
||||
{
|
||||
@@ -18,6 +18,7 @@ public sealed class HostSampler
|
||||
private readonly Func<(long Total, long Used)?> _disk;
|
||||
private readonly string _procRoot;
|
||||
private readonly string _sysRoot;
|
||||
private readonly bool _native;
|
||||
private (long Total, long Idle)? _previousCpu;
|
||||
private (long At, long Rx, long Tx)? _previousNet;
|
||||
|
||||
@@ -25,6 +26,7 @@ public sealed class HostSampler
|
||||
string? procRoot = null, string? sysRoot = null, string? diskRoot = null)
|
||||
{
|
||||
_time = time;
|
||||
_native = read is null && OperatingSystem.IsWindows(); // 只有默认读法才走 Windows 的系统调用;测试注入 read 时仍按 Linux 的文本解析
|
||||
_read = read ?? ReadProc;
|
||||
_procRoot = Root(procRoot, "HOST_PROC", "/proc");
|
||||
_sysRoot = Root(sysRoot, "HOST_SYS", "/sys");
|
||||
@@ -37,7 +39,8 @@ public sealed class HostSampler
|
||||
long now = _time.GetTimestamp();
|
||||
var snapshot = new HostSnapshot { AtUtcMs = _time.GetUtcNow().ToUnixTimeMilliseconds() };
|
||||
|
||||
if (_read(Path.Combine(_procRoot, "stat")) is { } stat && ParseCpu(stat) is { } cpu)
|
||||
(long Total, long Idle)? cpuNow = _native ? WindowsCpu() : _read(Path.Combine(_procRoot, "stat")) is { } stat ? ParseCpu(stat) : null;
|
||||
if (cpuNow is { } cpu)
|
||||
{
|
||||
if (_previousCpu is { } before && cpu.Total > before.Total)
|
||||
{
|
||||
@@ -48,7 +51,8 @@ public sealed class HostSampler
|
||||
_previousCpu = cpu;
|
||||
}
|
||||
|
||||
if (_read(Path.Combine(_procRoot, "meminfo")) is { } meminfo && ParseMemory(meminfo) is { } memory)
|
||||
(long Total, long Used)? memoryNow = _native ? WindowsMemory() : _read(Path.Combine(_procRoot, "meminfo")) is { } meminfo ? ParseMemory(meminfo) : null;
|
||||
if (memoryNow is { } memory)
|
||||
{
|
||||
snapshot.MemTotal = memory.Total;
|
||||
snapshot.MemUsed = memory.Used;
|
||||
@@ -60,11 +64,21 @@ public sealed class HostSampler
|
||||
snapshot.DiskUsed = disk.Used;
|
||||
}
|
||||
|
||||
if (_read(Path.Combine(_procRoot, "net/dev")) is { } dev && ParseNetwork(dev) is { } net)
|
||||
(long Rx, long Tx, long? LinkMbps)? netNow = null;
|
||||
if (_native)
|
||||
{
|
||||
netNow = WindowsNetwork();
|
||||
}
|
||||
else if (_read(Path.Combine(_procRoot, "net/dev")) is { } dev && ParseNetwork(dev) is { } parsed)
|
||||
{
|
||||
netNow = (parsed.Rx, parsed.Tx, ReadLinkSpeed(dev));
|
||||
}
|
||||
|
||||
if (netNow is { } net)
|
||||
{
|
||||
snapshot.NetRxTotal = net.Rx;
|
||||
snapshot.NetTxTotal = net.Tx;
|
||||
snapshot.NetLinkMbps = ReadLinkSpeed(dev);
|
||||
snapshot.NetLinkMbps = net.LinkMbps;
|
||||
if (_previousNet is { } before && net.Rx >= before.Rx && net.Tx >= before.Tx)
|
||||
{
|
||||
double seconds = Math.Max(0.001, _time.GetElapsedTime(before.At, now).TotalSeconds);
|
||||
@@ -83,6 +97,12 @@ public sealed class HostSampler
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private static (long Total, long Idle)? WindowsCpu() => OperatingSystem.IsWindows() ? WindowsHost.Cpu() : null;
|
||||
|
||||
private static (long Total, long Used)? WindowsMemory() => OperatingSystem.IsWindows() ? WindowsHost.Memory() : null;
|
||||
|
||||
private static (long Rx, long Tx, long? LinkMbps)? WindowsNetwork() => OperatingSystem.IsWindows() ? WindowsHost.Network() : null;
|
||||
|
||||
/// <summary>第一行 <c>cpu user nice system idle iowait irq softirq steal …</c>:总时间与空闲时间(idle + iowait)。</summary>
|
||||
public static (long Total, long Idle)? ParseCpu(string stat)
|
||||
{
|
||||
Loaded 100 of 417 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user