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 |
No files matched your search
@@ -1,3 +1,4 @@
|
||||
/.gitattributes text eol=lf
|
||||
ci/build-main-amd64.sh text eol=lf
|
||||
ci/index-backend-image.sh text eol=lf
|
||||
ci/publish-release-offer.sh text eol=lf
|
||||
@@ -7,3 +8,11 @@ packaging/container/*.sh text eol=lf
|
||||
ci/release-node-images.sh text eol=lf
|
||||
packaging/install/*.sh text eol=lf
|
||||
packaging/install/*.ps1 text eol=lf
|
||||
*.sh text eol=lf
|
||||
*.init text eol=lf
|
||||
*.service text eol=lf
|
||||
*.container text eol=lf
|
||||
*.Containerfile text eol=lf
|
||||
*.ps1 text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.md text eol=lf
|
||||
@@ -0,0 +1,128 @@
|
||||
labels:
|
||||
ci: domain-linux-native
|
||||
platform: linux/amd64
|
||||
|
||||
clone:
|
||||
- name: git
|
||||
image: plugin-git
|
||||
settings:
|
||||
lfs: false
|
||||
# 要拿到 tag 才算得出开发版本号(ci/version.sh:自最近 tag 的提交数)
|
||||
tags: true
|
||||
|
||||
when:
|
||||
- event: push
|
||||
branch: main
|
||||
evaluate: 'CI_COMMIT_MESSAGE contains "[CI]"'
|
||||
|
||||
# 先等后端测试和前端构建都通过再出镜像(提交信息写 [SKIP TESTS] 只跳过后端测试)
|
||||
depends_on:
|
||||
- verify
|
||||
- web-build
|
||||
|
||||
concurrency: 1
|
||||
|
||||
steps:
|
||||
- name: build-linux-amd64-backend
|
||||
image: bash
|
||||
environment:
|
||||
NUGET_PACKAGES: /home/justme/woodpecker/cache/nuget/packages
|
||||
ZOT_REGISTRY: registry.oeo.one
|
||||
ZOT_USERNAME: justme
|
||||
ZOT_SECRET:
|
||||
from_secret: zot_secret
|
||||
RELEASE_API_URL: https://mo.oeo.one
|
||||
RELEASE_SIGNING_KEY:
|
||||
from_secret: release_signing_key
|
||||
commands:
|
||||
- bash ci/build-dev-image.sh backend
|
||||
depends_on: []
|
||||
|
||||
- name: build-linux-amd64-agent
|
||||
image: bash
|
||||
environment:
|
||||
ZOT_REGISTRY: registry.oeo.one
|
||||
ZOT_USERNAME: justme
|
||||
ZOT_SECRET:
|
||||
from_secret: zot_secret
|
||||
RELEASE_API_URL: https://mo.oeo.one
|
||||
RELEASE_SIGNING_KEY:
|
||||
from_secret: release_signing_key
|
||||
commands:
|
||||
- bash ci/build-dev-image.sh agent
|
||||
depends_on: []
|
||||
|
||||
- name: build-linux-amd64-probe
|
||||
image: bash
|
||||
environment:
|
||||
ZOT_REGISTRY: registry.oeo.one
|
||||
ZOT_USERNAME: justme
|
||||
ZOT_SECRET:
|
||||
from_secret: zot_secret
|
||||
RELEASE_API_URL: https://mo.oeo.one
|
||||
RELEASE_SIGNING_KEY:
|
||||
from_secret: release_signing_key
|
||||
commands:
|
||||
- bash ci/build-dev-image.sh probe
|
||||
depends_on: []
|
||||
|
||||
# Build both node architectures in parallel; each multi-architecture index waits for both of its images.
|
||||
- name: build-linux-arm64-agent
|
||||
image: bash
|
||||
environment:
|
||||
ZOT_REGISTRY: registry.oeo.one
|
||||
ZOT_USERNAME: justme
|
||||
ZOT_SECRET:
|
||||
from_secret: zot_secret
|
||||
RELEASE_API_URL: https://mo.oeo.one
|
||||
RELEASE_SIGNING_KEY:
|
||||
from_secret: release_signing_key
|
||||
commands:
|
||||
- bash ci/build-dev-image.sh agent arm64
|
||||
depends_on: []
|
||||
|
||||
- name: build-linux-arm64-probe
|
||||
image: bash
|
||||
environment:
|
||||
ZOT_REGISTRY: registry.oeo.one
|
||||
ZOT_USERNAME: justme
|
||||
ZOT_SECRET:
|
||||
from_secret: zot_secret
|
||||
RELEASE_API_URL: https://mo.oeo.one
|
||||
RELEASE_SIGNING_KEY:
|
||||
from_secret: release_signing_key
|
||||
commands:
|
||||
- bash ci/build-dev-image.sh probe arm64
|
||||
depends_on: []
|
||||
|
||||
- name: publish-linux-agent-multiarch
|
||||
image: bash
|
||||
environment:
|
||||
ZOT_REGISTRY: registry.oeo.one
|
||||
ZOT_USERNAME: justme
|
||||
ZOT_SECRET:
|
||||
from_secret: zot_secret
|
||||
RELEASE_API_URL: https://mo.oeo.one
|
||||
RELEASE_SIGNING_KEY:
|
||||
from_secret: release_signing_key
|
||||
commands:
|
||||
- bash ci/publish-dev-node-index.sh agent
|
||||
depends_on:
|
||||
- build-linux-amd64-agent
|
||||
- build-linux-arm64-agent
|
||||
|
||||
- name: publish-linux-probe-multiarch
|
||||
image: bash
|
||||
environment:
|
||||
ZOT_REGISTRY: registry.oeo.one
|
||||
ZOT_USERNAME: justme
|
||||
ZOT_SECRET:
|
||||
from_secret: zot_secret
|
||||
RELEASE_API_URL: https://mo.oeo.one
|
||||
RELEASE_SIGNING_KEY:
|
||||
from_secret: release_signing_key
|
||||
commands:
|
||||
- bash ci/publish-dev-node-index.sh probe
|
||||
depends_on:
|
||||
- build-linux-amd64-probe
|
||||
- build-linux-arm64-probe
|
||||
@@ -0,0 +1,32 @@
|
||||
labels:
|
||||
ci: domain-linux-native
|
||||
platform: linux/amd64
|
||||
|
||||
clone:
|
||||
- name: git
|
||||
image: plugin-git
|
||||
settings:
|
||||
lfs: false
|
||||
|
||||
# 带 [CI] 的推送会出镜像,慢测试放在 dev-image 里、等镜像构建完再跑(不和 AOT 编译抢内存);这个 workflow 管其余的:PR 和没带 [CI] 的推送
|
||||
when:
|
||||
- event: pull_request
|
||||
- event: push
|
||||
branch: main
|
||||
evaluate: 'not (CI_COMMIT_MESSAGE contains "[CI]")'
|
||||
|
||||
# 等 verify 编译完、快的测试过了再跑(共用它的增量编译缓存,不重新编译)
|
||||
depends_on:
|
||||
- verify
|
||||
|
||||
steps:
|
||||
- name: backend-e2e-tests
|
||||
image: bash
|
||||
# [SKIP TESTS] 和 verify 一样,跳过后端测试
|
||||
when:
|
||||
- evaluate: 'not (CI_COMMIT_MESSAGE contains "[SKIP TESTS]")'
|
||||
environment:
|
||||
NUGET_PACKAGES: /home/justme/woodpecker/cache/nuget/packages
|
||||
commands:
|
||||
# 标了 Category=Slow 的端到端测试,见 ci/verify-backend.sh
|
||||
- bash ci/verify-backend.sh slow
|
||||
@@ -22,6 +22,8 @@ steps:
|
||||
environment:
|
||||
NPM_CONFIG_CACHE: /home/justme/.npm
|
||||
commands:
|
||||
# 版本取自 tag:先写进工作区(仓库里的版本号只是占位),前端才会带上对的版本
|
||||
- bash ci/version.sh apply
|
||||
- npm ci --prefix web --prefer-offline
|
||||
- timeout 600 npm run build --prefix web
|
||||
depends_on: []
|
||||
@@ -29,7 +31,7 @@ steps:
|
||||
- name: linux-amd64-backend
|
||||
image: bash
|
||||
environment:
|
||||
NUGET_PACKAGES: /home/justme/.nuget/packages
|
||||
NUGET_PACKAGES: /home/justme/woodpecker/cache/nuget/packages
|
||||
ZOT_REGISTRY: registry.oeo.one
|
||||
ZOT_USERNAME: justme
|
||||
ZOT_SECRET:
|
||||
@@ -42,7 +44,7 @@ steps:
|
||||
- name: linux-arm64-backend
|
||||
image: bash
|
||||
environment:
|
||||
NUGET_PACKAGES: /home/justme/.nuget/packages
|
||||
NUGET_PACKAGES: /home/justme/woodpecker/cache/nuget/packages
|
||||
ZOT_REGISTRY: registry.oeo.one
|
||||
ZOT_USERNAME: justme
|
||||
ZOT_SECRET:
|
||||
|
||||
@@ -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:
|
||||
- event: pull_request
|
||||
- event: push
|
||||
branch: dev
|
||||
branch: main
|
||||
|
||||
# On Gitea PR events, CI_COMMIT_MESSAGE is the PR title.
|
||||
steps:
|
||||
@@ -21,6 +21,7 @@ steps:
|
||||
when:
|
||||
- evaluate: 'not (CI_COMMIT_MESSAGE contains "[SKIP TESTS]")'
|
||||
environment:
|
||||
NUGET_PACKAGES: /home/justme/.nuget/packages
|
||||
NUGET_PACKAGES: /home/justme/woodpecker/cache/nuget/packages
|
||||
commands:
|
||||
- cd backend && dotnet test --solution MoShiro.slnx -c Release
|
||||
# 增量编译 + 快的那批测试(约 20 秒);慢的端到端测试在 e2e workflow 里和出镜像并行跑。见 ci/verify-backend.sh
|
||||
- bash ci/verify-backend.sh fast
|
||||
@@ -11,17 +11,14 @@ clone:
|
||||
when:
|
||||
- event: pull_request
|
||||
- event: push
|
||||
branch: dev
|
||||
|
||||
depends_on:
|
||||
- verify
|
||||
branch: main
|
||||
|
||||
# 和 verify 并行:前端构建不依赖后端测试,两个同时跑
|
||||
steps:
|
||||
- name: web-build
|
||||
image: bash
|
||||
environment:
|
||||
NPM_CONFIG_CACHE: /home/justme/.npm
|
||||
commands:
|
||||
- npm ci --prefix web --prefer-offline
|
||||
- npm run build --prefix web
|
||||
- npm run lint:css --prefix web
|
||||
# node_modules 按锁文件哈希缓存,展示端和管理端并行构建,再检查 CSS,见 ci/web-build.sh
|
||||
- bash ci/web-build.sh
|
||||
@@ -22,6 +22,7 @@ steps:
|
||||
GITEA_TOKEN:
|
||||
from_secret: gitea_token
|
||||
NUGET_PACKAGES: 'C:\Users\JustMe-NAS\.nuget\packages'
|
||||
MOSHIRO_QUIC_CACHE: 'C:\Users\JustMe-NAS\.nuget\moshiro-quic-runtime'
|
||||
RID: linux-x64
|
||||
COMPONENT: agent
|
||||
commands: &build-commands
|
||||
|
||||
@@ -6,8 +6,9 @@ MoShiro is a self-hosted network path quality monitor. Backend coordinates Agent
|
||||
|
||||
## Branch and workspace safety
|
||||
|
||||
- Treat `main` as the stable release branch and `dev` as the integration branch. Keep ordinary implementation work on `dev` or a feature branch; integrate into `main` through a pull request.
|
||||
- A push to `main` builds images only if its commit message contains `[CI]` (for a merged PR, put it in the PR or merge commit title). Without `[CI]` nothing is built; use `[CI SKIP]` to skip the pipeline entirely. When squashing or pushing to `main`, add `[CI]` unless a build is explicitly not wanted.
|
||||
- `main` is the only working branch (the old `dev` branch is retired and no pipeline uses it). Commit and push to `main` directly; use a pull request when a review is wanted.
|
||||
- A push to `main` publishes development images only if its commit message contains `[CI]` (for a merged PR, put it in the PR or merge commit title). Without `[CI]` nothing is built; use `[CI SKIP]` to skip the pipeline entirely. The fast Backend tests (`verify`) and the web build run on every `main` push and must pass first; the slow end-to-end tests (`[Trait("Category", "Slow")]`) run after the images are built and pushed (inside `dev-image` for `[CI]` pushes, workflow `e2e` otherwise) and never block them; `[SKIP TESTS]` skips only the Backend tests.
|
||||
- Versions are filled in by CI (`ci/version.sh`): a `vX.Y.Z` tag builds exactly `X.Y.Z`; a `[CI]` build on `main` is `vX.Y.(Z+1)-dev.<N>-<sha7>` (`N` = commits since the latest tag). The `<Version>` in `backend/Directory.Build.props` and the `version` in the `web` `package.json` files are placeholders (`0.0.0-dev`); never bump them by hand.
|
||||
- Before editing, inspect `git status`. Preserve pre-existing user changes: do not reset, clean, stash, or include unrelated files in a commit.
|
||||
- Do not commit credentials, API keys, registry passwords, or generated runtime data. CI credentials belong in Woodpecker secrets (`zot_secret`, `gitea_token`, `release_signing_key` for signing release manifests). CI registers the signed manifests at `/api/release/offer`, which needs no token because the panel verifies the signature. The release signing private key never goes into the repository or the panel; only the public key in `packaging/update/` does.
|
||||
- Keep development OCI images in the `moshiro-dev/` repository path and release images in `moshiro/`. Do not let a development build overwrite release tags such as `latest`.
|
||||
@@ -40,4 +41,4 @@ Follow the user's requested validation scope. Do not start a full tag/release bu
|
||||
|
||||
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.
|
||||
@@ -58,7 +58,7 @@ MoShiro 是一个自托管的**网络线路质量观测工具**。它在若干 P
|
||||
│ Agent × 10+ │ ◀── 测量会话 ── │ Probe × N │
|
||||
│ 有公网 IP,监听 │ 先敲门再 TLS │ 家宽等出口,NAT 后 │
|
||||
└────────────────────┘ └──────────────────────┘
|
||||
测量会话:TLS 控制通道 + UDP/TCP 数据通道,由 Probe 主动拨出
|
||||
测量会话:TLS 控制通道 + QUIC Datagram/TCP 数据通道,由 Probe 主动拨出
|
||||
宿主机更新器(容器节点):用本机公钥验签 → 按 digest 拉镜像 → 重启,失败回滚
|
||||
```
|
||||
|
||||
@@ -75,7 +75,7 @@ Backend 用段文件保存原始数据,用 SQLite 保存索引、指标与配
|
||||
|
||||
- **控制链路(Fleet)**:节点到 Backend 的 `wss` 长连接(`/api/agent/v1/rpc`),JSON-RPC 2.0 信封加二进制通道;挑战加签名认证,`hello` 协商版本和能力,心跳;**期望状态同步**(Backend 下发完整快照,节点调和并回报已应用的版本和被拒绝的条目);**可靠发件箱**(原始块至少一次投递,按 id 去重)。Fleet 与业务无关,放在 `backend/fleet/`。
|
||||
- **业务方法**:`moshiro.*`(按需测试、Probe 断开、事件、主机汇总、更新通知等),状态文档有 `task_set`(Agent 的任务集)和 `probe_agents`(Probe 的 Agent 列表)。
|
||||
- **测量数据面**:Probe 到 Agent 的 TLS 控制通道、UDP 和 TCP 数据通道(默认 `7443/tcp`、`7443/udp`、`7444/tcp`);会话用 `session_id` 和 MAC 认证、不绑定五元组,NAT 映射变化后自动跟随。
|
||||
- **测量数据面**:Probe 到 Agent 的 TLS 控制通道、QUIC Datagram 和 TCP 数据通道(默认 `7443/tcp`、`7443/udp`、`7444/tcp`);测量包不走可靠重传,继续由会话 MAC、序号窗口和速率准入校验。
|
||||
- **REST + SSE**:`/api/public`(展示,可匿名)、`/api/admin`(管理);OpenAPI 在 `backend/openapi/v1.json`,前端据此生成类型。
|
||||
|
||||
细节见 [`docs/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)。
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
## 测量
|
||||
|
||||
- 路由跳数、路径漂移和 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 采集
|
||||
|
||||
@@ -14,10 +14,6 @@
|
||||
- 单个 Agent 的任务(目前是线路模板加按 Agent 的覆盖,没有只属于某一个 Agent 的独立任务)
|
||||
- 原生 webhook 投递;告警(基于 webhook)。实现之前先定约束(只允许 HTTPS、禁止私网和回环、不跟随重定向、限制超时和响应大小、解析后再检查地址),见 [`docs/security/security.md`](docs/security/security.md) 第 9.2 节
|
||||
|
||||
## 部署
|
||||
|
||||
- 后端自动下载前端资源:用 Podman、Docker 部署时,默认拉取最新的前端资源(现在前端要自己构建,镜像里没有)。
|
||||
[security.md](docs/security/security.md)
|
||||
## 访客页面
|
||||
|
||||
- 人机验证:访客页面要通过验证才能拉数据,前端访问时静默触发。候选 tiago2/cap 和 Cloudflare Turnstile。
|
||||
|
||||
@@ -13,11 +13,17 @@
|
||||
<UseArtifactsOutput>true</UseArtifactsOutput>
|
||||
<ArtifactsPath>$(MSBuildThisFileDirectory)artifacts</ArtifactsPath>
|
||||
<Deterministic>true</Deterministic>
|
||||
<Version>0.4.0</Version>
|
||||
<Version>0.0.0-dev</Version>
|
||||
<NuGetAudit>false</NuGetAudit>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" PrivateAssets="all" />
|
||||
<AdditionalFiles Include="$(MSBuildThisFileDirectory)BannedSymbols.txt" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Condition="'$(UseAotAnywhere)' == 'true' and $([MSBuild]::IsOSPlatform('Linux'))">
|
||||
<PackageReference Include="Vezel.Zig.Toolsets.linux-x64"
|
||||
VersionOverride="0.16.0.2"
|
||||
PrivateAssets="all"
|
||||
GeneratePathProperty="true" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -7,7 +7,7 @@ Agent 仅开放配置的控制和数据端口。它不提供远程终端、命
|
||||
## 工作流程
|
||||
|
||||
```text
|
||||
Probe ──TLS 控制 / UDP、TCP 测量──> Agent ──长连接与数据上报──> Backend
|
||||
Probe ──TLS 控制 / QUIC Datagram、TCP 测量──> Agent ──长连接与数据上报──> Backend
|
||||
▲
|
||||
fleet-enroll 注册
|
||||
```
|
||||
|
||||
@@ -11,7 +11,7 @@ using MoShiro.Protocol;
|
||||
namespace MoShiro.Agent;
|
||||
|
||||
/// <summary>Agent 的组装点:数据面、点对点监听、与 Backend 的链路都在这里接起来。没有全局状态,测试可以在同一进程里启动多个。</summary>
|
||||
public sealed class AgentApp : IDisposable
|
||||
public sealed class AgentApp : IDisposable, IAsyncDisposable
|
||||
{
|
||||
public const string Version = MoShiro.Protocol.BuildInfo.Version;
|
||||
|
||||
@@ -33,13 +33,14 @@ public sealed class AgentApp : IDisposable
|
||||
private readonly Outbox _outbox = new(64 << 20);
|
||||
private readonly RestartUpdater? _update;
|
||||
private readonly BackendUplink _uplink;
|
||||
private readonly UdpMux _udp;
|
||||
private readonly QuicDatagramTransport _datagrams;
|
||||
private readonly TcpAcceptor _tcp;
|
||||
private readonly PeerListener _peers;
|
||||
private readonly PeerHub _hub;
|
||||
private readonly TaskManager _tasks;
|
||||
private readonly TestRunner _tests;
|
||||
private readonly FleetLink _link;
|
||||
private int _disposed;
|
||||
|
||||
/// <param name="dataDirectory">配置目录(容器里的 <c>/etc/moshiro</c>):宿主机更新器和节点在这里交换清单、请求和状态。</param>
|
||||
public AgentApp(AgentConfig config, LoadedNodeIdentity identity, string dataDirectory, TimeProvider time, INodeLog log, RestartController? restart = null)
|
||||
@@ -62,7 +63,7 @@ public sealed class AgentApp : IDisposable
|
||||
|
||||
_ipv6Supported = bind.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6;
|
||||
_admission = new AgentAdmission(_streams);
|
||||
_udp = new UdpMux(_sessions, _admission, _clock, _counters.Data, log, bind, config.Listen.UdpPorts);
|
||||
_datagrams = new QuicDatagramTransport(_sessions, _admission, _clock, _counters.Data, log, bind, config.Listen.EffectiveQuicPorts, identity.Certificate);
|
||||
// 预握手敲门:控制端口和数据端口共用一个关卡(限速和去重一起算);名册就是期望状态里的 peers
|
||||
_trust = TrustAnchor.Load(config.Fleet.IdentityDir, log);
|
||||
_knock = new KnockGate(identity.PeerIdentity, RosterKeys, time);
|
||||
@@ -74,7 +75,7 @@ public sealed class AgentApp : IDisposable
|
||||
Clock = _clock,
|
||||
Log = log,
|
||||
Session = () => null, // 每个 Probe 会话各用自己绑定的环境(BlockEnv.WithSession)
|
||||
Udp = _udp,
|
||||
Datagrams = _datagrams,
|
||||
TcpListen = _tcp,
|
||||
Admission = _admission,
|
||||
Counters = _counters.Data,
|
||||
@@ -83,14 +84,14 @@ public sealed class AgentApp : IDisposable
|
||||
};
|
||||
var chunks = new ChunkAssembler(_streams, _clock, _counters, _outbox, identity.NodeId, time, log);
|
||||
var targets = new TargetRunners(chunks, identity.NodeId, _clock, time, log);
|
||||
_tasks = new TaskManager(CreateGuard, _streams, env, targets, () => _udp.BoundPorts, () => _tcp.BoundPorts, _trust);
|
||||
_tasks = new TaskManager(CreateGuard, _streams, env, targets, () => _datagrams.BoundPorts, () => _tcp.BoundPorts, _trust);
|
||||
_hub = new PeerHub(_sessions, _clock, chunks, _tasks, _uplink, time, log);
|
||||
_tests = new TestRunner(_tasks, _sessions, _streams, env, _uplink, config.Limits.MaxConcurrentTests, transport => transport == "udp" ? _udp.BoundPorts : _tcp.BoundPorts, time, log);
|
||||
_tests = new TestRunner(_tasks, _sessions, _streams, env, _uplink, config.Limits.MaxConcurrentTests, transport => transport == "quic_datagram" ? _datagrams.BoundPorts : _tcp.BoundPorts, time, log);
|
||||
|
||||
_tasks.OnLineTasksChanged = (probe, set) => _ = _hub.PushToAsync(probe, set).AsTask();
|
||||
_tasks.OnPeersChanged = _hub.CloseNotIn;
|
||||
_hub.OnProbeLost = probe => _tests.ProbeLost(probe);
|
||||
_admission.OnEndpointChanged = probe => _ = _uplink.EmitEventAsync(AgentEventKinds.EndpointChanged, probe.UdpEndpoint?.ToString()).AsTask();
|
||||
_admission.OnEndpointChanged = probe => _ = _uplink.EmitEventAsync(AgentEventKinds.EndpointChanged, probe.DatagramRemoteEndPoint?.ToString()).AsTask();
|
||||
|
||||
_peers = new PeerListener(
|
||||
new PeerListenerOptions
|
||||
@@ -113,13 +114,29 @@ public sealed class AgentApp : IDisposable
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
internal StreamRegistry Streams => _streams;
|
||||
|
||||
public IReadOnlyList<int> UdpPorts => _udp.BoundPorts;
|
||||
public IReadOnlyList<int> QuicPorts => _datagrams.BoundPorts;
|
||||
|
||||
public IReadOnlyList<int> TcpPorts => _tcp.BoundPorts;
|
||||
|
||||
@@ -143,25 +160,26 @@ public sealed class AgentApp : IDisposable
|
||||
|
||||
public async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
_log.Info("moshiro agent starting", MoShiro.Protocol.BuildInfo.Fields);
|
||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
// 数据面和控制端口先开始监听,再连接 Backend:hello 里上报的是实际端口
|
||||
_udp.Bind();
|
||||
_datagrams.Bind();
|
||||
_tcp.Bind();
|
||||
Task udp = _udp.RunAsync(linked.Token);
|
||||
Task quic = _datagrams.RunAsync(linked.Token);
|
||||
Task tcp = _tcp.RunAsync(linked.Token);
|
||||
Task peers = _peers.RunAsync(linked.Token);
|
||||
await Task.WhenAny(_peers.Started.Task, peers);
|
||||
if (peers.IsFaulted)
|
||||
{
|
||||
await linked.CancelAsync();
|
||||
await Task.WhenAll(udp, tcp, peers); // 抛出监听失败的原因(端口被占用等)
|
||||
await Task.WhenAll(quic, tcp, peers); // 抛出监听失败的原因(端口被占用等)
|
||||
}
|
||||
|
||||
_ready.TrySetResult();
|
||||
_log.Info("agent listening", ("control", ControlPort), ("udp", string.Join(',', UdpPorts)), ("tcp", string.Join(',', TcpPorts)));
|
||||
_log.Info("agent listening", ("control", ControlPort), ("quic", string.Join(',', QuicPorts)), ("tcp", string.Join(',', TcpPorts)));
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(udp, tcp, peers, _link.RunAsync(linked.Token), ReportHostAsync(linked.Token));
|
||||
await Task.WhenAll(quic, tcp, peers, _link.RunAsync(linked.Token), ReportHostAsync(linked.Token));
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -202,7 +220,7 @@ public sealed class AgentApp : IDisposable
|
||||
MaxTotalPps = _config.Limits.MaxTotalPps,
|
||||
AllowedTargetCidrs = _config.Limits.AllowedTargetCidrs,
|
||||
MaxConcurrentTests = _config.Limits.MaxConcurrentTests,
|
||||
AllowedUdpPorts = _udp.BoundPorts,
|
||||
AllowedQuicPorts = _datagrams.BoundPorts,
|
||||
AllowedTcpPorts = _tcp.BoundPorts,
|
||||
},
|
||||
BlockCatalog.AgentKinds);
|
||||
@@ -281,6 +299,7 @@ public sealed class AgentApp : IDisposable
|
||||
Capabilities = [MoShiroCapabilities.Base, MoShiroCapabilities.KindsPrefix + string.Join(',', BlockCatalog.AgentKinds)],
|
||||
HelloMeta = HelloMeta,
|
||||
HeartbeatExtra = HeartbeatExtra,
|
||||
ShutdownReason = () => _restart.NowReason == "update" ? "updating" : "stopping",
|
||||
};
|
||||
var link = new FleetLink(options, router, [new StateHandler<TaskSet>(TaskSet.StateType, MoShiroJsonContext.Default.TaskSet, _tasks.ApplyAsync)], _outbox, _time, _log);
|
||||
|
||||
@@ -309,7 +328,8 @@ public sealed class AgentApp : IDisposable
|
||||
Platform = MoShiro.Measurement.PlatformDetector.Detect(),
|
||||
TlsPublicKey = Convert.ToBase64String(_identity.Certificate.PublicKey.ExportSubjectPublicKeyInfo()),
|
||||
ProbeTcpPort = ControlPort,
|
||||
ProbeUdpPorts = [.. UdpPorts],
|
||||
ProbeQuicPorts = [.. QuicPorts],
|
||||
LegacyProbeUdpPorts = [.. QuicPorts],
|
||||
LocalLimits = new LocalLimits
|
||||
{
|
||||
MaxTotalMbps = _config.Limits.MaxTotalMbps,
|
||||
@@ -333,6 +353,7 @@ public sealed class AgentApp : IDisposable
|
||||
long packetsOut = _counters.PacketsOut;
|
||||
var extra = new AgentHeartbeatExtra
|
||||
{
|
||||
RuntimeStatus = _update?.Status().State == "applying" ? NodeRuntimeStatus.Updating : NodeRuntimeStatus.Running,
|
||||
Ipv6 = Ipv6Diagnostics.Sample(_ipv6Supported, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")),
|
||||
Probes = [.. _hub.ConnectedProbes()],
|
||||
Sessions = _sessions.All.Count,
|
||||
|
||||
@@ -51,8 +51,15 @@ public sealed class ListenSection
|
||||
[JsonPropertyName("control_port")]
|
||||
public int ControlPort { get; set; } = 7443;
|
||||
|
||||
[JsonPropertyName("quic_ports")]
|
||||
public List<int> QuicPorts { get; set; } = [7443];
|
||||
|
||||
/// <summary>Legacy config key accepted while deployments migrate to QUIC Datagram.</summary>
|
||||
[JsonPropertyName("udp_ports")]
|
||||
public List<int> UdpPorts { get; set; } = [7443];
|
||||
public List<int>? LegacyUdpPorts { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public IReadOnlyList<int> EffectiveQuicPorts => LegacyUdpPorts ?? QuicPorts;
|
||||
|
||||
[JsonPropertyName("tcp_ports")]
|
||||
public List<int> TcpPorts { get; set; } = [7444];
|
||||
|
||||
@@ -44,7 +44,7 @@ public sealed class ProbeSession : DataSession
|
||||
{
|
||||
private readonly ConcurrentDictionary<uint, ReplayWindow> _windows = new();
|
||||
private readonly ConcurrentDictionary<ulong, byte> _nonces = new();
|
||||
private SocketAddress? _udpSource;
|
||||
private SocketAddress? _datagramSource;
|
||||
|
||||
public ProbeSession(PeerSessionInfo info)
|
||||
: base(info.Credentials.SessionIdValue, info.Credentials.Key)
|
||||
@@ -71,11 +71,11 @@ public sealed class ProbeSession : DataSession
|
||||
public RpcSession<PeerContext>? Rpc { get; set; }
|
||||
|
||||
/// <summary>最近一次认证通过的 UDP 包的源地址(Agent 把单向数据发往这里)。</summary>
|
||||
public override IPEndPoint? UdpEndpoint
|
||||
public override IPEndPoint? DatagramRemoteEndPoint
|
||||
{
|
||||
get
|
||||
{
|
||||
SocketAddress? source = Volatile.Read(ref _udpSource);
|
||||
SocketAddress? source = Volatile.Read(ref _datagramSource);
|
||||
return source is null ? null : (IPEndPoint)new IPEndPoint(source.Family == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any, 0).Create(source);
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,7 @@ public sealed class ProbeSession : DataSession
|
||||
/// <summary>记录 UDP 源地址;NAT 映射变化后地址变了返回 true(上报 endpoint_changed)。第一次看到地址不算变化。</summary>
|
||||
public bool UpdateSource(SocketAddress source)
|
||||
{
|
||||
SocketAddress? previous = Volatile.Read(ref _udpSource);
|
||||
SocketAddress? previous = Volatile.Read(ref _datagramSource);
|
||||
if (previous is not null && previous.Equals(source))
|
||||
{
|
||||
return false;
|
||||
@@ -96,7 +96,7 @@ public sealed class ProbeSession : DataSession
|
||||
copy[i] = source[i];
|
||||
}
|
||||
|
||||
Volatile.Write(ref _udpSource, copy);
|
||||
Volatile.Write(ref _datagramSource, copy);
|
||||
return previous is not null;
|
||||
}
|
||||
|
||||
@@ -312,7 +312,7 @@ public sealed class AgentAdmission : IAdmission
|
||||
/// <summary>NAT 映射变化,源地址变了:上报 <c>endpoint_changed</c>。</summary>
|
||||
public Action<ProbeSession>? OnEndpointChanged { get; set; }
|
||||
|
||||
public Admission AdmitPacket(DataSession session, in UdpHeader header, SocketAddress source, long nowUs)
|
||||
public Admission AdmitDatagram(DataSession session, in MeasurementDatagramHeader header, SocketAddress source, long nowUs)
|
||||
{
|
||||
if (session is not ProbeSession probe || !_streams.TryGet(header.StreamId, out StreamInfo? stream) || !ReferenceEquals(stream.Owner, probe) || !probe.AcceptSequence(header.StreamId, header.Seq))
|
||||
{
|
||||
|
||||
@@ -78,8 +78,9 @@ public sealed class PeerHub
|
||||
{
|
||||
private const int DedupeWindow = 1024;
|
||||
|
||||
/// <summary>超过这么久没有收到 Probe 的任何帧就认为会话已死(Probe 每 30 秒至少发一次时钟同步)。</summary>
|
||||
private static readonly TimeSpan IdleLimit = TimeSpan.FromSeconds(75);
|
||||
/// <summary>超过这么久没有收到 Probe 的任何帧就认为会话已死(Probe 每 30 秒至少发一次时钟同步)。
|
||||
/// 要比线路可能的中断长得多:差的线路上几十秒甚至一两分钟的黑洞时有发生(TCP RTT 能到 30 秒),75 秒就关会话会把一次中断变成几分钟的数据空白(要重新握手,而握手在差线路上也慢)。</summary>
|
||||
private static readonly TimeSpan IdleLimit = TimeSpan.FromSeconds(180);
|
||||
|
||||
private readonly RpcRouter<PeerContext> _router;
|
||||
private readonly SessionRegistry _sessions;
|
||||
|
||||
@@ -43,7 +43,7 @@ catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidO
|
||||
using var restartScope = restart;
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ public sealed class TaskManager
|
||||
private readonly StreamRegistry _streams;
|
||||
private readonly BlockEnv _env;
|
||||
private readonly TargetRunners _targets;
|
||||
private readonly Func<IReadOnlyList<int>> _udpPorts;
|
||||
private readonly Func<IReadOnlyList<int>> _quicPorts;
|
||||
private readonly Func<IReadOnlyList<int>> _tcpPorts;
|
||||
private readonly TrustAnchor _trust;
|
||||
private readonly Dictionary<ProbeSession, Dictionary<string, LineEntry>> _sessionLines = [];
|
||||
@@ -33,14 +33,14 @@ public sealed class TaskManager
|
||||
private long _version;
|
||||
private BudgetGuard? _guard;
|
||||
|
||||
public TaskManager(Func<BudgetGuard> guardFactory, StreamRegistry streams, BlockEnv env, TargetRunners targets, Func<IReadOnlyList<int>> udpPorts, Func<IReadOnlyList<int>> tcpPorts, TrustAnchor? trust = null)
|
||||
public TaskManager(Func<BudgetGuard> guardFactory, StreamRegistry streams, BlockEnv env, TargetRunners targets, Func<IReadOnlyList<int>> quicPorts, Func<IReadOnlyList<int>> tcpPorts, TrustAnchor? trust = null)
|
||||
{
|
||||
_trust = trust ?? TrustAnchor.None;
|
||||
_guardFactory = guardFactory;
|
||||
_streams = streams;
|
||||
_env = env;
|
||||
_targets = targets;
|
||||
_udpPorts = udpPorts;
|
||||
_quicPorts = quicPorts;
|
||||
_tcpPorts = tcpPorts;
|
||||
}
|
||||
|
||||
@@ -229,7 +229,8 @@ public sealed class TaskManager
|
||||
{
|
||||
Version = _version,
|
||||
Tasks = [.. entries.Values.Select(e => e.Task)],
|
||||
UdpPorts = [.. _udpPorts()],
|
||||
QuicPorts = [.. _quicPorts()],
|
||||
LegacyUdpPorts = [.. _quicPorts()],
|
||||
TcpPorts = [.. _tcpPorts()],
|
||||
};
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ public sealed class TestRunner
|
||||
|
||||
// 按需测试的数据端口由 Agent 决定:Backend 给的端口如果不是本机监听的端口(模板里是出厂默认值,每个 Agent 可以改,
|
||||
// 而 Backend 并不知道 TCP 数据端口),就用本机监听的第一个端口。块头里记录的是实际用的端口。
|
||||
IReadOnlyList<int> ports = _ports(kind.Blocks.Transport == "udp" ? "udp" : "tcp");
|
||||
IReadOnlyList<int> ports = _ports(kind.Blocks.Transport == "quic_datagram" ? "quic_datagram" : "tcp");
|
||||
bool listening = phase.Params.TryGetValue("port", out System.Text.Json.JsonElement requested) && requested.TryGetInt32(out int port) && ports.Contains(port);
|
||||
if (!listening)
|
||||
{
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
2. **安全默认值**:Fleet 永远不提供 exec、shell、文件读写、远程下发代码这类方法;节点只响应本地注册过的方法(本地策略 `allow_methods` 还能收窄);调用对方不支持的方法在本地直接返回 `fleet_unsupported`。
|
||||
3. **权限分离**:注册(`fleet-enroll`,一次性)和常驻业务进程是两个进程;常驻进程不含注册代码,也不使用 `HttpClient`。
|
||||
4. **NativeAOT**:节点侧只依赖 BCL,泛型扩展点通过产品传入的 `JsonTypeInfo<T>` 序列化,不使用反射。
|
||||
5. **更新不在 Fleet 里**:节点进程没有自更新能力。容器节点的签名更新由宿主机上的更新器完成,见 [`docs/ops/deployment.md`](../../docs/ops/deployment.md);纯二进制节点的更新是待办,见 [`TODO.md`](../../TODO.md)。
|
||||
5. **更新不在 Fleet 里**:节点进程没有自更新能力。Quadlet 装的节点由退出后 systemd 重新拉镜像、Backend 和脚本装的节点由宿主机上的更新器验签更新,见 [`docs/ops/deployment.md`](../../docs/ops/deployment.md);纯二进制节点的更新是待办,见 [`TODO.md`](../../TODO.md)。
|
||||
|
||||
## 身份
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
| `fleet.challenge` / `fleet.auth` | 服务端→节点 / 节点→服务端 | 签名认证(`auth` 用途,nonce 30 秒内有效) |
|
||||
| `fleet.hello` | 节点→服务端 | 版本、能力、本地策略摘要、当前时间 → 返回时钟偏差;产品的元数据也放在这里 |
|
||||
| `fleet.heartbeat` | 节点→服务端 | 每 10 秒一次,产品可以附加计数 |
|
||||
| `fleet.stopping` | 节点→服务端 | 有序关闭前通知服务端,携带 `reason`(例如 `stopping` 或 `updating`),用于准确区分离线和计划停机 |
|
||||
| `fleet.state.sync` | 服务端→节点 | `{version, doc}` → `{applied_version, rejected[]}` |
|
||||
| `fleet.outbox.ack` | 服务端→节点 | `{ids[], duplicates[]}` |
|
||||
| `fleet.revoked` | 服务端→节点 | 节点被禁用或接管,随后断开 |
|
||||
|
||||
@@ -58,6 +58,9 @@ public sealed class FleetLinkOptions
|
||||
/// <summary>附加到 <c>fleet.heartbeat</c> 的进程内计数(不能包含主机信息)。</summary>
|
||||
public Func<JsonElement?>? HeartbeatExtra { get; init; }
|
||||
|
||||
/// <summary>Optional reason for an orderly client shutdown, so the server can distinguish updates from service stops.</summary>
|
||||
public Func<string>? ShutdownReason { get; init; }
|
||||
|
||||
public TimeSpan HeartbeatInterval { get; init; } = TimeSpan.FromSeconds(10);
|
||||
|
||||
public int MaxFrameBytes { get; init; } = 16 << 20;
|
||||
@@ -181,7 +184,8 @@ public sealed class FleetLink
|
||||
_secure = transport.Secure;
|
||||
|
||||
var session = new RpcSession<LinkContext>(transport, _router, new LinkContext(this), _time);
|
||||
using CancellationTokenSource sessionCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
// Keep the active WebSocket receive alive until the planned shutdown marker and Close frame have been sent.
|
||||
using var sessionCts = new CancellationTokenSource();
|
||||
Task run = session.RunAsync(sessionCts.Token);
|
||||
try
|
||||
{
|
||||
@@ -192,7 +196,7 @@ public sealed class FleetLink
|
||||
NowUtcMs = _time.GetUtcNow().ToUnixTimeMilliseconds(),
|
||||
Meta = _options.HelloMeta?.Invoke(),
|
||||
};
|
||||
Result<HelloResult> helloResult = await session.RequestAsync(FleetMethods.Hello, hello, FleetRpcContext.Default.HelloParams, FleetRpcContext.Default.HelloResult, sessionCts.Token);
|
||||
Result<HelloResult> helloResult = await session.RequestAsync(FleetMethods.Hello, hello, FleetRpcContext.Default.HelloParams, FleetRpcContext.Default.HelloResult, ct);
|
||||
if (!helloResult.IsSuccess)
|
||||
{
|
||||
return "hello failed: " + helloResult.Error.Code;
|
||||
@@ -208,7 +212,23 @@ public sealed class FleetLink
|
||||
await OnConnected(session, sessionCts.Token);
|
||||
}
|
||||
|
||||
Task finished = await Task.WhenAny(run, pump, heartbeat);
|
||||
Task cancellation = Task.Delay(Timeout.Infinite, ct);
|
||||
Task finished = await Task.WhenAny(run, pump, heartbeat, cancellation);
|
||||
if (ct.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var closeTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(1), _time);
|
||||
string reason = _options.ShutdownReason?.Invoke() ?? "stopping";
|
||||
await session.NotifyAsync(FleetMethods.Stopping, new StoppingParams { Reason = reason }, FleetRpcContext.Default.StoppingParams, closeTimeout.Token);
|
||||
await session.CloseAsync(reason, closeTimeout.Token);
|
||||
}
|
||||
catch (Exception ex) when (ex is OperationCanceledException or WebSocketException or IOException or ObjectDisposedException or InvalidOperationException)
|
||||
{
|
||||
// The socket may already be gone; shutdown must still complete.
|
||||
}
|
||||
}
|
||||
|
||||
await sessionCts.CancelAsync();
|
||||
await Task.WhenAll(Quiet(run), Quiet(pump), Quiet(heartbeat));
|
||||
return finished == run ? "closed by server" : "link task failed";
|
||||
|
||||
@@ -29,7 +29,8 @@ public sealed class PeerListenerOptions
|
||||
|
||||
public int MaxSessions { get; init; } = 8;
|
||||
|
||||
public TimeSpan HandshakeTimeout { get; init; } = TimeSpan.FromSeconds(10);
|
||||
/// <summary>敲门、TLS、挑战应答整个握手的上限。线路差的时候(RTT 好几秒、还丢包)握手本来就要很久,10 秒做不完就会一直连不上;敲门已经挡住了不认识的连接,放宽不会放大被占连接的风险。</summary>
|
||||
public TimeSpan HandshakeTimeout { get; init; } = PeerTimings.Handshake;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -209,7 +210,8 @@ public sealed class PeerListener
|
||||
}
|
||||
|
||||
/// <summary>发起点对点会话(Probe 一侧):按 SPKI 指纹固定对端证书,用身份私钥签名,派生同样的数据面凭据。</summary>
|
||||
/// <summary>点对点会话的套接字:开启 TCP keepalive(空闲 15 秒后每 5 秒探测,连续 3 次无响应就断开,约 30 秒),主机丢失或路径被切断时连接不会一直挂着。</summary>
|
||||
/// <summary>点对点会话的套接字:开启 TCP keepalive(空闲 30 秒后每 10 秒探测,连续 6 次无响应才断开,约 90 秒),主机真的没了的时候连接不会一直挂着;
|
||||
/// 但线路短暂中断(几十秒的黑洞)不能把会话杀掉——线路中断本来就是要测的东西,会话得扛过去,恢复之后接着测。</summary>
|
||||
internal static class PeerSockets
|
||||
{
|
||||
public static void EnableKeepAlive(Socket socket)
|
||||
@@ -217,9 +219,9 @@ internal static class PeerSockets
|
||||
try
|
||||
{
|
||||
socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true);
|
||||
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, 15);
|
||||
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, 5);
|
||||
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, 3);
|
||||
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, 30);
|
||||
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, 10);
|
||||
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, 6);
|
||||
}
|
||||
catch (Exception ex) when (ex is SocketException or ArgumentException or PlatformNotSupportedException)
|
||||
{
|
||||
@@ -228,6 +230,13 @@ internal static class PeerSockets
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>点对点会话的时间常数,Probe 和 Agent 两侧共用。</summary>
|
||||
public static class PeerTimings
|
||||
{
|
||||
/// <summary>建立会话(连接、敲门、TLS、挑战应答)的总时限。</summary>
|
||||
public static readonly TimeSpan Handshake = TimeSpan.FromSeconds(30);
|
||||
}
|
||||
|
||||
public static class PeerConnector
|
||||
{
|
||||
public static async Task<(PeerSessionInfo Info, TlsFrameTransport Transport)> ConnectAsync(
|
||||
@@ -240,7 +249,7 @@ public static class PeerConnector
|
||||
PeerSockets.EnableKeepAlive(client.Client);
|
||||
try
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10), time);
|
||||
using var timeout = new CancellationTokenSource(PeerTimings.Handshake, time);
|
||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
|
||||
await client.ConnectAsync(host, port, linked.Token);
|
||||
Stream stream = client.GetStream();
|
||||
|
||||
@@ -10,6 +10,7 @@ public static class FleetMethods
|
||||
public const string Auth = "fleet.auth";
|
||||
public const string Hello = "fleet.hello";
|
||||
public const string Heartbeat = "fleet.heartbeat";
|
||||
public const string Stopping = "fleet.stopping";
|
||||
public const string StateSync = "fleet.state.sync";
|
||||
public const string OutboxAck = "fleet.outbox.ack";
|
||||
public const string Revoked = "fleet.revoked";
|
||||
@@ -28,6 +29,7 @@ public static class FleetMethodTable
|
||||
MethodSpec.Request(FleetMethods.Auth, RpcSide.Node).AsInline(),
|
||||
MethodSpec.Request(FleetMethods.Hello, RpcSide.Node).AsInline(),
|
||||
MethodSpec.Notification(FleetMethods.Heartbeat, RpcSide.Node).AsInline(),
|
||||
MethodSpec.Notification(FleetMethods.Stopping, RpcSide.Node).AsInline(),
|
||||
MethodSpec.Request(FleetMethods.StateSync, RpcSide.Server),
|
||||
MethodSpec.Notification(FleetMethods.OutboxAck, RpcSide.Server),
|
||||
MethodSpec.Notification(FleetMethods.Revoked, RpcSide.Server),
|
||||
@@ -156,6 +158,12 @@ public sealed class RevokedParams
|
||||
public string Reason { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class StoppingParams
|
||||
{
|
||||
[JsonPropertyName("reason")]
|
||||
public string Reason { get; set; } = "stopping";
|
||||
}
|
||||
|
||||
[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
[JsonSerializable(typeof(RpcMessage))]
|
||||
[JsonSerializable(typeof(ChallengeParams))]
|
||||
@@ -164,6 +172,7 @@ public sealed class RevokedParams
|
||||
[JsonSerializable(typeof(HelloParams))]
|
||||
[JsonSerializable(typeof(HelloResult))]
|
||||
[JsonSerializable(typeof(HeartbeatParams))]
|
||||
[JsonSerializable(typeof(StoppingParams))]
|
||||
[JsonSerializable(typeof(StateSyncParams))]
|
||||
[JsonSerializable(typeof(StateSyncResult))]
|
||||
[JsonSerializable(typeof(OutboxAckParams))]
|
||||
|
||||
@@ -38,7 +38,7 @@ public enum NodeEventKind
|
||||
Heartbeat,
|
||||
}
|
||||
|
||||
public sealed record NodeEvent(NodeEventKind Kind, NodeRecord Node, long AtMs, HelloParams? Hello = null, JsonElement? Extra = null);
|
||||
public sealed record NodeEvent(NodeEventKind Kind, NodeRecord Node, long AtMs, HelloParams? Hello = null, JsonElement? Extra = null, string? Reason = null);
|
||||
|
||||
public interface INodeEventSink
|
||||
{
|
||||
|
||||
@@ -64,6 +64,17 @@ internal sealed partial class NodeConnection
|
||||
case FleetMethods.Heartbeat:
|
||||
await HandleHeartbeatAsync(message, ct);
|
||||
return;
|
||||
case FleetMethods.Stopping:
|
||||
try
|
||||
{
|
||||
string? reason = message.Params?.Deserialize(FleetRpcContext.Default.StoppingParams)?.Reason;
|
||||
_closeReason = reason is "updating" ? "updating" : "stopping";
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
_closeReason = "stopping";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (_node!.Status != NodeStatus.Active)
|
||||
|
||||
@@ -102,7 +102,7 @@ internal sealed partial class NodeConnection
|
||||
|
||||
private async Task FireAsync(NodeEventKind kind, CancellationToken ct, HelloParams? hello = null, JsonElement? extra = null)
|
||||
{
|
||||
var nodeEvent = new NodeEvent(kind, _node!, _host.Time.GetUtcNow().ToUnixTimeMilliseconds(), hello, extra);
|
||||
var nodeEvent = new NodeEvent(kind, _node!, _host.Time.GetUtcNow().ToUnixTimeMilliseconds(), hello, extra, kind == NodeEventKind.Offline ? _closeReason : null);
|
||||
foreach (INodeEventSink sink in _host.Sinks)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -17,6 +17,7 @@ internal sealed partial class NodeConnection
|
||||
ValueWebSocketReceiveResult result = await _socket.ReceiveAsync(buffer, ct);
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
_closeReason ??= _socket.CloseStatusDescription;
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
+352
-2
@@ -4821,6 +4821,76 @@
|
||||
"x-moshiro-capability": "fleet.write"
|
||||
}
|
||||
},
|
||||
"/api/admin/links/{probe_id}/{agent_id}/retry": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"admin-links"
|
||||
],
|
||||
"summary": "Enable and immediately retry the Probe-to-Agent connection",
|
||||
"operationId": "links_retry",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "probe_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "agent_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/LinkRetryDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-moshiro-capability": "fleet.write"
|
||||
}
|
||||
},
|
||||
"/healthz": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -5174,6 +5244,7 @@
|
||||
"public_host",
|
||||
"probe_tcp_port",
|
||||
"probe_udp_ports",
|
||||
"probe_quic_ports",
|
||||
"labels",
|
||||
"public_visible",
|
||||
"kinds",
|
||||
@@ -5184,6 +5255,7 @@
|
||||
"rejected",
|
||||
"heartbeat",
|
||||
"endpoint_changes",
|
||||
"runtime_status",
|
||||
"created_at",
|
||||
"last_seen_at"
|
||||
],
|
||||
@@ -5242,6 +5314,17 @@
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
"probe_quic_ports": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||
"type": [
|
||||
"integer",
|
||||
"string"
|
||||
],
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
"labels": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -5313,6 +5396,9 @@
|
||||
],
|
||||
"format": "int32"
|
||||
},
|
||||
"runtime_status": {
|
||||
"$ref": "#/components/schemas/NodeRuntimeStatus"
|
||||
},
|
||||
"created_at": {
|
||||
"format": "date-time"
|
||||
},
|
||||
@@ -6974,6 +7060,16 @@
|
||||
"ipv6_available": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"quic_health": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/QuicLineHealthDto"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -6985,7 +7081,9 @@
|
||||
"agent_name",
|
||||
"linked",
|
||||
"override",
|
||||
"connected"
|
||||
"connected",
|
||||
"probe_runtime_status",
|
||||
"agent_runtime_status"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -7012,6 +7110,46 @@
|
||||
},
|
||||
"connected": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"probe_runtime_status": {
|
||||
"$ref": "#/components/schemas/NodeRuntimeStatus"
|
||||
},
|
||||
"agent_runtime_status": {
|
||||
"$ref": "#/components/schemas/NodeRuntimeStatus"
|
||||
},
|
||||
"connection_state": {
|
||||
"type": "string",
|
||||
"default": "connecting"
|
||||
},
|
||||
"connection_reason": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"last_error": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"retry_attempt": {
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||
"type": [
|
||||
"integer",
|
||||
"string"
|
||||
],
|
||||
"format": "int32",
|
||||
"default": 0
|
||||
},
|
||||
"retry_in_ms": {
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||
"type": [
|
||||
"null",
|
||||
"integer",
|
||||
"string"
|
||||
],
|
||||
"format": "int64"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -7033,6 +7171,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"LinkRetryDto": {
|
||||
"required": [
|
||||
"queued",
|
||||
"link"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"queued": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"link": {
|
||||
"$ref": "#/components/schemas/LinkDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"LocalLimitsDto": {
|
||||
"required": [
|
||||
"max_total_mbps",
|
||||
@@ -7453,6 +7606,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"NodeRuntimeStatus": {
|
||||
"enum": [
|
||||
"initializing",
|
||||
"running",
|
||||
"probe_paused",
|
||||
"maintenance",
|
||||
"stopping",
|
||||
"offline",
|
||||
"updating"
|
||||
]
|
||||
},
|
||||
"NodeUpdateDto": {
|
||||
"required": [
|
||||
"state",
|
||||
@@ -7601,6 +7765,16 @@
|
||||
"ip_version": {
|
||||
"type": "string",
|
||||
"default": "ipv4"
|
||||
},
|
||||
"quic_health": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/QuicLineHealthDto"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -8417,7 +8591,8 @@
|
||||
"revoked_at",
|
||||
"revoked",
|
||||
"online",
|
||||
"connections"
|
||||
"connections",
|
||||
"runtime_status"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -8445,6 +8620,9 @@
|
||||
"$ref": "#/components/schemas/ProbeConnectionDto"
|
||||
}
|
||||
},
|
||||
"runtime_status": {
|
||||
"$ref": "#/components/schemas/NodeRuntimeStatus"
|
||||
},
|
||||
"ipv6": {
|
||||
"oneOf": [
|
||||
{
|
||||
@@ -8581,6 +8759,178 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"QuicLineHealthDto": {
|
||||
"required": [
|
||||
"connected",
|
||||
"handshake_attempts",
|
||||
"handshake_successes",
|
||||
"handshake_failures",
|
||||
"handshake_timeouts",
|
||||
"handshake_success_rate",
|
||||
"handshake_failure_rate",
|
||||
"handshake_timeout_rate",
|
||||
"handshake_p95_ms",
|
||||
"handshake_sample_count",
|
||||
"transport_packet_loss_rate",
|
||||
"transport_packets_sent",
|
||||
"transport_congestion_events",
|
||||
"transport_ecn_congestion_events",
|
||||
"path_mtu",
|
||||
"at"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"connected": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"handshake_attempts": {
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||
"type": [
|
||||
"integer",
|
||||
"string"
|
||||
],
|
||||
"format": "int64"
|
||||
},
|
||||
"handshake_successes": {
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||
"type": [
|
||||
"integer",
|
||||
"string"
|
||||
],
|
||||
"format": "int64"
|
||||
},
|
||||
"handshake_failures": {
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||
"type": [
|
||||
"integer",
|
||||
"string"
|
||||
],
|
||||
"format": "int64"
|
||||
},
|
||||
"handshake_timeouts": {
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||
"type": [
|
||||
"integer",
|
||||
"string"
|
||||
],
|
||||
"format": "int64"
|
||||
},
|
||||
"handshake_success_rate": {
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$",
|
||||
"type": [
|
||||
"null",
|
||||
"number",
|
||||
"string"
|
||||
],
|
||||
"format": "double"
|
||||
},
|
||||
"handshake_failure_rate": {
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$",
|
||||
"type": [
|
||||
"null",
|
||||
"number",
|
||||
"string"
|
||||
],
|
||||
"format": "double"
|
||||
},
|
||||
"handshake_timeout_rate": {
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$",
|
||||
"type": [
|
||||
"null",
|
||||
"number",
|
||||
"string"
|
||||
],
|
||||
"format": "double"
|
||||
},
|
||||
"handshake_p95_ms": {
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$",
|
||||
"type": [
|
||||
"null",
|
||||
"number",
|
||||
"string"
|
||||
],
|
||||
"format": "double"
|
||||
},
|
||||
"handshake_sample_count": {
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||
"type": [
|
||||
"integer",
|
||||
"string"
|
||||
],
|
||||
"format": "int64"
|
||||
},
|
||||
"transport_packet_loss_rate": {
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$",
|
||||
"type": [
|
||||
"null",
|
||||
"number",
|
||||
"string"
|
||||
],
|
||||
"format": "double"
|
||||
},
|
||||
"transport_packets_sent": {
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||
"type": [
|
||||
"null",
|
||||
"integer",
|
||||
"string"
|
||||
],
|
||||
"format": "uint64"
|
||||
},
|
||||
"transport_congestion_events": {
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||
"type": [
|
||||
"null",
|
||||
"integer",
|
||||
"string"
|
||||
],
|
||||
"format": "uint32"
|
||||
},
|
||||
"transport_ecn_congestion_events": {
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||
"type": [
|
||||
"null",
|
||||
"integer",
|
||||
"string"
|
||||
],
|
||||
"format": "uint32"
|
||||
},
|
||||
"path_mtu": {
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||
"type": [
|
||||
"null",
|
||||
"integer",
|
||||
"string"
|
||||
],
|
||||
"format": "uint16"
|
||||
},
|
||||
"at": {
|
||||
"format": "date-time"
|
||||
},
|
||||
"state": {
|
||||
"type": "string",
|
||||
"default": "connecting"
|
||||
},
|
||||
"retry_attempt": {
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||
"type": [
|
||||
"integer",
|
||||
"string"
|
||||
],
|
||||
"format": "int32",
|
||||
"default": 0
|
||||
},
|
||||
"retry_in_ms": {
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||
"type": [
|
||||
"null",
|
||||
"integer",
|
||||
"string"
|
||||
],
|
||||
"format": "int64"
|
||||
}
|
||||
}
|
||||
},
|
||||
"RawChunkMetaDto": {
|
||||
"required": [
|
||||
"chunk_id",
|
||||
|
||||
@@ -13,6 +13,8 @@ Backend ──任务──> Agent <──测量会话──> Probe
|
||||
|
||||
Probe 用 `probe.json` 读取面板地址(`fleet.server`)、身份目录(`fleet.identity_dir`,由 `fleet-enroll` 注册时生成)和本地测试限制。连上面板之后,面板下发要连接的 Agent 列表;Probe 将列表缓存到同目录的 `agents.json`,面板暂时连不上时靠它继续测量。身份目录里有私钥,请限制访问权限,不要提交到版本控制。
|
||||
|
||||
Agent 测量连接失败时,Probe 按 1、2、4、8、16 秒递增等待,再以 32 秒间隔重试 5 次,之后最多每 60 秒重试一次;连接恢复后退避重新从 1 秒开始。管理端把没有任何已启用 Agent 目标的 Probe 标为「暂停探测」,不会将链路断开误显示为已连接。
|
||||
|
||||
容器节点的运行方式(podman + systemd)和宿主机更新器见 [`../../docs/ops/deployment.md`](../../docs/ops/deployment.md);Probe 也可以用 OCI 镜像 `registry.oeo.one/moshiro/moshiro-probe:<version>`,容器默认读取 `/etc/moshiro/probe.json`,命名 volume 保存身份配置和缓存,镜像以 UID/GID 1654 运行。
|
||||
|
||||
## 构建
|
||||
|
||||
@@ -19,7 +19,7 @@ public sealed record ConnectionEnv(NodeIdentity Identity, string ProbeId, ProbeL
|
||||
/// 每 10 秒把已完成的行切成列块放进发件箱 → 响应按需测试的阶段。会话断开时所有角色停止(凭据属于会话),按退避重连。
|
||||
/// 会话内的所有状态(套接字、发件箱、角色)随会话一起创建和释放。
|
||||
/// </summary>
|
||||
public sealed class AgentConnection
|
||||
public sealed class AgentConnection : IDisposable
|
||||
{
|
||||
private static readonly TimeSpan s_flushInterval = TimeSpan.FromSeconds(ProtocolLimits.ChunkSeconds);
|
||||
private static readonly TimeSpan s_clockInterval = TimeSpan.FromSeconds(30);
|
||||
@@ -30,6 +30,18 @@ public sealed class AgentConnection
|
||||
|
||||
private readonly ConnectionEnv _env;
|
||||
private volatile ConnectionSession? _session;
|
||||
private int _previousQuicSourcePort;
|
||||
private QuicDatagramTransport? _activeDatagrams;
|
||||
private readonly object _handshakeGate = new();
|
||||
private readonly Queue<double> _handshakeSamplesMs = new();
|
||||
private 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)
|
||||
{
|
||||
@@ -45,28 +57,116 @@ public sealed class AgentConnection
|
||||
|
||||
public long ClockOffsetUs => _session?.Clock.OffsetUs ?? 0;
|
||||
|
||||
/// <summary>管理端手动配对时,跳过当前退避等待,马上再拨一次。</summary>
|
||||
public void RetryNow()
|
||||
{
|
||||
if (IsConnected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
_retrySignal.Release();
|
||||
}
|
||||
catch (SemaphoreFullException)
|
||||
{
|
||||
// 已经有一次手动重试排队。
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// 对应线路正在被移除。
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => _retrySignal.Dispose();
|
||||
|
||||
public ProbeQuicLinkHealth QuicHealth()
|
||||
{
|
||||
lock (_handshakeGate)
|
||||
{
|
||||
double? p95 = null;
|
||||
if (_handshakeSamplesMs.Count > 0)
|
||||
{
|
||||
double[] sorted = [.. _handshakeSamplesMs.Order()];
|
||||
p95 = sorted[(int)Math.Ceiling(sorted.Length * 0.95) - 1];
|
||||
}
|
||||
|
||||
QuicTransportSnapshot? transport = Volatile.Read(ref _activeDatagrams)?.GetTransportSnapshot();
|
||||
return new ProbeQuicLinkHealth
|
||||
{
|
||||
AgentId = Endpoint.AgentId,
|
||||
IpVersion = Endpoint.IpVersion,
|
||||
Connected = IsConnected,
|
||||
State = _attemptState,
|
||||
LastError = _lastError,
|
||||
RetryAttempt = _lastRetryAttempt,
|
||||
RetryInMs = _attemptState == ProbeLinkAttemptState.RetryWait && _retryAt is { } retryAt
|
||||
? Math.Max(0, (long)(retryAt - _env.Time.GetUtcNow()).TotalMilliseconds)
|
||||
: null,
|
||||
HandshakeAttempts = _handshakeAttempts,
|
||||
HandshakeSuccesses = _handshakeSuccesses,
|
||||
HandshakeFailures = _handshakeFailures,
|
||||
HandshakeTimeouts = _handshakeTimeouts,
|
||||
HandshakeP95Ms = p95,
|
||||
HandshakeSampleCount = _handshakeSamplesMs.Count,
|
||||
TransportPacketLossRate = transport?.PacketLossRate,
|
||||
TransportPacketsSent = transport?.PacketsSent,
|
||||
TransportCongestionEvents = transport?.CongestionEvents,
|
||||
TransportEcnCongestionEvents = transport?.EcnCongestionEvents,
|
||||
PathMtu = transport?.PathMtu,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private int _lastRetryAttempt;
|
||||
|
||||
public async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
var backoff = new Backoff(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(20));
|
||||
var backoff = new ProbeReconnectBackoff();
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
SetAttempt(ProbeLinkAttemptState.Connecting, null, null, 0);
|
||||
try
|
||||
{
|
||||
string reason = await RunOnceAsync(backoff, ct);
|
||||
SetAttempt(ProbeLinkAttemptState.RetryWait, reason, null, backoff.Attempts);
|
||||
_env.Log.Warn("agent session ended", ("agent", Endpoint.AgentId), ("err", reason));
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or System.Net.Sockets.SocketException or InvalidOperationException or System.Security.Authentication.AuthenticationException or OperationCanceledException or FormatException)
|
||||
catch (Exception ex)
|
||||
{
|
||||
_env.Log.Warn("agent connection failed", ("agent", Endpoint.AgentId), ("err", ex.Message));
|
||||
SetAttempt(ProbeLinkAttemptState.RetryWait, $"{ex.GetType().Name}: {ex.Message}", null, backoff.Attempts + 1);
|
||||
_env.Log.Error("agent connection attempt failed; retry will continue", ("agent", Endpoint.AgentId), ("exception", ex.GetType().FullName), ("err", ex.Message));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(backoff.Next(), _env.Time, ct);
|
||||
TimeSpan delay = backoff.Next();
|
||||
lock (_handshakeGate)
|
||||
{
|
||||
_attemptState = ProbeLinkAttemptState.RetryWait;
|
||||
_lastRetryAttempt = backoff.Attempts;
|
||||
_retryAt = _env.Time.GetUtcNow() + delay;
|
||||
}
|
||||
using var waitCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
Task delayTask = Task.Delay(delay, _env.Time, waitCts.Token);
|
||||
Task retryTask = _retrySignal.WaitAsync(waitCts.Token);
|
||||
Task completed = await Task.WhenAny(delayTask, retryTask);
|
||||
await waitCts.CancelAsync();
|
||||
if (completed == retryTask)
|
||||
{
|
||||
await retryTask;
|
||||
_env.Log.Info("manual link retry requested", ("agent", Endpoint.AgentId));
|
||||
}
|
||||
else
|
||||
{
|
||||
await delayTask;
|
||||
try { await retryTask; }
|
||||
catch (OperationCanceledException) { }
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -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;
|
||||
using var resolveTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(10), _env.Time);
|
||||
@@ -92,10 +192,22 @@ public sealed class AgentConnection
|
||||
byte[] agentKey = Convert.FromBase64String(Endpoint.PublicKey);
|
||||
(PeerSessionInfo info, TlsFrameTransport transport) = await PeerConnector.ConnectAsync(
|
||||
address.ToString(), Endpoint.TcpPort, agentKey, _env.Identity, _env.ProbeId, ProbeApp.Version, 8 << 20, _env.Time, ct);
|
||||
var remote = (IPEndPoint)info.Remote!;
|
||||
var data = new DataSession(info.Credentials.SessionIdValue, info.Credentials.Key);
|
||||
using var udp = new UdpMux(new SingleSession(data), null, _env.Clock, new DataPlaneCounters(), _env.Log, remote.AddressFamily == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any, []);
|
||||
udp.Bind();
|
||||
var remote = (IPEndPoint)info.Remote!;
|
||||
IReadOnlyList<int> quicPorts = Endpoint.EffectiveQuicPorts;
|
||||
int quicPort = quicPorts.Count == 0 ? 7443 : quicPorts[0];
|
||||
await using var datagrams = new QuicDatagramTransport(
|
||||
data,
|
||||
_env.Clock,
|
||||
new DataPlaneCounters(),
|
||||
_env.Log,
|
||||
new IPEndPoint(remote.Address, quicPort),
|
||||
agentKey,
|
||||
Volatile.Read(ref _previousQuicSourcePort));
|
||||
Volatile.Write(ref _activeDatagrams, datagrams);
|
||||
datagrams.Bind();
|
||||
using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
|
||||
var outbox = new Outbox(8 << 20);
|
||||
var clock = new ClockEstimator();
|
||||
var shipper = new FragmentShipper(outbox, new NodeId(Endpoint.AgentId), new ProbeId(_env.ProbeId), clock, _env.Time);
|
||||
@@ -104,7 +216,7 @@ public sealed class AgentConnection
|
||||
Clock = _env.Clock,
|
||||
Log = _env.Log,
|
||||
Session = () => data,
|
||||
Udp = udp,
|
||||
Datagrams = datagrams,
|
||||
PeerAddress = remote.Address,
|
||||
PeerSpkiSha256 = NodeIdentity.Fingerprint(agentKey),
|
||||
KnockKey = Knock.DeriveKey(_env.Identity, agentKey),
|
||||
@@ -116,16 +228,57 @@ public sealed class AgentConnection
|
||||
var rpc = new RpcSession<ConnectionSession>(transport, s_router, session, _env.Time);
|
||||
rpc.OnBinary = (_, _) => ValueTask.CompletedTask;
|
||||
|
||||
using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
Task run = rpc.RunAsync(cts.Token);
|
||||
Task receive = udp.RunAsync(cts.Token);
|
||||
Task receive = datagrams.RunAsync(cts.Token);
|
||||
Task pump = PumpAsync(rpc, outbox, cts.Token);
|
||||
try
|
||||
{
|
||||
// 先同步时钟(块头里要带偏移);Agent 随后推送线路任务集
|
||||
await SyncClockAsync(rpc, clock, rounds: 5, cts.Token);
|
||||
lock (_handshakeGate)
|
||||
{
|
||||
_handshakeAttempts++;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
SetAttempt(ProbeLinkAttemptState.Connecting, null, null, 0);
|
||||
await datagrams.ConnectAsync(cts.Token);
|
||||
lock (_handshakeGate)
|
||||
{
|
||||
_handshakeSuccesses++;
|
||||
if (datagrams.HandshakeDurationMs is { } elapsed)
|
||||
{
|
||||
_handshakeSamplesMs.Enqueue(elapsed);
|
||||
while (_handshakeSamplesMs.Count > 128)
|
||||
{
|
||||
_handshakeSamplesMs.Dequeue();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (!(ex is OperationCanceledException && cts.IsCancellationRequested))
|
||||
{
|
||||
lock (_handshakeGate)
|
||||
{
|
||||
_handshakeFailures++;
|
||||
if (ex is TimeoutException)
|
||||
{
|
||||
_handshakeTimeouts++;
|
||||
}
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
if (datagrams.BoundPorts.Count > 0)
|
||||
{
|
||||
Volatile.Write(ref _previousQuicSourcePort, datagrams.BoundPorts[0]);
|
||||
}
|
||||
|
||||
backoff.Reset();
|
||||
_retrySignal.Wait(0, CancellationToken.None); // 若“立即重试”恰好赶在本次拨号成功前到达,本次成功已满足请求。
|
||||
_session = session;
|
||||
SetAttempt(ProbeLinkAttemptState.Connected, null, null, 0);
|
||||
_env.Log.Info("agent session up", ("agent", Endpoint.AgentId), ("offset_us", clock.OffsetUs));
|
||||
Task maintenance = MaintainAsync(rpc, session, clock, cts.Token);
|
||||
Task finished = await Task.WhenAny(run, receive, pump, maintenance);
|
||||
@@ -136,6 +289,7 @@ public sealed class AgentConnection
|
||||
finally
|
||||
{
|
||||
_session = null;
|
||||
Volatile.Write(ref _activeDatagrams, null);
|
||||
await cts.CancelAsync();
|
||||
await session.StopAllAsync();
|
||||
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)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -73,14 +73,15 @@ public sealed class AgentDirectory
|
||||
_trust.Update(result.TrustChain);
|
||||
foreach (AgentEndpoint agent in result.Agents)
|
||||
{
|
||||
List<int> ports = agent.QuicPorts.Count > 0 ? agent.QuicPorts : agent.LegacyUdpPorts ?? [];
|
||||
if (!string.IsNullOrWhiteSpace(agent.Host))
|
||||
{
|
||||
next.Add(new KnownAgent { AgentId = agent.AgentId.Value, Host = agent.Host, TcpPort = agent.TcpPort, PublicKey = agent.PublicKey });
|
||||
next.Add(new KnownAgent { AgentId = agent.AgentId.Value, Host = agent.Host, TcpPort = agent.TcpPort, QuicPorts = [.. ports], PublicKey = agent.PublicKey });
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(agent.Ipv6Host))
|
||||
{
|
||||
next.Add(new KnownAgent { AgentId = agent.AgentId.Value, Host = agent.Ipv6Host, TcpPort = agent.TcpPort, PublicKey = agent.PublicKey, IpVersion = IpVersions.V6 });
|
||||
next.Add(new KnownAgent { AgentId = agent.AgentId.Value, Host = agent.Ipv6Host, TcpPort = agent.TcpPort, QuicPorts = [.. ports], PublicKey = agent.PublicKey, IpVersion = IpVersions.V6 });
|
||||
}
|
||||
}
|
||||
lock (_gate)
|
||||
@@ -111,5 +112,5 @@ public sealed class AgentDirectory
|
||||
a.Count == b.Count && a.Zip(b).All(p => Equal(p.First, p.Second));
|
||||
|
||||
public static bool Equal(KnownAgent a, KnownAgent b) =>
|
||||
a.AgentId == b.AgentId && a.IpVersion == b.IpVersion && a.Host == b.Host && a.TcpPort == b.TcpPort && a.PublicKey == b.PublicKey;
|
||||
a.AgentId == b.AgentId && a.IpVersion == b.IpVersion && a.Host == b.Host && a.TcpPort == b.TcpPort && a.QuicPorts.SequenceEqual(b.QuicPorts) && a.PublicKey == b.PublicKey;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace MoShiro.Probe;
|
||||
|
||||
/// <summary>Reconnect cadence for a single Probe-to-Agent path: 1, 2, 4, 8, 16, then five 32-second waits, then once per minute.</summary>
|
||||
public sealed class ProbeReconnectBackoff
|
||||
{
|
||||
private static readonly TimeSpan[] s_delays =
|
||||
[
|
||||
TimeSpan.FromSeconds(1),
|
||||
TimeSpan.FromSeconds(2),
|
||||
TimeSpan.FromSeconds(4),
|
||||
TimeSpan.FromSeconds(8),
|
||||
TimeSpan.FromSeconds(16),
|
||||
TimeSpan.FromSeconds(32),
|
||||
TimeSpan.FromSeconds(32),
|
||||
TimeSpan.FromSeconds(32),
|
||||
TimeSpan.FromSeconds(32),
|
||||
TimeSpan.FromSeconds(32),
|
||||
];
|
||||
|
||||
private int _attempt;
|
||||
|
||||
public int Attempts => Volatile.Read(ref _attempt);
|
||||
|
||||
public TimeSpan Next()
|
||||
{
|
||||
int attempt = Interlocked.Increment(ref _attempt) - 1;
|
||||
return attempt < s_delays.Length ? s_delays[attempt] : TimeSpan.FromMinutes(1);
|
||||
}
|
||||
|
||||
public void Reset() => Interlocked.Exchange(ref _attempt, 0);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using Fleet.Core;
|
||||
using Fleet.Hosting;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using Fleet.Identity;
|
||||
using Fleet.Link;
|
||||
@@ -19,6 +20,7 @@ public sealed class ProbeApp : IDisposable
|
||||
|
||||
private readonly ConnectionEnv _env;
|
||||
private readonly Dictionary<string, Running> _connections = [];
|
||||
private readonly ConcurrentDictionary<string, long> _pendingRetries = new(StringComparer.Ordinal);
|
||||
private readonly SemaphoreSlim _changed = new(0);
|
||||
private readonly INodeLog _log;
|
||||
private readonly FleetLink _link;
|
||||
@@ -45,7 +47,13 @@ public sealed class ProbeApp : IDisposable
|
||||
NodeId = identity.NodeId,
|
||||
NodeVersion = Version,
|
||||
Capabilities = [MoShiroCapabilities.Base],
|
||||
HeartbeatExtra = () => JsonSerializer.SerializeToElement(new ProbeHeartbeatExtra { Update = update?.Status(), 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),
|
||||
};
|
||||
var list = new StateHandler<ProbeAgentList>(
|
||||
@@ -57,6 +65,11 @@ public sealed class ProbeApp : IDisposable
|
||||
return ValueTask.FromResult(new StateSyncResult { AppliedVersion = version });
|
||||
});
|
||||
var router = new RpcRouter<LinkContext>(RpcSide.Node, MoShiroMethodTable.Table, NodeRoles.Probe)
|
||||
.OnNotify(MoShiroMethods.ProbeLinkRetry, MoShiroJsonContext.Default.ProbeLinkRetryParams, (_, retry, _) =>
|
||||
{
|
||||
RetryAgent(retry.AgentId.Value);
|
||||
return ValueTask.CompletedTask;
|
||||
})
|
||||
.OnNotify(MoShiroMethods.UpdateOffer, MoShiroJsonContext.Default.UpdateOfferParams, (_, offer, _) =>
|
||||
{
|
||||
update?.Offer(offer);
|
||||
@@ -101,8 +114,58 @@ public sealed class ProbeApp : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private void RetryAgent(string agentId, bool queueIfMissing = true)
|
||||
{
|
||||
AgentConnection[] matches;
|
||||
lock (_connections)
|
||||
{
|
||||
matches = [.. _connections.Values.Select(r => r.Connection).Where(c => c.Endpoint.AgentId == agentId)];
|
||||
}
|
||||
|
||||
if (matches.Length == 0)
|
||||
{
|
||||
if (queueIfMissing)
|
||||
{
|
||||
_pendingRetries[agentId] = _time.GetUtcNow().AddSeconds(30).ToUnixTimeMilliseconds();
|
||||
}
|
||||
_log.Warn("manual link retry requested but no matching Agent path is configured", ("agent", agentId));
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingRetries.TryRemove(agentId, out _);
|
||||
foreach (AgentConnection connection in matches)
|
||||
{
|
||||
connection.RetryNow();
|
||||
}
|
||||
}
|
||||
|
||||
private void RetryPending()
|
||||
{
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
foreach ((string agentId, long expiresAt) in _pendingRetries.ToArray())
|
||||
{
|
||||
if (expiresAt <= now)
|
||||
{
|
||||
_pendingRetries.TryRemove(agentId, out _);
|
||||
}
|
||||
else
|
||||
{
|
||||
RetryAgent(agentId, queueIfMissing: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<ProbeQuicLinkHealth> SnapshotQuicLinks()
|
||||
{
|
||||
lock (_connections)
|
||||
{
|
||||
return [.. _connections.Values.Select(r => r.Connection.QuicHealth())];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
_log.Info("moshiro probe starting", MoShiro.Protocol.BuildInfo.Fields);
|
||||
if (_env.Directory.Agents.Count == 0)
|
||||
{
|
||||
_log.Info("no agents known yet, waiting for the panel to send the list");
|
||||
@@ -115,6 +178,7 @@ public sealed class ProbeApp : IDisposable
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
await ReconcileAsync(_env.Directory.Agents, ct);
|
||||
RetryPending();
|
||||
await _changed.WaitAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -241,6 +305,10 @@ public sealed class ProbeApp : IDisposable
|
||||
Dispose();
|
||||
}
|
||||
|
||||
public void Dispose() => _cts.Dispose();
|
||||
public void Dispose()
|
||||
{
|
||||
Connection.Dispose();
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,15 @@ public sealed class KnownAgent
|
||||
[JsonPropertyName("tcp_port")]
|
||||
public int TcpPort { get; set; }
|
||||
|
||||
[JsonPropertyName("quic_ports")]
|
||||
public List<int> QuicPorts { get; set; } = [7443];
|
||||
|
||||
[JsonPropertyName("udp_ports")]
|
||||
public List<int>? LegacyUdpPorts { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public IReadOnlyList<int> EffectiveQuicPorts => LegacyUdpPorts ?? QuicPorts;
|
||||
|
||||
/// <summary>Agent 的 TLS 证书公钥(SPKI,base64):证书按它的 SHA-256 指纹固定,敲门密钥由它派生。</summary>
|
||||
[JsonPropertyName("public_key")]
|
||||
public string PublicKey { get; set; } = "";
|
||||
|
||||
@@ -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 =
|
||||
[
|
||||
new("udp_echo", (e, s) => new UdpEchoDialer(e, s), (e, s) => new UdpEchoListener(e, s)),
|
||||
new("udp_echo", (e, s) => new DatagramEchoDialer(e, s), (e, s) => new DatagramEchoListener(e, s)),
|
||||
new("tcp_echo", (e, s) => new TcpEchoDialer(e, s), (e, s) => new TcpEchoListener(e, s)),
|
||||
new("udp_train", (e, s) => new TrainDialer(e, s), (e, s) => new TrainListener(e, s), OnDemandOnly: true, Directions: ["rev"]),
|
||||
new("tcp_bulk", (e, s) => new TcpBulkDialer(e, s), (e, s) => new TcpBulkListener(e, s), OnDemandOnly: true, Directions: ["fwd", "rev"]),
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace MoShiro.Measurement;
|
||||
internal sealed class EchoRows
|
||||
{
|
||||
/// <summary>UDP:没有在这段时间内回来的包就是丢了。</summary>
|
||||
public const long UdpGraceUs = 2_000_000;
|
||||
public const long DatagramGraceUs = 2_000_000;
|
||||
|
||||
/// <summary>
|
||||
/// TCP 不会丢包,只会延迟(重传、队头阻塞):一次几秒的中断之后,积压的回显会陆续回来,RTT 就是那几秒。
|
||||
@@ -65,26 +65,27 @@ internal sealed class EchoRows
|
||||
/// <summary>
|
||||
/// UDP 回显,拨号方(Probe):按声明的速率发包,t1 在发出时记录,回复到达时回填 t4。回复丢失的行 t4 缺失(Agent 一侧再补 t2/t3)。
|
||||
/// </summary>
|
||||
public sealed class UdpEchoDialer : BlockBase
|
||||
public sealed class DatagramEchoDialer : BlockBase
|
||||
{
|
||||
private readonly EchoRows _rows;
|
||||
|
||||
public UdpEchoDialer(BlockEnv env, BlockSpec spec)
|
||||
public DatagramEchoDialer(BlockEnv env, BlockSpec spec)
|
||||
: base(env, spec)
|
||||
{
|
||||
_rows = new EchoRows(spec.Kind, 16384, EchoRows.UdpGraceUs, settleOnReply: false);
|
||||
_rows = new EchoRows(spec.Kind, 16384, EchoRows.DatagramGraceUs, settleOnReply: false);
|
||||
}
|
||||
|
||||
public override long Produced => _rows.Count;
|
||||
|
||||
public override async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
UdpMux udp = Env.Udp!;
|
||||
QuicDatagramTransport datagrams = Env.Datagrams!;
|
||||
DataSession session = Env.Session() ?? throw new BlockFaultException("no data session");
|
||||
await datagrams.WaitUntilConnectedAsync(ct);
|
||||
IPEndPoint target = PeerEndpoint(Spec.Port);
|
||||
int size = (int)Spec.Int("size");
|
||||
byte[] packet = new byte[size];
|
||||
udp.Register(Spec.StreamId, OnPacket);
|
||||
datagrams.Register(Spec.StreamId, OnPacket);
|
||||
try
|
||||
{
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1.0 / Spec.Int("rate_hz")), Env.Time);
|
||||
@@ -92,8 +93,8 @@ public sealed class UdpEchoDialer : BlockBase
|
||||
{
|
||||
long t1 = Env.Clock.NowUs();
|
||||
uint seq = _rows.Add(size, t1);
|
||||
UdpPacket.Write(packet, new UdpHeader(UdpPacketType.EchoRequest, session.SessionId, Spec.StreamId, seq, t1), session.Key);
|
||||
udp.Send(target, packet);
|
||||
MeasurementDatagram.Write(packet, new MeasurementDatagramHeader(MeasurementDatagramType.EchoRequest, session.SessionId, Spec.StreamId, seq, t1), session.Key);
|
||||
datagrams.Send(session, target, packet);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
@@ -102,7 +103,7 @@ public sealed class UdpEchoDialer : BlockBase
|
||||
}
|
||||
finally
|
||||
{
|
||||
udp.Unregister(Spec.StreamId);
|
||||
datagrams.Unregister(Spec.StreamId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,9 +119,9 @@ public sealed class UdpEchoDialer : BlockBase
|
||||
return 1;
|
||||
}
|
||||
|
||||
private void OnPacket(in UdpDelivery delivery)
|
||||
private void OnPacket(in QuicDatagramDelivery delivery)
|
||||
{
|
||||
if (delivery.Header.Type == UdpPacketType.EchoReply)
|
||||
if (delivery.Header.Type == MeasurementDatagramType.EchoReply)
|
||||
{
|
||||
_rows.Reply(delivery.Header.Seq, delivery.ReceiveUs);
|
||||
}
|
||||
@@ -128,11 +129,11 @@ public sealed class UdpEchoDialer : BlockBase
|
||||
}
|
||||
|
||||
/// <summary>UDP 回显,监听方(Agent):校验后立即回复一个等长的包,记录 t2(到达)和 t3(回复发出),按序号留给合并。</summary>
|
||||
public sealed class UdpEchoListener : BlockBase
|
||||
public sealed class DatagramEchoListener : BlockBase
|
||||
{
|
||||
private readonly RowStore _records;
|
||||
|
||||
public UdpEchoListener(BlockEnv env, BlockSpec spec)
|
||||
public DatagramEchoListener(BlockEnv env, BlockSpec spec)
|
||||
: base(env, spec)
|
||||
{
|
||||
_records = new RowStore(spec.Kind.FindStream("msg")!, Side.Agent, 32768);
|
||||
@@ -142,26 +143,26 @@ public sealed class UdpEchoListener : BlockBase
|
||||
|
||||
public override async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
Env.Udp!.Register(Spec.StreamId, OnPacket);
|
||||
Env.Datagrams!.Register(Spec.StreamId, OnPacket);
|
||||
try
|
||||
{
|
||||
await WaitUntilCancelledAsync(ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Env.Udp.Unregister(Spec.StreamId);
|
||||
Env.Datagrams.Unregister(Spec.StreamId);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPacket(in UdpDelivery delivery)
|
||||
private void OnPacket(in QuicDatagramDelivery delivery)
|
||||
{
|
||||
if (delivery.Header.Type != UdpPacketType.EchoRequest)
|
||||
if (delivery.Header.Type != MeasurementDatagramType.EchoRequest)
|
||||
{
|
||||
return; // Data:探活包,只用来保持 NAT 映射和让准入策略学到源地址
|
||||
}
|
||||
|
||||
long t2 = delivery.ReceiveUs;
|
||||
delivery.ReplyAs(UdpPacketType.EchoReply);
|
||||
delivery.ReplyAs(MeasurementDatagramType.EchoReply);
|
||||
Span<long> row = [t2, Env.Clock.NowUs()];
|
||||
_records.SetRow(delivery.Header.Seq, row);
|
||||
}
|
||||
|
||||
@@ -30,11 +30,11 @@ public sealed class TrainListener : BlockBase
|
||||
public override async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
// Probe 开始前发来的探活包(Data)要被接受,准入策略才能据此学到它当前的源地址(NAT 映射);它们本身没有内容
|
||||
Env.Udp!.Register(Spec.StreamId, static (in UdpDelivery _) => { });
|
||||
Env.Datagrams!.Register(Spec.StreamId, static (in QuicDatagramDelivery _) => { });
|
||||
try
|
||||
{
|
||||
DataSession session = await WaitForPeerAsync(ct);
|
||||
IPEndPoint target = session.UdpEndpoint!;
|
||||
IPEndPoint target = session.DatagramRemoteEndPoint!;
|
||||
TimeSpan duration = TimeSpan.FromSeconds(Spec.Int("duration_s"));
|
||||
var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var thread = new Thread(() =>
|
||||
@@ -58,7 +58,7 @@ public sealed class TrainListener : BlockBase
|
||||
}
|
||||
finally
|
||||
{
|
||||
Env.Udp.Unregister(Spec.StreamId);
|
||||
Env.Datagrams.Unregister(Spec.StreamId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ public sealed class TrainListener : BlockBase
|
||||
while (true)
|
||||
{
|
||||
DataSession? session = Env.Session();
|
||||
if (session?.UdpEndpoint is not null)
|
||||
if (session?.DatagramRemoteEndPoint is not null)
|
||||
{
|
||||
return session;
|
||||
}
|
||||
@@ -120,8 +120,8 @@ public sealed class TrainListener : BlockBase
|
||||
for (; seq < due; seq++)
|
||||
{
|
||||
long sendUs = Env.Clock.NowUs();
|
||||
UdpPacket.Write(packet, new UdpHeader(UdpPacketType.Data, session.SessionId, Spec.StreamId, seq, sendUs), session.Key);
|
||||
Env.Udp!.Send(target, packet);
|
||||
MeasurementDatagram.Write(packet, new MeasurementDatagramHeader(MeasurementDatagramType.Data, session.SessionId, Spec.StreamId, seq, sendUs), session.Key);
|
||||
Env.Datagrams!.Send(session, target, packet);
|
||||
Span<long> row = [size, sendUs];
|
||||
_records.SetRow(seq, row);
|
||||
Interlocked.Exchange(ref _sent, seq + 1L);
|
||||
@@ -159,18 +159,19 @@ public sealed class TrainDialer : BlockBase
|
||||
|
||||
public override async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
UdpMux udp = Env.Udp!;
|
||||
QuicDatagramTransport datagrams = Env.Datagrams!;
|
||||
DataSession session = Env.Session() ?? throw new BlockFaultException("no data session");
|
||||
await datagrams.WaitUntilConnectedAsync(ct);
|
||||
_startUs = Env.Clock.NowUs();
|
||||
udp.Register(Spec.StreamId, OnPacket);
|
||||
datagrams.Register(Spec.StreamId, OnPacket);
|
||||
try
|
||||
{
|
||||
byte[] hello = new byte[UdpPacket.MinBytes + 20];
|
||||
byte[] hello = new byte[MeasurementDatagram.MinBytes + 20];
|
||||
IPEndPoint target = PeerEndpoint(Spec.Port);
|
||||
for (uint seq = 0; seq < 3; seq++)
|
||||
{
|
||||
UdpPacket.Write(hello, new UdpHeader(UdpPacketType.Data, session.SessionId, Spec.StreamId, seq, Env.Clock.NowUs()), session.Key);
|
||||
udp.Send(target, hello);
|
||||
MeasurementDatagram.Write(hello, new MeasurementDatagramHeader(MeasurementDatagramType.Data, session.SessionId, Spec.StreamId, seq, Env.Clock.NowUs()), session.Key);
|
||||
datagrams.Send(session, target, hello);
|
||||
}
|
||||
|
||||
await WaitUntilCancelledAsync(ct);
|
||||
@@ -178,7 +179,7 @@ public sealed class TrainDialer : BlockBase
|
||||
finally
|
||||
{
|
||||
_endUs = Env.Clock.NowUs();
|
||||
udp.Unregister(Spec.StreamId);
|
||||
datagrams.Unregister(Spec.StreamId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,10 +229,10 @@ public sealed class TrainDialer : BlockBase
|
||||
private static System.Text.Json.JsonElement Number(long value) =>
|
||||
System.Text.Json.JsonDocument.Parse(value.ToString(System.Globalization.CultureInfo.InvariantCulture)).RootElement.Clone();
|
||||
|
||||
private void OnPacket(in UdpDelivery delivery)
|
||||
private void OnPacket(in QuicDatagramDelivery delivery)
|
||||
{
|
||||
long seq = delivery.Header.Seq;
|
||||
if (delivery.Header.Type == UdpPacketType.Data && seq < _maxSeq && !_received.IsSet(seq, _tRecv))
|
||||
if (delivery.Header.Type == MeasurementDatagramType.Data && seq < _maxSeq && !_received.IsSet(seq, _tRecv))
|
||||
{
|
||||
_received.Set(seq, _tRecv, delivery.ReceiveUs); // 只记第一次到达(重复的包不覆盖)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ namespace MoShiro.Measurement;
|
||||
/// </summary>
|
||||
public class DataSession
|
||||
{
|
||||
private IDatagramConnection? _datagramConnection;
|
||||
|
||||
public DataSession(long sessionId, byte[] key)
|
||||
{
|
||||
SessionId = sessionId;
|
||||
@@ -21,13 +23,26 @@ public class DataSession
|
||||
|
||||
public byte[] Key { get; }
|
||||
|
||||
public IDatagramConnection? DatagramConnection => Volatile.Read(ref _datagramConnection);
|
||||
|
||||
public void BindDatagramConnection(IDatagramConnection connection) => Volatile.Write(ref _datagramConnection, connection);
|
||||
|
||||
/// <summary>对端最近一次通过认证的 UDP 源地址(监听方把单向数据发往这里;拨号方不需要)。</summary>
|
||||
public virtual IPEndPoint? UdpEndpoint => null;
|
||||
public virtual IPEndPoint? DatagramRemoteEndPoint => null;
|
||||
|
||||
/// <summary>TCP 首帧的随机数只能用一次。拨号方不接受连接,所以默认放行。</summary>
|
||||
public virtual bool UseNonce(ReadOnlySpan<byte> nonce) => true;
|
||||
}
|
||||
|
||||
/// <summary>Unreliable QUIC datagram sender bound to one authenticated measurement session.</summary>
|
||||
public interface IDatagramConnection
|
||||
{
|
||||
bool Send(ReadOnlySpan<byte> datagram);
|
||||
}
|
||||
|
||||
/// <summary>Transport-layer QUIC counters. These are separate from application Datagram delivery/loss.</summary>
|
||||
public sealed record QuicTransportSnapshot(double? PacketLossRate, ulong PacketsSent, uint CongestionEvents, uint EcnCongestionEvents, ushort PathMtu);
|
||||
|
||||
public interface IDataSessions
|
||||
{
|
||||
DataSession? Find(long sessionId);
|
||||
@@ -60,7 +75,7 @@ public enum Admission
|
||||
public interface IAdmission
|
||||
{
|
||||
/// <summary>UDP 包:<paramref name="source"/> 是认证通过的源地址(实现可以据此学习 NAT 重绑定)。</summary>
|
||||
Admission AdmitPacket(DataSession session, in UdpHeader header, SocketAddress source, long nowUs);
|
||||
Admission AdmitDatagram(DataSession session, in MeasurementDatagramHeader header, SocketAddress source, long nowUs);
|
||||
|
||||
/// <summary>TCP 回显的一条消息(只限速)。</summary>
|
||||
bool AdmitMessage(DataSession session, uint streamId, long nowUs);
|
||||
@@ -201,7 +216,7 @@ public sealed class BlockEnv
|
||||
/// <summary>当前的数据面会话(拨号方固定;监听方是 Agent 当前的 Probe 会话,可能没有)。</summary>
|
||||
public required Func<DataSession?> Session { get; init; }
|
||||
|
||||
public UdpMux? Udp { get; init; }
|
||||
public QuicDatagramTransport? Datagrams { get; init; }
|
||||
|
||||
/// <summary>监听方的准入策略(限速等);拨号方为 null。</summary>
|
||||
public IAdmission? Admission { get; init; }
|
||||
@@ -238,7 +253,7 @@ public sealed class BlockEnv
|
||||
Clock = Clock,
|
||||
Log = Log,
|
||||
Session = () => session,
|
||||
Udp = Udp,
|
||||
Datagrams = Datagrams,
|
||||
Admission = Admission,
|
||||
Counters = Counters,
|
||||
TcpListen = TcpListen,
|
||||
|
||||
@@ -17,7 +17,7 @@ public sealed class NodeLimits
|
||||
public int MaxConcurrentTests { get; init; } = 1;
|
||||
|
||||
/// <summary>任务参数里的 <c>port</c> 只能取这些端口(本机配置的 listen 列表)。</summary>
|
||||
public IReadOnlyList<int> AllowedUdpPorts { get; init; } = [7443];
|
||||
public IReadOnlyList<int> AllowedQuicPorts { get; init; } = [7443];
|
||||
|
||||
public IReadOnlyList<int> AllowedTcpPorts { get; init; } = [7444];
|
||||
}
|
||||
@@ -143,7 +143,7 @@ public sealed class BudgetGuard
|
||||
}
|
||||
|
||||
int port = (int)values.GetInt("port");
|
||||
return kind.Blocks.Transport == "udp" ? _limits.AllowedUdpPorts.Contains(port) : _limits.AllowedTcpPorts.Contains(port);
|
||||
return kind.Blocks.Transport == "quic_datagram" ? _limits.AllowedQuicPorts.Contains(port) : _limits.AllowedTcpPorts.Contains(port);
|
||||
}
|
||||
|
||||
private static long EstimatePps(string kindId, ParamValues values) => kindId switch
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace MoShiro.Protocol;
|
||||
|
||||
/// <summary>构建信息的人能读的形式(启动时打进日志);常量部分由 MoShiro.Protocol.csproj 的 WriteBuildInfo 在构建时生成。</summary>
|
||||
public static partial class BuildInfo
|
||||
{
|
||||
/// <summary>版本、tag、提交、构建时间的字段,没有的(本地构建不设环境变量)写 unknown。</summary>
|
||||
public static (string Key, object? Value)[] Fields { get; } =
|
||||
[
|
||||
("version", Or(Version)),
|
||||
("tag", Or(Tag)),
|
||||
("commit", Or(Commit)),
|
||||
("built_at", Or(BuiltAt)),
|
||||
];
|
||||
|
||||
/// <summary>一行摘要,例如:0.4.1-dev.2-a3338e1 (tag v0.4.1-dev.2-a3338e1, commit a3338e1b2c, built 2026-10-09T03:00:00Z)。</summary>
|
||||
public static string Summary => $"{Or(Version)} (tag {Or(Tag)}, commit {Or(Commit)}, built {Or(BuiltAt)})";
|
||||
|
||||
private static string Or(string value) => string.IsNullOrWhiteSpace(value) ? "unknown" : value;
|
||||
}
|
||||
@@ -27,10 +27,10 @@ public static class Kinds
|
||||
|
||||
public static readonly KindSpec UdpEcho = new(
|
||||
"udp_echo",
|
||||
"UDP echo",
|
||||
"Fixed-rate UDP echo with four timestamps per packet.",
|
||||
"QUIC Datagram echo",
|
||||
"Fixed-rate QUIC Datagram echo with four timestamps per packet.",
|
||||
Scope.Line,
|
||||
new BlockSet("udp", "fixed", "fixed", "echo", []),
|
||||
new BlockSet("quic_datagram", "fixed", "fixed", "echo", []),
|
||||
[
|
||||
P.Int("rate_hz", 10, 1, 200, "Hz"),
|
||||
P.Int("size", 64, 44, 1400, "B"),
|
||||
@@ -41,10 +41,10 @@ public static class Kinds
|
||||
|
||||
public static readonly KindSpec UdpTrain = new(
|
||||
"udp_train",
|
||||
"UDP train",
|
||||
"Fixed-rate or laddered one-way UDP stream (Agent to Probe) for loss and jitter under load.",
|
||||
"QUIC Datagram train",
|
||||
"Fixed-rate or laddered one-way QUIC Datagram stream (Agent to Probe) for loss and delay variation under load.",
|
||||
Scope.Line,
|
||||
new BlockSet("udp", "ladder", "fixed", "sink", []),
|
||||
new BlockSet("quic_datagram", "ladder", "fixed", "sink", []),
|
||||
[
|
||||
P.Number("mbps", 10, 0.1, 1000, "Mbit/s"),
|
||||
P.Int("size", 1250, 200, 1400, "B"),
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<ItemGroup>
|
||||
<BuildInfoLine Include="namespace MoShiro.Protocol%3B" />
|
||||
<BuildInfoLine Include="/// <summary>这个程序是什么时候、从哪个版本构建出来的(构建时生成)。</summary>" />
|
||||
<BuildInfoLine Include="public static class BuildInfo" />
|
||||
<BuildInfoLine Include="public static partial class BuildInfo" />
|
||||
<BuildInfoLine Include="{" />
|
||||
<BuildInfoLine Include=" public const string Version = "$(Version)"%3B" />
|
||||
<BuildInfoLine Include=" public const string Tag = "$(MOSHIRO_TAG)"%3B" />
|
||||
|
||||
@@ -30,6 +30,7 @@ public static class MoShiroMethodTable
|
||||
MethodSpec.Request(MoShiroMethods.TestRun, RpcSide.Server, NodeRoles.Agent),
|
||||
MethodSpec.Request(MoShiroMethods.TestCancel, 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.UpdateApply, RpcSide.Server),
|
||||
MethodSpec.Notification(MoShiroMethods.Schedule, RpcSide.Server),
|
||||
|
||||
@@ -10,6 +10,7 @@ public static class MoShiroMethods
|
||||
public const string TestRun = "moshiro.test.run";
|
||||
public const string TestCancel = "moshiro.test.cancel";
|
||||
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 Event = "moshiro.event";
|
||||
public const string Host = "moshiro.host";
|
||||
@@ -98,6 +99,13 @@ public sealed class ProbeDisconnectParams
|
||||
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
|
||||
{
|
||||
[JsonPropertyName("test_id")]
|
||||
@@ -146,8 +154,11 @@ public sealed class AgentEndpoint
|
||||
[JsonPropertyName("tcp_port")]
|
||||
public int TcpPort { get; set; }
|
||||
|
||||
[JsonPropertyName("quic_ports")]
|
||||
public List<int> QuicPorts { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("udp_ports")]
|
||||
public List<int> UdpPorts { get; set; } = [];
|
||||
public List<int> LegacyUdpPorts { get; set; } = [];
|
||||
|
||||
/// <summary>Agent 面向 Probe 的 TLS 证书公钥(SPKI,base64):证书按它的 SHA-256 指纹固定,敲门密钥由它和 Probe 的身份密钥派生。</summary>
|
||||
[JsonPropertyName("public_key")]
|
||||
@@ -175,8 +186,11 @@ public sealed class AgentEnrollMeta
|
||||
[JsonPropertyName("probe_tcp_port")]
|
||||
public int ProbeTcpPort { get; set; } = 7443;
|
||||
|
||||
[JsonPropertyName("probe_quic_ports")]
|
||||
public List<int> ProbeQuicPorts { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("probe_udp_ports")]
|
||||
public List<int> ProbeUdpPorts { get; set; } = [7443];
|
||||
public List<int>? LegacyProbeUdpPorts { get; set; }
|
||||
|
||||
[JsonPropertyName("public_host")]
|
||||
public string? PublicHost { get; set; }
|
||||
@@ -296,6 +310,13 @@ public sealed class UpdateStatus
|
||||
/// <summary>Probe 的 <c>fleet.heartbeat</c> 附加字段:更新器的状态。</summary>
|
||||
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")]
|
||||
public Ipv6Diagnostic? Ipv6 { get; set; }
|
||||
|
||||
@@ -303,6 +324,76 @@ public sealed class ProbeHeartbeatExtra
|
||||
public UpdateStatus? Update { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Probe's rolling QUIC connection health snapshot for one Agent/IP path.</summary>
|
||||
public sealed class ProbeQuicLinkHealth
|
||||
{
|
||||
[JsonPropertyName("agent_id")]
|
||||
public string AgentId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("ip_version")]
|
||||
public string IpVersion { get; set; } = IpVersions.V4;
|
||||
|
||||
[JsonPropertyName("connected")]
|
||||
public bool Connected { get; set; }
|
||||
|
||||
[JsonPropertyName("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>
|
||||
public sealed class ProbeHelloMeta
|
||||
{
|
||||
@@ -345,8 +436,11 @@ public sealed class AgentHelloMeta
|
||||
[JsonPropertyName("probe_tcp_port")]
|
||||
public int? ProbeTcpPort { get; set; }
|
||||
|
||||
[JsonPropertyName("probe_quic_ports")]
|
||||
public List<int>? ProbeQuicPorts { get; set; }
|
||||
|
||||
[JsonPropertyName("probe_udp_ports")]
|
||||
public List<int>? ProbeUdpPorts { get; set; }
|
||||
public List<int>? LegacyProbeUdpPorts { get; set; }
|
||||
|
||||
[JsonPropertyName("local_limits")]
|
||||
public LocalLimits? LocalLimits { get; set; }
|
||||
@@ -420,6 +514,10 @@ public sealed class ConnectedProbe
|
||||
/// <summary><c>fleet.heartbeat</c> 的附加字段:只有进程内的计数,没有主机信息。</summary>
|
||||
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")]
|
||||
public Ipv6Diagnostic? Ipv6 { get; set; }
|
||||
|
||||
@@ -482,8 +580,11 @@ public sealed class LineTaskSet
|
||||
public List<LineTask> Tasks { get; set; } = [];
|
||||
|
||||
/// <summary>Agent 的数据面端口(Probe 把包发到这里)。</summary>
|
||||
[JsonPropertyName("quic_ports")]
|
||||
public List<int> QuicPorts { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("udp_ports")]
|
||||
public List<int> UdpPorts { get; set; } = [];
|
||||
public List<int> LegacyUdpPorts { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("tcp_ports")]
|
||||
public List<int> TcpPorts { get; set; } = [];
|
||||
@@ -583,6 +684,7 @@ public static class FragmentKeys
|
||||
[JsonSerializable(typeof(TestRunResult))]
|
||||
[JsonSerializable(typeof(TestCancelParams))]
|
||||
[JsonSerializable(typeof(ProbeDisconnectParams))]
|
||||
[JsonSerializable(typeof(ProbeLinkRetryParams))]
|
||||
[JsonSerializable(typeof(HostSnapshot))]
|
||||
[JsonSerializable(typeof(TestStatusParams))]
|
||||
[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;
|
||||
|
||||
public enum UdpPacketType : byte
|
||||
public enum MeasurementDatagramType : byte
|
||||
{
|
||||
EchoRequest = 1,
|
||||
EchoReply = 2,
|
||||
@@ -12,7 +12,7 @@ public enum UdpPacketType : byte
|
||||
Data = 3,
|
||||
}
|
||||
|
||||
public readonly record struct UdpHeader(UdpPacketType Type, long SessionId, uint StreamId, uint Seq, long SendTsUs);
|
||||
public readonly record struct MeasurementDatagramHeader(MeasurementDatagramType Type, long SessionId, uint StreamId, uint Seq, long SendTsUs);
|
||||
|
||||
/// <summary>
|
||||
/// 数据面的 UDP 包(Fleet 之外的线格式):
|
||||
@@ -20,7 +20,7 @@ public readonly record struct UdpHeader(UdpPacketType Type, long SessionId, uint
|
||||
/// mac = HMAC-SHA256(session_key, 前面全部字节) 截断到 16 字节。所有整数小端。
|
||||
/// 接收方对不合法的包静默丢弃并计数,不回复(防放大)。
|
||||
/// </summary>
|
||||
public static class UdpPacket
|
||||
public static class MeasurementDatagram
|
||||
{
|
||||
public const ushort Magic = 0x4D4E; // "MN"
|
||||
public const byte Version = 1;
|
||||
@@ -30,19 +30,19 @@ public static class UdpPacket
|
||||
public const int MaxBytes = 1472;
|
||||
|
||||
/// <summary>检查长度、magic、version、type 并解析头部(不校验 MAC)。</summary>
|
||||
public static bool TryParseHeader(ReadOnlySpan<byte> packet, out UdpHeader header)
|
||||
public static bool TryParseHeader(ReadOnlySpan<byte> packet, out MeasurementDatagramHeader header)
|
||||
{
|
||||
header = default;
|
||||
if (packet.Length is < MinBytes or > MaxBytes
|
||||
|| BinaryPrimitives.ReadUInt16LittleEndian(packet) != Magic
|
||||
|| packet[2] != Version
|
||||
|| packet[3] is < (byte)UdpPacketType.EchoRequest or > (byte)UdpPacketType.Data)
|
||||
|| packet[3] is < (byte)MeasurementDatagramType.EchoRequest or > (byte)MeasurementDatagramType.Data)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
header = new UdpHeader(
|
||||
(UdpPacketType)packet[3],
|
||||
header = new MeasurementDatagramHeader(
|
||||
(MeasurementDatagramType)packet[3],
|
||||
BinaryPrimitives.ReadInt64LittleEndian(packet[4..]),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(packet[12..]),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(packet[16..]),
|
||||
@@ -51,7 +51,7 @@ public static class UdpPacket
|
||||
}
|
||||
|
||||
/// <summary>写入整个包(长度就是 <paramref name="destination"/> 的长度),填充为零,末尾是 MAC。</summary>
|
||||
public static void Write(Span<byte> destination, in UdpHeader header, ReadOnlySpan<byte> key)
|
||||
public static void Write(Span<byte> destination, in MeasurementDatagramHeader header, ReadOnlySpan<byte> key)
|
||||
{
|
||||
if (destination.Length is < MinBytes or > MaxBytes)
|
||||
{
|
||||
@@ -19,8 +19,8 @@ public static class TcpWire
|
||||
|
||||
public static void WriteAuth(Span<byte> destination, long sessionId, uint streamId, ReadOnlySpan<byte> nonce, ReadOnlySpan<byte> key)
|
||||
{
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(destination, UdpPacket.Magic);
|
||||
destination[2] = UdpPacket.Version;
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(destination, MeasurementDatagram.Magic);
|
||||
destination[2] = MeasurementDatagram.Version;
|
||||
BinaryPrimitives.WriteInt64LittleEndian(destination[3..], sessionId);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(destination[11..], streamId);
|
||||
nonce[..NonceBytes].CopyTo(destination[15..]);
|
||||
@@ -35,7 +35,7 @@ public static class TcpWire
|
||||
sessionId = 0;
|
||||
streamId = 0;
|
||||
nonce = default;
|
||||
if (frame.Length != AuthBytes || BinaryPrimitives.ReadUInt16LittleEndian(frame) != UdpPacket.Magic || frame[2] != UdpPacket.Version)
|
||||
if (frame.Length != AuthBytes || BinaryPrimitives.ReadUInt16LittleEndian(frame) != MeasurementDatagram.Magic || frame[2] != MeasurementDatagram.Version)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ public sealed class MeasurementTests
|
||||
Params = parameters.ToDictionary(p => p.Key, p => Json(p.Raw)),
|
||||
};
|
||||
|
||||
private static BudgetGuard Guard(NodeLimits? limits = null) => new(limits ?? new NodeLimits { AllowedUdpPorts = [7443], AllowedTcpPorts = [7444] }, BlockCatalog.AgentKinds);
|
||||
private static BudgetGuard Guard(NodeLimits? limits = null) => new(limits ?? new NodeLimits { AllowedQuicPorts = [7443], AllowedTcpPorts = [7444] }, BlockCatalog.AgentKinds);
|
||||
|
||||
[Fact]
|
||||
public void BudgetGuard_AcceptsDefaults_RejectsUnknownKindsBadParamsAndForeignPorts()
|
||||
@@ -34,7 +34,7 @@ public sealed class MeasurementTests
|
||||
[Fact]
|
||||
public void BudgetGuard_AccumulatesTheBudget_AndRejectsWhatDoesNotFit()
|
||||
{
|
||||
BudgetGuard guard = Guard(new NodeLimits { MaxTotalMbps = 15, AllowedUdpPorts = [7443] });
|
||||
BudgetGuard guard = Guard(new NodeLimits { MaxTotalMbps = 15, AllowedQuicPorts = [7443] });
|
||||
double mbps = 0;
|
||||
long pps = 0;
|
||||
Assert.True(guard.CheckPhase("udp_train", new Dictionary<string, JsonElement> { ["mbps"] = Json("10") }).Accepted);
|
||||
@@ -298,7 +298,7 @@ public sealed class MeasurementTests
|
||||
[Fact]
|
||||
public void EchoRows_Udp_GivesUpOnAMissingReplyAfterTwoSeconds()
|
||||
{
|
||||
var rows = new EchoRows(Kinds.UdpEcho, 64, EchoRows.UdpGraceUs, settleOnReply: false);
|
||||
var rows = new EchoRows(Kinds.UdpEcho, 64, EchoRows.DatagramGraceUs, settleOnReply: false);
|
||||
rows.Add(64, 0);
|
||||
rows.Add(64, 1_000_000);
|
||||
RawStream cut = rows.Take(2_500_000, all: false)!;
|
||||
|
||||
@@ -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);
|
||||
|
||||
public static readonly MetricDef LossRun = Metric.RunLength(
|
||||
"loss.run", s_udpMsg, Ops.LossRuns, "Loss runs", "Lengths of consecutive lost packets.",
|
||||
"loss.run", s_udpMsg, Ops.LossRuns(Dir.Down), "Loss runs (legacy downstream alias)", "Legacy downstream-only consecutive loss metric.",
|
||||
new MetricExport("loss.run_max", Stat.Max, Unit.Packets, 2, 10),
|
||||
new MetricExport("loss.run_mean", Stat.Mean, Unit.Packets));
|
||||
|
||||
public static readonly MetricDef LossRunUp = Metric.Max(
|
||||
"loss.run.up", s_udpMsg, Ops.LossRunMax(Dir.Up), "Upstream longest loss run", "Longest consecutive Probe-to-Agent Datagram loss run in each chunk; zero means no loss.",
|
||||
Unit.Packets, "loss.run.up_max", good: 0, poor: 10);
|
||||
|
||||
public static readonly MetricDef LossRunDown = Metric.Max(
|
||||
"loss.run.down", s_udpMsg, Ops.LossRunMax(Dir.Down), "Downstream longest loss run", "Longest consecutive Agent-to-Probe Datagram loss run in each chunk, conditioned on the request reaching the Agent; zero means no loss.",
|
||||
Unit.Packets, "loss.run.down_max", good: 0, poor: 10);
|
||||
|
||||
public static readonly MetricDef Reorder = Metric.Counter(
|
||||
"reorder", s_udpMsg, Ops.Reorder, "Reordered", "Replies that arrived after a higher sequence number.", new MetricExport("reorder", Stat.Count, Unit.Count));
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ public static class MetricRegistry
|
||||
public static readonly IReadOnlyList<MetricDef> All =
|
||||
[
|
||||
LinkMetrics.Rtt, LinkMetrics.LossUp, LinkMetrics.LossDown, LinkMetrics.LossTotal,
|
||||
LinkMetrics.JitterUp, LinkMetrics.JitterDown, LinkMetrics.LossRun, LinkMetrics.Reorder, LinkMetrics.Dup, LinkMetrics.SamplesUdp,
|
||||
LinkMetrics.JitterUp, LinkMetrics.JitterDown, LinkMetrics.LossRun, LinkMetrics.LossRunUp, LinkMetrics.LossRunDown, LinkMetrics.Reorder, LinkMetrics.Dup, LinkMetrics.SamplesUdp,
|
||||
TcpMetrics.Rtt, TcpMetrics.Slow, TcpMetrics.Stall, TcpMetrics.StallMax, TcpMetrics.Retrans, TcpMetrics.Srtt, TcpMetrics.BulkRate, TcpMetrics.BulkPeak, TcpMetrics.SamplesTcp,
|
||||
SessionMetrics.ConnectSuccess, SessionMetrics.ConnectTcp, SessionMetrics.ConnectTls,
|
||||
SessionMetrics.FailTimeout, SessionMetrics.FailReset, SessionMetrics.FailTls,
|
||||
@@ -22,7 +22,8 @@ public static class MetricRegistry
|
||||
/// <summary>指标分组:前端按分组展示,新增指标只需要把它加进分组。</summary>
|
||||
public static readonly IReadOnlyList<PresetDef> Presets =
|
||||
[
|
||||
new("udp", "UDP", ["loss.up", "loss.down", "loss.total", "rtt.p50", "rtt.p95", "rtt.p99", "jitter.up.p95", "jitter.down.p95", "loss.run_max", "reorder", "dup"]),
|
||||
new("quic", "QUIC Datagram", ["loss.up", "loss.down", "loss.total", "rtt.p50", "rtt.p95", "rtt.p99", "jitter.up.p95", "jitter.down.p95", "loss.run.up_max", "loss.run.down_max", "reorder", "dup"]),
|
||||
new("udp", "QUIC Datagram (legacy alias)", ["loss.up", "loss.down", "loss.total", "rtt.p50", "rtt.p95", "rtt.p99", "jitter.up.p95", "jitter.down.p95", "loss.run_max", "reorder", "dup"]),
|
||||
new("tcp", "TCP", ["tcp.rtt.p50", "tcp.rtt.p95", "tcp.rtt.p99", "tcp.rtt.p999", "tcp.rtt.max", "tcp.rtt.tail", "tcp.slow", "tcp.stall.per_min", "tcp.stall_max", "tcp.retrans", "tcp.srtt.p95"]),
|
||||
new("connect", "Connect", ["connect.success", "connect.tcp_ms.p95", "connect.tls_ms.p95", "connect.fail.timeout", "connect.fail.rst", "connect.fail.tls"]),
|
||||
new("session", "Session", ["hold.resets.per_h", "hold.lifetime_max"]),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace MoShiro.Backend.Analysis;
|
||||
|
||||
/// <summary>连接建立、长连接、UDP 压测(udp_train)的指标。</summary>
|
||||
/// <summary>TCP 建连、长连接、QUIC Datagram 压测(legacy kind udp_train)的指标。</summary>
|
||||
public static class SessionMetrics
|
||||
{
|
||||
private static readonly string[] s_conn = ["connect.conn"];
|
||||
@@ -33,13 +33,13 @@ public static class SessionMetrics
|
||||
"hold.lifetime", s_hold, Ops.HoldLifetime, "Longest connection lifetime", "Longest observed lifetime of the held connection (ms).", Unit.Ms, "hold.lifetime_max", Better.Higher);
|
||||
|
||||
public static readonly MetricDef TrainLoss = Metric.Ratio(
|
||||
"train.loss", s_train, Ops.TrainLoss, "Train loss", "Loss of a fixed-rate UDP stream.", good: 1, poor: 10);
|
||||
"train.loss", s_train, Ops.TrainLoss, "Datagram train loss", "Loss of a fixed-rate QUIC Datagram stream.", good: 1, poor: 10);
|
||||
|
||||
public static readonly MetricDef TrainJitter = Metric.Histogram(
|
||||
"train.jitter", s_train, Ops.TrainJitter, "Train jitter", "One-way delay variation of a fixed-rate UDP stream.", Unit.Ms, [Stat.P95], good: 3, poor: 20);
|
||||
"train.jitter", s_train, Ops.TrainJitter, "Datagram train delay variation", "One-way delay variation of a fixed-rate QUIC Datagram stream.", Unit.Ms, [Stat.P95], good: 3, poor: 20);
|
||||
|
||||
public static readonly MetricDef TrainRate = Metric.Histogram(
|
||||
"train.rate", s_train, Ops.TrainRate, "Train rate", "Delivered rate of a fixed-rate UDP stream per second.", Unit.Mbps, [Stat.P50, Stat.Mean], Better.Higher);
|
||||
"train.rate", s_train, Ops.TrainRate, "Datagram train rate", "Delivered rate of a fixed-rate QUIC Datagram stream per second.", Unit.Mbps, [Stat.P50, Stat.Mean], Better.Higher);
|
||||
|
||||
public static readonly MetricDef SamplesConnect = Metric.Counter(
|
||||
"n.connect", s_conn, Ops.RowCount, "connect samples", "Number of connection attempts.", new MetricExport("n.connect", Stat.Count, Unit.Count), Better.None);
|
||||
|
||||
@@ -12,9 +12,9 @@ public sealed class BaseModel : ServiceModelBase
|
||||
|
||||
public override string Title => "Line quality";
|
||||
|
||||
public override int Version => 1;
|
||||
public override int Version => 2;
|
||||
|
||||
public override IReadOnlyList<string> Needs { get; } = ["rtt.p95", "loss.total", "jitter.down.p95", "jitter.up.p95", "loss.run_max"];
|
||||
public override IReadOnlyList<string> Needs { get; } = ["rtt.p95", "loss.total", "jitter.down.p95", "jitter.up.p95", "loss.run.up_max", "loss.run.down_max"];
|
||||
|
||||
public override ModelScore Score(MetricView view) =>
|
||||
Combine(
|
||||
@@ -24,7 +24,8 @@ public sealed class BaseModel : ServiceModelBase
|
||||
Dim(view, "loss.total", "Packet loss", 4, s_loss),
|
||||
Dim(view, "jitter.down.p95", "Jitter (down)", 1.5, s_jitter),
|
||||
Dim(view, "jitter.up.p95", "Jitter (up)", 1.5, s_jitter),
|
||||
Dim(view, "loss.run_max", "Longest loss run", 1, s_run),
|
||||
Dim(view, "loss.run.up_max", "Longest upstream loss run", 0.5, s_run),
|
||||
Dim(view, "loss.run.down_max", "Longest downstream loss run", 0.5, s_run),
|
||||
],
|
||||
estimated: false);
|
||||
}
|
||||
|
||||
@@ -273,7 +273,14 @@ internal static class JitterEmitter
|
||||
/// <summary>连续丢失段的长度(按发送顺序)。</summary>
|
||||
internal sealed class LossRunsOp : MetricOp
|
||||
{
|
||||
public override string Signature => "loss_runs/1";
|
||||
private readonly Dir _direction;
|
||||
|
||||
public LossRunsOp(Dir direction)
|
||||
{
|
||||
_direction = direction;
|
||||
}
|
||||
|
||||
public override string Signature => $"loss_runs/{_direction}/2";
|
||||
|
||||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||||
{
|
||||
@@ -292,7 +299,13 @@ internal sealed class LossRunsOp : MetricOp
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c.IsReplyLost(i))
|
||||
bool lost = _direction switch
|
||||
{
|
||||
Dir.Up => c.IsUpLost(i),
|
||||
Dir.Down => !c.IsUpLost(i) && c.IsReplyLost(i),
|
||||
_ => c.IsReplyLost(i),
|
||||
};
|
||||
if (lost)
|
||||
{
|
||||
if (run == 0)
|
||||
{
|
||||
@@ -315,6 +328,59 @@ internal sealed class LossRunsOp : MetricOp
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>每个块一个最长连续丢包值;无丢包的有效块明确输出 0。</summary>
|
||||
internal sealed class LossRunMaxOp(Dir direction) : MetricOp
|
||||
{
|
||||
public override string Signature => $"loss_run_max/{direction}/1";
|
||||
|
||||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||||
{
|
||||
var c = new EchoColumns(stream);
|
||||
if (!c.HasBasics || (direction != Dir.Total && c.T2 is null))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int run = 0;
|
||||
int maximum = 0;
|
||||
bool hasSample = false;
|
||||
long sampleAt = 0;
|
||||
for (int i = 0; i < c.Count; i++)
|
||||
{
|
||||
if (!c.T1!.IsValid(i))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
bool lost = direction switch
|
||||
{
|
||||
Dir.Up => c.IsUpLost(i),
|
||||
Dir.Down => !c.IsUpLost(i) && c.IsReplyLost(i),
|
||||
_ => c.IsReplyLost(i),
|
||||
};
|
||||
if (direction == Dir.Down && c.IsUpLost(i))
|
||||
{
|
||||
// A request lost before reaching the Agent is not a downstream sample.
|
||||
run = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!hasSample)
|
||||
{
|
||||
sampleAt = c.SentUtcMs(view, i);
|
||||
hasSample = true;
|
||||
}
|
||||
run = lost ? run + 1 : 0;
|
||||
maximum = Math.Max(maximum, run);
|
||||
}
|
||||
|
||||
if (hasSample)
|
||||
{
|
||||
sink.Emit(sampleAt, maximum);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>乱序:按到达顺序,序号比之前见过的最大序号还小。重复的序号不算乱序。</summary>
|
||||
internal sealed class ReorderOp : MetricOp
|
||||
{
|
||||
|
||||
@@ -11,7 +11,9 @@ public static class Ops
|
||||
|
||||
public static MetricOp OwdVar(Dir dir) => new OwdVarOp(dir);
|
||||
|
||||
public static MetricOp LossRuns { get; } = new LossRunsOp();
|
||||
public static MetricOp LossRuns(Dir direction) => new LossRunsOp(direction);
|
||||
|
||||
public static MetricOp LossRunMax(Dir direction) => new LossRunMaxOp(direction);
|
||||
|
||||
public static MetricOp Reorder { get; } = new ReorderOp();
|
||||
|
||||
|
||||
@@ -20,9 +20,12 @@ public static class LinkEndpoints
|
||||
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)
|
||||
.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);
|
||||
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))
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
@@ -52,10 +55,71 @@ public static class LinkEndpoints
|
||||
|
||||
await links.SetAsync(probe_id, agent_id, request.Enabled, ct);
|
||||
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) =>
|
||||
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));
|
||||
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)
|
||||
{
|
||||
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);
|
||||
@@ -31,7 +31,28 @@ public static class Mappers
|
||||
public static OverviewLineDto ToDto(OverviewLine line) =>
|
||||
new(
|
||||
line.Line.Key.Id, line.Line.Agent.Agent.Id.Value, line.Line.Probe.Id, line.Line.Probe.Name, line.Line.Agent.Agent.Name, line.Line.Agent.Agent.Labels,
|
||||
line.Line.Agent.Online, line.Line.Connected, line.Line.Agent.EndpointChanges, ToDto(line.Summary), Summary(line.LastTest), line.Line.IpVersion);
|
||||
line.Line.Agent.Online, line.Line.Connected, line.Line.Agent.EndpointChanges, ToDto(line.Summary), Summary(line.LastTest), line.Line.IpVersion, ToDto(line.Line.QuicHealth));
|
||||
|
||||
public static QuicLineHealthDto? ToDto(ProbeQuicLinkSnapshot? snapshot)
|
||||
{
|
||||
if (snapshot is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
ProbeQuicLinkHealth health = snapshot.Health;
|
||||
double? Rate(long count) => health.HandshakeAttempts > 0 ? count * 100.0 / health.HandshakeAttempts : null;
|
||||
return new QuicLineHealthDto(
|
||||
health.Connected, health.HandshakeAttempts, health.HandshakeSuccesses, health.HandshakeFailures, health.HandshakeTimeouts,
|
||||
Rate(health.HandshakeSuccesses), Rate(health.HandshakeFailures), Rate(health.HandshakeTimeouts), health.HandshakeP95Ms, health.HandshakeSampleCount,
|
||||
health.TransportPacketLossRate, health.TransportPacketsSent, health.TransportCongestionEvents,
|
||||
health.TransportEcnCongestionEvents, health.PathMtu, Time(snapshot.AtMs), health.State switch
|
||||
{
|
||||
ProbeLinkAttemptState.Connected => "connected",
|
||||
ProbeLinkAttemptState.RetryWait => "retry_wait",
|
||||
_ => "connecting",
|
||||
}, health.RetryAttempt, health.RetryInMs);
|
||||
}
|
||||
|
||||
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));
|
||||
@@ -41,7 +62,7 @@ public static class Mappers
|
||||
public static ProbeDto ToDto(ProbeView v) =>
|
||||
new(
|
||||
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) =>
|
||||
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))
|
||||
: null;
|
||||
return new AgentDto(
|
||||
a.Id.Value, a.Name, StatusName(a.Status), view.Online, view.ProbeConnected, [.. view.Probes.Select(ToDto)], view.Host is null ? null : ToDto(view.Host), a.PublicHost, a.ProbeTcpPort, a.ProbeUdpPorts, a.Labels, a.PublicVisible,
|
||||
a.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.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, 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)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Fleet.Core;
|
||||
using System.Text.Json;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Api;
|
||||
|
||||
@@ -42,6 +43,7 @@ public sealed record AgentDto(
|
||||
string PublicHost,
|
||||
int ProbeTcpPort,
|
||||
IReadOnlyList<int> ProbeUdpPorts,
|
||||
IReadOnlyList<int> ProbeQuicPorts,
|
||||
IReadOnlyList<string> Labels,
|
||||
bool PublicVisible,
|
||||
IReadOnlyList<string> Kinds,
|
||||
@@ -52,6 +54,7 @@ public sealed record AgentDto(
|
||||
IReadOnlyList<RejectedDto> Rejected,
|
||||
HeartbeatDto? Heartbeat,
|
||||
int EndpointChanges,
|
||||
NodeRuntimeStatus RuntimeStatus,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? LastSeenAt,
|
||||
string Ipv6Host = "",
|
||||
@@ -122,10 +125,10 @@ public sealed record ConnectedProbeDto(string ProbeId, string? Version, string?
|
||||
/// <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 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>
|
||||
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);
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ namespace MoShiro.Backend.Api;
|
||||
[JsonSerializable(typeof(DistributionDto))]
|
||||
[JsonSerializable(typeof(HeatmapDto))]
|
||||
[JsonSerializable(typeof(LineDto))]
|
||||
[JsonSerializable(typeof(QuicLineHealthDto))]
|
||||
[JsonSerializable(typeof(TestDto))]
|
||||
[JsonSerializable(typeof(PageDto<TestDto>))]
|
||||
[JsonSerializable(typeof(PageDto<KindDto>))]
|
||||
@@ -40,6 +41,7 @@ namespace MoShiro.Backend.Api;
|
||||
[JsonSerializable(typeof(AgentPatchRequest))]
|
||||
[JsonSerializable(typeof(LinkDto))]
|
||||
[JsonSerializable(typeof(LinkMatrixDto))]
|
||||
[JsonSerializable(typeof(LinkRetryDto))]
|
||||
[JsonSerializable(typeof(SetLinkRequest))]
|
||||
[JsonSerializable(typeof(BuildDto))]
|
||||
[JsonSerializable(typeof(ReleaseDto))]
|
||||
|
||||
@@ -28,7 +28,30 @@ public sealed record OverviewLineDto(
|
||||
int EndpointChanges,
|
||||
SummaryDto Summary,
|
||||
TestSummaryDto? LastTest,
|
||||
string IpVersion = "ipv4");
|
||||
string IpVersion = "ipv4",
|
||||
QuicLineHealthDto? QuicHealth = null);
|
||||
|
||||
/// <summary>Latest QUIC session health reported by the Probe; cumulative counters reset when the Probe restarts.</summary>
|
||||
public sealed record QuicLineHealthDto(
|
||||
bool Connected,
|
||||
long HandshakeAttempts,
|
||||
long HandshakeSuccesses,
|
||||
long HandshakeFailures,
|
||||
long HandshakeTimeouts,
|
||||
double? HandshakeSuccessRate,
|
||||
double? HandshakeFailureRate,
|
||||
double? HandshakeTimeoutRate,
|
||||
double? HandshakeP95Ms,
|
||||
long HandshakeSampleCount,
|
||||
double? TransportPacketLossRate,
|
||||
ulong? TransportPacketsSent,
|
||||
uint? TransportCongestionEvents,
|
||||
uint? TransportEcnCongestionEvents,
|
||||
ushort? PathMtu,
|
||||
DateTimeOffset At,
|
||||
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);
|
||||
|
||||
@@ -90,7 +113,8 @@ public sealed record LineDto(
|
||||
IReadOnlyList<string> Kinds,
|
||||
TestSummaryDto? LastTest,
|
||||
string IpVersion = "ipv4",
|
||||
bool Ipv6Available = false);
|
||||
bool Ipv6Available = false,
|
||||
QuicLineHealthDto? QuicHealth = null);
|
||||
|
||||
public sealed record DimensionDto(string Id, string Label, double Weight, double? Value, double? Score);
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@ namespace MoShiro.Backend.Api;
|
||||
public static class QueryEndpoints
|
||||
{
|
||||
private const string Tag = "public";
|
||||
private const string DefaultOverviewPreset = "udp,tcp,scores";
|
||||
private const string DefaultSeriesPreset = "udp,tcp";
|
||||
private const string DefaultOverviewPreset = "quic,tcp,scores";
|
||||
private const string DefaultSeriesPreset = "quic,tcp";
|
||||
|
||||
public static void Map(RouteGroupBuilder group)
|
||||
{
|
||||
@@ -102,7 +102,7 @@ public static class QueryEndpoints
|
||||
return TypedResults.Json(
|
||||
new LineDto(
|
||||
line.Key.Id, view.Agent.Id.Value, line.Probe.Id, line.Probe.Name, view.Agent.Name, view.Agent.Labels, view.Online, line.Connected,
|
||||
Mappers.Time(await query.EarliestAsync(line.Key, ct)), view.Agent.Kinds, Mappers.Summary(last), line.IpVersion, view.Agent.Ipv6Host.Length > 0),
|
||||
Mappers.Time(await query.EarliestAsync(line.Key, ct)), view.Agent.Kinds, Mappers.Summary(last), line.IpVersion, view.Agent.Ipv6Host.Length > 0, Mappers.ToDto(line.QuicHealth)),
|
||||
ApiJsonContext.Default.LineDto);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ public sealed class AgentRuntimeState
|
||||
private readonly object _gate = new();
|
||||
private int _endpointChanges;
|
||||
private IReadOnlyList<ConnectedProbeInfo> _probes = [];
|
||||
private string? _lastDisconnectReason;
|
||||
|
||||
/// <summary>当前连着这个 Agent 的 Probe(每个 Probe 一项)。</summary>
|
||||
public IReadOnlyList<ConnectedProbeInfo> Probes => Volatile.Read(ref _probes);
|
||||
@@ -60,6 +61,17 @@ public sealed class AgentRuntimeState
|
||||
|
||||
public int EndpointChanges => Volatile.Read(ref _endpointChanges);
|
||||
|
||||
public string? LastDisconnectReason => Volatile.Read(ref _lastDisconnectReason);
|
||||
|
||||
public void MarkOnline()
|
||||
{
|
||||
Volatile.Write(ref _lastDisconnectReason, null);
|
||||
LastHeartbeat = null;
|
||||
LastHeartbeatAtMs = 0;
|
||||
}
|
||||
|
||||
public void MarkOffline(string? reason) => Volatile.Write(ref _lastDisconnectReason, reason);
|
||||
|
||||
public void CountEndpointChange() => Interlocked.Increment(ref _endpointChanges);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,10 +7,46 @@ using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
public sealed record AgentView(AgentRecord Agent, bool Online, IReadOnlyList<ConnectedProbeInfo> Probes, AgentHeartbeatExtra? Heartbeat, int EndpointChanges, long? HeartbeatAtMs = null, HostSnapshot? Host = null)
|
||||
public sealed record AgentView(AgentRecord Agent, bool Online, IReadOnlyList<ConnectedProbeInfo> Probes, AgentHeartbeatExtra? Heartbeat, int EndpointChanges, long? HeartbeatAtMs = null, HostSnapshot? Host = null, string? LastDisconnectReason = null)
|
||||
{
|
||||
/// <summary>至少有一个 Probe 连着。</summary>
|
||||
public bool ProbeConnected => Probes.Count > 0;
|
||||
|
||||
public NodeRuntimeStatus RuntimeStatus
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Agent.Status == NodeStatus.Disabled)
|
||||
{
|
||||
return NodeRuntimeStatus.Maintenance;
|
||||
}
|
||||
if (Agent.Status == NodeStatus.Pending)
|
||||
{
|
||||
return NodeRuntimeStatus.Initializing;
|
||||
}
|
||||
if (!Online)
|
||||
{
|
||||
if (LastDisconnectReason == "updating")
|
||||
{
|
||||
return NodeRuntimeStatus.Updating;
|
||||
}
|
||||
if (LastDisconnectReason == "stopping")
|
||||
{
|
||||
return Heartbeat?.Update?.State == "applying" ? NodeRuntimeStatus.Updating : NodeRuntimeStatus.Stopping;
|
||||
}
|
||||
return NodeRuntimeStatus.Offline;
|
||||
}
|
||||
if (Heartbeat?.Update?.State == "applying")
|
||||
{
|
||||
return NodeRuntimeStatus.Updating;
|
||||
}
|
||||
if (Heartbeat is null)
|
||||
{
|
||||
return NodeRuntimeStatus.Initializing;
|
||||
}
|
||||
return Heartbeat.RuntimeStatus;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Agent 的管理:列表、修改、审批、启停、接管、删除,以及 Probe 需要的 Agent 列表。</summary>
|
||||
@@ -53,9 +89,12 @@ public sealed partial class AgentService
|
||||
public AgentView View(AgentRecord agent)
|
||||
{
|
||||
_runtime.TryGet(agent.Id, out AgentRuntimeState? state);
|
||||
return new AgentView(agent, _gateway.IsOnline(agent.Id), state?.Probes ?? [], state?.LastHeartbeat, state?.EndpointChanges ?? 0, state?.LastHeartbeatAtMs, state?.Host);
|
||||
return new AgentView(agent, _gateway.IsOnline(agent.Id), state?.Probes ?? [], state?.LastHeartbeat, state?.EndpointChanges ?? 0, state?.LastHeartbeatAtMs, state?.Host, state?.LastDisconnectReason);
|
||||
}
|
||||
|
||||
public ValueTask<bool> NotifyProbeLinkRetryAsync(NodeId probe, NodeId agent, CancellationToken ct) =>
|
||||
_gateway.NotifyProbeLinkRetryAsync(probe, agent, ct);
|
||||
|
||||
private static bool ValidHost(string host, string ipVersion)
|
||||
{
|
||||
if (IPAddress.TryParse(host, out IPAddress? address))
|
||||
@@ -173,7 +212,8 @@ public sealed partial class AgentService
|
||||
Host = agent.PublicHost,
|
||||
Ipv6Host = agent.Ipv6Supported && agent.Ipv6Host.Length > 0 ? agent.Ipv6Host : null,
|
||||
TcpPort = agent.ProbeTcpPort,
|
||||
UdpPorts = [.. agent.ProbeUdpPorts],
|
||||
QuicPorts = [.. agent.ProbeUdpPorts],
|
||||
LegacyUdpPorts = [.. agent.ProbeUdpPorts],
|
||||
PublicKey = Convert.ToBase64String(agent.TlsPublicKey!),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ public sealed class MoShiroEnrollmentHook : IEnrollmentHook
|
||||
// 注册请求带了 public_host 就用它;否则来源地址是公网地址时用来源地址(私网或隧道出口不用,等 hello 里的本机地址)
|
||||
string host = !string.IsNullOrWhiteSpace(meta.PublicHost) ? meta.PublicHost
|
||||
: System.Net.IPAddress.TryParse(context.RemoteIp, out System.Net.IPAddress? remote) && PublicAddress.IsPublicIpv4(remote) ? remote.ToString() : "";
|
||||
await _agents.ApplyEnrollmentAsync(node.Id, new AgentEnrollment(host, meta.ProbeTcpPort, meta.ProbeUdpPorts, meta.Kinds), ct);
|
||||
await _agents.ApplyEnrollmentAsync(node.Id, new AgentEnrollment(host, meta.ProbeTcpPort, meta.ProbeQuicPorts.Count > 0 ? meta.ProbeQuicPorts : meta.LegacyProbeUdpPorts ?? [7443], meta.Kinds), ct);
|
||||
if (await _agents.GetAsync(node.Id, ct) is { } enrolled)
|
||||
{
|
||||
(_, string? observedV6) = AgentAddressPolicy.Choose(enrolled, [], context.RemoteIp);
|
||||
@@ -70,10 +70,14 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
|
||||
private readonly ScheduleService _schedule;
|
||||
private readonly IProbeStore _probes;
|
||||
private readonly NodeOwnerService _owners;
|
||||
private readonly ProbeQuicRuntime _quicRuntime;
|
||||
private readonly ProbeRuntime _probeRuntime;
|
||||
|
||||
public MoShiroNodeEventSink(IAgentStore agents, AgentRuntime runtime, IEventStore events, LiveHub live, AgentService service, NodeInfoService nodes, ReleaseService releases, ScheduleService schedule, IProbeStore probes, NodeOwnerService owners)
|
||||
public MoShiroNodeEventSink(IAgentStore agents, AgentRuntime runtime, IEventStore events, LiveHub live, AgentService service, NodeInfoService nodes, ReleaseService releases, ScheduleService schedule, IProbeStore probes, NodeOwnerService owners, ProbeQuicRuntime quicRuntime, ProbeRuntime probeRuntime)
|
||||
{
|
||||
_owners = owners;
|
||||
_quicRuntime = quicRuntime;
|
||||
_probeRuntime = probeRuntime;
|
||||
_releases = releases;
|
||||
_schedule = schedule;
|
||||
_probes = probes;
|
||||
@@ -97,10 +101,12 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
|
||||
switch (nodeEvent.Kind)
|
||||
{
|
||||
case NodeEventKind.Online:
|
||||
_runtime.Get(id).MarkOnline();
|
||||
_live.Publish(new NodeSignal("agent", id.Value, true, nodeEvent.AtMs));
|
||||
await _events.AddAsync(id, "agent_online", nodeEvent.AtMs, null, ct);
|
||||
break;
|
||||
case NodeEventKind.Offline:
|
||||
_runtime.Get(id).MarkOffline(nodeEvent.Reason);
|
||||
_runtime.MarkOffline(id);
|
||||
_releases.RecordStatus(id, null);
|
||||
_live.Publish(new NodeSignal("agent", id.Value, false, nodeEvent.AtMs));
|
||||
@@ -141,6 +147,7 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
|
||||
switch (nodeEvent.Kind)
|
||||
{
|
||||
case NodeEventKind.Online:
|
||||
_probeRuntime.MarkOnline(id.Value);
|
||||
_live.Publish(new NodeSignal("probe", id.Value, true, nodeEvent.AtMs));
|
||||
break;
|
||||
case NodeEventKind.Hello:
|
||||
@@ -168,12 +175,22 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
|
||||
case NodeEventKind.Offline:
|
||||
_live.Publish(new NodeSignal("probe", id.Value, false, nodeEvent.AtMs));
|
||||
_releases.RecordStatus(id, null);
|
||||
_probeRuntime.MarkOffline(id.Value, nodeEvent.Reason);
|
||||
_quicRuntime.MarkProbeOffline(id.Value, nodeEvent.AtMs);
|
||||
break;
|
||||
case NodeEventKind.Heartbeat when nodeEvent.Extra is { } extra:
|
||||
try
|
||||
{
|
||||
ProbeHeartbeatExtra? heartbeat = extra.Deserialize(MoShiroJsonContext.Default.ProbeHeartbeatExtra);
|
||||
_releases.RecordStatus(id, heartbeat?.Update);
|
||||
if (heartbeat is not null)
|
||||
{
|
||||
_probeRuntime.RecordHeartbeat(id.Value, nodeEvent.AtMs, heartbeat);
|
||||
}
|
||||
if (heartbeat?.QuicLinks is { } quicLinks)
|
||||
{
|
||||
_quicRuntime.Replace(id.Value, nodeEvent.AtMs, quicLinks);
|
||||
}
|
||||
if (heartbeat?.Ipv6 is { } ipv6)
|
||||
{
|
||||
await _probes.SetIpv6DiagnosticsAsync(id.Value, JsonSerializer.Serialize(ipv6, MoShiroJsonContext.Default.Ipv6Diagnostic), ct);
|
||||
@@ -236,7 +253,8 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
|
||||
AgentRecord? before = await _agents.GetAsync(id, ct);
|
||||
string? limitsJson = meta.LocalLimits is null ? null : JsonSerializer.Serialize(meta.LocalLimits, LocalLimitsJson.Default.LocalLimits);
|
||||
string? ipv6Json = meta.Ipv6 is null ? null : JsonSerializer.Serialize(meta.Ipv6, MoShiroJsonContext.Default.Ipv6Diagnostic);
|
||||
await _agents.ApplyHelloAsync(id, new AgentHello(kinds, hello.NodeVersion, limitsJson, spki is { Length: 32 } ? spki : null, meta.ProbeTcpPort, meta.ProbeUdpPorts, meta.Ipv6Supported, ipv6Json, meta.Addresses, tlsKey), ct);
|
||||
IReadOnlyList<int>? quicPorts = meta.ProbeQuicPorts is { Count: > 0 } ? meta.ProbeQuicPorts : meta.LegacyProbeUdpPorts;
|
||||
await _agents.ApplyHelloAsync(id, new AgentHello(kinds, hello.NodeVersion, limitsJson, spki is { Length: 32 } ? spki : null, meta.ProbeTcpPort, quicPorts, meta.Ipv6Supported, ipv6Json, meta.Addresses, tlsKey), ct);
|
||||
|
||||
// 自动识别对外地址:Agent 上报的公网地址;手填的不动
|
||||
if (meta.Addresses is not null && await _agents.GetAsync(id, ct) is { } reported)
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Collections.Concurrent;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
public sealed record ProbeQuicLinkSnapshot(ProbeQuicLinkHealth Health, long AtMs);
|
||||
|
||||
/// <summary>Latest per-line QUIC health reported by connected Probes. Volatile runtime state, not historical metrics.</summary>
|
||||
public sealed class ProbeQuicRuntime
|
||||
{
|
||||
private readonly ConcurrentDictionary<(string Probe, string Agent, string IpVersion), ProbeQuicLinkSnapshot> _links = new();
|
||||
|
||||
public ProbeQuicLinkSnapshot? Get(string probeId, string agentId, string ipVersion) =>
|
||||
_links.TryGetValue((probeId, agentId, ipVersion), out ProbeQuicLinkSnapshot? snapshot) ? snapshot : null;
|
||||
|
||||
public void Replace(string probeId, long atMs, IReadOnlyList<ProbeQuicLinkHealth> links)
|
||||
{
|
||||
var current = new HashSet<(string Probe, string Agent, string IpVersion)>();
|
||||
foreach (ProbeQuicLinkHealth link in links)
|
||||
{
|
||||
var key = (probeId, link.AgentId, link.IpVersion);
|
||||
current.Add(key);
|
||||
_links[key] = new ProbeQuicLinkSnapshot(link, atMs);
|
||||
}
|
||||
|
||||
foreach ((string Probe, string Agent, string IpVersion) key in _links.Keys.Where(k => k.Probe == probeId && !current.Contains(k)))
|
||||
{
|
||||
_links.TryRemove(key, out _);
|
||||
}
|
||||
}
|
||||
|
||||
public void MarkProbeOffline(string probeId, long atMs)
|
||||
{
|
||||
foreach (KeyValuePair<(string Probe, string Agent, string IpVersion), ProbeQuicLinkSnapshot> pair in _links.ToArray().Where(p => p.Key.Probe == probeId))
|
||||
{
|
||||
ProbeQuicLinkHealth old = pair.Value.Health;
|
||||
_links[pair.Key] = new ProbeQuicLinkSnapshot(new ProbeQuicLinkHealth
|
||||
{
|
||||
AgentId = old.AgentId,
|
||||
IpVersion = old.IpVersion,
|
||||
Connected = false,
|
||||
State = ProbeLinkAttemptState.RetryWait,
|
||||
LastError = "Probe 与 Backend 的连接中断",
|
||||
RetryAttempt = old.RetryAttempt,
|
||||
RetryInMs = old.RetryInMs,
|
||||
HandshakeAttempts = old.HandshakeAttempts,
|
||||
HandshakeSuccesses = old.HandshakeSuccesses,
|
||||
HandshakeFailures = old.HandshakeFailures,
|
||||
HandshakeTimeouts = old.HandshakeTimeouts,
|
||||
HandshakeP95Ms = old.HandshakeP95Ms,
|
||||
HandshakeSampleCount = old.HandshakeSampleCount,
|
||||
TransportPacketLossRate = old.TransportPacketLossRate,
|
||||
TransportPacketsSent = old.TransportPacketsSent,
|
||||
TransportCongestionEvents = old.TransportCongestionEvents,
|
||||
TransportEcnCongestionEvents = old.TransportEcnCongestionEvents,
|
||||
PathMtu = old.PathMtu,
|
||||
}, atMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Collections.Concurrent;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>Volatile runtime status reported by each Probe's heartbeat and Fleet connection lifecycle.</summary>
|
||||
public sealed class ProbeRuntime
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ProbeRuntimeSnapshot> _probes = new(StringComparer.Ordinal);
|
||||
|
||||
public ProbeRuntimeSnapshot? Get(string probeId) => _probes.TryGetValue(probeId, out ProbeRuntimeSnapshot? snapshot) ? snapshot : null;
|
||||
|
||||
public void RecordHeartbeat(string probeId, long atMs, ProbeHeartbeatExtra heartbeat) =>
|
||||
_probes[probeId] = new ProbeRuntimeSnapshot(heartbeat, atMs, null);
|
||||
|
||||
public void MarkOnline(string probeId) => _probes[probeId] = new ProbeRuntimeSnapshot(null, 0, null);
|
||||
|
||||
public void MarkOffline(string probeId, string? reason) =>
|
||||
_probes.AddOrUpdate(probeId, _ => new ProbeRuntimeSnapshot(null, 0, reason), (_, current) => current with { DisconnectReason = reason });
|
||||
}
|
||||
|
||||
public sealed record ProbeRuntimeSnapshot(ProbeHeartbeatExtra? Heartbeat, long AtMs, string? DisconnectReason);
|
||||
@@ -8,9 +8,41 @@ namespace MoShiro.Backend.Application;
|
||||
/// <summary>某个 Probe 现在连着的一个 Agent(Agent 上报)。</summary>
|
||||
public sealed record ProbeConnection(NodeId AgentId, string AgentName, bool AgentOnline, string? Version, string? Remote, long SinceMs, string IpVersion = IpVersions.V4);
|
||||
|
||||
public sealed record ProbeView(ProbeRecord Probe, bool Online, IReadOnlyList<ProbeConnection> Connections)
|
||||
public sealed record ProbeView(ProbeRecord Probe, bool Online, IReadOnlyList<ProbeConnection> Connections, ProbeRuntimeSnapshot? Runtime = null)
|
||||
{
|
||||
public bool Revoked => Probe.RevokedAtMs is not null;
|
||||
|
||||
public NodeRuntimeStatus RuntimeStatus
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Revoked)
|
||||
{
|
||||
return NodeRuntimeStatus.Maintenance;
|
||||
}
|
||||
if (!Online)
|
||||
{
|
||||
if (Runtime?.DisconnectReason == "updating")
|
||||
{
|
||||
return NodeRuntimeStatus.Updating;
|
||||
}
|
||||
if (Runtime?.DisconnectReason == "stopping")
|
||||
{
|
||||
return Runtime.Heartbeat?.Update?.State == "applying" ? NodeRuntimeStatus.Updating : NodeRuntimeStatus.Stopping;
|
||||
}
|
||||
return NodeRuntimeStatus.Offline;
|
||||
}
|
||||
if (Runtime?.Heartbeat?.Update?.State == "applying")
|
||||
{
|
||||
return NodeRuntimeStatus.Updating;
|
||||
}
|
||||
if (Runtime?.Heartbeat is null)
|
||||
{
|
||||
return NodeRuntimeStatus.Initializing;
|
||||
}
|
||||
return Runtime.Heartbeat.RuntimeStatus;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -28,13 +60,15 @@ public sealed partial class ProbeService
|
||||
private readonly TimeProvider _time;
|
||||
private readonly NodeInfoService _nodes;
|
||||
private readonly LinkService _links;
|
||||
private readonly ProbeRuntime _probeRuntime;
|
||||
|
||||
private readonly NodeOwnerService _owners;
|
||||
|
||||
public ProbeService(NodeOwnerService owners, IProbeStore probes, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TimeProvider time, NodeInfoService nodes, LinkService links)
|
||||
public ProbeService(NodeOwnerService owners, IProbeStore probes, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TimeProvider time, NodeInfoService nodes, LinkService links, ProbeRuntime probeRuntime)
|
||||
{
|
||||
_owners = owners;
|
||||
_links = links;
|
||||
_probeRuntime = probeRuntime;
|
||||
_nodes = nodes;
|
||||
_probes = probes;
|
||||
_agents = agents;
|
||||
@@ -133,7 +167,7 @@ public sealed partial class ProbeService
|
||||
}
|
||||
}
|
||||
|
||||
return new ProbeView(probe, _gateway.IsOnline(new NodeId(probe.Id)), [.. connections.OrderBy(c => c.AgentName, StringComparer.Ordinal)]);
|
||||
return new ProbeView(probe, _gateway.IsOnline(new NodeId(probe.Id)), [.. connections.OrderBy(c => c.AgentName, StringComparer.Ordinal)], _probeRuntime.Get(probe.Id));
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^[\p{L}\p{M}\p{N}._-]{1,64}$")]
|
||||
|
||||
@@ -16,6 +16,8 @@ public static class ApplicationServiceCollectionExtensions
|
||||
services.AddHostedService<StartupTasks>();
|
||||
|
||||
services.AddSingleton<AgentRuntime>();
|
||||
services.AddSingleton<ProbeRuntime>();
|
||||
services.AddSingleton<ProbeQuicRuntime>();
|
||||
services.AddSingleton<LiveHub>();
|
||||
services.AddSingleton<HotLayer>();
|
||||
services.AddSingleton<BackendCounters>();
|
||||
|
||||
@@ -30,6 +30,9 @@ public interface IAgentGateway
|
||||
|
||||
ValueTask DisconnectAsync(NodeId agent, string reason, CancellationToken ct);
|
||||
|
||||
/// <summary>Notify an online Probe to immediately retry all IP paths to an Agent.</summary>
|
||||
ValueTask<bool> NotifyProbeLinkRetryAsync(NodeId probe, NodeId agent, CancellationToken ct) => ValueTask.FromResult(false);
|
||||
|
||||
/// <summary>把发布清单和签名原样交给节点(通知,不等回复)。</summary>
|
||||
ValueTask NotifyUpdateOfferAsync(NodeId node, string manifest, string signature, CancellationToken ct);
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ public readonly record struct LineKey(NodeId Agent, ProbeId Probe, string IpVers
|
||||
}
|
||||
|
||||
/// <summary>一条线路的展示视图:Agent 的状态、Probe 记录,以及这一对现在是否连着。</summary>
|
||||
public sealed record LineView(AgentView Agent, ProbeRecord Probe, string IpVersion = IpVersions.V4)
|
||||
public sealed record LineView(AgentView Agent, ProbeRecord Probe, string IpVersion = IpVersions.V4, ProbeQuicLinkSnapshot? QuicHealth = null)
|
||||
{
|
||||
public LineKey Key => new(Agent.Agent.Id, new ProbeId(Probe.Id), IpVersion);
|
||||
|
||||
@@ -40,12 +40,14 @@ public sealed class LineService
|
||||
private readonly AgentService _agents;
|
||||
private readonly IProbeStore _probes;
|
||||
private readonly LinkService _links;
|
||||
private readonly ProbeQuicRuntime _quicRuntime;
|
||||
|
||||
public LineService(AgentService agents, IProbeStore probes, LinkService links)
|
||||
public LineService(AgentService agents, IProbeStore probes, LinkService links, ProbeQuicRuntime quicRuntime)
|
||||
{
|
||||
_links = links;
|
||||
_agents = agents;
|
||||
_probes = probes;
|
||||
_quicRuntime = quicRuntime;
|
||||
}
|
||||
|
||||
/// <summary>所有线路:每个 Agent 和每个<b>未吊销</b>的 Probe 配成一对,只留下线路开着的(见 <see cref="LinkService"/>);关掉的线路历史数据还在,按 id 仍然查得到。</summary>
|
||||
@@ -59,10 +61,10 @@ public sealed class LineService
|
||||
{
|
||||
foreach (ProbeRecord probe in probes.Where(p => links.IsLinked(p.Id, agent.Agent.Id.Value)))
|
||||
{
|
||||
result.Add(new LineView(agent, probe));
|
||||
result.Add(new LineView(agent, probe, IpVersions.V4, _quicRuntime.Get(probe.Id, agent.Agent.Id.Value, IpVersions.V4)));
|
||||
if (agent.Agent.Ipv6Host.Length > 0)
|
||||
{
|
||||
result.Add(new LineView(agent, probe, IpVersions.V6));
|
||||
result.Add(new LineView(agent, probe, IpVersions.V6, _quicRuntime.Get(probe.Id, agent.Agent.Id.Value, IpVersions.V6)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,6 +119,6 @@ public sealed class LineService
|
||||
probe = active[0];
|
||||
}
|
||||
|
||||
return Result<LineView>.Ok(new LineView(agent, probe, ipVersion));
|
||||
return Result<LineView>.Ok(new LineView(agent, probe, ipVersion, _quicRuntime.Get(probe.Id, agent.Agent.Id.Value, ipVersion)));
|
||||
}
|
||||
}
|
||||
@@ -240,7 +240,7 @@ public sealed class TestService
|
||||
}
|
||||
|
||||
// 没有指定端口的 UDP 阶段使用 Agent 上报的数据端口(目录里的默认值只是出厂默认,每个 Agent 可以改)
|
||||
if (!merged.ContainsKey("port") && kind.Blocks.Transport == "udp" && kind.Params.Any(s => s.Name == "port") && agent.ProbeUdpPorts.Count > 0)
|
||||
if (!merged.ContainsKey("port") && kind.Blocks.Transport == "quic_datagram" && kind.Params.Any(s => s.Name == "port") && agent.ProbeUdpPorts.Count > 0)
|
||||
{
|
||||
merged["port"] = JsonSerializer.SerializeToElement(agent.ProbeUdpPorts[0], PlanPortJson.Default.Int32);
|
||||
}
|
||||
|
||||
@@ -55,9 +55,14 @@ public sealed class BackendUpdates : IHostedService
|
||||
return "release";
|
||||
}
|
||||
|
||||
return BuildInfo.Tag is { } tag && (tag.StartsWith("dev-", StringComparison.Ordinal) || tag.StartsWith("main-", StringComparison.Ordinal)) ? "dev" : "release";
|
||||
return IsDevTag(BuildInfo.Tag) ? "dev" : "release";
|
||||
}
|
||||
|
||||
/// <summary>开发构建的 tag:新格式 v0.4.1-dev.12-abc1234(含 -dev.),以及旧的 dev-<sha>、main-<sha>。</summary>
|
||||
public static bool IsDevTag(string? tag) =>
|
||||
tag is not null
|
||||
&& (tag.Contains("-dev.", StringComparison.Ordinal) || tag.StartsWith("dev-", StringComparison.Ordinal) || tag.StartsWith("main-", StringComparison.Ordinal));
|
||||
|
||||
public async ValueTask<BackendUpdateView> GetAsync(CancellationToken ct)
|
||||
{
|
||||
string channel = ChannelOf(_options);
|
||||
|
||||
@@ -64,6 +64,9 @@ public sealed class FleetGateway : IAgentGateway
|
||||
|
||||
public ValueTask DisconnectAsync(NodeId agent, string reason, CancellationToken ct) => Connections.DisconnectAsync(agent, reason, ct);
|
||||
|
||||
public ValueTask<bool> NotifyProbeLinkRetryAsync(NodeId probe, NodeId agent, CancellationToken ct) =>
|
||||
Connections.NotifyAsync(probe, MoShiroMethods.ProbeLinkRetry, new ProbeLinkRetryParams { AgentId = agent }, MoShiroJsonContext.Default.ProbeLinkRetryParams, ct);
|
||||
|
||||
public async ValueTask NotifyUpdateOfferAsync(NodeId node, string manifest, string signature, CancellationToken ct) =>
|
||||
await Connections.NotifyAsync(node, MoShiroMethods.UpdateOffer, new UpdateOfferParams { Manifest = manifest, Signature = signature }, MoShiroJsonContext.Default.UpdateOfferParams, ct);
|
||||
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
using MoShiro.Backend;
|
||||
|
||||
await BackendHost.Build(args).RunAsync();
|
||||
WebApplication app = BackendHost.Build(args);
|
||||
// 启动时打一行构建信息:版本、tag、提交、构建时间(和 Agent、Probe 启动时打的是同一组)
|
||||
if (app.Logger.IsEnabled(LogLevel.Information))
|
||||
{
|
||||
app.Logger.LogInformation("moshiro backend starting: version {BuildInfo}", MoShiro.Protocol.BuildInfo.Summary);
|
||||
}
|
||||
|
||||
await app.RunAsync();
|
||||
@@ -40,8 +40,8 @@ public sealed class ModelAndTestEvaluatorTests
|
||||
[Fact]
|
||||
public void BaseModel_PerfectLine_ScoresHigh_AndBadLine_ScoresLow()
|
||||
{
|
||||
double good = new BaseModel().Score(View(("rtt.p95", 25), ("loss.total", 0), ("jitter.down.p95", 1), ("jitter.up.p95", 1), ("loss.run_max", 1))).Score!.Value;
|
||||
double bad = new BaseModel().Score(View(("rtt.p95", 400), ("loss.total", 12), ("jitter.down.p95", 90), ("jitter.up.p95", 90), ("loss.run_max", 40))).Score!.Value;
|
||||
double good = new BaseModel().Score(View(("rtt.p95", 25), ("loss.total", 0), ("jitter.down.p95", 1), ("jitter.up.p95", 1), ("loss.run.up_max", 1), ("loss.run.down_max", 1))).Score!.Value;
|
||||
double bad = new BaseModel().Score(View(("rtt.p95", 400), ("loss.total", 12), ("jitter.down.p95", 90), ("jitter.up.p95", 90), ("loss.run.up_max", 40), ("loss.run.down_max", 40))).Score!.Value;
|
||||
Assert.True(good > 95);
|
||||
Assert.True(bad < 10);
|
||||
}
|
||||
@@ -83,7 +83,7 @@ public sealed class ModelAndTestEvaluatorTests
|
||||
var ranges = new Dictionary<string, double[]>
|
||||
{
|
||||
["rtt.p95"] = [10, 30, 80, 150, 300, 600], ["loss.total"] = [0, 0.5, 1, 3, 10, 30],
|
||||
["jitter.down.p95"] = [0, 2, 8, 20, 80, 200], ["jitter.up.p95"] = [0, 2, 8, 20, 80, 200], ["loss.run_max"] = [1, 3, 10, 30, 60],
|
||||
["jitter.down.p95"] = [0, 2, 8, 20, 80, 200], ["jitter.up.p95"] = [0, 2, 8, 20, 80, 200], ["loss.run.up_max"] = [1, 3, 10, 30, 60], ["loss.run.down_max"] = [1, 3, 10, 30, 60],
|
||||
["tcp.rtt.p95"] = [10, 40, 100, 200, 400, 800], ["tcp.stall.per_min"] = [0, 1, 5, 20, 60, 200], ["tcp.stall_max"] = [50, 150, 500, 2000, 5000],
|
||||
["tcp.retrans"] = [0, 0.1, 1, 3, 10, 30], ["connect.tls_ms.p95"] = [50, 150, 400, 1000, 3000], ["hold.resets.per_h"] = [0, 1, 4, 12, 30],
|
||||
["connect.success"] = [100, 99, 95, 80, 50], ["tcp.bulk.peak.max"] = [800, 400, 100, 10, 1], ["hy2.sustainable_mbps"] = [100, 50, 25, 10, 0],
|
||||
|
||||
@@ -90,6 +90,28 @@ public sealed class OpTests
|
||||
Assert.Equal(2, ((RunLengthAcc)EvaluateOneBucket(chunk, "loss.run").Items["loss.run"]).Max);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LossRuns_AreSeparatedByDirection_AndDownstreamExcludesRequestsLostUpstream()
|
||||
{
|
||||
bool[] upLost = [false, true, true, false, false, false, true, true];
|
||||
bool[] downLost = [false, false, true, true, false, true, false, true];
|
||||
RawChunk chunk = ChunkFactory.Echo(s_agent, "udp_echo", T0, upLost.Length, 100,
|
||||
i => new EchoPacket(upLost[i], downLost[i], 20_000, 20_000));
|
||||
|
||||
MetricSet set = EvaluateOneBucket(chunk, "loss.run.up", "loss.run.down");
|
||||
Assert.Equal(2, ((MaxAcc)set.Items["loss.run.up"]).Result);
|
||||
Assert.Equal(1, ((MaxAcc)set.Items["loss.run.down"]).Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LossRuns_NoLoss_ExportsZeroForBothDirections()
|
||||
{
|
||||
RawChunk chunk = ChunkFactory.UdpEchoClean(s_agent, T0, count: 8);
|
||||
MetricSet set = EvaluateOneBucket(chunk, "loss.run.up", "loss.run.down");
|
||||
Assert.Equal(0, ((MaxAcc)set.Items["loss.run.up"]).Result);
|
||||
Assert.Equal(0, ((MaxAcc)set.Items["loss.run.down"]).Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reorder_CountsLateArrivals_AndIgnoresDuplicates()
|
||||
{
|
||||
|
||||
@@ -8,8 +8,7 @@ using MoShiro.TestSupport;
|
||||
namespace MoShiro.Backend.Tests;
|
||||
|
||||
/// <summary>public_host 和 ipv6_host 自动识别:Agent 上报公网地址,手填的优先,可以撤销手填。</summary>
|
||||
[Collection("probe-e2e")]
|
||||
public sealed class AddressTests
|
||||
public sealed class AddressTests : IClassFixture<BackendFixture>
|
||||
{
|
||||
private const string V4 = "8.8.4.4";
|
||||
private const string V6 = "2606:4700:4700::1111";
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using MoShiro.Measurement;
|
||||
using MoShiro.Protocol;
|
||||
using MoShiro.TestSupport;
|
||||
|
||||
namespace MoShiro.Backend.Tests;
|
||||
|
||||
/// <summary>数据面协议拒绝错误首帧、伪造包、重放包和垃圾数据。</summary>
|
||||
[Trait("Category", "Slow")]
|
||||
public sealed class AgentDataPlaneTests : IClassFixture<BackendFixture>
|
||||
{
|
||||
private readonly BackendFixture _backend;
|
||||
|
||||
public AgentDataPlaneTests(BackendFixture backend)
|
||||
{
|
||||
_backend = backend;
|
||||
}
|
||||
|
||||
private async Task<(AgentHarness Agent, ProbeSimulator Probe, LineTask Udp)> LineAsync()
|
||||
{
|
||||
AgentHarness agent = await AgentHarness.StartAsync(_backend);
|
||||
await agent.OverrideLineTasksAsync(rateHz: 30);
|
||||
ProbeSimulator probe = await agent.ConnectProbeAsync();
|
||||
LineTaskSet set = await probe.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "udp_echo" && t.Port == agent.App.QuicPorts[0]));
|
||||
return (agent, probe, set.Tasks.Single(t => t.Kind == "udp_echo"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TcpDataPlane_ClosesConnectionsWithoutAValidFirstFrame()
|
||||
{
|
||||
(AgentHarness agent, ProbeSimulator probe, _) = await LineAsync();
|
||||
await using (agent)
|
||||
await using (probe)
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(IPAddress.Loopback, agent.App.TcpPorts[0]);
|
||||
await client.GetStream().WriteAsync(new byte[TcpWire.AuthBytes]); // 全零:magic 不对
|
||||
byte[] buffer = new byte[16];
|
||||
Assert.Equal(0, await ReadOrResetAsync(client.GetStream(), buffer)); // 连接被关闭:没有任何字节(Windows 上可能是 RST)
|
||||
await BackendFixture.EventuallyAsync(() => Task.FromResult(agent.App.Counters.DroppedInvalid >= 1));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DataPlane_DropsForgedReplayedAndGarbagePackets_WithoutAnswering()
|
||||
{
|
||||
(AgentHarness agent, ProbeSimulator probe, LineTask udp) = await LineAsync();
|
||||
await using (agent)
|
||||
await using (probe)
|
||||
{
|
||||
long before = agent.App.Counters.DroppedInvalid;
|
||||
await probe.SendRawUdpAsync([1, 2, 3]);
|
||||
await probe.SendRawUdpAsync(new byte[64]);
|
||||
await probe.SendForgedEchoAsync(udp, 1);
|
||||
await BackendFixture.EventuallyAsync(() => Task.FromResult(agent.App.Counters.DroppedInvalid - before >= 3), because: "garbage and forged packets are counted");
|
||||
|
||||
// 重放:同一个序号发两次,只有第一次被接受
|
||||
await probe.SendEchoAsync(udp, 500);
|
||||
await BackendFixture.EventuallyAsync(() => Task.FromResult(agent.App.Counters.PacketsOut >= 1));
|
||||
long dropped = agent.App.Counters.DroppedInvalid;
|
||||
await probe.SendEchoAsync(udp, 500);
|
||||
await BackendFixture.EventuallyAsync(() => Task.FromResult(agent.App.Counters.DroppedInvalid > dropped), because: "a replayed packet is dropped");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<int> ReadOrResetAsync(Stream stream, byte[] buffer)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await stream.ReadAsync(buffer).AsTask().WaitAsync(TimeSpan.FromSeconds(10));
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,9 @@ using MoShiro.TestSupport;
|
||||
namespace MoShiro.Backend.Tests;
|
||||
|
||||
/// <summary>真实的后端 + 真实的 Agent(进程内)+ 假 Probe:覆盖 Agent 与 Backend 的链路、Probe 与 Agent 的点对点会话和数据面。</summary>
|
||||
[Collection("backend")]
|
||||
public sealed class AgentEndToEndTests
|
||||
// 使用真实 Agent/QUIC/TCP 会话的端到端验证放在慢测集合,避免和大量 API/单元测试争抢本机套接字。
|
||||
[Trait("Category", "Slow")]
|
||||
public sealed class AgentEndToEndTests : IClassFixture<BackendFixture>
|
||||
{
|
||||
private readonly BackendFixture _backend;
|
||||
|
||||
@@ -21,7 +22,7 @@ public sealed class AgentEndToEndTests
|
||||
AgentHarness agent = await AgentHarness.StartAsync(_backend);
|
||||
await agent.OverrideLineTasksAsync(rateHz: 30);
|
||||
ProbeSimulator probe = await agent.ConnectProbeAsync();
|
||||
LineTaskSet set = await probe.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "udp_echo" && t.Port == agent.App.UdpPorts[0]));
|
||||
LineTaskSet set = await probe.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "udp_echo" && t.Port == agent.App.QuicPorts[0]));
|
||||
return (agent, probe, set.Tasks.Single(t => t.Kind == "udp_echo"));
|
||||
}
|
||||
|
||||
@@ -37,20 +38,20 @@ public sealed class AgentEndToEndTests
|
||||
|
||||
await using ProbeSimulator probe = await agent.ConnectProbeAsync();
|
||||
await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["probe_connected"]!.GetValue<bool>());
|
||||
await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["runtime_status"]!.GetValue<string>() == "running");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LocalLimits_RejectTasksOutsideTheAllowedPorts_AndTheReasonReachesTheAdmin()
|
||||
public async Task Agent_OrderlyShutdownIsReportedAsStoppingRatherThanOffline()
|
||||
{
|
||||
// 默认模板的端口(7443/7444)不在这个 Agent 实际监听的端口里:任务被拒绝,原因是 policy_denied
|
||||
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
|
||||
await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["rejected"]!.AsArray().Count == Defaults.TaskCount);
|
||||
JsonNode rejected = (await AgentViewAsync(agent))["rejected"]!;
|
||||
Assert.All(rejected.AsArray(), item => Assert.Equal("policy_denied", item!["reason"]!.GetValue<string>()));
|
||||
AgentHarness agent = await AgentHarness.StartAsync(_backend);
|
||||
await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["runtime_status"]!.GetValue<string>() == "running");
|
||||
|
||||
// 覆盖成实际端口之后恢复
|
||||
await agent.OverrideLineTasksAsync();
|
||||
await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["rejected"]!.AsArray().Count == 0);
|
||||
await agent.DisposeAsync();
|
||||
|
||||
await BackendFixture.EventuallyAsync(async () => !(await AgentViewAsync(agent))["online"]!.GetValue<bool>());
|
||||
JsonNode final = await AgentViewAsync(agent);
|
||||
Assert.Equal("stopping", final["runtime_status"]!.GetValue<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -95,57 +96,6 @@ public sealed class AgentEndToEndTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>读到 0 字节(对端关闭)或者连接被重置,都算「没收到任何东西」;收到数据返回字节数。</summary>
|
||||
internal static async Task<int> ReadOrResetAsync(Stream stream, byte[] buffer)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await stream.ReadAsync(buffer).AsTask().WaitAsync(TimeSpan.FromSeconds(10));
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TcpDataPlane_ClosesConnectionsWithoutAValidFirstFrame()
|
||||
{
|
||||
(AgentHarness agent, ProbeSimulator probe, _) = await LineAsync();
|
||||
await using (agent)
|
||||
await using (probe)
|
||||
{
|
||||
using var client = new System.Net.Sockets.TcpClient();
|
||||
await client.ConnectAsync(System.Net.IPAddress.Loopback, agent.App.TcpPorts[0]);
|
||||
await client.GetStream().WriteAsync(new byte[TcpWire.AuthBytes]); // 全零:magic 不对
|
||||
byte[] buffer = new byte[16];
|
||||
Assert.Equal(0, await ReadOrResetAsync(client.GetStream(), buffer)); // 连接被关闭:没有任何字节(Windows 上可能是 RST)
|
||||
await BackendFixture.EventuallyAsync(() => Task.FromResult(agent.App.Counters.DroppedInvalid >= 1));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DataPlane_DropsForgedReplayedAndGarbagePackets_WithoutAnswering()
|
||||
{
|
||||
(AgentHarness agent, ProbeSimulator probe, LineTask udp) = await LineAsync();
|
||||
await using (agent)
|
||||
await using (probe)
|
||||
{
|
||||
long before = agent.App.Counters.DroppedInvalid;
|
||||
await probe.SendRawUdpAsync([1, 2, 3]);
|
||||
await probe.SendRawUdpAsync(new byte[64]);
|
||||
await probe.SendForgedEchoAsync(udp, 1);
|
||||
await BackendFixture.EventuallyAsync(() => Task.FromResult(agent.App.Counters.DroppedInvalid - before >= 3), because: "garbage and forged packets are counted");
|
||||
|
||||
// 重放:同一个序号发两次,只有第一次被接受
|
||||
await probe.SendEchoAsync(udp, 500);
|
||||
await BackendFixture.EventuallyAsync(() => Task.FromResult(agent.App.Counters.PacketsOut >= 1));
|
||||
long dropped = agent.App.Counters.DroppedInvalid;
|
||||
await probe.SendEchoAsync(udp, 500);
|
||||
await BackendFixture.EventuallyAsync(() => Task.FromResult(agent.App.Counters.DroppedInvalid > dropped), because: "a replayed packet is dropped");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ARestartedProbe_NeverInheritsTheAgentRecordsOfItsPreviousSession()
|
||||
{
|
||||
@@ -159,8 +109,8 @@ public sealed class AgentEndToEndTests
|
||||
|
||||
// The probe process restarts: a new session with its own streams; sequence numbers start from 0 again
|
||||
await using ProbeSimulator second = first.Restarted();
|
||||
await second.ConnectAsync("127.0.0.1", agent.App.ControlPort, agent.TlsPublicKey, agent.App.UdpPorts[0], agent.NodeId);
|
||||
LineTaskSet set = await second.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "udp_echo" && t.Port == agent.App.UdpPorts[0]));
|
||||
await second.ConnectAsync("127.0.0.1", agent.App.ControlPort, agent.TlsPublicKey, agent.App.QuicPorts[0], agent.NodeId);
|
||||
LineTaskSet set = await second.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "udp_echo" && t.Port == agent.App.QuicPorts[0]));
|
||||
LineTask udp2 = set.Tasks.Single(t => t.Kind == "udp_echo");
|
||||
Assert.NotEqual(udp.StreamId, udp2.StreamId);
|
||||
|
||||
@@ -221,11 +171,11 @@ public sealed class AgentEndToEndTests
|
||||
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
|
||||
await agent.CreateProbeAsync();
|
||||
await using var stranger = new ProbeSimulator("prb_stranger", Fleet.Identity.NodeIdentity.Generate());
|
||||
await Assert.ThrowsAnyAsync<Exception>(() => stranger.ConnectAsync("127.0.0.1", agent.App.ControlPort, agent.TlsPublicKey, agent.App.UdpPorts[0], agent.NodeId));
|
||||
await Assert.ThrowsAnyAsync<Exception>(() => stranger.ConnectAsync("127.0.0.1", agent.App.ControlPort, agent.TlsPublicKey, agent.App.QuicPorts[0], agent.NodeId));
|
||||
|
||||
// 证书公钥不对(中间人)也不会连上
|
||||
await using ProbeSimulator probe = await agent.CreateProbeAsync();
|
||||
await Assert.ThrowsAnyAsync<Exception>(() => probe.ConnectAsync("127.0.0.1", agent.App.ControlPort, Fleet.Identity.NodeIdentity.Generate().PublicKeySpki, agent.App.UdpPorts[0], agent.NodeId));
|
||||
await Assert.ThrowsAnyAsync<Exception>(() => probe.ConnectAsync("127.0.0.1", agent.App.ControlPort, Fleet.Identity.NodeIdentity.Generate().PublicKeySpki, agent.App.QuicPorts[0], agent.NodeId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -240,100 +190,13 @@ public sealed class AgentEndToEndTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LineTaskSet_FollowsTheDesiredState_AndIsPushedToTheProbe()
|
||||
{
|
||||
(AgentHarness agent, ProbeSimulator probe, LineTask udp) = await LineAsync();
|
||||
await using (agent)
|
||||
await using (probe)
|
||||
{
|
||||
JsonNode tasks = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/tasks");
|
||||
JsonNode mine = tasks["items"]!.AsArray().Single(t => t!["agent_id"]?.GetValue<string>() == agent.Id && t["kind"]!.GetValue<string>() == "udp_echo")!;
|
||||
using HttpResponseMessage disabled = await _backend.Admin.PatchAsync("/api/admin/tasks/" + mine["id"]!.GetValue<string>(), BackendFixture.Json(new JsonObject { ["enabled"] = false }, "application/merge-patch+json"));
|
||||
disabled.EnsureSuccessStatusCode();
|
||||
|
||||
LineTaskSet next = await probe.WaitForTasksAsync(s => s.Tasks.All(t => t.Kind != "udp_echo") && s.Tasks.Count >= 0 && s.Version > 0);
|
||||
Assert.DoesNotContain(next.Tasks, t => t.Id == udp.Id);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Hy2Ladder_EndToEnd_AgentSendsTheTrain_ProbeReportsReceipts_AndTheTestSucceeds()
|
||||
{
|
||||
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
|
||||
await agent.OverrideLineTasksAsync();
|
||||
await using ProbeSimulator probe = await agent.ConnectProbeAsync();
|
||||
await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["probe_connected"]!.GetValue<bool>());
|
||||
|
||||
HttpResponseMessage? accepted = null;
|
||||
string? startFailure = null;
|
||||
for (int attempt = 0; attempt < 5; attempt++)
|
||||
{
|
||||
accepted = await _backend.Admin.PostAsync($"/api/admin/lines/{agent.Id}~{probe.ProbeId}/tests", BackendFixture.Json(new JsonObject { ["plan"] = "hy2_ladder", ["params"] = new JsonObject { ["size"] = 1000, ["duration_s"] = 5 } }));
|
||||
if (accepted.StatusCode == System.Net.HttpStatusCode.Accepted)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
startFailure = await accepted.Content.ReadAsStringAsync();
|
||||
string? code = JsonNode.Parse(startFailure)?["code"]?.GetValue<string>();
|
||||
accepted.Dispose();
|
||||
accepted = null;
|
||||
if (code != "probe_offline" || attempt == 4)
|
||||
{
|
||||
Assert.Fail($"The test request was rejected: {startFailure}");
|
||||
}
|
||||
|
||||
await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["probe_connected"]!.GetValue<bool>(), TimeSpan.FromSeconds(15), "the probe should reconnect after a transient disconnect");
|
||||
}
|
||||
|
||||
HttpResponseMessage acceptedResponse = accepted ?? throw new InvalidOperationException("The test request was not accepted.");
|
||||
using (acceptedResponse)
|
||||
{
|
||||
string testId = JsonNode.Parse(await acceptedResponse.Content.ReadAsStringAsync())!["id"]!.GetValue<string>();
|
||||
|
||||
JsonNode test = null!;
|
||||
await BackendFixture.EventuallyAsync(
|
||||
async () =>
|
||||
{
|
||||
test = await _backend.GetJsonAsync(_backend.Admin, "/api/public/tests/" + testId);
|
||||
return test["status"]!.GetValue<string>() is "succeeded" or "failed" or "aborted";
|
||||
},
|
||||
TimeSpan.FromSeconds(90));
|
||||
Assert.True(test["status"]!.GetValue<string>() == "succeeded", test.ToJsonString());
|
||||
Assert.Equal(4, probe.Phases.Count);
|
||||
Assert.All(probe.Phases, phase => Assert.Equal("udp_train", phase.Kind));
|
||||
|
||||
// 单块最多 50000 条记录:50 和 100 Mbit/s 阶段(5 s、1000 B 的包)里 100 Mbit/s 的那一段有 6 万多条记录,被 Probe 切成两个列块
|
||||
Assert.True(agent.App.Counters.Fragments > 4);
|
||||
await BackendFixture.EventuallyAsync(
|
||||
async () => (await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/raw/chunks?agent_id={agent.Id}"))["items"]!.AsArray().Count(c => c!["kind"]!.GetValue<string>() == "udp_train") == agent.App.Counters.Fragments,
|
||||
because: (await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/events?agent_id={agent.Id}")).ToJsonString() + $" fragments={agent.App.Counters.Fragments} discarded={agent.App.Counters.FragmentsDiscarded} pending={agent.App.Link.Outbox.PendingCount}/{agent.App.Link.Outbox.PendingBytes} dropped={agent.App.Link.Outbox.Dropped} link={agent.App.Link.IsConnected}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Plan_UsingTcpBulk_IsAcceptedByARealAgentAndStartsItsFirstPhase()
|
||||
{
|
||||
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
|
||||
await using ProbeSimulator probe = await agent.ConnectProbeAsync();
|
||||
await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["probe_connected"]!.GetValue<bool>());
|
||||
|
||||
// reality_session 需要 connect、tcp_bulk、hold:真实的 Agent 都实现了
|
||||
using HttpResponseMessage response = await _backend.Admin.PostAsync($"/api/admin/lines/{agent.Id}~{probe.ProbeId}/tests", BackendFixture.Json(new JsonObject { ["plan"] = "reality_session" }));
|
||||
Assert.Equal(System.Net.HttpStatusCode.Accepted, response.StatusCode);
|
||||
await BackendFixture.EventuallyAsync(() => Task.FromResult(!probe.Phases.IsEmpty), because: "the first phase should start");
|
||||
Assert.True(probe.Phases.TryPeek(out var first));
|
||||
Assert.Equal("connect", first.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_IsRefusedWhenNoProbeIsConnected_AndWhenAPhaseExceedsTheLocalLimits()
|
||||
{
|
||||
await using AgentHarness agent = await AgentHarness.StartAsync(_backend, config => config.Limits.MaxTotalMbps = 20);
|
||||
await agent.OverrideLineTasksAsync();
|
||||
|
||||
var phase = new TestPhase { Kind = "udp_train", DurationSeconds = 1, Params = { ["port"] = System.Text.Json.JsonSerializer.SerializeToElement(agent.App.UdpPorts[0]) } };
|
||||
var phase = new TestPhase { Kind = "udp_train", DurationSeconds = 1, Params = { ["port"] = System.Text.Json.JsonSerializer.SerializeToElement(agent.App.QuicPorts[0]) } };
|
||||
var run = new TestRunParams { TestId = new TestId("tst_x"), ProbeId = new ProbeId("prb_nobody"), Plan = "x", Phases = [phase] };
|
||||
Assert.Equal("probe_offline", agent.App.Tests.Start(run).Error.Code.Value);
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ public sealed class AgentHarness : IAsyncDisposable
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "moshiro-agent-" + Guid.NewGuid().ToString("N"));
|
||||
string token = pin?.Code ?? await backend.CreateJoinTokenAsync();
|
||||
using var http = new HttpClient();
|
||||
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(15) };
|
||||
Result<EnrollmentResult> enrolled = await EnrollmentClient.EnrollAsync(
|
||||
http, backend.BaseAddress, token, name ?? backend.UniqueName("agent"), directory, AgentApp.Version, null, TimeProvider.System, CancellationToken.None, pin?.Genesis, pin?.Controllers);
|
||||
Assert.True(enrolled.IsSuccess, enrolled.IsSuccess ? "" : enrolled.Error.ToString());
|
||||
@@ -58,11 +58,11 @@ public sealed class AgentHarness : IAsyncDisposable
|
||||
var config = new AgentConfig
|
||||
{
|
||||
Fleet = { Server = backend.BaseAddress.ToString(), IdentityDir = directory },
|
||||
Listen = { Bind = "127.0.0.1", ControlPort = 0, UdpPorts = [0], TcpPorts = [0] },
|
||||
Listen = { Bind = "127.0.0.1", ControlPort = 0, QuicPorts = [0], TcpPorts = [0] },
|
||||
};
|
||||
configure?.Invoke(config);
|
||||
LoadedNodeIdentity identity = LoadedNodeIdentity.Load(directory);
|
||||
var app = new AgentApp(config, identity, directory, TimeProvider.System, new ConsoleNodeLog(TimeProvider.System, Console.Out));
|
||||
var app = new AgentApp(config, identity, directory, TimeProvider.System, NullNodeLog.Instance);
|
||||
var harness = new AgentHarness(backend, directory, identity, app);
|
||||
await app.Ready.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
await BackendFixture.EventuallyAsync(() => Task.FromResult(app.Link.IsConnected), because: "the agent should connect to the backend");
|
||||
@@ -87,7 +87,7 @@ public sealed class AgentHarness : IAsyncDisposable
|
||||
public async Task<ProbeSimulator> ConnectProbeAsync()
|
||||
{
|
||||
ProbeSimulator probe = await CreateProbeAsync();
|
||||
await probe.ConnectAsync("127.0.0.1", App.ControlPort, TlsPublicKey, App.UdpPorts[0], NodeId);
|
||||
await probe.ConnectAsync("127.0.0.1", App.ControlPort, TlsPublicKey, App.QuicPorts[0], NodeId);
|
||||
return probe;
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ public sealed class AgentHarness : IAsyncDisposable
|
||||
}
|
||||
|
||||
string kind = task["kind"]!.GetValue<string>();
|
||||
var parameters = new JsonObject { ["port"] = kind == "udp_echo" ? App.UdpPorts[0] : App.TcpPorts[0] };
|
||||
var parameters = new JsonObject { ["port"] = kind == "udp_echo" ? App.QuicPorts[0] : App.TcpPorts[0] };
|
||||
// 默认的 connect 模板在这些用例里不需要(也占预算):对这个 Agent 关掉它
|
||||
bool enabled = kind != "connect";
|
||||
if (rateHz is { } hz && kind != "connect")
|
||||
@@ -137,7 +137,7 @@ public sealed class AgentHarness : IAsyncDisposable
|
||||
// 停止中
|
||||
}
|
||||
|
||||
App.Dispose();
|
||||
await App.DisposeAsync();
|
||||
_identity.Dispose();
|
||||
_stop.Dispose();
|
||||
try
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace MoShiro.Backend.Tests;
|
||||
|
||||
/// <summary>Agent local policy validation has its own Backend fixture so task overrides from line-session tests cannot leak into it.</summary>
|
||||
[Trait("Category", "Slow")]
|
||||
public sealed class AgentLimitsTests : IClassFixture<BackendFixture>
|
||||
{
|
||||
private readonly BackendFixture _backend;
|
||||
|
||||
public AgentLimitsTests(BackendFixture backend)
|
||||
{
|
||||
_backend = backend;
|
||||
}
|
||||
|
||||
private async Task<JsonNode> AgentViewAsync(AgentHarness agent) => await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.Id);
|
||||
|
||||
[Fact]
|
||||
public async Task LocalLimits_RejectTasksOutsideTheAllowedPorts_AndTheReasonReachesTheAdmin()
|
||||
{
|
||||
// The default task ports (7443/7444) do not match this Agent's actual listeners, so policy rejects them.
|
||||
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
|
||||
await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["rejected"]!.AsArray().Count == Defaults.TaskCount);
|
||||
JsonNode rejected = (await AgentViewAsync(agent))["rejected"]!;
|
||||
Assert.All(rejected.AsArray(), item => Assert.Equal("policy_denied", item!["reason"]!.GetValue<string>()));
|
||||
|
||||
// Override the task ports to match the actual listeners and verify recovery.
|
||||
await agent.OverrideLineTasksAsync();
|
||||
await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["rejected"]!.AsArray().Count == 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using MoShiro.Measurement;
|
||||
using MoShiro.Protocol;
|
||||
using MoShiro.TestSupport;
|
||||
|
||||
namespace MoShiro.Backend.Tests;
|
||||
|
||||
/// <summary>Exercises the complete Backend → Agent → Probe desired task update with an isolated Backend fixture.</summary>
|
||||
[Trait("Category", "Slow")]
|
||||
public sealed class AgentTaskSetEndToEndTests : IClassFixture<BackendFixture>
|
||||
{
|
||||
private readonly BackendFixture _backend;
|
||||
|
||||
public AgentTaskSetEndToEndTests(BackendFixture backend)
|
||||
{
|
||||
_backend = backend;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LineTaskSet_FollowsTheDesiredState_AndIsPushedToTheProbe()
|
||||
{
|
||||
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
|
||||
await agent.OverrideLineTasksAsync(rateHz: 30);
|
||||
await using ProbeSimulator probe = await agent.ConnectProbeAsync();
|
||||
LineTask udp = (await probe.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "udp_echo" && t.Port == agent.App.QuicPorts[0])))
|
||||
.Tasks.Single(t => t.Kind == "udp_echo");
|
||||
|
||||
JsonNode tasks = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/tasks");
|
||||
JsonNode mine = tasks["items"]!.AsArray().Single(t => t!["agent_id"]?.GetValue<string>() == agent.Id && t["kind"]!.GetValue<string>() == "udp_echo")!;
|
||||
using HttpResponseMessage disabled = await _backend.Admin.PatchAsync("/api/admin/tasks/" + mine["id"]!.GetValue<string>(), BackendFixture.Json(new JsonObject { ["enabled"] = false }, "application/merge-patch+json"));
|
||||
disabled.EnsureSuccessStatusCode();
|
||||
|
||||
LineTaskSet next = await probe.WaitForTasksAsync(s => s.Tasks.All(t => t.Kind != "udp_echo") && s.Version > 0);
|
||||
Assert.DoesNotContain(next.Tasks, t => t.Id == udp.Id);
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ public sealed class AotSmokeTests
|
||||
{
|
||||
Assert.SkipUnless(Url is not null, "set MOSHIRO_AOT_URL to the address of a running NativeAOT build to run this test");
|
||||
var baseAddress = new Uri(Url!);
|
||||
using var http = new HttpClient { BaseAddress = baseAddress };
|
||||
using var http = new HttpClient { BaseAddress = baseAddress, Timeout = TimeSpan.FromSeconds(15) };
|
||||
|
||||
// 管理员:登录 → 令牌
|
||||
using HttpResponseMessage login = await http.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = BackendFixture.AdminUser, ["password"] = BackendFixture.AdminPassword }));
|
||||
@@ -41,7 +41,7 @@ public sealed class AotSmokeTests
|
||||
using HttpResponseMessage probeJoin = await http.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "probe", ["max_uses"] = 1 }));
|
||||
probeJoin.EnsureSuccessStatusCode();
|
||||
string probeToken = JsonNode.Parse(await probeJoin.Content.ReadAsStringAsync())!["plaintext"]!.GetValue<string>();
|
||||
using var probeHttp = new HttpClient();
|
||||
using var probeHttp = new HttpClient { Timeout = TimeSpan.FromSeconds(15) };
|
||||
Fleet.Core.Result<Fleet.Enrollment.Client.EnrollmentResult> probeEnrolled = await Fleet.Enrollment.Client.EnrollmentClient.EnrollAsync(
|
||||
probeHttp, baseAddress, probeToken, "aot-probe-" + Guid.NewGuid().ToString("N")[..6], Path.Combine(Path.GetTempPath(), "moshiro-aot-probe-" + Guid.NewGuid().ToString("N")), "0.0.0", null, TimeProvider.System, default);
|
||||
Assert.True(probeEnrolled.IsSuccess, probeEnrolled.IsSuccess ? "" : probeEnrolled.Error.ToString());
|
||||
@@ -51,7 +51,7 @@ public sealed class AotSmokeTests
|
||||
using HttpResponseMessage joinResponse = await http.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["max_uses"] = 1 }));
|
||||
joinResponse.EnsureSuccessStatusCode();
|
||||
string joinToken = JsonNode.Parse(await joinResponse.Content.ReadAsStringAsync())!["plaintext"]!.GetValue<string>();
|
||||
using var anonymous = new HttpClient { BaseAddress = baseAddress };
|
||||
using var anonymous = new HttpClient { BaseAddress = baseAddress, Timeout = TimeSpan.FromSeconds(15) };
|
||||
await using AgentSimulator agent = await AgentSimulator.EnrollAsync(anonymous, joinToken, "aot-" + Guid.NewGuid().ToString("N")[..6]);
|
||||
agent.ProbeForChunks = new ProbeId(probeId);
|
||||
string lineId = $"{agent.NodeId.Value}~{probeId}";
|
||||
|
||||
@@ -4,8 +4,7 @@ using System.Text.Json.Nodes;
|
||||
|
||||
namespace MoShiro.Backend.Tests;
|
||||
|
||||
[Collection("backend")]
|
||||
public sealed class ApiBasicsTests
|
||||
public sealed class ApiBasicsTests : IClassFixture<BackendFixture>
|
||||
{
|
||||
private readonly BackendFixture _backend;
|
||||
|
||||
@@ -226,9 +225,3 @@ public sealed class ApiBasicsTests
|
||||
Assert.Contains("moshiro_analysis_queue_length", text);
|
||||
}
|
||||
}
|
||||
|
||||
[CollectionDefinition("backend")]
|
||||
public sealed class BackendCollection : ICollectionFixture<BackendFixture>;
|
||||
|
||||
[CollectionDefinition("probe-e2e")]
|
||||
public sealed class ProbeEndToEndCollection : ICollectionFixture<BackendFixture>;
|
||||
@@ -4,8 +4,7 @@ using System.Text.Json.Nodes;
|
||||
namespace MoShiro.Backend.Tests;
|
||||
|
||||
/// <summary>审计日志:写操作、登录成败都会记一行;谁做的(用户名或 key:令牌名)、结果、细节。</summary>
|
||||
[Collection("probe-e2e")]
|
||||
public sealed class AuditTests
|
||||
public sealed class AuditTests : IClassFixture<BackendFixture>
|
||||
{
|
||||
private readonly BackendFixture _backend;
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ public class BackendFixture : IAsyncLifetime
|
||||
{
|
||||
string token = await CreateJoinTokenAsync(role: "probe");
|
||||
string directory = Path.Combine(Path.GetTempPath(), "moshiro-probe-id-" + Guid.NewGuid().ToString("N"));
|
||||
using var http = new HttpClient();
|
||||
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(15) };
|
||||
Fleet.Core.Result<Fleet.Enrollment.Client.EnrollmentResult> enrolled = await Fleet.Enrollment.Client.EnrollmentClient.EnrollAsync(
|
||||
http, BaseAddress, token, name ?? UniqueName("probe"), directory, "0.0.0-test", null, TimeProvider.System, default);
|
||||
Assert.True(enrolled.IsSuccess, enrolled.IsSuccess ? "" : enrolled.Error.ToString());
|
||||
@@ -179,7 +179,7 @@ public class BackendFixture : IAsyncLifetime
|
||||
return (client, passkey, name);
|
||||
}
|
||||
|
||||
public HttpClient NewClient() => new() { BaseAddress = BaseAddress };
|
||||
public HttpClient NewClient() => new() { BaseAddress = BaseAddress, Timeout = TimeSpan.FromSeconds(15) };
|
||||
|
||||
public string UniqueName(string prefix) => $"{prefix}-{Interlocked.Increment(ref _counter)}";
|
||||
|
||||
|
||||
@@ -30,6 +30,18 @@ public sealed class BackendUpdateTests : IAsyncLifetime
|
||||
Assert.Equal(expected, BackendUpdates.ChannelOf(new BackendOptions { UpdateChannel = "auto", ImageNamespace = imageNamespace }));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("v0.4.1-dev.12-044ce0c", true)]
|
||||
[InlineData("dev-725fe17a7de8", true)]
|
||||
[InlineData("main-725fe17a7d", true)]
|
||||
[InlineData("v0.4.0", false)]
|
||||
[InlineData("v0.4.1-rc.1", false)]
|
||||
[InlineData(null, false)]
|
||||
public void IsDevTag_RecognisesDevelopmentBuilds(string? tag, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, BackendUpdates.IsDevTag(tag));
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> PublishAsync(long seq, string channel = "dev")
|
||||
{
|
||||
string manifest = $$"""{"component":"backend","channel":"{{channel}}","image":"registry.example/moshiro-{{channel}}/moshiro-backend","digest":"{{Digest}}","tag":"{{channel}}-{{seq}}","version":"0.1.1","seq":{{seq}}}""";
|
||||
|
||||
@@ -9,8 +9,7 @@ using MoShiro.Backend.Application;
|
||||
namespace MoShiro.Backend.Tests;
|
||||
|
||||
/// <summary>能力模型(docs/security/access.md 第 3 节):每个管理端点声明能力;密钥只能拿到自己拥有能力的子集;写蕴含读。</summary>
|
||||
[Collection("probe-e2e")]
|
||||
public sealed class CapabilityTests
|
||||
public sealed class CapabilityTests : IClassFixture<BackendFixture>
|
||||
{
|
||||
private readonly BackendFixture _backend;
|
||||
|
||||
|
||||
@@ -5,8 +5,7 @@ using MoShiro.TestSupport;
|
||||
|
||||
namespace MoShiro.Backend.Tests;
|
||||
|
||||
[Collection("backend")]
|
||||
public sealed class EnrollmentAndTaskSetTests
|
||||
public sealed class EnrollmentAndTaskSetTests : IClassFixture<BackendFixture>
|
||||
{
|
||||
private readonly BackendFixture _backend;
|
||||
|
||||
@@ -377,7 +376,7 @@ public sealed class EnrollmentAndTaskSetTests
|
||||
JsonNode listed = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/probes");
|
||||
Assert.Contains(listed["items"]!.AsArray(), p => p!["id"]!.GetValue<string>() == id && !p["revoked"]!.GetValue<bool>());
|
||||
string duplicateToken = await _backend.CreateJoinTokenAsync(role: "probe");
|
||||
using var http = new HttpClient();
|
||||
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(15) };
|
||||
Fleet.Core.Result<Fleet.Enrollment.Client.EnrollmentResult> duplicate = await Fleet.Enrollment.Client.EnrollmentClient.EnrollAsync(
|
||||
http, _backend.BaseAddress, duplicateToken, name, Path.Combine(Path.GetTempPath(), "moshiro-dup-" + Guid.NewGuid().ToString("N")), "0.0.0", null, TimeProvider.System, default);
|
||||
Assert.False(duplicate.IsSuccess);
|
||||
|
||||
@@ -115,8 +115,7 @@ public sealed class HostSamplerTests
|
||||
}
|
||||
}
|
||||
|
||||
[Collection("backend")]
|
||||
public sealed class HostStatusTests
|
||||
public sealed class HostStatusTests : IClassFixture<BackendFixture>
|
||||
{
|
||||
private readonly BackendFixture _backend;
|
||||
|
||||
@@ -185,6 +184,7 @@ public sealed class HostStatusTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Slow")]
|
||||
public async Task ARealProbeReportsItsHostStatus_LikeAnAgent_NeverCarriesModelsOrAddresses_AndDisappearsWhenRevoked()
|
||||
{
|
||||
await _backend.SiteMustBePublicAsync();
|
||||
@@ -221,6 +221,7 @@ public sealed class HostStatusTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Slow")]
|
||||
public async Task AProbeThatTurnsReportingOff_SendsNothing()
|
||||
{
|
||||
await _backend.SiteMustBePublicAsync();
|
||||
|
||||
Loaded 100 of 235 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user