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