chore: initial project import

This commit is contained in:
justme committed 2026-10-04 21:46:39 +08:00
commit 26620707a0
347 files changed
+56172

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
artifacts/
bin/
obj/
*.user
.vs/
data/
*.db
*.db-wal
*.db-shm
TestResults/
.idea/
__pycache__/
node_modules/
dist/
*.tsbuildinfo
.env
.env.*
!.env.example
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+94
View File
@@ -0,0 +1,94 @@
# Monitor
Monitor 是一个自托管的网络线路质量观测工具。它在 Probe 与多个 Agent 之间执行主动网络测量,记录逐包和逐事件结果;Backend 保存原始数据并生成可查询的线路指标,Web 界面用于查看、比较和管理节点。
它面向需要比较不同网络出口、评估线路稳定性,或观察特定网络服务体验的场景。Agent 与 Probe 专注于测量和上报;它们不提供远程终端、命令执行或文件管理能力。
## 能力概览
- **主动测量**:组合 TCP、UDP 等测量模块,采集延迟、丢包、抖动、吞吐和连接事件。
- **线路分析**:从原始测量数据生成时间序列指标,并比较线路在不同服务模型下的体验。
- **节点管理**:通过 Fleet 节点框架处理身份、注册、连接、任务同步和数据上报。
- **实时面板**:展示端查看线路状态和历史趋势;管理端负责节点、任务和系统配置。
- **自托管**:Backend 使用段文件和 SQLite 保存数据;提供 Linux、systemd 和 OpenWrt 相关部署文件。
## 架构
```text
Probe <──测量会话──> Agent ──长连接与数据上报──> Backend <──REST / SSE──> Web 展示端与管理端
├── 段文件:原始数据
└── SQLite:索引、指标与配置
```
## 快速开始
### 环境要求
[README.md](backend/README.md)
- .NET SDK 10;项目使用 `backend/global.json` 指定 SDK 版本和滚动策略。
- Node.js 与 npm,用于构建 Web 前端。
### 构建
从仓库根目录构建后端:
```sh
cd backend
dotnet build Monitor.slnx -c Release
```
安装前端依赖并构建展示端和管理端(在仓库根目录运行):
```sh
npm ci --prefix web
npm run build --prefix web
```
前端产物位于 `web/display/dist/` 和 `web/admin/dist/`。NativeAOT 发布还需要对应平台的 C/C++ 工具链;Linux 发布脚本和 RID 说明见 [`docs/deployment.md`](docs/deployment.md)。
### 本地运行
1. **启动 Backend。** 在仓库根目录运行:
```sh
cd backend
dotnet run --project src/Monitor.Backend -- --urls http://localhost:5000
```
2. **启动前端。** 保持 Backend 运行,在另一个终端的仓库根目录运行:
```sh
npm ci --prefix web
npm run dev --prefix web
```
打开 <http://localhost:5173/> 使用展示端,或访问 <http://localhost:5174/admin/> 进入管理端。依赖安装和前端配置见 [`web/README.md`](web/README.md);Backend 的首次启动与配置见 [`backend/README.md`](backend/README.md)。
## 项目布局
| 路径 | 说明 |
| --- | --- |
| [`backend/`](backend/README.md) | Backend 服务、Fleet 框架、共享测量代码与 .NET 测试 |
| [`backend/agent/`](backend/agent/README.md) | Agent 节点:注册到 Backend,接受 Probe 测量并上报数据 |
| [`backend/probe/`](backend/probe/README.md) | Probe 节点:发起线路测量并将采集数据交给 Agent |
| `backend/fleet/` | 可复用的节点身份、注册和连接框架 |
| `backend/shared/` | 测量引擎、协议和原始数据格式 |
| `web/display/`、`web/admin/` | 展示端和管理端 |
| `web/shared/` | 前端 API、图表、组件与主题 |
| `packaging/`、`scripts/` | 部署、构建与维护脚本 |
| `docs/` | [架构](docs/architecture.md)、[测量模型](docs/measurement.md)、[API](docs/api.md)、[部署](docs/deployment.md)等文档 |
## 测试
```sh
cd backend
dotnet test --solution Monitor.slnx
```
```sh
# 前端样式检查(仓库根目录)
npm run lint:css --prefix web
```
## 许可证
本项目采用 [Apache License 2.0](LICENSE)。
+81
View File
@@ -0,0 +1,81 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 4
insert_final_newline = true
trim_trailing_whitespace = true
[*.{json,props,csproj,slnx,yml,yaml,md}]
indent_size = 2
[*.cs]
csharp_new_line_before_open_brace = all
csharp_new_line_before_else = true
csharp_new_line_before_catch = true
csharp_new_line_before_finally = true
csharp_prefer_braces = true:warning
csharp_style_namespace_declarations = file_scoped:warning
csharp_using_directive_placement = outside_namespace:warning
dotnet_sort_system_directives_first = true
dotnet_separate_import_directive_groups = false
dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning
dotnet_style_qualification_for_field = false:warning
dotnet_style_qualification_for_method = false:warning
dotnet_style_qualification_for_property = false:warning
dotnet_style_predefined_type_for_locals_parameters_members = true:warning
dotnet_style_predefined_type_for_member_access = true:warning
# 命名:私有/内部实例字段 _camelCase,静态字段 s_camelCase,线程静态 t_camelCase
dotnet_naming_rule.static_fields.symbols = static_fields
dotnet_naming_rule.static_fields.style = s_camel
dotnet_naming_rule.static_fields.severity = warning
dotnet_naming_symbols.static_fields.applicable_kinds = field
dotnet_naming_symbols.static_fields.applicable_accessibilities = private, internal, private_protected
dotnet_naming_symbols.static_fields.required_modifiers = static
dotnet_naming_rule.private_instance_fields.symbols = private_instance_fields
dotnet_naming_rule.private_instance_fields.style = underscore_camel
dotnet_naming_rule.private_instance_fields.severity = warning
dotnet_naming_symbols.private_instance_fields.applicable_kinds = field
dotnet_naming_symbols.private_instance_fields.applicable_accessibilities = private, internal, private_protected
dotnet_naming_rule.interfaces.symbols = interfaces
dotnet_naming_rule.interfaces.style = i_pascal
dotnet_naming_rule.interfaces.severity = warning
dotnet_naming_symbols.interfaces.applicable_kinds = interface
dotnet_naming_style.underscore_camel.required_prefix = _
dotnet_naming_style.underscore_camel.capitalization = camel_case
dotnet_naming_style.s_camel.required_prefix = s_
dotnet_naming_style.s_camel.capitalization = camel_case
dotnet_naming_style.i_pascal.required_prefix = I
dotnet_naming_style.i_pascal.capitalization = pascal_case
# 对本项目不适用的建议规则
dotnet_diagnostic.CA1707.severity = none
dotnet_diagnostic.CA1716.severity = none
dotnet_diagnostic.CA1819.severity = none
dotnet_diagnostic.CA2007.severity = none
dotnet_diagnostic.CA1305.severity = none
dotnet_diagnostic.CA1308.severity = none
dotnet_diagnostic.CA1002.severity = none
dotnet_diagnostic.CA1000.severity = none
dotnet_diagnostic.CA1034.severity = none
dotnet_diagnostic.CA1062.severity = none
dotnet_diagnostic.CA1031.severity = none
dotnet_diagnostic.CA1848.severity = none
dotnet_diagnostic.CA2201.severity = none
dotnet_diagnostic.CA1861.severity = none
dotnet_diagnostic.CA1812.severity = none
dotnet_diagnostic.CA1805.severity = none
dotnet_diagnostic.CA1822.severity = suggestion
dotnet_diagnostic.IDE0005.severity = none
dotnet_diagnostic.IDE0058.severity = none
dotnet_diagnostic.CA1720.severity = none
dotnet_diagnostic.CA1711.severity = none
[{**/tests/**.cs,tests/**.cs}]
dotnet_diagnostic.xUnit1051.severity = none
dotnet_diagnostic.CA1707.severity = none
+9
View File
@@ -0,0 +1,9 @@
T:System.Diagnostics.Process;节点是纯采集器,禁止启动进程
M:System.Reflection.Assembly.Load(System.String);禁止加载外部代码
M:System.Reflection.Assembly.LoadFrom(System.String);禁止加载外部代码
M:System.Reflection.Assembly.LoadFile(System.String);禁止加载外部代码
M:System.Runtime.InteropServices.NativeLibrary.Load(System.String);禁止加载本机库
M:System.IO.File.WriteAllText(System.String,System.String);只有 ConfigStore 可以写文件
M:System.IO.File.WriteAllBytes(System.String,System.Byte[]);只有 ConfigStore 可以写文件
M:System.IO.File.Create(System.String);只有 ConfigStore 可以写文件
T:System.Net.Http.HttpClient;常驻节点进程不使用 HttpClient(注册由 fleet-enroll 完成)
+19
View File
@@ -0,0 +1,19 @@
M:System.Threading.Thread.Sleep(System.Int32);用 Task.Delay(TimeProvider)
M:System.Threading.Thread.Sleep(System.TimeSpan);用 Task.Delay(TimeProvider)
M:System.Threading.Tasks.Task.Wait();禁止同步等待
M:System.Threading.Tasks.Task.Wait(System.Int32);禁止同步等待
M:System.Threading.Tasks.Task.Wait(System.TimeSpan);禁止同步等待
P:System.Threading.Tasks.Task`1.Result;禁止同步等待,改用 await
P:System.DateTime.Now;时间一律通过 TimeProvider
P:System.DateTime.UtcNow;时间一律通过 TimeProvider
P:System.DateTime.Today;时间一律通过 TimeProvider
P:System.DateTimeOffset.Now;时间一律通过 TimeProvider
P:System.DateTimeOffset.UtcNow;时间一律通过 TimeProvider
M:System.Diagnostics.Stopwatch.StartNew();耗时通过 TimeProvider.GetTimestamp
M:System.Activator.CreateInstance(System.Type);禁止反射创建实例(AOT)
M:System.Type.GetType(System.String);禁止按名称取类型(AOT)
M:System.Text.Json.JsonSerializer.Serialize``1(``0,System.Text.Json.JsonSerializerOptions);必须使用源生成的 JsonTypeInfo
M:System.Text.Json.JsonSerializer.Deserialize``1(System.String,System.Text.Json.JsonSerializerOptions);必须使用源生成的 JsonTypeInfo
M:System.Text.Json.JsonSerializer.Deserialize``1(System.ReadOnlySpan{System.Byte},System.Text.Json.JsonSerializerOptions);必须使用源生成的 JsonTypeInfo
M:System.Text.Json.JsonSerializer.Serialize``1(``0);必须使用源生成的 JsonTypeInfo
M:System.Text.Json.JsonSerializer.Deserialize``1(System.String);必须使用源生成的 JsonTypeInfo
+21
View File
@@ -0,0 +1,21 @@
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AnalysisLevel>latest-recommended</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<InvariantGlobalization>true</InvariantGlobalization>
<UseArtifactsOutput>true</UseArtifactsOutput>
<ArtifactsPath>$(MSBuildThisFileDirectory)artifacts</ArtifactsPath>
<Deterministic>true</Deterministic>
<Version>0.1.0</Version>
<NuGetAudit>false</NuGetAudit>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" PrivateAssets="all" />
<AdditionalFiles Include="$(MSBuildThisFileDirectory)BannedSymbols.txt" />
</ItemGroup>
</Project>
+13
View File
@@ -0,0 +1,13 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="5.6.0" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.ApiDescription.Server" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.0.0" />
<PackageVersion Include="xunit.v3" Version="4.0.1" />
</ItemGroup>
</Project>
+49
View File
@@ -0,0 +1,49 @@
<Solution>
<Folder Name="/fleet/src/">
<Project Path="fleet/src/Fleet.Core/Fleet.Core.csproj" />
<Project Path="fleet/src/Fleet.Enrollment.Client/Fleet.Enrollment.Client.csproj" />
<Project Path="fleet/src/Fleet.Hosting/Fleet.Hosting.csproj" />
<Project Path="fleet/src/Fleet.Identity/Fleet.Identity.csproj" />
<Project Path="fleet/src/Fleet.Link/Fleet.Link.csproj" />
<Project Path="fleet/src/Fleet.Rpc/Fleet.Rpc.csproj" />
<Project Path="fleet/src/Fleet.Server.Abstractions/Fleet.Server.Abstractions.csproj" />
<Project Path="fleet/src/Fleet.Server.Enrollment/Fleet.Server.Enrollment.csproj" />
<Project Path="fleet/src/Fleet.Server.Link/Fleet.Server.Link.csproj" />
</Folder>
<Folder Name="/fleet/tests/">
<Project Path="fleet/tests/Fleet.Tests/Fleet.Tests.csproj" />
</Folder>
<Folder Name="/fleet/tools/">
<Project Path="fleet/tools/fleet-enroll/fleet-enroll.csproj" />
</Folder>
<Folder Name="/shared/">
<Project Path="shared/Monitor.Measurement/Monitor.Measurement.csproj" />
<Project Path="shared/Monitor.Protocol/Monitor.Protocol.csproj" />
</Folder>
<Folder Name="/shared/tests/">
<Project Path="shared/tests/Monitor.Measurement.Tests/Monitor.Measurement.Tests.csproj" />
<Project Path="shared/tests/Monitor.Protocol.Tests/Monitor.Protocol.Tests.csproj" />
</Folder>
<Folder Name="/agent/src/">
<Project Path="agent/src/Monitor.Agent/Monitor.Agent.csproj" />
</Folder>
<Folder Name="/probe/src/">
<Project Path="probe/src/Monitor.Probe/Monitor.Probe.csproj" />
</Folder>
<Folder Name="/probe/tests/">
<Project Path="probe/tests/Monitor.Probe.Tests/Monitor.Probe.Tests.csproj" />
</Folder>
<Folder Name="/backend/src/">
<Project Path="src/Monitor.Backend.Analysis/Monitor.Backend.Analysis.csproj" />
<Project Path="src/Monitor.Backend.Api/Monitor.Backend.Api.csproj" />
<Project Path="src/Monitor.Backend.Application/Monitor.Backend.Application.csproj" />
<Project Path="src/Monitor.Backend.Gateway/Monitor.Backend.Gateway.csproj" />
<Project Path="src/Monitor.Backend.Storage.Sqlite/Monitor.Backend.Storage.Sqlite.csproj" />
<Project Path="src/Monitor.Backend/Monitor.Backend.csproj" />
</Folder>
<Folder Name="/backend/tests/">
<Project Path="tests/Monitor.Backend.Analysis.Tests/Monitor.Backend.Analysis.Tests.csproj" />
<Project Path="tests/Monitor.Backend.Tests/Monitor.Backend.Tests.csproj" />
<Project Path="tests/Monitor.TestSupport/Monitor.TestSupport.csproj" />
</Folder>
</Solution>
+52
View File
@@ -0,0 +1,52 @@
# Backend
Backend 是 Monitor 的控制面和数据服务,负责 Agent 注册与连接、任务编排、原始测量数据持久化、指标分析,以及供前端使用的 REST API 和 SSE 实时数据流。
## 组件
| 路径 | 职责 |
| --- | --- |
| `src/Monitor.Backend/` | 应用入口与依赖组合 |
| `src/Monitor.Backend.Api/` | Public、Admin API 和 OpenAPI |
| `src/Monitor.Backend.Gateway/` | Fleet RPC、节点状态与测量数据接收 |
| `src/Monitor.Backend.Application/` | 业务用例、查询、任务和后台服务 |
| `src/Monitor.Backend.Analysis/` | 指标计算、累加器和服务模型 |
| `src/Monitor.Backend.Storage.Sqlite/` | 段文件与 SQLite 存储 |
| `fleet/` | 通用节点身份、注册和连接框架 |
| `shared/` | 测量引擎、协议和数据格式 |
| `tests/` | 集成测试和测试支持代码 |
## 环境要求
使用 .NET SDK 10。仓库根目录的 `backend/global.json` 指定所需 SDK 版本及滚动策略。
## 构建与测试
在 `backend/` 目录运行:
```sh
cd backend
dotnet build Monitor.slnx -c Release
dotnet test --solution Monitor.slnx
```
发布当前平台的 NativeAOT Backend:
```sh
dotnet publish src/Monitor.Backend -c Release -r <RID> -p:PublishAot=true
```
例如 `<RID>` 可为 `win-x64`、`linux-x64` 或 `linux-musl-x64`。NativeAOT 发布需要对应平台的 C/C++ 工具链。仓库根目录的 `scripts/publish.sh` 也提供发布和容器构建流程,详见 [`../docs/deployment.md`](../docs/deployment.md)。
## 本地运行
在 `backend/` 目录启动服务:
```sh
cd backend
dotnet run --project src/Monitor.Backend -- --urls http://localhost:5000
```
首次启动时可用 `Monitor__InitialAdminUser` 和 `Monitor__InitialAdminPassword` 设置初始管理员。数据目录、公开地址、Cookie 和 CORS 等设置通过 `Monitor` 配置节传入;完整配置说明见 [`../docs/deployment.md`](../docs/deployment.md)。站点默认需要登录,可在管理端修改公开设置。
OpenAPI 文档由构建生成到 `openapi/v1.json`。API 设计和端点说明见 [`../docs/api.md`](../docs/api.md);项目总览见仓库根目录的 [`README.md`](../README.md)。
+17
View File
@@ -0,0 +1,17 @@
<Project>
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" />
<PropertyGroup>
<OptimizationPreference>Size</OptimizationPreference>
<UseSystemResourceKeys>true</UseSystemResourceKeys>
<StackTraceSupport>false</StackTraceSupport>
<IlcFoldIdenticalMethodBodies>true</IlcFoldIdenticalMethodBodies>
<EventSourceSupport>false</EventSourceSupport>
<DebuggerSupport>false</DebuggerSupport>
<HttpActivityPropagationSupport>false</HttpActivityPropagationSupport>
<ServerGarbageCollection>false</ServerGarbageCollection>
<ConcurrentGarbageCollection>false</ConcurrentGarbageCollection>
</PropertyGroup>
<ItemGroup>
<AdditionalFiles Include="$(MSBuildThisFileDirectory)../BannedSymbols.Node.txt" />
</ItemGroup>
</Project>
+57
View File
@@ -0,0 +1,57 @@
# Monitor Agent
Agent 是部署在 VPS 或其他可达网络节点上的采集进程。它向 Backend 注册并保持连接,接受 Probe 的测量会话,执行线路任务中的 Agent 侧工作,再将两端数据合并后上报。
Agent 仅开放配置的控制和数据端口。它不提供远程终端、命令执行或文件管理;本地配置中的网络范围、速率和并发限制由节点自行执行,Backend 下发的任务不能越过这些限制。
## 工作流程
```text
Probe ──TLS 控制 / UDP、TCP 测量──> Agent ──长连接与数据上报──> Backend
▲
fleet-enroll 注册
```
## 构建
需要 .NET SDK 10。为应用 `backend/global.json` 的 SDK 设置,请从 `backend/` 目录构建:
```sh
cd backend
dotnet build agent/src/Monitor.Agent -c Release
dotnet build fleet/tools/fleet-enroll -c Release
```
发布 NativeAOT 程序需要对应平台的 C/C++ 工具链;`<RID>` 应与构建环境和目标平台匹配:
```sh
dotnet publish agent/src/Monitor.Agent -c Release -r <RID> -p:PublishAot=true
dotnet publish fleet/tools/fleet-enroll -c Release -r <RID> -p:PublishAot=true
```
也可在仓库根目录运行 `bash scripts/publish.sh agent <RID>` 和 `bash scripts/publish.sh fleet-enroll <RID>`。部署包和平台说明见 [`../../docs/deployment.md`](../../docs/deployment.md)。
## 注册与运行
先在管理端创建加入令牌,然后在 Agent 主机上运行一次性注册工具:
```sh
fleet-enroll --server https://<backend> --join <token> --name <agent-name> --identity-dir /etc/monitor
```
注册工具会在身份目录写入节点身份和证书。准备本地 `agent.json`,其中 `fleet.server` 指向 Backend,`fleet.identity_dir` 指向上述目录;`listen`、`limits` 和 `reporting` 可按节点环境调整。配置文件不会由 Backend 修改。限制目标网段和测量预算时,应按实际网络与机器能力配置;`allowed_target_cidrs` 默认为空,不允许 Agent 执行目标探测任务。
启动 Agent:
```sh
monitor-agent --config /etc/monitor/agent.json
```
不传 `--config` 时默认读取 `/etc/monitor/agent.json`。Agent 默认监听控制端口 `7443/tcp`,数据端口 `7443/udp` 和 `7444/tcp`;防火墙和面板中的节点端口配置需与本地配置一致。`reporting.host_stats` 可关闭主机状态上报;启用时只上报资源使用等汇总数据,不包含主机名、进程或 IP。
## 设计说明
- 每个 Probe 使用独立的 TLS 会话和数据密钥;Probe 断开时对应的会话资源会释放。
- 数据面会校验会话认证、重放窗口和速率限制;无效数据包会静默丢弃。
- 测量引擎和协议由 `backend/shared/` 提供;节点身份、注册和连接由 `backend/fleet/` 提供。
- 详细安全边界与拓扑见 [`../../docs/architecture.md`](../../docs/architecture.md),协议说明见 [`../../docs/protocol.md`](../../docs/protocol.md),部署见 [`../../docs/deployment.md`](../../docs/deployment.md)。
+286
View File
@@ -0,0 +1,286 @@
using System.Net;
using System.Text.Json;
using Fleet.Core;
using Fleet.Hosting;
using Fleet.Link;
using Fleet.Rpc;
using Monitor.Measurement;
using Monitor.Protocol;
namespace Monitor.Agent;
/// <summary>Agent 的组装点:数据面、点对点监听、与 Backend 的链路都在这里接起来。没有全局状态,测试可以在同一进程里启动多个。</summary>
public sealed class AgentApp : IDisposable
{
public const string Version = "0.1.0";
private readonly AgentConfig _config;
private readonly LoadedNodeIdentity _identity;
private readonly TimeProvider _time;
private readonly INodeLog _log;
private readonly MonoClock _clock;
private readonly AgentCounters _counters = new();
private readonly SessionRegistry _sessions = new();
private readonly StreamRegistry _streams = new();
private readonly TaskCompletionSource _ready = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly AgentAdmission _admission;
private readonly Outbox _outbox = new(64 << 20);
private readonly BackendUplink _uplink;
private readonly UdpMux _udp;
private readonly TcpAcceptor _tcp;
private readonly PeerListener _peers;
private readonly PeerHub _hub;
private readonly TaskManager _tasks;
private readonly TestRunner _tests;
private readonly FleetLink _link;
public AgentApp(AgentConfig config, LoadedNodeIdentity identity, TimeProvider time, INodeLog log)
{
_config = config;
_identity = identity;
_time = time;
_log = log;
_clock = new MonoClock(time);
_uplink = new BackendUplink(time);
IPAddress bind = IPAddress.Parse(config.Listen.Bind);
_admission = new AgentAdmission(_streams);
_udp = new UdpMux(_sessions, _admission, _clock, _counters.Data, log, bind, config.Listen.UdpPorts);
_tcp = new TcpAcceptor(bind, config.Listen.TcpPorts, identity.Certificate, _sessions, _counters.Data);
// 监听方积木的环境:和 Probe 的拨号方共用同一套积木,区别只在这里装配的部分(准入策略、TCP 监听)
var env = new BlockEnv
{
Clock = _clock,
Log = log,
Session = () => null, // 每个 Probe 会话各用自己绑定的环境(BlockEnv.WithSession)
Udp = _udp,
TcpListen = _tcp,
Admission = _admission,
Counters = _counters.Data,
MaxPacketsPerSecond = config.Limits.MaxTotalPps,
MaxBulkMbps = config.Limits.MaxTotalMbps,
};
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);
_hub = new PeerHub(_sessions, _clock, chunks, _tasks, _uplink, time, log);
_tests = new TestRunner(_tasks, _sessions, _streams, env, _uplink, config.Limits.MaxConcurrentTests, transport => transport == "udp" ? _udp.BoundPorts : _tcp.BoundPorts, time, log);
_tasks.OnLineTasksChanged = (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();
_peers = new PeerListener(
new PeerListenerOptions
{
Listen = new IPEndPoint(bind, config.Listen.ControlPort),
Certificate = identity.Certificate,
LookupPeerKey = _tasks.LookupPeerKey,
},
_hub.OnSessionAsync,
log,
time);
_link = BuildLink();
_uplink.Attach(_link);
}
/// <summary>数据面和控制端口都开始监听之后完成(配置里的端口为 0 时,这里能读到实际端口)。</summary>
public Task Ready => _ready.Task;
public void Dispose() => _udp.Dispose();
internal StreamRegistry Streams => _streams;
public IReadOnlyList<int> UdpPorts => _udp.BoundPorts;
public IReadOnlyList<int> TcpPorts => _tcp.BoundPorts;
public int ControlPort => _peers.Port;
public AgentCounters Counters => _counters;
public FleetLink Link => _link;
/// <summary>期望状态里是否已经有这个 Probe 的公钥(测试用:握手会按它校验)。</summary>
public bool IsPeerKnown(string probeId, string publicKeyBase64) =>
_tasks.LookupPeerKey(probeId) is { } key && Convert.ToBase64String(key) == publicKeyBase64;
public TestRunner Tests => _tests;
public async Task RunAsync(CancellationToken ct)
{
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct);
// 数据面和控制端口先开始监听,再连接 Backend:hello 里上报的是实际端口
_udp.Bind();
_tcp.Bind();
Task udp = _udp.RunAsync(linked.Token);
Task tcp = _tcp.RunAsync(linked.Token);
Task peers = _peers.RunAsync(linked.Token);
await Task.WhenAny(_peers.Started.Task, peers);
if (peers.IsFaulted)
{
await linked.CancelAsync();
await Task.WhenAll(udp, tcp, peers); // 抛出监听失败的原因(端口被占用等)
}
_ready.TrySetResult();
_log.Info("agent listening", ("control", ControlPort), ("udp", string.Join(',', UdpPorts)), ("tcp", string.Join(',', TcpPorts)));
try
{
await Task.WhenAll(udp, tcp, peers, _link.RunAsync(linked.Token), ReportHostAsync(linked.Token));
}
finally
{
await _tasks.StopAsync();
await linked.CancelAsync();
}
}
/// <summary>每隔几秒把主机资源占用的快照发给 Backend(尽力而为:链路断开时丢弃,重连之后下一次就补上)。</summary>
private async Task ReportHostAsync(CancellationToken ct)
{
if (!_config.Reporting.HostStats)
{
return;
}
var sampler = new HostSampler(_time);
sampler.Sample(); // 第一次只用来建立 CPU 和网速的基线
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(Math.Clamp(_config.Reporting.HostIntervalSeconds, 1, 60)), _time);
try
{
while (await timer.WaitForNextTickAsync(ct))
{
await _uplink.SendHostAsync(sampler.Sample());
}
}
catch (OperationCanceledException)
{
// 停止
}
}
private BudgetGuard CreateGuard() =>
new(
new NodeLimits
{
MaxTotalMbps = _config.Limits.MaxTotalMbps,
MaxTotalPps = _config.Limits.MaxTotalPps,
AllowedTargetCidrs = _config.Limits.AllowedTargetCidrs,
MaxConcurrentTests = _config.Limits.MaxConcurrentTests,
AllowedUdpPorts = _udp.BoundPorts,
AllowedTcpPorts = _tcp.BoundPorts,
},
BlockCatalog.AgentKinds);
private FleetLink BuildLink()
{
var router = new RpcRouter<LinkContext>();
HashSet<string>? allowed = _config.Policy.AllowMethods is { } list ? [.. list] : null;
bool Allow(string method) => allowed is null || allowed.Contains(method);
if (Allow(MonitorMethods.TestRun))
{
router.On(
MonitorMethods.TestRun,
MonitorJsonContext.Default.TestRunParams,
MonitorJsonContext.Default.TestRunResult,
(_, run, _) => ValueTask.FromResult(_tests.Start(run)));
}
if (Allow(MonitorMethods.TestCancel))
{
router.On(
MonitorMethods.TestCancel,
MonitorJsonContext.Default.TestCancelParams,
MonitorJsonContext.Default.TestRunResult,
(_, cancel, _) => ValueTask.FromResult(Result<TestRunResult>.Ok(new TestRunResult { Accepted = _tests.Cancel(cancel.TestId) })));
}
if (Allow(MonitorMethods.ProbeDisconnect))
{
router.On(
MonitorMethods.ProbeDisconnect,
MonitorJsonContext.Default.ProbeDisconnectParams,
MonitorJsonContext.Default.TestRunResult,
async (_, request, _) => Result<TestRunResult>.Ok(new TestRunResult { Accepted = await _hub.DisconnectAsync(request.ProbeId.Value) }));
}
if (Allow(MonitorMethods.ProbeAgentsChanged))
{
router.OnNotify(
MonitorMethods.ProbeAgentsChanged,
MonitorJsonContext.Default.ProbeAgentsResult,
(_, list, _) => _hub.RelayAgentsAsync(list));
}
var options = new FleetLinkOptions
{
Server = new Uri(_config.Fleet.Server),
Identity = _identity.Identity,
NodeId = _identity.NodeId,
NodeVersion = Version,
Capabilities = [MonitorCapabilities.Base, MonitorCapabilities.KindsPrefix + string.Join(',', BlockCatalog.AgentKinds)],
HelloMeta = HelloMeta,
HeartbeatExtra = HeartbeatExtra,
};
var link = new FleetLink(options, router, [new StateHandler<TaskSet>(TaskSet.StateType, MonitorJsonContext.Default.TaskSet, _tasks.ApplyAsync)], _outbox, _time, _log);
// 链路重新连上之后补报:在线的 Probe 都告诉 Backend(Backend 重启或链路抖动期间错过了 probe_connected)
link.OnConnected = async (_, _) =>
{
foreach (ConnectedProbe probe in _hub.ConnectedProbes())
{
await _uplink.EmitEventAsync(AgentEventKinds.ProbeConnected, probe.Id.Value, probe);
}
};
return link;
}
private JsonElement? HelloMeta()
{
var meta = new AgentHelloMeta
{
TlsSpki = _identity.File.TlsSpki,
ProbeTcpPort = ControlPort,
ProbeUdpPorts = [.. UdpPorts],
LocalLimits = new LocalLimits
{
MaxTotalMbps = _config.Limits.MaxTotalMbps,
MaxTotalPps = _config.Limits.MaxTotalPps,
AllowedTargetCidrs = _config.Limits.AllowedTargetCidrs,
MaxConcurrentTests = _config.Limits.MaxConcurrentTests,
},
};
return JsonSerializer.SerializeToElement(meta, MonitorJsonContext.Default.AgentHelloMeta);
}
private long _lastIn;
private long _lastOut;
private long _lastAtMs;
private JsonElement? HeartbeatExtra()
{
long nowMs = _time.GetUtcNow().ToUnixTimeMilliseconds();
long seconds = Math.Max(1, (nowMs - _lastAtMs) / 1000);
long packetsIn = _counters.PacketsIn;
long packetsOut = _counters.PacketsOut;
var extra = new AgentHeartbeatExtra
{
Probes = [.. _hub.ConnectedProbes()],
Sessions = _sessions.All.Count,
PpsIn = _lastAtMs == 0 ? 0 : (packetsIn - _lastIn) / seconds,
PpsOut = _lastAtMs == 0 ? 0 : (packetsOut - _lastOut) / seconds,
DroppedInvalid = _counters.DroppedInvalid,
DroppedRateLimited = _counters.DroppedRateLimited,
QueueBytes = _outbox.PendingBytes,
};
_lastIn = packetsIn;
_lastOut = packetsOut;
_lastAtMs = nowMs;
return JsonSerializer.SerializeToElement(extra, MonitorJsonContext.Default.AgentHeartbeatExtra);
}
}
@@ -0,0 +1,86 @@
using System.Text.Json.Serialization;
namespace Monitor.Agent;
/// <summary>
/// <c>agent.json</c>:节点本地配置,面板无法修改。其中 <c>limits</c> 是本地硬上限:期望任务集超出这些范围时,
/// 调和器拒绝执行并在 <c>rejected[]</c> 里给出原因。默认不使用 443,目标任务默认不开放。
/// </summary>
public sealed class AgentConfig
{
[JsonPropertyName("fleet")]
public FleetSection Fleet { get; set; } = new();
[JsonPropertyName("listen")]
public ListenSection Listen { get; set; } = new();
[JsonPropertyName("limits")]
public LimitsSection Limits { get; set; } = new();
[JsonPropertyName("fleet_policy")]
public PolicySection Policy { get; set; } = new();
[JsonPropertyName("reporting")]
public ReportingSection Reporting { get; set; } = new();
}
public sealed class ReportingSection
{
/// <summary>上报主机资源占用(CPU、内存、磁盘、流量、运行时长,只有汇总数字,没有型号和 IP)。设为 false 就一个字也不上报。</summary>
[JsonPropertyName("host_stats")]
public bool HostStats { get; set; } = true;
[JsonPropertyName("host_interval_s")]
public int HostIntervalSeconds { get; set; } = 2;
}
public sealed class FleetSection
{
[JsonPropertyName("server")]
public string Server { get; set; } = "";
[JsonPropertyName("identity_dir")]
public string IdentityDir { get; set; } = "/etc/monitor";
}
public sealed class ListenSection
{
[JsonPropertyName("bind")]
public string Bind { get; set; } = "0.0.0.0";
/// <summary>Probe 控制通道(TLS,点对点会话)。</summary>
[JsonPropertyName("control_port")]
public int ControlPort { get; set; } = 7443;
[JsonPropertyName("udp_ports")]
public List<int> UdpPorts { get; set; } = [7443];
[JsonPropertyName("tcp_ports")]
public List<int> TcpPorts { get; set; } = [7444];
}
public sealed class LimitsSection
{
[JsonPropertyName("max_total_mbps")]
public double MaxTotalMbps { get; set; } = 1200;
[JsonPropertyName("max_total_pps")]
public long MaxTotalPps { get; set; } = 20_000;
/// <summary>默认为空:不允许执行任何目标任务。</summary>
[JsonPropertyName("allowed_target_cidrs")]
public List<string> AllowedTargetCidrs { get; set; } = [];
[JsonPropertyName("max_concurrent_tests")]
public int MaxConcurrentTests { get; set; } = 1;
}
public sealed class PolicySection
{
/// <summary>在默认值的基础上进一步收窄:只响应这些产品方法。为空表示不收窄。</summary>
[JsonPropertyName("allow_methods")]
public List<string>? AllowMethods { get; set; }
}
[JsonSerializable(typeof(AgentConfig))]
public sealed partial class AgentConfigContext : System.Text.Json.Serialization.JsonSerializerContext;
@@ -0,0 +1,75 @@
using Fleet.Core;
using Fleet.Link;
using Monitor.Measurement;
using Monitor.Protocol;
namespace Monitor.Agent;
/// <summary>
/// Probe 的列块到达之后:找到对应的流 → 与 Agent 在数据面记录的列合并 → 盖章(块头、时钟锚点)→ 编码 → 放进发件箱(通道 16)。
/// 找不到对应流的列块(任务已经被撤销、测试已经结束)被丢弃并计数;这只会发生在配置变更的瞬间。
/// </summary>
public sealed class ChunkAssembler
{
private readonly StreamRegistry _streams;
private readonly MonoClock _clock;
private readonly AgentCounters _counters;
private readonly Outbox _outbox;
private readonly NodeId _agent;
private readonly TimeProvider _time;
private readonly INodeLog _log;
public ChunkAssembler(StreamRegistry streams, MonoClock clock, AgentCounters counters, Outbox outbox, NodeId agent, TimeProvider time, INodeLog log)
{
_streams = streams;
_clock = clock;
_counters = counters;
_outbox = outbox;
_agent = agent;
_time = time;
_log = log;
}
/// <summary>
/// 处理某个 Probe 交来的一个列块(已经解码)。返回是否被接受。
/// 块头里的 <c>probe_id</c> 以认证过的会话为准(Probe 自报的值不可信),别的 Probe 的流也不会被命中。
/// </summary>
public bool Accept(RawChunk fragment, ProbeSession probe)
{
RawChunkHeader header = fragment.Header;
StreamInfo? stream = header switch
{
{ TestId: { } test, Phase: { } phase } => _streams.FindByTest(test, phase) is { } found && ReferenceEquals(found.Owner, probe) ? found : null,
{ TaskId: { } task } => _streams.FindByTask(task, probe),
_ => null,
};
header.ProbeId = new ProbeId(probe.ProbeId);
KindSpec? spec = stream is null ? null : Kinds.Find(stream.Kind);
if (stream is null || spec is null || spec.Id != header.Kind)
{
_counters.FragmentDiscarded();
return false;
}
Result<IReadOnlyList<RawStream>> merged = FragmentMerger.Merge(spec, fragment, stream.Id, _streams);
if (!merged.TryGet(out IReadOnlyList<RawStream>? streams, out FleetError? error))
{
_log.Warn("fragment discarded", ("kind", spec.Id), ("err", error.Code));
_counters.FragmentDiscarded();
stream.FragmentDone();
return false;
}
RawChunk chunk = ChunkFinalizer.Finalize(spec, fragment, streams, _agent, _clock.Anchor(), stream.Params.Values, stream.Port, ChunkId.New(_time));
Emit(chunk);
_counters.FragmentAccepted();
stream.FragmentDone();
return true;
}
/// <summary>Agent 自己采集的块(目标任务)直接发出。</summary>
public void Emit(RawChunk chunk)
{
_outbox.Enqueue(MonitorChannels.RawChunk, chunk.Header.ChunkId.Value, RawChunkCodec.Encode(chunk));
}
}
@@ -0,0 +1,333 @@
using System.Collections.Concurrent;
using System.Net;
using System.Net.Sockets;
using Fleet.Core;
using Fleet.Link;
using Fleet.Rpc;
using Monitor.Measurement;
using Monitor.Protocol;
namespace Monitor.Agent;
/// <summary>进程内计数(只有计数,没有主机信息):用于心跳和面板上的节点状态。数据面的包计数由共享的 <see cref="DataPlaneCounters"/> 提供。</summary>
public sealed class AgentCounters
{
private long _fragments;
private long _fragmentsDiscarded;
public DataPlaneCounters Data { get; } = new();
public long PacketsIn => Data.PacketsIn;
public long PacketsOut => Data.PacketsOut;
public long DroppedInvalid => Data.DroppedInvalid;
public long DroppedRateLimited => Data.DroppedRateLimited;
public long Fragments => Interlocked.Read(ref _fragments);
public long FragmentsDiscarded => Interlocked.Read(ref _fragmentsDiscarded);
public void FragmentAccepted() => Interlocked.Increment(ref _fragments);
public void FragmentDiscarded() => Interlocked.Increment(ref _fragmentsDiscarded);
}
public sealed record PeerContext(ProbeSession Session, MonoClock Clock);
/// <summary>
/// 一个已经认证的 Probe 会话:数据面的 <c>session_id</c> 和密钥(由点对点握手里的 ECDH 派生)、最近一次的 UDP 源地址、
/// 每个流的重放窗口。数据面的包不绑定五元组,NAT 映射变化之后自动跟随新的源地址。
/// </summary>
public sealed class ProbeSession : DataSession
{
private readonly ConcurrentDictionary<uint, ReplayWindow> _windows = new();
private readonly ConcurrentDictionary<ulong, byte> _nonces = new();
private SocketAddress? _udpSource;
public ProbeSession(PeerSessionInfo info)
: base(info.Credentials.SessionIdValue, info.Credentials.Key)
{
Info = info;
}
public PeerSessionInfo Info { get; }
public string ProbeId => Info.PeerId;
/// <summary>Probe 的程序版本(握手里自报,只用于展示)。</summary>
public string? Version => Info.Version;
/// <summary>Probe 的远端地址(经过 NAT 之后的公网 IP)。</summary>
public string? RemoteAddress => (Info.Remote as IPEndPoint)?.Address.ToString();
public long ConnectedAtUtcMs { get; init; }
public RpcSession<PeerContext>? Rpc { get; set; }
/// <summary>最近一次认证通过的 UDP 包的源地址(Agent 把单向数据发往这里)。</summary>
public override IPEndPoint? UdpEndpoint
{
get
{
SocketAddress? source = Volatile.Read(ref _udpSource);
return source is null ? null : (IPEndPoint)new IPEndPoint(IPAddress.Any, 0).Create(source);
}
}
/// <summary>记录 UDP 源地址;NAT 映射变化后地址变了返回 true(上报 endpoint_changed)。第一次看到地址不算变化。</summary>
public bool UpdateSource(SocketAddress source)
{
SocketAddress? previous = Volatile.Read(ref _udpSource);
if (previous is not null && previous.Equals(source))
{
return false;
}
// SocketAddress 的缓冲区会被接收循环复用,所以必须复制
var copy = new SocketAddress(source.Family, source.Size);
for (int i = 0; i < source.Size; i++)
{
copy[i] = source[i];
}
Volatile.Write(ref _udpSource, copy);
return previous is not null;
}
public bool AcceptSequence(uint streamId, uint seq)
{
ReplayWindow window = _windows.GetOrAdd(streamId, _ => new ReplayWindow());
lock (window)
{
return window.TryAccept(seq);
}
}
/// <summary>TCP 首帧的随机数只能用一次,防止被截获之后重放。</summary>
public override bool UseNonce(ReadOnlySpan<byte> nonce)
{
ulong hash = 14695981039346656037UL; // FNV-1a:随机数本身已经是 128 位随机,这里只需要一个去重用的指纹
foreach (byte b in nonce)
{
hash = (hash ^ b) * 1099511628211UL;
}
if (_nonces.Count > 4096)
{
_nonces.Clear();
}
return _nonces.TryAdd(hash, 0);
}
public void ForgetStream(uint streamId) => _windows.TryRemove(streamId, out _);
}
/// <summary>当前连着的 Probe 会话:每个 Probe 一个;同一个 Probe 重连时新会话取代它自己的旧会话。</summary>
public sealed class SessionRegistry : IDataSessions
{
private readonly object _gate = new();
private readonly Dictionary<string, ProbeSession> _byProbe = new(StringComparer.Ordinal);
private readonly Dictionary<long, ProbeSession> _bySession = [];
public IReadOnlyList<ProbeSession> All
{
get
{
lock (_gate)
{
return [.. _byProbe.Values];
}
}
}
public ProbeSession? Get(string probeId)
{
lock (_gate)
{
return _byProbe.GetValueOrDefault(probeId);
}
}
/// <summary>登记新会话;返回同一个 Probe 的旧会话(如果有)。</summary>
public ProbeSession? Replace(ProbeSession session)
{
lock (_gate)
{
_byProbe.TryGetValue(session.ProbeId, out ProbeSession? old);
if (old is not null)
{
_bySession.Remove(old.SessionId);
}
_byProbe[session.ProbeId] = session;
_bySession[session.SessionId] = session;
return old;
}
}
public void Remove(ProbeSession session)
{
lock (_gate)
{
if (_byProbe.TryGetValue(session.ProbeId, out ProbeSession? current) && ReferenceEquals(current, session))
{
_byProbe.Remove(session.ProbeId);
}
if (_bySession.TryGetValue(session.SessionId, out ProbeSession? bySession) && ReferenceEquals(bySession, session))
{
_bySession.Remove(session.SessionId);
}
}
}
public DataSession? Find(long sessionId)
{
lock (_gate)
{
return _bySession.GetValueOrDefault(sessionId);
}
}
}
/// <summary>数据面上一个流(一个线路任务,或测试的一个阶段)的元数据:准入策略据此限速,合并据此找到积木的记录。</summary>
public sealed class StreamInfo
{
private readonly object _bucketGate = new();
private int _fragmentsDone;
private double _tokens = -1;
private long _lastRefillUs;
public StreamInfo(uint id, string kind, ParamValues parameters)
{
Id = id;
Kind = kind;
Params = parameters;
KindSpec spec = Kinds.Find(kind)!;
DeclaredPps = kind switch
{
"udp_echo" or "tcp_echo" => parameters.GetInt("rate_hz"),
"udp_train" => parameters.GetNumber("mbps") * 1_000_000 / 8 / Math.Max(1, parameters.GetInt("size")),
_ => 20,
};
Port = spec.Params.Any(p => p.Name == "port") ? (int)parameters.GetInt("port") : 0;
}
public uint Id { get; }
public string Kind { get; }
public ParamValues Params { get; }
public TaskId? TaskId { get; init; }
public TestId? TestId { get; init; }
public int? Phase { get; init; }
/// <summary>这个流属于哪个 Probe 会话:别的会话的包即使带着这个流编号也不会被接受。</summary>
public ProbeSession? Owner { get; init; }
public int Port { get; }
public double DeclaredPps { get; }
/// <summary>运行这个流的监听方积木(合并时从它取记录)。</summary>
public RunningBlock? Running { get; set; }
/// <summary>该流已经处理(交给 Backend)的列块数:测试阶段据此等待全部列块到齐。</summary>
public int FragmentsDone => _fragmentsDone;
public void FragmentDone() => Interlocked.Increment(ref _fragmentsDone);
/// <summary>令牌桶:实际速率超过声明速率的 1.2 倍(外加一点突发余量)就丢弃。</summary>
public bool TryAdmit(long nowUs)
{
double rate = DeclaredPps * 1.2;
double burst = Math.Max(10, rate * 0.25);
lock (_bucketGate)
{
if (_tokens < 0)
{
_tokens = burst;
_lastRefillUs = nowUs;
}
_tokens = Math.Min(burst, _tokens + ((nowUs - _lastRefillUs) / 1_000_000.0 * rate));
_lastRefillUs = nowUs;
if (_tokens < 1)
{
return false;
}
_tokens -= 1;
return true;
}
}
}
public sealed class StreamRegistry : IRecordSource
{
private readonly ConcurrentDictionary<uint, StreamInfo> _streams = new();
private int _next;
public uint Allocate() => (uint)Interlocked.Increment(ref _next);
public void Add(StreamInfo stream) => _streams[stream.Id] = stream;
public void Remove(uint id) => _streams.TryRemove(id, out _);
public bool TryGet(uint id, out StreamInfo stream) => _streams.TryGetValue(id, out stream!);
public StreamInfo? FindByTask(TaskId task, ProbeSession owner) => _streams.Values.FirstOrDefault(s => s.TaskId == task && ReferenceEquals(s.Owner, owner));
public StreamInfo? FindByTest(TestId test, int phase) => _streams.Values.FirstOrDefault(s => s.TestId == test && s.Phase == phase);
public IReadOnlyCollection<StreamInfo> All => [.. _streams.Values];
public RowStore? Records(uint streamId, string streamName) =>
_streams.TryGetValue(streamId, out StreamInfo? info) ? info.Running?.Block.Records(streamName) : null;
}
/// <summary>
/// Agent 的准入策略(安全边界):重放窗口 → 流的令牌桶(声明速率的 1.2 倍)→ 源地址学习(NAT 重绑定自动跟随)。
/// 积木本身不含任何策略;这里的任何一步失败,数据面都是静默丢弃并计数、不回复。
/// </summary>
public sealed class AgentAdmission : IAdmission
{
private readonly StreamRegistry _streams;
public AgentAdmission(StreamRegistry streams)
{
_streams = streams;
}
/// <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)
{
if (session is not ProbeSession probe || !_streams.TryGet(header.StreamId, out StreamInfo? stream) || !ReferenceEquals(stream.Owner, probe) || !probe.AcceptSequence(header.StreamId, header.Seq))
{
return Admission.Invalid;
}
if (!stream.TryAdmit(nowUs))
{
return Admission.RateLimited;
}
if (probe.UpdateSource(source))
{
OnEndpointChanged?.Invoke(probe);
}
return Admission.Accepted;
}
public bool AdmitMessage(DataSession session, uint streamId, long nowUs) =>
_streams.TryGet(streamId, out StreamInfo? stream) && ReferenceEquals(stream.Owner, session) && stream.TryAdmit(nowUs);
}
@@ -0,0 +1,181 @@
using System.Globalization;
using Monitor.Protocol;
namespace Monitor.Agent;
/// <summary>
/// 主机资源占用的只读快照:CPU 占用、内存、根分区、网卡累计流量与瞬时网速、系统运行时长。
/// 只有汇总的数字:<b>不采集 CPU 型号、主机名、网卡名和任何 IP 地址</b>,所以面板和展示页想泄漏也泄漏不出去。
/// Linux 读 <c>/proc</c>;读不到的项是 null(例如 Windows 上的 CPU)。不执行任何命令,不写文件。
/// </summary>
public sealed class HostSampler
{
private static readonly string[] s_virtualPrefixes = ["lo", "veth", "docker", "br-", "virbr", "cni", "flannel", "cali", "tun", "tap"];
private readonly TimeProvider _time;
private readonly Func<string, string?> _read;
private readonly Func<(long Total, long Used)?> _disk;
private (long Total, long Idle)? _previousCpu;
private (long At, long Rx, long Tx)? _previousNet;
public HostSampler(TimeProvider time, Func<string, string?>? read = null, Func<(long Total, long Used)?>? disk = null)
{
_time = time;
_read = read ?? ReadProc;
_disk = disk ?? ReadRootDisk;
}
public HostSnapshot Sample()
{
long now = _time.GetTimestamp();
var snapshot = new HostSnapshot { AtUtcMs = _time.GetUtcNow().ToUnixTimeMilliseconds() };
if (_read("/proc/stat") is { } stat && ParseCpu(stat) is { } cpu)
{
if (_previousCpu is { } before && cpu.Total > before.Total)
{
double busy = 1.0 - ((double)(cpu.Idle - before.Idle) / (cpu.Total - before.Total));
snapshot.CpuPercent = Math.Round(Math.Clamp(busy * 100, 0, 100), 1);
}
_previousCpu = cpu;
}
if (_read("/proc/meminfo") is { } meminfo && ParseMemory(meminfo) is { } memory)
{
snapshot.MemTotal = memory.Total;
snapshot.MemUsed = memory.Used;
}
if (_disk() is { } disk)
{
snapshot.DiskTotal = disk.Total;
snapshot.DiskUsed = disk.Used;
}
if (_read("/proc/net/dev") is { } dev && ParseNetwork(dev) is { } net)
{
snapshot.NetRxTotal = net.Rx;
snapshot.NetTxTotal = net.Tx;
if (_previousNet is { } before && net.Rx >= before.Rx && net.Tx >= before.Tx)
{
double seconds = Math.Max(0.001, _time.GetElapsedTime(before.At, now).TotalSeconds);
snapshot.NetRxBps = (long)((net.Rx - before.Rx) / seconds);
snapshot.NetTxBps = (long)((net.Tx - before.Tx) / seconds);
}
_previousNet = (now, net.Rx, net.Tx);
}
snapshot.UptimeSeconds = _read("/proc/uptime") is { } uptime && ParseUptime(uptime) is { } up ? up : Environment.TickCount64 / 1000;
return snapshot;
}
/// <summary>第一行 <c>cpu user nice system idle iowait irq softirq steal …</c>:总时间与空闲时间(idle + iowait)。</summary>
public static (long Total, long Idle)? ParseCpu(string stat)
{
string? line = stat.Split('\n').FirstOrDefault(l => l.StartsWith("cpu ", StringComparison.Ordinal));
if (line is null)
{
return null;
}
long[] fields = [.. line.Split(' ', StringSplitOptions.RemoveEmptyEntries).Skip(1).Take(8).Select(f => long.TryParse(f, CultureInfo.InvariantCulture, out long v) ? v : 0)];
if (fields.Length < 5)
{
return null;
}
return (fields.Sum(), fields[3] + fields[4]);
}
/// <summary>MemTotal 与 MemAvailable(kB)→ 字节;已用 = 总量 − 可用。</summary>
public static (long Total, long Used)? ParseMemory(string meminfo)
{
long? total = null;
long? available = null;
foreach (string line in meminfo.Split('\n'))
{
if (line.StartsWith("MemTotal:", StringComparison.Ordinal))
{
total = KiloBytes(line);
}
else if (line.StartsWith("MemAvailable:", StringComparison.Ordinal))
{
available = KiloBytes(line);
}
}
return total is { } t && available is { } a ? (t, Math.Max(0, t - a)) : null;
}
/// <summary>所有物理网卡(排除回环、容器、虚拟网桥)的累计收发字节。</summary>
public static (long Rx, long Tx)? ParseNetwork(string dev)
{
long rx = 0;
long tx = 0;
bool any = false;
foreach (string line in dev.Split('\n'))
{
int colon = line.IndexOf(':', StringComparison.Ordinal);
if (colon < 0)
{
continue;
}
string name = line[..colon].Trim();
if (s_virtualPrefixes.Any(p => name.StartsWith(p, StringComparison.Ordinal)))
{
continue;
}
string[] fields = line[(colon + 1)..].Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (fields.Length >= 9 && long.TryParse(fields[0], CultureInfo.InvariantCulture, out long r) && long.TryParse(fields[8], CultureInfo.InvariantCulture, out long t))
{
rx += r;
tx += t;
any = true;
}
}
return any ? (rx, tx) : null;
}
public static long? ParseUptime(string uptime)
{
string first = uptime.Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? "";
return double.TryParse(first, NumberStyles.Float, CultureInfo.InvariantCulture, out double seconds) ? (long)seconds : null;
}
private static long? KiloBytes(string line)
{
string[] parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
return parts.Length >= 2 && long.TryParse(parts[1], CultureInfo.InvariantCulture, out long kb) ? kb * 1024 : null;
}
private static string? ReadProc(string path)
{
try
{
return OperatingSystem.IsLinux() && File.Exists(path) ? File.ReadAllText(path) : null;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return null;
}
}
private static (long Total, long Used)? ReadRootDisk()
{
try
{
string root = OperatingSystem.IsWindows() ? Path.GetPathRoot(Environment.SystemDirectory) ?? "C:\\" : "/";
var drive = new DriveInfo(root);
return drive.IsReady ? (drive.TotalSize, drive.TotalSize - drive.AvailableFreeSpace) : null;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException)
{
return null;
}
}
}
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<AssemblyName>monitor-agent</AssemblyName>
<RootNamespace>Monitor.Agent</RootNamespace>
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="Monitor.Backend.Tests" />
<ProjectReference Include="..\..\..\fleet\src\Fleet.Link\Fleet.Link.csproj" />
<ProjectReference Include="..\..\..\fleet\src\Fleet.Hosting\Fleet.Hosting.csproj" />
<ProjectReference Include="..\..\..\shared\Monitor.Measurement\Monitor.Measurement.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,315 @@
using System.Text.Json;
using Fleet.Core;
using Fleet.Link;
using Fleet.Rpc;
using Monitor.Measurement;
using Monitor.Protocol;
namespace Monitor.Agent;
/// <summary>Agent → Backend 的上行:事件和测试状态是尽力而为的通知(链路断开时丢弃;列块走发件箱,不会丢)。</summary>
public sealed class BackendUplink
{
private readonly TimeProvider _time;
private volatile FleetLink? _link;
public BackendUplink(TimeProvider time)
{
_time = time;
}
public void Attach(FleetLink link) => _link = link;
public bool IsConnected => _link?.CurrentSession is not null;
public async ValueTask EmitEventAsync(string kind, string? detail = null, ConnectedProbe? probe = null)
{
RpcSession<LinkContext>? session = _link?.CurrentSession;
if (session is null)
{
return;
}
var parameters = new AgentEventParams { Kind = kind, AtUtcMs = _time.GetUtcNow().ToUnixTimeMilliseconds(), Detail = detail, Probe = probe };
await SafeAsync(() => session.NotifyAsync(MonitorMethods.Event, parameters, MonitorJsonContext.Default.AgentEventParams, CancellationToken.None));
}
public async ValueTask SendHostAsync(HostSnapshot snapshot)
{
RpcSession<LinkContext>? session = _link?.CurrentSession;
if (session is null)
{
return;
}
await SafeAsync(() => session.NotifyAsync(MonitorMethods.Host, snapshot, MonitorJsonContext.Default.HostSnapshot, CancellationToken.None));
}
public async ValueTask SendTestStatusAsync(TestId test, string status, int? phase = null, string? errorCode = null)
{
RpcSession<LinkContext>? session = _link?.CurrentSession;
if (session is null)
{
return;
}
var parameters = new TestStatusParams { TestId = test, Status = status, Phase = phase, ErrorCode = errorCode };
await SafeAsync(() => session.NotifyAsync(MonitorMethods.TestStatus, parameters, MonitorJsonContext.Default.TestStatusParams, CancellationToken.None));
}
/// <summary>代 Probe 向 Backend 取 Agent 列表(Probe 不认识 Backend)。链路断开时返回 fleet_node_offline。</summary>
public async ValueTask<Result<ProbeAgentsResult>> RequestProbeAgentsAsync(ProbeAgentsParams request, CancellationToken ct)
{
RpcSession<LinkContext>? session = _link?.CurrentSession;
if (session is null)
{
return Result<ProbeAgentsResult>.Fail(FleetErrors.NodeOffline, "the agent has no link to the backend");
}
return await session.RequestAsync(MonitorMethods.ProbeAgents, request, MonitorJsonContext.Default.ProbeAgentsParams, MonitorJsonContext.Default.ProbeAgentsResult, ct);
}
private static async ValueTask SafeAsync(Func<ValueTask> send)
{
try
{
await send();
}
catch (Exception ex) when (ex is ObjectDisposedException or InvalidOperationException or IOException or System.Net.WebSockets.WebSocketException)
{
// 链路刚好断开:状态在重连之后由 hello 与下一次事件补上
}
}
}
/// <summary>
/// Probe 的点对点会话:每次握手成功之后登记会话(同一个 Probe 的新会话取代它自己的旧会话,不同 Probe 的会话并存)、为这个会话起一组线路任务的流并下发线路任务集、接收 Probe 的列块并交给
/// <see cref="ChunkAssembler"/>。会话结束时通知 Backend,并让正在运行的测试以 <c>probe_offline</c> 结束。
/// </summary>
public sealed class PeerHub
{
private const int DedupeWindow = 1024;
/// <summary>超过这么久没有收到 Probe 的任何帧就认为会话已死(Probe 每 30 秒至少发一次时钟同步)。</summary>
private static readonly TimeSpan IdleLimit = TimeSpan.FromSeconds(75);
private readonly RpcRouter<PeerContext> _router;
private readonly SessionRegistry _sessions;
private readonly MonoClock _clock;
private readonly ChunkAssembler _chunks;
private readonly TaskManager _tasks;
private readonly BackendUplink _uplink;
private readonly TimeProvider _time;
private readonly INodeLog _log;
public PeerHub(SessionRegistry sessions, MonoClock clock, ChunkAssembler chunks, TaskManager tasks, BackendUplink uplink, TimeProvider time, INodeLog log)
{
_router = BuildRouter(uplink);
_sessions = sessions;
_clock = clock;
_chunks = chunks;
_tasks = tasks;
_uplink = uplink;
_time = time;
_log = log;
}
/// <summary>Probe 会话断开(这个 Probe 上的测试据此中止)。</summary>
public Action<ProbeSession>? OnProbeLost { get; set; }
public async Task OnSessionAsync(PeerSessionInfo info, IRpcTransport transport, CancellationToken ct)
{
var probe = new ProbeSession(info) { ConnectedAtUtcMs = _time.GetUtcNow().ToUnixTimeMilliseconds() };
var rpc = new RpcSession<PeerContext>(transport, _router, new PeerContext(probe, _clock), _time);
probe.Rpc = rpc;
var seen = new HashSet<Guid>();
var order = new Queue<Guid>();
rpc.OnBinary = async (frame, token) =>
{
if (!OutboxFrame.TryParse(frame, out byte channel, out Guid id, out ReadOnlyMemory<byte> payload) || channel != MonitorChannels.ColumnBlock)
{
return;
}
// 至少一次投递:重复到达的列块只处理一次,但每次都要确认
if (seen.Add(id))
{
order.Enqueue(id);
if (order.Count > DedupeWindow)
{
seen.Remove(order.Dequeue());
}
Result<RawChunk> decoded = RawChunkCodec.Decode(payload.Span);
if (decoded.TryGet(out RawChunk? fragment, out FleetError? error))
{
_chunks.Accept(fragment, probe);
}
else
{
_log.Warn("fragment undecodable", ("err", error.Code), ("detail", error.Message));
}
}
var ack = new OutboxAckParams { Ids = [id.ToString()] };
await rpc.NotifyAsync(FleetMethods.OutboxAck, ack, FleetRpcContext.Default.OutboxAckParams, token);
};
ProbeSession? previous = _sessions.Replace(probe);
if (previous?.Rpc is { } old)
{
await old.CloseAsync("replaced", CancellationToken.None);
}
// 这个会话自己的一组流:序号从 0 开始,上一个会话留下的记录不会算到它头上
LineTaskSet initial = _tasks.OpenSession(probe);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct);
Task run = rpc.RunAsync(linked.Token);
Task watchdog = WatchIdleAsync(probe, rpc, linked.Token);
_log.Info("probe connected", ("probe", info.PeerId), ("version", info.Version ?? ""), ("remote", probe.RemoteAddress ?? ""));
await _uplink.EmitEventAsync(AgentEventKinds.ProbeConnected, info.PeerId, Describe(probe));
await PushAsync(probe, initial, linked.Token);
try
{
await run;
}
catch (OperationCanceledException)
{
// 停止
}
finally
{
_sessions.Remove(probe);
await linked.CancelAsync();
await watchdog;
await rpc.DisposeAsync();
await _tasks.CloseSessionAsync(probe);
_log.Info("probe disconnected", ("probe", info.PeerId));
OnProbeLost?.Invoke(probe);
await _uplink.EmitEventAsync(AgentEventKinds.ProbeDisconnected, info.PeerId);
}
}
/// <summary>
/// Probe 每 30 秒发一次时钟同步请求,所以会话里至少每 30 秒有一帧:超过 <see cref="IdleLimit"/> 没有收到任何东西,
/// 说明 Probe 已经静默死亡(断电、进程被冻结),关闭会话,让 Backend 看到 probe_disconnected,而不是一直显示在线。
/// </summary>
private async Task WatchIdleAsync(ProbeSession probe, RpcSession<PeerContext> rpc, CancellationToken ct)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10), _time);
try
{
while (await timer.WaitForNextTickAsync(ct))
{
if (rpc.IdleFor > IdleLimit)
{
_log.Warn("probe session is silent, closing it", ("probe", probe.ProbeId), ("idle_s", (long)rpc.IdleFor.TotalSeconds));
await rpc.CloseAsync("idle", CancellationToken.None);
return;
}
}
}
catch (OperationCanceledException)
{
// 会话结束
}
}
/// <summary>当前连着的 Probe(心跳和 Backend 重连之后的补报都用它)。</summary>
public IReadOnlyList<ConnectedProbe> ConnectedProbes() => [.. _sessions.All.Select(Describe)];
private static ConnectedProbe Describe(ProbeSession p) => new() { Id = new ProbeId(p.ProbeId), Version = p.Version, Remote = p.RemoteAddress, SinceUtcMs = p.ConnectedAtUtcMs };
public IReadOnlyList<string> ConnectedProbeIds() => [.. _sessions.All.Select(p => p.ProbeId)];
/// <summary>向一个 Probe 下发它的线路任务集(Probe 连上时由 <see cref="OnSessionAsync"/> 先发一份)。</summary>
public async ValueTask PushToAsync(ProbeSession probe, LineTaskSet set) => await PushAsync(probe, set, CancellationToken.None);
/// <summary>Backend 要求断开某个 Probe(它会按退避重连,除非已经被吊销)。没有这个会话时返回 false。</summary>
public async ValueTask<bool> DisconnectAsync(string probeId)
{
if (_sessions.Get(probeId)?.Rpc is not { } rpc)
{
return false;
}
await rpc.CloseAsync("disconnected", CancellationToken.None);
return true;
}
/// <summary>名单(期望状态里的 peers)里已经没有的 Probe:立刻关闭它的会话(吊销生效)。</summary>
public void CloseNotIn(IReadOnlySet<string> allowed)
{
foreach (ProbeSession probe in _sessions.All)
{
if (!allowed.Contains(probe.ProbeId) && probe.Rpc is { } rpc)
{
_log.Info("probe is no longer allowed, closing its session", ("probe", probe.ProbeId));
_ = rpc.CloseAsync("revoked", CancellationToken.None).AsTask();
}
}
}
/// <summary>Backend 的 Agent 列表变化时转交给所有 Probe(Probe 据此调整它连接的 Agent)。</summary>
public async ValueTask RelayAgentsAsync(ProbeAgentsResult list)
{
foreach (ProbeSession probe in _sessions.All)
{
if (probe.Rpc is not { } rpc)
{
continue;
}
try
{
await rpc.NotifyAsync(MonitorMethods.ProbeAgentsChanged, list, MonitorJsonContext.Default.ProbeAgentsResult, CancellationToken.None);
}
catch (Exception ex) when (ex is ObjectDisposedException or InvalidOperationException or IOException)
{
// 会话刚好结束
}
}
}
private async ValueTask PushAsync(ProbeSession probe, LineTaskSet set, CancellationToken ct)
{
if (probe.Rpc is null)
{
return;
}
var parameters = new StateSyncParams
{
Type = LineTaskSet.StateType,
Version = set.Version,
Doc = JsonSerializer.SerializeToElement(set, MonitorJsonContext.Default.LineTaskSet),
};
try
{
Result<StateSyncResult> result = await probe.Rpc.RequestAsync(FleetMethods.StateSync, parameters, FleetRpcContext.Default.StateSyncParams, FleetRpcContext.Default.StateSyncResult, ct);
if (!result.IsSuccess)
{
_log.Warn("probe rejected the line task set", ("err", result.Error.Code));
}
}
catch (Exception ex) when (ex is ObjectDisposedException or InvalidOperationException or IOException or OperationCanceledException)
{
// 会话已经结束:下一个会话连上时补发
}
}
private static RpcRouter<PeerContext> BuildRouter(BackendUplink uplink) =>
new RpcRouter<PeerContext>()
.On(
MonitorMethods.ProbeAgents,
MonitorJsonContext.Default.ProbeAgentsParams,
MonitorJsonContext.Default.ProbeAgentsResult,
(_, request, ct) => uplink.RequestProbeAgentsAsync(request, ct))
.On(
MonitorMethods.Clock,
MonitorJsonContext.Default.ClockParams,
MonitorJsonContext.Default.ClockResult,
(context, _, _) => ValueTask.FromResult(Result<ClockResult>.Ok(new ClockResult { AgentMonoUs = context.Clock.NowUs() })));
}
@@ -0,0 +1,42 @@
using Fleet.Core;
using Fleet.Hosting;
using Monitor.Agent;
TimeProvider time = TimeProvider.System;
INodeLog log = new ConsoleNodeLog(time);
if (PlatformCheck.VerifyCrypto() is { } problem)
{
log.Error("startup failed", ("err", problem));
return 1;
}
string configPath = "/etc/monitor/agent.json";
for (int i = 0; i < args.Length - 1; i++)
{
if (args[i] == "--config")
{
configPath = args[i + 1];
}
}
AgentConfig config;
LoadedNodeIdentity identity;
try
{
config = NodeConfig.Load(configPath, AgentConfigContext.Default.AgentConfig);
identity = LoadedNodeIdentity.Load(config.Fleet.IdentityDir);
}
catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or System.Text.Json.JsonException or System.Security.Cryptography.CryptographicException)
{
log.Error("startup failed", ("err", ex.Message));
return 1;
}
using (identity)
{
using var app = new AgentApp(config, identity, time, log);
await ServiceShell.RunAsync(app.RunAsync, log);
}
return 0;
@@ -0,0 +1,163 @@
using System.Net;
using System.Text.Json;
using Fleet.Core;
using Monitor.Measurement;
using Monitor.Protocol;
namespace Monitor.Agent;
/// <summary>
/// 目标任务(Agent 自己探测一个地址,没有对端):用和线路任务同一套积木(<see cref="BlockCatalog"/> 里的拨号方,例如 icmp_echo、connect),
/// 只是列块不交给 Agent 合并,而是自己盖章、10 秒一块直接上传。目标地址必须在本地 <c>allowed_target_cidrs</c> 内
/// (由 <see cref="BudgetGuard"/> 在接受任务之前检查)。
/// </summary>
public sealed class TargetRunners
{
private static readonly TimeSpan s_chunkSpan = TimeSpan.FromSeconds(ProtocolLimits.ChunkSeconds);
private readonly Dictionary<string, Running> _running = [];
private readonly object _gate = new();
private readonly ChunkAssembler _chunks;
private readonly NodeId _agent;
private readonly MonoClock _clock;
private readonly TimeProvider _time;
private readonly INodeLog _log;
public TargetRunners(ChunkAssembler chunks, NodeId agent, MonoClock clock, TimeProvider time, INodeLog log)
{
_chunks = chunks;
_agent = agent;
_clock = clock;
_time = time;
_log = log;
}
/// <summary>让运行中的任务与期望集合一致:参数没有变化的保持,其余启动或停止。</summary>
public void Reconcile(IReadOnlyList<(TaskSpec Spec, TaskDecision Decision)> desired)
{
lock (_gate)
{
var wanted = desired.ToDictionary(d => d.Spec.Id.Value, d => d);
foreach ((string id, Running running) in _running.ToList())
{
if (!wanted.TryGetValue(id, out (TaskSpec Spec, TaskDecision Decision) entry) || Signature(entry.Spec) != running.Signature)
{
running.Cancel.Cancel();
_running.Remove(id);
}
}
foreach ((string id, (TaskSpec spec, TaskDecision decision)) in wanted)
{
if (_running.ContainsKey(id))
{
continue;
}
var cts = new CancellationTokenSource();
_running[id] = new Running(Signature(spec), cts);
_ = Task.Run(() => RunAsync(spec, decision, cts.Token), CancellationToken.None);
}
}
}
private static string Signature(TaskSpec spec) =>
spec.Kind + "|" + spec.Target + "|" + string.Join(',', spec.Params.OrderBy(p => p.Key, StringComparer.Ordinal).Select(p => p.Key + "=" + p.Value.GetRawText()));
private async Task RunAsync(TaskSpec spec, TaskDecision decision, CancellationToken ct)
{
try
{
if (!TryResolve(spec.Target!, decision.Resolved!, spec.Kind, out IPAddress? address, out int port))
{
_log.Warn("target is not an address", ("task", spec.Id.Value));
return;
}
var env = new BlockEnv
{
Clock = _clock,
Log = _log,
Session = () => null,
PeerAddress = address,
Fragments = new LocalSink(this, spec),
};
IBlock block = BlockCatalog.Find(spec.Kind)!.Dialer(env, new BlockSpec(decision.Kind!, decision.Resolved!, 0, port, new FragmentScope(spec.Id, null, null)));
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
Task run = block.RunAsync(cts.Token);
try
{
using var timer = new PeriodicTimer(s_chunkSpan, _time);
while (await timer.WaitForNextTickAsync(ct))
{
block.Flush(FlushMode.Settled);
}
}
finally
{
await cts.CancelAsync();
await run;
block.Flush(FlushMode.All);
}
}
catch (OperationCanceledException)
{
// 任务被撤销
}
catch (Exception ex) when (ex is System.Net.Sockets.SocketException or InvalidOperationException or BlockFaultException)
{
_log.Warn("target task stopped", ("task", spec.Id.Value), ("err", ex.Message));
}
}
private static bool TryResolve(string target, ParamValues values, string kind, out IPAddress address, out int port)
{
port = kind == "connect" ? (int)values.GetInt("port") : 0;
if (IPEndPoint.TryParse(target, out IPEndPoint? endpoint))
{
address = endpoint.Address;
if (endpoint.Port != 0)
{
port = endpoint.Port;
}
return true;
}
return IPAddress.TryParse(target, out address!);
}
/// <summary>目标任务的行没有 Probe 一侧可以合并:积木交出的就是完整的行,Agent 自己盖章(目标、时钟锚点)并放进发件箱。</summary>
private sealed class LocalSink : IFragmentSink
{
private readonly TargetRunners _owner;
private readonly TaskSpec _spec;
public LocalSink(TargetRunners owner, TaskSpec spec)
{
_owner = owner;
_spec = spec;
}
public void Ship(string kind, FragmentScope scope, IReadOnlyList<RawStream> streams, IReadOnlyDictionary<string, JsonElement>? parameters, long startUtcMs, int durationMs, int port)
{
ClockAnchor anchor = _owner._clock.Anchor();
var header = new RawChunkHeader
{
ChunkId = ChunkId.New(_owner._time),
Kind = kind,
TaskId = scope.Task,
AgentId = _owner._agent,
Target = _spec.Target,
Params = parameters is null ? [] : new Dictionary<string, JsonElement>(parameters),
Port = port,
StartUtcMs = startUtcMs,
DurationMs = Math.Clamp(durationMs, 1, ChunkFinalizer.MaxDurationMs),
Clock = new ChunkClock { AgentMonoUs = anchor.AgentMonoUs, UtcMs = anchor.UtcMs, ProbeOffsetUs = 0 },
};
_owner._chunks.Emit(new RawChunk(header, streams));
}
}
private sealed record Running(string Signature, CancellationTokenSource Cancel);
}
@@ -0,0 +1,266 @@
using System.Text.Json;
using Fleet.Core;
using Fleet.Rpc;
using Monitor.Measurement;
using Monitor.Protocol;
namespace Monitor.Agent;
/// <summary>
/// 期望任务集(<c>task_set</c>)的调和器:每个任务先经过 <see cref="BudgetGuard"/>(参数校验 + 本地硬上限)。
/// 通过的线路任务<b>为每个 Probe 会话各登记一组流</b>(流编号和监听方积木都属于会话,互不干扰),并汇总成 <c>line_task_set</c> 下发给这个 Probe;
/// 通过的目标任务在本地运行,与 Probe 无关。被拒绝的任务带着原因(<c>rejected[]</c>)回报给 Backend,其余任务照常运行。
/// 每次调用都应用完整的快照,所以是幂等的。预算(码率、包速率)按单个 Probe 会话计算,会话总数由 <c>PeerListenerOptions.MaxSessions</c> 限制。
/// </summary>
public sealed class TaskManager
{
// 线路任务 = 有监听方积木、并且不是只用于按需测试的 Kind(udp_train、tcp_bulk 会占满链路);目标任务 = 可以由 Agent 自己运行的 Kind
private static readonly HashSet<string> s_lineKinds = [.. BlockCatalog.All.Where(p => p.Listener is not null && !p.OnDemandOnly).Select(p => p.Kind)];
private static readonly HashSet<string> s_targetKinds = [.. BlockCatalog.All.Where(p => p.RunsAsTarget).Select(p => p.Kind)];
private readonly object _gate = new();
private readonly Func<BudgetGuard> _guardFactory;
private readonly StreamRegistry _streams;
private readonly BlockEnv _env;
private readonly TargetRunners _targets;
private readonly Func<IReadOnlyList<int>> _udpPorts;
private readonly Func<IReadOnlyList<int>> _tcpPorts;
private readonly Dictionary<ProbeSession, Dictionary<string, LineEntry>> _sessionLines = [];
private volatile Dictionary<string, byte[]> _peers = new(StringComparer.Ordinal);
private Dictionary<string, (TaskSpec Task, TaskDecision Decision)> _lineSpecs = [];
private long _version;
private BudgetGuard? _guard;
public TaskManager(Func<BudgetGuard> guardFactory, StreamRegistry streams, BlockEnv env, TargetRunners targets, Func<IReadOnlyList<int>> udpPorts, Func<IReadOnlyList<int>> tcpPorts)
{
_guardFactory = guardFactory;
_streams = streams;
_env = env;
_targets = targets;
_udpPorts = udpPorts;
_tcpPorts = tcpPorts;
}
public BudgetGuard Guard => _guard ??= _guardFactory();
/// <summary>某个 Probe 的线路任务集变化(版本递增):由 <see cref="PeerHub"/> 推送给这个 Probe。</summary>
public Action<ProbeSession, LineTaskSet>? OnLineTasksChanged { get; set; }
/// <summary>允许连接的 Probe 变了(期望状态里的 peers):已经不在名单里的会话由 <see cref="PeerHub"/> 关闭。</summary>
public Action<IReadOnlySet<string>>? OnPeersChanged { get; set; }
/// <summary>点对点握手时查找 Probe 的公钥(SPKI)。</summary>
public byte[]? LookupPeerKey(string peerId) => _peers.TryGetValue(peerId, out byte[]? key) ? key : null;
/// <summary>一个新的 Probe 会话开始:为它登记全部线路任务的流并启动监听方积木,返回要下发给它的线路任务集。</summary>
public LineTaskSet OpenSession(ProbeSession probe)
{
lock (_gate)
{
var entries = new Dictionary<string, LineEntry>();
foreach ((string id, (TaskSpec task, TaskDecision decision)) in _lineSpecs)
{
LineEntry entry = BuildLineEntry(task, decision, probe);
Start(entry);
entries[id] = entry;
}
_sessionLines[probe] = entries;
return SetFor(entries);
}
}
/// <summary>Probe 会话结束:释放它的所有流和积木(记录随之丢弃,下一个会话从零开始)。</summary>
public async Task CloseSessionAsync(ProbeSession probe)
{
List<LineEntry> entries;
lock (_gate)
{
if (!_sessionLines.Remove(probe, out Dictionary<string, LineEntry>? lines))
{
return;
}
entries = [.. lines.Values];
}
foreach (LineEntry entry in entries)
{
await Retire(entry);
}
}
public async ValueTask<StateSyncResult> ApplyAsync(long version, TaskSet set, CancellationToken ct)
{
_ = ct;
var result = new StateSyncResult { AppliedVersion = version };
var peers = new Dictionary<string, byte[]>(StringComparer.Ordinal);
foreach (PeerSpec peer in set.Peers)
{
try
{
peers[peer.ProbeId.Value] = Convert.FromBase64String(peer.PublicKey);
}
catch (FormatException)
{
result.Rejected.Add(new RejectedItem { Id = peer.ProbeId.Value, Reason = RejectReasons.InvalidParams });
}
}
_peers = peers;
BudgetGuard guard = Guard;
double usedMbps = 0;
long usedPps = 0;
var line = new Dictionary<string, (TaskSpec Task, TaskDecision Decision)>();
var targets = new List<(TaskSpec Spec, TaskDecision Decision)>();
foreach (TaskSpec task in set.Tasks)
{
if (!task.Enabled)
{
continue;
}
bool shapeOk = task.Scope == "target" ? s_targetKinds.Contains(task.Kind) : s_lineKinds.Contains(task.Kind);
TaskDecision decision = shapeOk ? guard.Check(task, ref usedMbps, ref usedPps) : TaskDecision.Reject(RejectReasons.Unsupported);
if (!decision.Accepted)
{
result.Rejected.Add(new RejectedItem { Id = task.Id.Value, Reason = decision.Reason! });
continue;
}
if (task.Scope == "target")
{
targets.Add((task, decision));
}
else
{
line[task.Id.Value] = (task, decision);
}
}
_targets.Reconcile(targets);
// 参数没有变化的任务沿用原来的流和积木,数据面上的会话(重放窗口、记录)不被打断
var retired = new List<LineEntry>();
var changes = new List<(ProbeSession Probe, LineTaskSet Set)>();
lock (_gate)
{
_lineSpecs = line;
_version = version;
foreach ((ProbeSession probe, Dictionary<string, LineEntry> current) in _sessionLines)
{
var next = new Dictionary<string, LineEntry>();
foreach ((string id, (TaskSpec task, TaskDecision decision)) in line)
{
LineEntry candidate = BuildLineEntry(task, decision, probe);
if (current.TryGetValue(id, out LineEntry? existing) && existing.Signature == candidate.Signature)
{
next[id] = existing;
}
else
{
Start(candidate);
next[id] = candidate;
}
}
foreach ((string id, LineEntry entry) in current)
{
if (!next.TryGetValue(id, out LineEntry? kept) || !ReferenceEquals(kept, entry))
{
retired.Add(entry);
}
}
current.Clear();
foreach ((string id, LineEntry entry) in next)
{
current[id] = entry;
}
changes.Add((probe, SetFor(current)));
}
}
foreach (LineEntry entry in retired)
{
await Retire(entry);
}
OnPeersChanged?.Invoke(new HashSet<string>(peers.Keys, StringComparer.Ordinal));
foreach ((ProbeSession probe, LineTaskSet next) in changes)
{
OnLineTasksChanged?.Invoke(probe, next);
}
return result;
}
/// <summary>停止时释放所有 Probe 会话的流和所有目标任务(Backend 失联的时候任务继续运行:节点自治)。</summary>
public async Task StopAsync()
{
_targets.Reconcile([]);
List<LineEntry> entries;
lock (_gate)
{
entries = [.. _sessionLines.Values.SelectMany(l => l.Values)];
_sessionLines.Clear();
}
foreach (LineEntry entry in entries)
{
await Retire(entry);
}
}
private LineTaskSet SetFor(Dictionary<string, LineEntry> entries) =>
new()
{
Version = _version,
Tasks = [.. entries.Values.Select(e => e.Task)],
UdpPorts = [.. _udpPorts()],
TcpPorts = [.. _tcpPorts()],
};
private LineEntry BuildLineEntry(TaskSpec task, TaskDecision decision, ProbeSession probe)
{
var info = new StreamInfo(_streams.Allocate(), task.Kind, decision.Resolved!) { TaskId = task.Id, Owner = probe };
var lineTask = new LineTask
{
Id = task.Id,
Kind = task.Kind,
Params = new Dictionary<string, JsonElement>(decision.Resolved!.Values),
StreamId = info.Id,
Port = info.Port,
};
var spec = new BlockSpec(decision.Kind!, decision.Resolved, info.Id, info.Port, new FragmentScope(task.Id, null, null));
return new LineEntry(lineTask, info, spec, _env.WithSession(probe));
}
/// <summary>在数据面登记这个流并启动它的监听方积木。</summary>
private void Start(LineEntry entry)
{
IBlock block = BlockCatalog.Find(entry.Stream.Kind)!.Listener!(entry.Env, entry.Spec);
entry.Stream.Running = RunningBlock.Start(block, entry.Signature);
_streams.Add(entry.Stream);
}
private async Task Retire(LineEntry entry)
{
_streams.Remove(entry.Stream.Id);
if (entry.Stream.Running is { } running)
{
await running.StopAsync();
}
entry.Stream.Owner?.ForgetStream(entry.Stream.Id);
}
private sealed record LineEntry(LineTask Task, StreamInfo Stream, BlockSpec Spec, BlockEnv Env)
{
public string Signature => Spec.ConfigSignature;
}
}
@@ -0,0 +1,292 @@
using System.Collections.Concurrent;
using Fleet.Core;
using Fleet.Rpc;
using Monitor.Measurement;
using Monitor.Protocol;
namespace Monitor.Agent;
/// <summary>
/// 按需测试的编排(<c>monitor.test.run</c>):Agent 是时间的主人。每个阶段:
/// 登记数据面的流 → <c>monitor.test.phase</c>(Probe 准备好自己一侧的角色)→ 运行(单向数据流由 Agent 发包,回显类等待 Probe 发包)
/// → <c>monitor.test.phase_end</c>(Probe 收尾,回复它交出的列块个数)→ 等所有列块合并上传 → <c>phase_done</c>。
/// 同一时间只能运行 <c>max_concurrent_tests</c> 个测试;Probe 掉线或被取消时测试中止,已经上传的阶段数据保留。
/// </summary>
public sealed class TestRunner
{
private static readonly TimeSpan s_phaseSlack = TimeSpan.FromSeconds(15);
private static readonly TimeSpan s_fragmentWait = TimeSpan.FromSeconds(10);
private readonly ConcurrentDictionary<string, RunningTest> _running = new();
private readonly TaskManager _tasks;
private readonly SessionRegistry _sessions;
private readonly StreamRegistry _streams;
private readonly BlockEnv _env;
private readonly BackendUplink _uplink;
private readonly int _maxConcurrent;
private readonly Func<string, IReadOnlyList<int>> _ports;
private readonly TimeProvider _time;
private readonly INodeLog _log;
public TestRunner(TaskManager tasks, SessionRegistry sessions, StreamRegistry streams, BlockEnv env, BackendUplink uplink, int maxConcurrent, Func<string, IReadOnlyList<int>> ports, TimeProvider time, INodeLog log)
{
_tasks = tasks;
_sessions = sessions;
_streams = streams;
_env = env;
_uplink = uplink;
_maxConcurrent = Math.Max(1, maxConcurrent);
_ports = ports;
_time = time;
_log = log;
}
public int Running => _running.Count;
/// <summary>校验并接受一个测试(立即返回),实际运行在后台进行,状态通过 <c>monitor.test.status</c> 回报。</summary>
public Result<TestRunResult> Start(TestRunParams run)
{
if (_sessions.Get(run.ProbeId.Value) is not { } probe)
{
return Result<TestRunResult>.Fail(MonitorErrors.ProbeOffline, $"probe {run.ProbeId.Value} is not connected to this agent");
}
if (run.Phases.Count == 0)
{
return Result<TestRunResult>.Fail(MonitorErrors.ValidationFailed, "a test needs at least one phase");
}
var resolved = new List<(TestPhase Phase, TaskDecision Decision)>();
foreach (TestPhase phase in run.Phases)
{
WithDefaultPort(phase);
TaskDecision decision = _tasks.Guard.CheckPhase(phase.Kind, phase.Params);
if (!decision.Accepted)
{
return Result<TestRunResult>.Fail(
decision.Reason == RejectReasons.Unsupported ? FleetErrors.Unsupported : MonitorErrors.BudgetExceeded,
$"phase {phase.Kind} refused: {decision.Reason}");
}
string? direction = decision.Resolved!.Values.ContainsKey("direction") ? decision.Resolved.GetString("direction") : null;
if (BlockCatalog.Find(phase.Kind) is not { Listener: not null } pair || !pair.Supports(direction))
{
return Result<TestRunResult>.Fail(FleetErrors.Unsupported, $"{phase.Kind} does not support direction={direction}");
}
}
foreach (TestPhase phase in run.Phases)
{
resolved.Add((phase, _tasks.Guard.CheckPhase(phase.Kind, phase.Params)));
}
var running = new RunningTest(probe);
if (_running.Count >= _maxConcurrent || !_running.TryAdd(run.TestId.Value, running))
{
running.Dispose();
return Result<TestRunResult>.Fail(MonitorErrors.TestBusy, "this agent is already running a test");
}
_ = Task.Run(() => RunAsync(run.TestId, resolved, running), CancellationToken.None);
return Result<TestRunResult>.Ok(new TestRunResult { Accepted = true });
}
/// <summary>阶段没有指定端口时用本 Agent 自己监听的第一个端口(Backend 的模板不知道每个 Agent 的实际端口)。</summary>
private void WithDefaultPort(TestPhase phase)
{
KindSpec? kind = Kinds.Find(phase.Kind);
if (kind is null || !kind.Params.Any(p => p.Name == "port"))
{
return;
}
// 按需测试的数据端口由 Agent 决定:Backend 给的端口如果不是本机监听的端口(模板里是出厂默认值,每个 Agent 可以改,
// 而 Backend 并不知道 TCP 数据端口),就用本机监听的第一个端口。块头里记录的是实际用的端口。
IReadOnlyList<int> ports = _ports(kind.Blocks.Transport == "udp" ? "udp" : "tcp");
bool listening = phase.Params.TryGetValue("port", out System.Text.Json.JsonElement requested) && requested.TryGetInt32(out int port) && ports.Contains(port);
if (!listening)
{
phase.Params["port"] = System.Text.Json.JsonDocument.Parse(ports[0].ToString(System.Globalization.CultureInfo.InvariantCulture)).RootElement;
}
}
public bool Cancel(TestId test)
{
if (_running.TryGetValue(test.Value, out RunningTest? running))
{
running.Cancel.Cancel();
return true;
}
return false;
}
/// <summary>某个 Probe 掉线:它上面的测试中止,状态是 failed / probe_offline;别的 Probe 上的测试不受影响。</summary>
public void ProbeLost(ProbeSession probe)
{
foreach (RunningTest running in _running.Values)
{
if (ReferenceEquals(running.Probe, probe))
{
running.Lost = true;
running.Cancel.Cancel();
}
}
}
private async Task RunAsync(TestId test, List<(TestPhase Phase, TaskDecision Decision)> phases, RunningTest running)
{
CancellationToken ct = running.Cancel.Token;
string finalStatus = TestStatuses.Succeeded;
string? errorCode = null;
int? lastPhase = null;
try
{
ProbeSession probe = running.Probe;
for (int index = 0; index < phases.Count; index++)
{
lastPhase = index;
await _uplink.SendTestStatusAsync(test, TestStatuses.Running, index);
await RunPhaseAsync(probe, test, index, phases[index].Phase, phases[index].Decision, ct);
await _uplink.SendTestStatusAsync(test, TestStatuses.PhaseDone, index);
}
}
catch (OperationCanceledException)
{
finalStatus = running.Lost ? TestStatuses.Failed : TestStatuses.Aborted;
errorCode = running.Lost ? MonitorErrors.ProbeOffline.Value : null;
await TryAbortProbeAsync(running.Probe, test);
}
catch (TestFailure failure)
{
finalStatus = TestStatuses.Failed;
errorCode = failure.Code;
await TryAbortProbeAsync(running.Probe, test);
}
finally
{
_running.TryRemove(test.Value, out _);
running.Dispose();
}
_log.Info("test finished", ("test", test.Value), ("status", finalStatus));
await _uplink.SendTestStatusAsync(test, finalStatus, lastPhase, errorCode);
}
private async Task RunPhaseAsync(ProbeSession probe, TestId test, int index, TestPhase phase, TaskDecision decision, CancellationToken ct)
{
ParamValues values = decision.Resolved!;
var stream = new StreamInfo(_streams.Allocate(), phase.Kind, values) { TestId = test, Phase = index, Owner = probe };
int durationSeconds = phase.DurationSeconds > 0 ? phase.DurationSeconds : (int)(values.Values.ContainsKey("duration_s") ? values.GetInt("duration_s") : 10);
var blockSpec = new BlockSpec(decision.Kind!, values, stream.Id, stream.Port, new FragmentScope(null, test, index));
stream.Running = RunningBlock.Start(BlockCatalog.Find(phase.Kind)!.Listener!(_env.WithSession(probe), blockSpec), blockSpec.Signature);
_streams.Add(stream);
try
{
RpcSession<PeerContext> rpc = probe.Rpc ?? throw new TestFailure(MonitorErrors.ProbeOffline.Value);
var start = new TestPhaseStartParams
{
TestId = test,
Phase = index,
Kind = phase.Kind,
Params = new Dictionary<string, System.Text.Json.JsonElement>(values.Values),
StreamId = stream.Id,
DurationSeconds = durationSeconds,
Port = stream.Port,
};
Result<TestPhaseStartResult> accepted = await rpc.RequestAsync(MonitorMethods.TestPhase, start, MonitorJsonContext.Default.TestPhaseStartParams, MonitorJsonContext.Default.TestPhaseStartResult, ct);
if (!accepted.TryGet(out TestPhaseStartResult? started, out FleetError? error) || !started.Accepted)
{
throw new TestFailure(error?.Code.Value ?? FleetErrors.Unsupported.Value);
}
// 单向数据流的发送端自己运行到结束(或失败);其他积木是反应式的,等够时长即可
if (phase.Kind == "udp_train")
{
try
{
await stream.Running.Completion.WaitAsync(ct);
}
catch (BlockFaultException)
{
throw new TestFailure(MonitorErrors.ProbeOffline.Value);
}
}
else
{
await Task.Delay(TimeSpan.FromSeconds(durationSeconds), _time, ct);
}
long sent = stream.Running.Block.Produced;
var end = new TestPhaseEndParams { TestId = test, Phase = index, StreamId = stream.Id, SentCount = sent };
Result<TestPhaseEndResult> ended = await rpc.RequestAsync(MonitorMethods.TestPhaseEnd, end, MonitorJsonContext.Default.TestPhaseEndParams, MonitorJsonContext.Default.TestPhaseEndResult, ct, s_phaseSlack);
if (!ended.TryGet(out TestPhaseEndResult? finished, out FleetError? endError))
{
throw new TestFailure(endError.Code.Value);
}
// 列块是经 Probe 的发件箱至少一次送达的:全部合并上传之后才进入下一阶段(超时就带着已有的数据继续)
DateTimeOffset deadline = _time.GetUtcNow() + s_fragmentWait;
while (stream.FragmentsDone < finished.Fragments && _time.GetUtcNow() < deadline)
{
await Task.Delay(TimeSpan.FromMilliseconds(20), _time, ct);
}
if (stream.FragmentsDone < finished.Fragments)
{
_log.Warn("fragments missing", ("test", test.Value), ("phase", index), ("expected", finished.Fragments), ("got", stream.FragmentsDone));
}
}
finally
{
_streams.Remove(stream.Id);
await stream.Running.StopAsync();
probe.ForgetStream(stream.Id);
}
}
private async Task TryAbortProbeAsync(ProbeSession probe, TestId test)
{
if (probe.Rpc is not { } rpc)
{
return;
}
try
{
await rpc.NotifyAsync(MonitorMethods.TestAbort, new TestAbortParams { TestId = test }, MonitorJsonContext.Default.TestAbortParams, CancellationToken.None);
}
catch (Exception ex) when (ex is ObjectDisposedException or InvalidOperationException or IOException)
{
// 会话已经结束
}
}
/// <summary>一个正在运行的测试:它在哪个 Probe 上、怎样取消、是不是因为 Probe 掉线而中止。</summary>
private sealed class RunningTest : IDisposable
{
public RunningTest(ProbeSession probe)
{
Probe = probe;
}
public ProbeSession Probe { get; }
public CancellationTokenSource Cancel { get; } = new();
public volatile bool Lost;
public void Dispose() => Cancel.Dispose();
}
private sealed class TestFailure : Exception
{
public TestFailure(string code)
: base(code)
{
Code = code;
}
public string Code { get; }
}
}
+3
View File
@@ -0,0 +1,3 @@
<Project>
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" />
</Project>
+28
View File
@@ -0,0 +1,28 @@
namespace Fleet.Core;
/// <summary>
/// 错误码:snake_case 字符串。框架的错误码以 <c>fleet_</c> 开头,产品可以扩展自己的错误码。
/// </summary>
public readonly record struct ErrorCode(string Value)
{
public override string ToString() => Value;
}
public static class FleetErrors
{
public static readonly ErrorCode Unauthorized = new("fleet_unauthorized");
public static readonly ErrorCode Forbidden = new("fleet_forbidden");
public static readonly ErrorCode Unsupported = new("fleet_unsupported");
public static readonly ErrorCode InvalidParams = new("fleet_invalid_params");
public static readonly ErrorCode NotFound = new("fleet_not_found");
public static readonly ErrorCode Internal = new("fleet_internal");
public static readonly ErrorCode Timeout = new("fleet_timeout");
public static readonly ErrorCode NodeOffline = new("fleet_node_offline");
public static readonly ErrorCode NodePending = new("fleet_node_pending");
public static readonly ErrorCode NodeDisabled = new("fleet_node_disabled");
public static readonly ErrorCode NameConflict = new("fleet_name_conflict");
public static readonly ErrorCode JoinTokenInvalid = new("fleet_join_token_invalid");
public static readonly ErrorCode JoinTokenExpired = new("fleet_join_token_expired");
public static readonly ErrorCode ProofInvalid = new("fleet_proof_invalid");
public static readonly ErrorCode FrameTooLarge = new("fleet_frame_too_large");
}
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsAotCompatible>true</IsAotCompatible>
<RootNamespace>Fleet.Core</RootNamespace>
</PropertyGroup>
<ItemGroup>
<AdditionalFiles Include="$(MSBuildThisFileDirectory)../../../BannedSymbols.Node.txt" />
</ItemGroup>
</Project>
+119
View File
@@ -0,0 +1,119 @@
using System.Security.Cryptography;
namespace Fleet.Core;
public enum NodeLogLevel
{
Info,
Warn,
Error,
}
/// <summary>节点日志:字段名统一为 logfmt(<c>ts level msg agent probe session task result err</c>)。逐包事件不记录,只累加计数。</summary>
public interface INodeLog
{
void Write(NodeLogLevel level, string message, params (string Key, object? Value)[] fields);
}
public sealed class NullNodeLog : INodeLog
{
public static NullNodeLog Instance { get; } = new();
public void Write(NodeLogLevel level, string message, params (string Key, object? Value)[] fields)
{
}
}
public static class NodeLogExtensions
{
public static void Info(this INodeLog log, string message, params (string Key, object? Value)[] fields) => log.Write(NodeLogLevel.Info, message, fields);
public static void Warn(this INodeLog log, string message, params (string Key, object? Value)[] fields) => log.Write(NodeLogLevel.Warn, message, fields);
public static void Error(this INodeLog log, string message, params (string Key, object? Value)[] fields) => log.Write(NodeLogLevel.Error, message, fields);
}
/// <summary>指数退避(带随机抖动):1 s → 20 s(对端恢复之后最多等 20 秒;节点数量不大,重连风暴不是问题)。连接成功之后调用 <see cref="Reset"/>。</summary>
public sealed class Backoff
{
private readonly TimeSpan _initial;
private readonly TimeSpan _max;
private int _attempt;
public Backoff(TimeSpan? initial = null, TimeSpan? max = null)
{
_initial = initial ?? TimeSpan.FromSeconds(1);
_max = max ?? TimeSpan.FromSeconds(20);
}
public TimeSpan Next()
{
double baseMs = Math.Min(_max.TotalMilliseconds, _initial.TotalMilliseconds * Math.Pow(2, Math.Min(_attempt, 20)));
_attempt++;
// 抖动:0.5 ~ 1.0 倍,避免所有节点同时重连
double jitter = 0.5 + (RandomNumberGenerator.GetInt32(0, 1000) / 2000.0);
return TimeSpan.FromMilliseconds(baseMs * jitter);
}
public void Reset() => _attempt = 0;
}
/// <summary>
/// 序号重放窗口(滑动 1024 位):同一个序号只接受一次;比窗口还旧的序号视为重放。
/// 用于数据面的 UDP 包,每个 (session, stream) 一个。
/// </summary>
public sealed class ReplayWindow
{
private const int Size = 1024;
private readonly ulong[] _bits = new ulong[Size / 64];
private long _highest = -1;
public bool TryAccept(uint seq)
{
if (_highest < 0)
{
_highest = seq;
Set(seq);
return true;
}
if (seq > _highest)
{
long shift = seq - _highest;
if (shift >= Size)
{
Array.Clear(_bits);
}
else
{
for (long s = _highest + 1; s <= seq; s++)
{
Clear((uint)s);
}
}
_highest = seq;
Set(seq);
return true;
}
if (_highest - seq >= Size)
{
return false; // 太旧
}
if (IsSet(seq))
{
return false; // 重复
}
Set(seq);
return true;
}
private void Set(uint seq) => _bits[(int)(seq % Size) / 64] |= 1UL << (int)(seq % 64);
private void Clear(uint seq) => _bits[(int)(seq % Size) / 64] &= ~(1UL << (int)(seq % 64));
private bool IsSet(uint seq) => (_bits[(int)(seq % Size) / 64] & (1UL << (int)(seq % 64))) != 0;
}
+50
View File
@@ -0,0 +1,50 @@
namespace Fleet.Core;
public record FleetError(ErrorCode Code, string Message)
{
public override string ToString() => $"{Code}: {Message}";
}
/// <summary>预期内的失败用结果表示,不抛异常。</summary>
public readonly struct Result<T>
{
private readonly T? _value;
private readonly FleetError? _error;
private Result(T? value, FleetError? error)
{
_value = value;
_error = error;
}
public bool IsSuccess => _error is null;
public T Value => _error is null ? _value! : throw new InvalidOperationException(_error.ToString());
public FleetError Error => _error ?? throw new InvalidOperationException("Result is successful.");
public static Result<T> Ok(T value) => new(value, null);
public static Result<T> Fail(FleetError error) => new(default, error);
public static Result<T> Fail(ErrorCode code, string message) => new(default, new FleetError(code, message));
public bool TryGet(out T value, out FleetError error)
{
value = _value!;
error = _error!;
return _error is null;
}
public Result<TOut> Map<TOut>(Func<T, TOut> map) =>
_error is null ? Result<TOut>.Ok(map(_value!)) : Result<TOut>.Fail(_error);
public static implicit operator Result<T>(FleetError error) => Fail(error);
}
public static class Result
{
public static Result<T> Ok<T>(T value) => Result<T>.Ok(value);
public static Result<T> Fail<T>(ErrorCode code, string message) => Result<T>.Fail(code, message);
}
+38
View File
@@ -0,0 +1,38 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Fleet.Core;
/// <summary>节点 ID(带类型前缀的 ULID)。Monitor 中的 AgentId 就是 NodeId。</summary>
[JsonConverter(typeof(NodeIdJsonConverter))]
public readonly record struct NodeId(string Value)
{
public override string ToString() => Value;
}
/// <summary>强类型字符串 ID 的 JSON 转换器基类(源生成友好,不使用反射)。</summary>
public abstract class StringIdJsonConverter<TId> : JsonConverter<TId>
{
protected abstract TId Create(string value);
protected abstract string GetValue(TId id);
public sealed override TId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
Create(reader.GetString() ?? throw new JsonException("id must be a string"));
public sealed override void Write(Utf8JsonWriter writer, TId value, JsonSerializerOptions options) =>
writer.WriteStringValue(GetValue(value));
public sealed override TId ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
Create(reader.GetString() ?? throw new JsonException("id must be a string"));
public sealed override void WriteAsPropertyName(Utf8JsonWriter writer, TId value, JsonSerializerOptions options) =>
writer.WritePropertyName(GetValue(value));
}
public sealed class NodeIdJsonConverter : StringIdJsonConverter<NodeId>
{
protected override NodeId Create(string value) => new(value);
protected override string GetValue(NodeId id) => id.Value;
}
+43
View File
@@ -0,0 +1,43 @@
using System.Security.Cryptography;
using System.Text;
namespace Fleet.Core;
/// <summary>令牌的生成与哈希:服务端只保存 SHA-256,明文只在创建时返回一次。</summary>
public static class TokenHash
{
public static byte[] Sha256(string token) => SHA256.HashData(Encoding.UTF8.GetBytes(token));
/// <summary>生成高熵令牌:<c>{prefix}_{43 个 base64url 字符}</c>。</summary>
public static string NewToken(string prefix)
{
Span<byte> random = stackalloc byte[32];
RandomNumberGenerator.Fill(random);
return prefix + "_" + Base64Url.Encode(random);
}
public static bool FixedTimeEquals(ReadOnlySpan<byte> a, ReadOnlySpan<byte> b) =>
CryptographicOperations.FixedTimeEquals(a, b);
}
public static class Base64Url
{
public static string Encode(ReadOnlySpan<byte> data) =>
Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_');
public static bool TryDecode(string text, out byte[] data)
{
string s = text.Replace('-', '+').Replace('_', '/');
s = s.PadRight(s.Length + ((4 - (s.Length % 4)) % 4), '=');
try
{
data = Convert.FromBase64String(s);
return true;
}
catch (FormatException)
{
data = [];
return false;
}
}
}
+41
View File
@@ -0,0 +1,41 @@
using System.Security.Cryptography;
namespace Fleet.Core;
/// <summary>26 个字符的 ULID(Crockford base32,前 48 位为毫秒时间戳),按时间可排序。</summary>
public static class Ulid
{
private const string Alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
public static string New(TimeProvider time)
{
long ms = time.GetUtcNow().ToUnixTimeMilliseconds();
Span<byte> random = stackalloc byte[10];
RandomNumberGenerator.Fill(random);
Span<char> chars = stackalloc char[26];
for (int i = 9; i >= 0; i--)
{
chars[i] = Alphabet[(int)(ms & 31)];
ms >>= 5;
}
// 80 位随机数 → 16 个 base32 字符
UInt128 r = 0;
foreach (byte b in random)
{
r = (r << 8) | b;
}
for (int i = 25; i >= 10; i--)
{
chars[i] = Alphabet[(int)(r & 31)];
r >>= 5;
}
return new string(chars);
}
/// <summary>带类型前缀的 ID,例如 <c>agt_01J…</c>。</summary>
public static string NewPrefixed(string prefix, TimeProvider time) => prefix + "_" + New(time);
}
+66
View File
@@ -0,0 +1,66 @@
using System.Buffers.Binary;
namespace Fleet.Core;
/// <summary>LEB128 变长整数(无符号),以及 zigzag 有符号映射。</summary>
public static class VarInt
{
public const int MaxBytes = 10;
public static ulong ZigZag(long value) => (ulong)((value << 1) ^ (value >> 63));
public static long UnZigZag(ulong value) => (long)(value >> 1) ^ -(long)(value & 1);
public static int Write(Span<byte> destination, ulong value)
{
int i = 0;
while (value >= 0x80)
{
destination[i++] = (byte)(value | 0x80);
value >>= 7;
}
destination[i++] = (byte)value;
return i;
}
public static void Write(Stream destination, ulong value)
{
Span<byte> buffer = stackalloc byte[MaxBytes];
int n = Write(buffer, value);
destination.Write(buffer[..n]);
}
/// <summary>读取失败(数据被截断或超过 10 字节)时返回 false。</summary>
public static bool TryRead(ReadOnlySpan<byte> source, out ulong value, out int consumed)
{
value = 0;
consumed = 0;
int shift = 0;
while (consumed < source.Length && consumed < MaxBytes)
{
byte b = source[consumed++];
value |= (ulong)(b & 0x7F) << shift;
if ((b & 0x80) == 0)
{
return true;
}
shift += 7;
}
return false;
}
public static bool TryReadUInt32LittleEndian(ReadOnlySpan<byte> source, out uint value)
{
if (source.Length < 4)
{
value = 0;
return false;
}
value = BinaryPrimitives.ReadUInt32LittleEndian(source);
return true;
}
}
@@ -0,0 +1,82 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Fleet.Core;
using Fleet.Hosting;
using Fleet.Identity;
namespace Fleet.Enrollment.Client;
public sealed record EnrollmentResult(string NodeId, string Status, string IdentityDirectory);
/// <summary>
/// 用加入令牌注册(供 <c>fleet-enroll</c> 使用,也可以嵌入产品自己的安装工具):
/// 生成身份密钥和 TLS 证书 → 用身份私钥签名持有证明 → 调用注册端点 → 写入身份目录。
/// 注册逻辑只存在于这里,常驻的业务进程不包含注册代码,也不使用 HttpClient。
/// </summary>
public static class EnrollmentClient
{
public static async Task<Result<EnrollmentResult>> EnrollAsync(
HttpClient http, Uri server, string joinToken, string name, string identityDirectory, string nodeVersion, JsonNode? productMeta, TimeProvider time, CancellationToken ct)
{
string nodeFile = Path.Combine(identityDirectory, LoadedNodeIdentity.NodeFileName);
if (File.Exists(nodeFile))
{
return Result<EnrollmentResult>.Fail(FleetErrors.NameConflict, $"{nodeFile} already exists: this node is already enrolled (remove the directory to enroll again)");
}
using NodeIdentity identity = NodeIdentity.Generate();
byte[] pfx = NodeCertificate.CreateSelfSignedPfx(name, TimeSpan.FromDays(3650), time);
byte[] spki;
using (var certificate = NodeCertificate.LoadPfx(pfx))
{
spki = NodeCertificate.SpkiFingerprint(certificate);
}
string pubkey = Convert.ToBase64String(identity.PublicKeySpki);
string tokenHash = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(joinToken)));
var body = new JsonObject
{
["join_token"] = joinToken,
["name"] = name,
["pubkey"] = pubkey,
["tls_spki"] = Convert.ToBase64String(spki),
["proof"] = Convert.ToBase64String(identity.Sign(SignaturePurpose.Enroll, tokenHash, pubkey)),
["node_version"] = nodeVersion,
["meta"] = productMeta,
};
using var content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json");
using HttpResponseMessage response = await http.PostAsync(new Uri(server, "/api/agent/v1/enroll"), content, ct);
string text = await response.Content.ReadAsStringAsync(ct);
if (!response.IsSuccessStatusCode)
{
string code = "fleet_internal";
string detail = text;
try
{
JsonNode? problem = JsonNode.Parse(text);
code = problem?["code"]?.GetValue<string>() ?? code;
detail = problem?["detail"]?.GetValue<string>() ?? detail;
}
catch (JsonException)
{
// 不是 problem+json:原样返回
}
return Result<EnrollmentResult>.Fail(new ErrorCode(code), $"{(int)response.StatusCode}: {detail}");
}
JsonNode result = JsonNode.Parse(text)!;
string nodeId = result["node_id"]!.GetValue<string>();
var store = new ConfigStore(identityDirectory);
store.Write(LoadedNodeIdentity.KeyFileName, identity.ExportPkcs8());
store.Write(LoadedNodeIdentity.TlsFileName, pfx);
// node.json 最后写:它存在就表示注册完整完成
store.Write(
LoadedNodeIdentity.NodeFileName,
JsonSerializer.SerializeToUtf8Bytes(new NodeFile { NodeId = nodeId, Server = server.ToString().TrimEnd('/'), TlsSpki = Convert.ToBase64String(spki) }, NodeFileContext.Default.NodeFile));
return Result<EnrollmentResult>.Ok(new EnrollmentResult(nodeId, result["status"]!.GetValue<string>(), identityDirectory));
}
}
@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Fleet.Core\Fleet.Core.csproj" />
<ProjectReference Include="..\Fleet.Identity\Fleet.Identity.csproj" />
<ProjectReference Include="..\Fleet.Hosting\Fleet.Hosting.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>
<ItemGroup>
<AdditionalFiles Include="$(MSBuildThisFileDirectory)../../../BannedSymbols.Node.txt" />
<ProjectReference Include="..\Fleet.Core\Fleet.Core.csproj" />
<ProjectReference Include="..\Fleet.Identity\Fleet.Identity.csproj" />
</ItemGroup>
</Project>
+317
View File
@@ -0,0 +1,317 @@
using System.Globalization;
using System.Runtime.InteropServices;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using Fleet.Core;
using Fleet.Identity;
namespace Fleet.Hosting;
/// <summary>logfmt 日志:<c>ts=… level=info msg="…" key=value</c>。令牌、会话密钥和 MAC 不能出现在日志里。</summary>
public sealed class ConsoleNodeLog : INodeLog
{
private readonly TimeProvider _time;
private readonly TextWriter _writer;
private readonly object _gate = new();
public ConsoleNodeLog(TimeProvider time, TextWriter? writer = null)
{
_time = time;
_writer = writer ?? Console.Error;
}
public void Write(NodeLogLevel level, string message, params (string Key, object? Value)[] fields)
{
var line = new StringBuilder();
line.Append("ts=").Append(_time.GetUtcNow().ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture));
line.Append(" level=").Append(level.ToString().ToLowerInvariant());
line.Append(" msg=").Append(Quote(message));
foreach ((string key, object? value) in fields)
{
line.Append(' ').Append(key).Append('=').Append(Quote(Convert.ToString(value, CultureInfo.InvariantCulture) ?? ""));
}
lock (_gate)
{
_writer.WriteLine(line.ToString());
}
}
private static string Quote(string text) =>
text.Length > 0 && text.All(c => char.IsLetterOrDigit(c) || c is '_' or '-' or '.' or ':' or '/') ? text : "\"" + text.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal) + "\"";
}
/// <summary>可以独立运行的功能模块(连接、数据面、切块……)。</summary>
public interface IModule
{
string Name { get; }
Task RunAsync(CancellationToken ct);
}
/// <summary>
/// 模块宿主:同时运行所有模块,隔离故障(一个模块崩溃只重启它自己,指数退避),收到取消之后等所有模块退出。
/// 模块之间通过构造函数传入的接口或 <c>Channel&lt;T&gt;</c> 通信,不使用静态全局状态。
/// </summary>
public sealed class ModuleHost
{
private readonly List<IModule> _modules = [];
private readonly INodeLog _log;
private readonly TimeProvider _time;
public ModuleHost(INodeLog log, TimeProvider time)
{
_log = log;
_time = time;
}
public ModuleHost Add(IModule module)
{
_modules.Add(module);
return this;
}
public Task RunAsync(CancellationToken ct) => Task.WhenAll(_modules.Select(m => RunModuleAsync(m, ct)));
private async Task RunModuleAsync(IModule module, CancellationToken ct)
{
var backoff = new Backoff(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(30));
while (!ct.IsCancellationRequested)
{
try
{
await module.RunAsync(ct);
return; // 模块正常结束
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
return;
}
catch (Exception ex)
{
// 模块边界:一个模块的故障不能带倒整个进程
TimeSpan delay = backoff.Next();
_log.Error("module crashed", ("module", module.Name), ("err", ex.GetType().Name + ": " + ex.Message), ("retry_ms", (long)delay.TotalMilliseconds));
await Task.Delay(delay, _time, ct);
}
}
}
}
/// <summary>
/// 启动自检:Linux 上 .NET 的加密和 TLS 依赖系统的 OpenSSL(<c>libssl.so.3</c>)。OpenWrt 默认用 mbedTLS/wolfSSL,需要另外安装 <c>libopenssl3</c>。
/// 缺少时在启动的第一步明确报错,而不是在第一次握手时抛出一个难以理解的异常。
/// </summary>
public static class PlatformCheck
{
/// <summary>返回 null 表示正常,否则是给人看的错误说明。</summary>
public static string? VerifyCrypto()
{
try
{
using var key = System.Security.Cryptography.ECDsa.Create(System.Security.Cryptography.ECCurve.NamedCurves.nistP256);
_ = key.SignData("check"u8, System.Security.Cryptography.HashAlgorithmName.SHA256);
return null;
}
catch (Exception ex) when (ex is DllNotFoundException or TypeInitializationException or System.Security.Cryptography.CryptographicException or PlatformNotSupportedException or InvalidOperationException)
{
return $"cannot use the system cryptography library ({ex.GetType().Name}: {ex.Message}). Install OpenSSL 3: Debian/Ubuntu `apt install libssl3`, Alpine `apk add libssl3`, OpenWrt `opkg install libopenssl3`";
}
}
}
/// <summary>处理 Ctrl+C 和 SIGTERM:取消令牌,让各个模块有序退出。</summary>
public static class ServiceShell
{
public static async Task RunAsync(Func<CancellationToken, Task> run, INodeLog log)
{
using var cts = new CancellationTokenSource();
using PosixSignalRegistration term = PosixSignalRegistration.Create(PosixSignal.SIGTERM, context =>
{
context.Cancel = true;
log.Info("stopping", ("result", "SIGTERM"));
cts.Cancel();
});
using PosixSignalRegistration interrupt = PosixSignalRegistration.Create(PosixSignal.SIGINT, context =>
{
context.Cancel = true;
log.Info("stopping", ("result", "SIGINT"));
cts.Cancel();
});
// Windows 上 Ctrl+Break 对应 SIGQUIT(没有控制台窗口的后台运行方式用它来请求有序退出)
using PosixSignalRegistration quit = PosixSignalRegistration.Create(PosixSignal.SIGQUIT, context =>
{
context.Cancel = true;
log.Info("stopping", ("result", "SIGQUIT"));
cts.Cancel();
});
// Windows 上关闭控制台窗口 / taskkill(不带 /F)对应 SIGHUP
using PosixSignalRegistration hangup = PosixSignalRegistration.Create(PosixSignal.SIGHUP, context =>
{
context.Cancel = true;
log.Info("stopping", ("result", "SIGHUP"));
cts.Cancel();
});
Task trimmer = TrimHeapAsync(cts.Token);
try
{
await run(cts.Token);
}
catch (OperationCanceledException) when (cts.IsCancellationRequested)
{
// 正常退出
}
finally
{
await cts.CancelAsync();
await trimmer;
}
}
/// <summary>
/// 节点进程的托管堆只有几百 KB,但工作站 GC 的第 0 代预算很大,按默认设置要攒很多垃圾才回收一次,
/// 在 1 GB 内存的小机器上表现为常驻内存缓慢爬升(不是泄漏,强制回收后曲线是平的)。
/// 每 30 秒回收一次:堆这么小,一次完整回收不到 1 ms。
/// </summary>
private static async Task TrimHeapAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30));
try
{
while (await timer.WaitForNextTickAsync(ct))
{
GC.Collect(2, GCCollectionMode.Forced, blocking: true, compacting: false);
}
}
catch (OperationCanceledException)
{
// 退出
}
}
}
/// <summary>读取 JSON 配置(源生成,不使用反射)。解析失败时给出明确的错误,而不是带着默认值悄悄运行。</summary>
public static class NodeConfig
{
public static T Load<T>(string path, JsonTypeInfo<T> typeInfo)
where T : class
{
try
{
if (!File.Exists(path))
{
throw new InvalidDataException($"config file {path} does not exist");
}
byte[] bytes = File.ReadAllBytes(path);
ReadOnlySpan<byte> json = bytes;
if (json.StartsWith(""u8)) // Windows 编辑器常带 UTF-8 BOM,System.Text.Json 不接受
{
json = json[3..];
}
return JsonSerializer.Deserialize(json, typeInfo) ?? throw new InvalidDataException($"{path} is empty");
}
catch (JsonException ex)
{
throw new InvalidDataException($"{path} is not valid: {ex.Message}", ex);
}
}
}
/// <summary>
/// 常驻业务进程里唯一允许写文件的地方,而且只能写配置/身份目录(原子替换,类 Unix 上权限 0600)。
/// 例如 Probe 把最新的 Agent 列表写回 bootstrap。测量数据本来就不落盘。
/// </summary>
public sealed class ConfigStore
{
private readonly string _directory;
public ConfigStore(string directory)
{
_directory = Path.GetFullPath(directory);
}
public void Write(string fileName, byte[] content)
{
string path = Path.GetFullPath(Path.Combine(_directory, fileName));
if (!path.StartsWith(_directory, StringComparison.Ordinal))
{
throw new InvalidOperationException("config files must stay inside the config directory");
}
Directory.CreateDirectory(_directory);
string temp = path + ".tmp";
#pragma warning disable RS0030 // 纯采集边界的唯一豁免点:ConfigStore 只写配置目录,先写临时文件再原子替换
File.WriteAllBytes(temp, content);
#pragma warning restore RS0030
if (!OperatingSystem.IsWindows())
{
File.SetUnixFileMode(temp, UnixFileMode.UserRead | UnixFileMode.UserWrite);
}
File.Move(temp, path, overwrite: true);
}
}
/// <summary><c>node.json</c>:<c>fleet-enroll</c> 写入,节点只读。</summary>
public sealed class NodeFile
{
[JsonPropertyName("node_id")]
public string NodeId { get; set; } = "";
[JsonPropertyName("server")]
public string Server { get; set; } = "";
/// <summary>本节点 TLS 证书的 SPKI 指纹(base64),注册时上报。</summary>
[JsonPropertyName("tls_spki")]
public string TlsSpki { get; set; } = "";
}
[JsonSerializable(typeof(NodeFile))]
public sealed partial class NodeFileContext : JsonSerializerContext;
/// <summary>身份目录里的文件(<c>fleet-enroll</c> 生成):<c>node.json</c>、<c>node.key</c>(PKCS#8)、<c>tls.pfx</c>。</summary>
public sealed class LoadedNodeIdentity : IDisposable
{
public const string NodeFileName = "node.json";
public const string KeyFileName = "node.key";
public const string TlsFileName = "tls.pfx";
public LoadedNodeIdentity(NodeFile file, NodeIdentity identity, X509Certificate2 certificate)
{
File = file;
Identity = identity;
Certificate = certificate;
}
public NodeFile File { get; }
public NodeId NodeId => new(File.NodeId);
public NodeIdentity Identity { get; }
public X509Certificate2 Certificate { get; }
public static LoadedNodeIdentity Load(string directory)
{
if (!System.IO.File.Exists(Path.Combine(directory, KeyFileName)) || !System.IO.File.Exists(Path.Combine(directory, TlsFileName)))
{
throw new InvalidDataException($"no node identity in {directory}: run fleet-enroll first");
}
NodeFile file = NodeConfig.Load(Path.Combine(directory, NodeFileName), NodeFileContext.Default.NodeFile);
NodeIdentity identity = NodeIdentity.FromPkcs8(System.IO.File.ReadAllBytes(Path.Combine(directory, KeyFileName)));
X509Certificate2 certificate = NodeCertificate.LoadPfx(System.IO.File.ReadAllBytes(Path.Combine(directory, TlsFileName)));
return new LoadedNodeIdentity(file, identity, certificate);
}
public void Dispose()
{
Identity.Dispose();
Certificate.Dispose();
}
}
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>
<ItemGroup>
<AdditionalFiles Include="$(MSBuildThisFileDirectory)../../../BannedSymbols.Node.txt" />
<ProjectReference Include="..\Fleet.Core\Fleet.Core.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,30 @@
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
namespace Fleet.Identity;
/// <summary>
/// 点对点会话的 TLS 证书:自签名的 ECDSA P-256(和身份密钥是两把不同的密钥)。
/// 对端不按证书链校验,而是按 SPKI 的 SHA-256 指纹校验(指纹由服务端经 Agent 列表下发)。
/// </summary>
public static class NodeCertificate
{
/// <summary>生成自签名证书,返回无口令的 PFX 字节(文件权限由写入方设为 0600)。</summary>
public static byte[] CreateSelfSignedPfx(string commonName, TimeSpan validity, TimeProvider time)
{
using ECDsa key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var request = new CertificateRequest("CN=" + commonName, key, HashAlgorithmName.SHA256);
request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, critical: true));
request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(
[new Oid("1.3.6.1.5.5.7.3.1"), new Oid("1.3.6.1.5.5.7.3.2")], critical: false));
DateTimeOffset now = time.GetUtcNow();
using X509Certificate2 certificate = request.CreateSelfSigned(now.AddDays(-1), now.Add(validity));
return certificate.Export(X509ContentType.Pfx);
}
public static X509Certificate2 LoadPfx(byte[] pfx) => X509CertificateLoader.LoadPkcs12(pfx, null);
/// <summary>证书公钥(SPKI)的 SHA-256:对端用它来固定这张证书。</summary>
public static byte[] SpkiFingerprint(X509Certificate2 certificate) =>
SHA256.HashData(certificate.PublicKey.ExportSubjectPublicKeyInfo());
}
@@ -0,0 +1,81 @@
using System.Security.Cryptography;
using System.Text;
namespace Fleet.Identity;
/// <summary>签名用途前缀:同一份签名不能在不同场景下复用。产品可以追加自己的用途,但不能占用 fleet 保留的这些。</summary>
public static class SignaturePurpose
{
public const string Auth = "auth";
public const string Peer = "peer";
public const string Enroll = "enroll";
}
public static class SignatureString
{
public static string Build(string purpose, params string[] fields) =>
"fleet|v1|" + purpose + "|" + string.Join('|', fields);
}
/// <summary>节点身份:ECDSA P-256 密钥对。服务端只保存公钥(SPKI)。</summary>
public sealed class NodeIdentity : IDisposable
{
private readonly ECDsa _key;
private NodeIdentity(ECDsa key)
{
_key = key;
PublicKeySpki = key.ExportSubjectPublicKeyInfo();
}
public byte[] PublicKeySpki { get; }
public static NodeIdentity Generate() => new(ECDsa.Create(ECCurve.NamedCurves.nistP256));
public static NodeIdentity FromPkcs8(ReadOnlySpan<byte> pkcs8)
{
ECDsa key = ECDsa.Create();
key.ImportPkcs8PrivateKey(pkcs8, out _);
return new NodeIdentity(key);
}
public byte[] ExportPkcs8() => _key.ExportPkcs8PrivateKey();
public byte[] Sign(string purpose, params string[] fields) =>
_key.SignData(Encoding.UTF8.GetBytes(SignatureString.Build(purpose, fields)), HashAlgorithmName.SHA256,
DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
/// <summary>验证签名;公钥或签名格式不合法时返回 false,不抛异常。</summary>
public static bool Verify(ReadOnlySpan<byte> publicKeySpki, ReadOnlySpan<byte> signature, string purpose, params string[] fields)
{
try
{
using ECDsa key = ECDsa.Create();
key.ImportSubjectPublicKeyInfo(publicKeySpki, out _);
return key.VerifyData(Encoding.UTF8.GetBytes(SignatureString.Build(purpose, fields)), signature,
HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
}
catch (CryptographicException)
{
return false;
}
}
public static bool IsValidPublicKey(ReadOnlySpan<byte> publicKeySpki)
{
try
{
using ECDsa key = ECDsa.Create();
key.ImportSubjectPublicKeyInfo(publicKeySpki, out _);
return key.KeySize == 256;
}
catch (CryptographicException)
{
return false;
}
}
public static byte[] Fingerprint(ReadOnlySpan<byte> spki) => SHA256.HashData(spki);
public void Dispose() => _key.Dispose();
}
@@ -0,0 +1,55 @@
using System.Security.Cryptography;
using System.Text;
namespace Fleet.Identity;
/// <summary>点对点会话派生出的数据面凭据。<c>SessionId</c> 8 字节,<c>Key</c> 32 字节(HMAC-SHA256 的密钥)。</summary>
public sealed record SessionCredentials(byte[] SessionId, byte[] Key)
{
public long SessionIdValue => BitConverter.ToInt64(SessionId, 0);
}
/// <summary>临时 ECDH 密钥:每个会话各生成一个,用完即弃,所以会话密钥具有前向保密。</summary>
public sealed class EphemeralKey : IDisposable
{
private readonly ECDiffieHellman _key = ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256);
public byte[] PublicKeySpki => _key.ExportSubjectPublicKeyInfo();
/// <summary>
/// 与对端的临时公钥协商,再经 HKDF-SHA256 派生会话凭据。
/// 盐是服务端的随机数,info 绑定双方的临时公钥,防止凭据被挪用到别的握手上。
/// </summary>
public SessionCredentials Derive(ReadOnlySpan<byte> remotePublicKeySpki, ReadOnlySpan<byte> nonce, ReadOnlySpan<byte> clientPublic, ReadOnlySpan<byte> serverPublic)
{
using ECDiffieHellman remote = ECDiffieHellman.Create();
remote.ImportSubjectPublicKeyInfo(remotePublicKeySpki, out _);
byte[] secret = _key.DeriveRawSecretAgreement(remote.PublicKey);
byte[] info = [.. Encoding.UTF8.GetBytes("fleet|v1|session|"), .. SHA256.HashData(clientPublic), .. SHA256.HashData(serverPublic)];
byte[] material = HKDF.DeriveKey(HashAlgorithmName.SHA256, secret, 40, nonce.ToArray(), info);
CryptographicOperations.ZeroMemory(secret);
return new SessionCredentials(material[..8], material[8..]);
}
public void Dispose() => _key.Dispose();
}
/// <summary>数据面的消息认证:HMAC-SHA256 截断到 16 字节。</summary>
public static class DataPlaneMac
{
public const int Bytes = 16;
public static void Compute(ReadOnlySpan<byte> key, ReadOnlySpan<byte> data, Span<byte> destination)
{
Span<byte> full = stackalloc byte[32];
HMACSHA256.HashData(key, data, full);
full[..Bytes].CopyTo(destination);
}
public static bool Verify(ReadOnlySpan<byte> key, ReadOnlySpan<byte> data, ReadOnlySpan<byte> mac)
{
Span<byte> expected = stackalloc byte[Bytes];
Compute(key, data, expected);
return CryptographicOperations.FixedTimeEquals(expected, mac);
}
}
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>
<ItemGroup>
<AdditionalFiles Include="$(MSBuildThisFileDirectory)../../../BannedSymbols.Node.txt" />
<ProjectReference Include="..\Fleet.Core\Fleet.Core.csproj" />
<ProjectReference Include="..\Fleet.Identity\Fleet.Identity.csproj" />
<ProjectReference Include="..\Fleet.Rpc\Fleet.Rpc.csproj" />
</ItemGroup>
</Project>
+317
View File
@@ -0,0 +1,317 @@
using System.Net.WebSockets;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using Fleet.Core;
using Fleet.Identity;
using Fleet.Rpc;
namespace Fleet.Link;
public sealed record LinkContext(FleetLink Link);
/// <summary>期望状态的处理者:应用完整快照,返回已应用的版本和被拒绝的条目(带结构化原因)。</summary>
public interface IStateHandler
{
string Type { get; }
ValueTask<StateSyncResult> ApplyAsync(long version, JsonElement document, CancellationToken ct);
}
public sealed class StateHandler<TDoc> : IStateHandler
{
private readonly JsonTypeInfo<TDoc> _info;
private readonly Func<long, TDoc, CancellationToken, ValueTask<StateSyncResult>> _apply;
public StateHandler(string type, JsonTypeInfo<TDoc> info, Func<long, TDoc, CancellationToken, ValueTask<StateSyncResult>> apply)
{
Type = type;
_info = info;
_apply = apply;
}
public string Type { get; }
public async ValueTask<StateSyncResult> ApplyAsync(long version, JsonElement document, CancellationToken ct)
{
TDoc? doc = document.Deserialize(_info);
return doc is null ? new StateSyncResult { AppliedVersion = 0 } : await _apply(version, doc, ct);
}
}
public sealed class FleetLinkOptions
{
/// <summary>面板的地址(http 或 https),连接路径固定为 <c>/api/agent/v1/rpc</c>。</summary>
public required Uri Server { get; init; }
public required NodeIdentity Identity { get; init; }
public required NodeId NodeId { get; init; }
public string NodeVersion { get; init; } = "0.0.0";
/// <summary>框架之外的能力,例如 <c>monitor.v1</c> 与 <c>monitor.kinds:udp_echo,tcp_echo</c>。</summary>
public IReadOnlyList<string> Capabilities { get; init; } = [];
/// <summary>附加到 <c>fleet.hello</c> 的产品元数据。</summary>
public Func<JsonElement?>? HelloMeta { get; init; }
/// <summary>附加到 <c>fleet.heartbeat</c> 的进程内计数(不能包含主机信息)。</summary>
public Func<JsonElement?>? HeartbeatExtra { get; init; }
public TimeSpan HeartbeatInterval { get; init; } = TimeSpan.FromSeconds(10);
public int MaxFrameBytes { get; init; } = 16 << 20;
/// <summary>发件箱在途字节上限(服务端慢时不会把内存撑爆)。</summary>
public long MaxInflightBytes { get; init; } = 4 << 20;
public Backoff Backoff { get; init; } = new();
}
/// <summary>
/// 节点到服务端的连接:挑战 → 签名认证 → hello → 状态调和 / 可靠发件箱 / 心跳,断线之后指数退避重连。
/// 被服务端撤销(<c>fleet.revoked</c>)或禁用时停止重试。
/// </summary>
public sealed class FleetLink
{
private const string RpcPath = "/api/agent/v1/rpc";
private readonly FleetLinkOptions _options;
private readonly RpcRouter<LinkContext> _router;
private readonly Dictionary<string, IStateHandler> _handlers;
private readonly TimeProvider _time;
private readonly INodeLog _log;
private volatile RpcSession<LinkContext>? _session;
private volatile bool _stopped;
public FleetLink(FleetLinkOptions options, RpcRouter<LinkContext> router, IEnumerable<IStateHandler> handlers, Outbox outbox, TimeProvider time, INodeLog log)
{
_options = options;
_router = router;
_handlers = handlers.ToDictionary(h => h.Type, StringComparer.Ordinal);
Outbox = outbox;
_time = time;
_log = log;
_router
.On(FleetMethods.StateSync, FleetRpcContext.Default.StateSyncParams, FleetRpcContext.Default.StateSyncResult, HandleStateSyncAsync)
.OnNotify(FleetMethods.OutboxAck, FleetRpcContext.Default.OutboxAckParams, HandleAckAsync)
.OnNotify(FleetMethods.Revoked, FleetRpcContext.Default.RevokedParams, HandleRevokedAsync);
}
public Outbox Outbox { get; }
public bool IsConnected => _session is not null;
public RpcSession<LinkContext>? CurrentSession => _session;
/// <summary>被服务端撤销或禁用:不会再重连。</summary>
public bool Revoked => _stopped;
/// <summary>每次连接并完成 hello 之后调用。</summary>
public Func<RpcSession<LinkContext>, CancellationToken, ValueTask>? OnConnected { get; set; }
public Action<string>? OnDisconnected { get; set; }
public async Task RunAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested && !_stopped)
{
string reason;
try
{
reason = await RunOnceAsync(ct);
_options.Backoff.Reset();
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
return;
}
catch (Exception ex) when (ex is WebSocketException or IOException or HttpRequestException or InvalidOperationException or TimeoutException)
{
reason = ex.Message;
}
OnDisconnected?.Invoke(reason);
if (ct.IsCancellationRequested)
{
return;
}
if (_stopped)
{
_log.Warn("link stopped", ("err", reason));
return;
}
TimeSpan delay = _options.Backoff.Next();
_log.Warn("link down, retrying", ("err", reason), ("retry_ms", (long)delay.TotalMilliseconds));
await Task.Delay(delay, _time, ct);
}
}
private async Task<string> RunOnceAsync(CancellationToken ct)
{
using var socket = new ClientWebSocket();
socket.Options.KeepAliveInterval = TimeSpan.FromSeconds(15);
socket.Options.KeepAliveTimeout = TimeSpan.FromSeconds(20); // 对端 20 秒没有回 Pong 就判定连接已死
var builder = new UriBuilder(_options.Server) { Scheme = _options.Server.Scheme == "https" ? "wss" : "ws", Path = RpcPath };
await socket.ConnectAsync(builder.Uri, ct);
await using var transport = new WsTransport(socket, _options.MaxFrameBytes);
string? failure = await AuthenticateAsync(transport, ct);
if (failure is not null)
{
return failure;
}
var session = new RpcSession<LinkContext>(transport, _router, new LinkContext(this), _time);
using CancellationTokenSource sessionCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
Task run = session.RunAsync(sessionCts.Token);
try
{
var hello = new HelloParams
{
NodeVersion = _options.NodeVersion,
Capabilities = [.. _options.Capabilities],
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);
if (!helloResult.IsSuccess)
{
return "hello failed: " + helloResult.Error.Code;
}
Outbox.ResetSent();
_session = session;
_log.Info("link up", ("result", "connected"));
Task pump = PumpAsync(session, sessionCts.Token);
Task heartbeat = HeartbeatAsync(session, sessionCts.Token);
if (OnConnected is not null)
{
await OnConnected(session, sessionCts.Token);
}
Task finished = await Task.WhenAny(run, pump, heartbeat);
await sessionCts.CancelAsync();
await Task.WhenAll(Quiet(run), Quiet(pump), Quiet(heartbeat));
return finished == run ? "closed by server" : "link task failed";
}
finally
{
_session = null;
await sessionCts.CancelAsync();
await Quiet(run);
await session.DisposeAsync();
}
}
/// <summary>挑战 → 签名 → 认证结果。失败返回原因;服务端禁用该节点时不再重试。</summary>
private async Task<string?> AuthenticateAsync(WsTransport transport, CancellationToken ct)
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15), _time);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
RpcMessage? challenge = await ReceiveMessageAsync(transport, linked.Token);
if (challenge?.Method != FleetMethods.Challenge || challenge.Params is null)
{
return "no challenge from the server";
}
ChallengeParams nonce = challenge.Params.Value.Deserialize(FleetRpcContext.Default.ChallengeParams)!;
string signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.Auth, _options.NodeId.Value, nonce.Nonce));
RpcMessage request = RpcMessage.Request(1, FleetMethods.Auth, new AuthParams { NodeId = _options.NodeId.Value, Signature = signature }, FleetRpcContext.Default.AuthParams);
await transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(request, FleetRpcContext.Default.RpcMessage)), linked.Token);
while (true)
{
RpcMessage? reply = await ReceiveMessageAsync(transport, linked.Token);
if (reply is null)
{
return "connection closed during authentication";
}
if (reply.Method == FleetMethods.Revoked)
{
_stopped = true;
return "revoked";
}
if (!reply.IsResponse)
{
continue;
}
if (reply.Error is { } error)
{
if (error.Data?.Code is "fleet_node_disabled")
{
_stopped = true;
}
return "authentication failed: " + error.Data?.Code;
}
return null;
}
}
private static async Task<RpcMessage?> ReceiveMessageAsync(WsTransport transport, CancellationToken ct)
{
RpcFrame? frame = await transport.ReceiveAsync(ct);
return frame is { Kind: FrameKind.Text } f ? JsonSerializer.Deserialize(f.Data.Span, FleetRpcContext.Default.RpcMessage) : null;
}
private async Task PumpAsync(RpcSession<LinkContext> session, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
byte[] frame = await Outbox.TakeNextAsync(_options.MaxInflightBytes, ct);
await session.SendBinaryAsync(frame, ct);
}
}
private async Task HeartbeatAsync(RpcSession<LinkContext> session, CancellationToken ct)
{
using var timer = new PeriodicTimer(_options.HeartbeatInterval, _time);
while (await timer.WaitForNextTickAsync(ct))
{
await session.NotifyAsync(FleetMethods.Heartbeat, new HeartbeatParams { Extra = _options.HeartbeatExtra?.Invoke() }, FleetRpcContext.Default.HeartbeatParams, ct);
}
}
private async ValueTask<Result<StateSyncResult>> HandleStateSyncAsync(LinkContext context, StateSyncParams parameters, CancellationToken ct)
{
if (!_handlers.TryGetValue(parameters.Type, out IStateHandler? handler))
{
return Result<StateSyncResult>.Fail(FleetErrors.Unsupported, $"unknown state type {parameters.Type}");
}
return Result<StateSyncResult>.Ok(await handler.ApplyAsync(parameters.Version, parameters.Doc, ct));
}
private ValueTask HandleAckAsync(LinkContext context, OutboxAckParams parameters, CancellationToken ct)
{
Outbox.Acknowledge(parameters.Ids.Concat(parameters.Duplicates).Select(id => Guid.TryParse(id, out Guid g) ? g : Guid.Empty));
return ValueTask.CompletedTask;
}
private ValueTask HandleRevokedAsync(LinkContext context, RevokedParams parameters, CancellationToken ct)
{
_stopped = true;
_log.Warn("revoked by the server", ("err", parameters.Reason));
return ValueTask.CompletedTask;
}
private static async Task Quiet(Task task)
{
try
{
await task;
}
catch (Exception ex) when (ex is OperationCanceledException or WebSocketException or IOException or ObjectDisposedException)
{
// 会话结束时的正常异常
}
}
}
+182
View File
@@ -0,0 +1,182 @@
using Fleet.Rpc;
namespace Fleet.Link;
/// <summary>
/// 可靠发件箱:至少一次投递。消息带 msg_id(UUIDv7),服务端按它去重,持久化之后才回复 ACK。
/// 有界(按字节和条数):满了丢弃最旧的,并累计缺口数通知产品。默认只在内存里,节点重启会丢失未上传的数据。
/// 重新连接之后,所有还没确认的消息按原顺序重发。
/// </summary>
public sealed class Outbox
{
private readonly object _gate = new();
private readonly LinkedList<Entry> _entries = [];
private readonly Dictionary<Guid, LinkedListNode<Entry>> _byId = [];
private readonly long _maxBytes;
private readonly int _maxCount;
private TaskCompletionSource _signal = NewSignal();
private long _bytes;
private long _inflightBytes;
private long _dropped;
public Outbox(long maxBytes, int maxCount = int.MaxValue)
{
_maxBytes = maxBytes;
_maxCount = maxCount;
}
/// <summary>因队列已满而丢弃的消息数(缺口)。</summary>
public long Dropped => Interlocked.Read(ref _dropped);
public long PendingBytes
{
get
{
lock (_gate)
{
return _bytes;
}
}
}
public int PendingCount
{
get
{
lock (_gate)
{
return _entries.Count;
}
}
}
/// <summary>每丢弃一批消息调用一次(参数是这一批的条数)。</summary>
public Action<int>? OnGap { get; set; }
public void Enqueue(byte channel, Guid msgId, ReadOnlySpan<byte> payload)
{
byte[] frame = OutboxFrame.Build(channel, msgId, payload);
int dropped = 0;
lock (_gate)
{
if (_byId.ContainsKey(msgId))
{
return;
}
while (_entries.Count > 0 && (_bytes + frame.Length > _maxBytes || _entries.Count >= _maxCount))
{
Remove(_entries.First!);
dropped++;
}
_byId[msgId] = _entries.AddLast(new Entry(msgId, frame));
_bytes += frame.Length;
Wake();
}
if (dropped > 0)
{
Interlocked.Add(ref _dropped, dropped);
OnGap?.Invoke(dropped);
}
}
/// <summary>连接断开之后:所有消息都视为没有发出,重连时重发。</summary>
public void ResetSent()
{
lock (_gate)
{
foreach (Entry entry in _entries)
{
entry.Sent = false;
}
_inflightBytes = 0;
Wake();
}
}
public void Acknowledge(IEnumerable<Guid> ids)
{
lock (_gate)
{
foreach (Guid id in ids)
{
if (_byId.TryGetValue(id, out LinkedListNode<Entry>? node))
{
Remove(node);
}
}
Wake();
}
}
/// <summary>
/// 取下一条还没发出的消息;在途字节达到 <paramref name="maxInflightBytes"/> 时等待 ACK(服务端慢时不会把内存撑爆)。
/// </summary>
public async ValueTask<byte[]> TakeNextAsync(long maxInflightBytes, CancellationToken ct)
{
while (true)
{
Task wait;
lock (_gate)
{
foreach (Entry entry in _entries)
{
if (!entry.Sent)
{
// 一条消息比在途上限还大时也要放行,否则会永远卡住
if (_inflightBytes == 0 || _inflightBytes + entry.Frame.Length <= maxInflightBytes)
{
entry.Sent = true;
_inflightBytes += entry.Frame.Length;
return entry.Frame;
}
break;
}
}
wait = _signal.Task;
}
await wait.WaitAsync(ct);
}
}
private void Remove(LinkedListNode<Entry> node)
{
_entries.Remove(node);
_byId.Remove(node.Value.Id);
_bytes -= node.Value.Frame.Length;
if (node.Value.Sent)
{
_inflightBytes = Math.Max(0, _inflightBytes - node.Value.Frame.Length);
}
}
private void Wake()
{
_signal.TrySetResult();
_signal = NewSignal();
}
private static TaskCompletionSource NewSignal() => new(TaskCreationOptions.RunContinuationsAsynchronously);
private sealed class Entry
{
public Entry(Guid id, byte[] frame)
{
Id = id;
Frame = frame;
}
public Guid Id { get; }
public byte[] Frame { get; }
public bool Sent { get; set; }
}
}
+278
View File
@@ -0,0 +1,278 @@
using System.Net;
using System.Net.Security;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text.Json;
using Fleet.Core;
using Fleet.Identity;
using Fleet.Rpc;
namespace Fleet.Link;
/// <summary>一个已经认证的点对点会话:对端是谁、数据面使用的会话凭据、对端地址。</summary>
public sealed record PeerSessionInfo(string PeerId, SessionCredentials Credentials, EndPoint? Remote, string? Version = null);
public sealed class PeerListenerOptions
{
public required IPEndPoint Listen { get; init; }
public required X509Certificate2 Certificate { get; init; }
/// <summary>按对端 ID 查找允许连接的公钥(SPKI)。不在列表里的对端一律拒绝(期望状态里的 peers)。</summary>
public required Func<string, byte[]?> LookupPeerKey { get; init; }
public int MaxFrameBytes { get; init; } = 8 << 20;
public int MaxSessions { get; init; } = 8;
public TimeSpan HandshakeTimeout { get; init; } = TimeSpan.FromSeconds(10);
}
/// <summary>
/// 监听点对点会话(Agent 一侧):TLS(自签名证书,对端按 SPKI 指纹固定)→ 挑战 → 对端用身份私钥签名(purpose = peer)
/// → 临时 ECDH 派生数据面凭据。认证通过之后把会话交给产品代码,由它挂上自己的路由。
/// </summary>
public sealed class PeerListener
{
private readonly PeerListenerOptions _options;
private readonly Func<PeerSessionInfo, IRpcTransport, CancellationToken, Task> _onSession;
private readonly INodeLog _log;
private readonly TimeProvider _time;
private int _sessions;
public PeerListener(PeerListenerOptions options, Func<PeerSessionInfo, IRpcTransport, CancellationToken, Task> onSession, INodeLog log, TimeProvider time)
{
_options = options;
_onSession = onSession;
_log = log;
_time = time;
}
/// <summary>实际监听的端口(配置为 0 时由系统分配);<see cref="RunAsync"/> 开始监听之后有效。</summary>
public int Port { get; private set; }
public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public async Task RunAsync(CancellationToken ct)
{
var listener = new TcpListener(_options.Listen);
listener.Start();
Port = ((IPEndPoint)listener.LocalEndpoint).Port;
Started.TrySetResult();
try
{
while (!ct.IsCancellationRequested)
{
TcpClient client = await listener.AcceptTcpClientAsync(ct);
if (Interlocked.Increment(ref _sessions) > _options.MaxSessions)
{
Interlocked.Decrement(ref _sessions);
client.Dispose();
continue;
}
_ = Task.Run(() => HandleAsync(client, ct), CancellationToken.None);
}
}
catch (OperationCanceledException)
{
// 停止监听
}
finally
{
listener.Stop();
}
}
private async Task HandleAsync(TcpClient client, CancellationToken ct)
{
try
{
client.NoDelay = true;
PeerSockets.EnableKeepAlive(client.Client);
using var timeout = new CancellationTokenSource(_options.HandshakeTimeout, _time);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
var tls = new SslStream(client.GetStream(), leaveInnerStreamOpen: false);
await tls.AuthenticateAsServerAsync(
new SslServerAuthenticationOptions { ServerCertificate = _options.Certificate, ClientCertificateRequired = false, EnabledSslProtocols = System.Security.Authentication.SslProtocols.Tls12 | System.Security.Authentication.SslProtocols.Tls13 },
linked.Token);
var transport = new TlsFrameTransport(tls, _options.MaxFrameBytes);
PeerSessionInfo? info = await HandshakeAsync(transport, client.Client.RemoteEndPoint, linked.Token);
if (info is null)
{
await transport.DisposeAsync();
return;
}
_log.Info("peer session", ("probe", info.PeerId), ("result", "established"));
await using (transport)
{
await _onSession(info, transport, ct);
}
}
catch (Exception ex) when (ex is IOException or System.Security.Authentication.AuthenticationException or OperationCanceledException or SocketException or ObjectDisposedException)
{
// 握手失败或连接断开:这个会话结束,监听继续
}
finally
{
client.Dispose();
Interlocked.Decrement(ref _sessions);
}
}
private async Task<PeerSessionInfo?> HandshakeAsync(TlsFrameTransport transport, EndPoint? remote, CancellationToken ct)
{
using var ephemeral = new EphemeralKey();
byte[] nonceBytes = RandomNumberGenerator.GetBytes(32);
string nonce = Convert.ToBase64String(nonceBytes);
string serverPub = Convert.ToBase64String(ephemeral.PublicKeySpki);
await SendAsync(transport, RpcMessage.Notification(PeerMethods.Challenge, new PeerChallengeParams { Nonce = nonce, EcdhPublicKey = serverPub }, PeerRpcContext.Default.PeerChallengeParams), ct);
RpcFrame? frame = await transport.ReceiveAsync(ct);
RpcMessage? message = frame is { Kind: FrameKind.Text } f ? JsonSerializer.Deserialize(f.Data.Span, FleetRpcContext.Default.RpcMessage) : null;
if (message?.Method != PeerMethods.Auth || message.Id is null || message.Params is null)
{
return null;
}
PeerAuthParams? auth = null;
try
{
auth = message.Params.Value.Deserialize(PeerRpcContext.Default.PeerAuthParams);
}
catch (JsonException)
{
// 按缺失处理
}
byte[]? key = auth is null ? null : _options.LookupPeerKey(auth.PeerId);
byte[] clientPublic = [];
bool ok = key is not null
&& TryDecode(auth!.Signature, out byte[] signature)
&& TryDecode(auth.EcdhPublicKey, out clientPublic)
&& NodeIdentity.Verify(key, signature, SignaturePurpose.Peer, auth.PeerId, nonce, auth.EcdhPublicKey, serverPub);
if (!ok)
{
// 对端不在允许列表里和签名错误返回同样的错误
await SendAsync(transport, RpcMessage.Failure(message.Id, new FleetError(FleetErrors.Unauthorized, "peer authentication failed")), ct);
_log.Warn("peer rejected", ("result", "unauthorized"));
return null;
}
SessionCredentials credentials;
try
{
credentials = ephemeral.Derive(clientPublic, nonceBytes, clientPublic, ephemeral.PublicKeySpki);
}
catch (CryptographicException)
{
await SendAsync(transport, RpcMessage.Failure(message.Id, new FleetError(FleetErrors.InvalidParams, "invalid ecdh key")), ct);
return null;
}
await SendAsync(transport, RpcMessage.Success(message.Id.Value, new PeerAuthResult { SessionId = Convert.ToHexStringLower(credentials.SessionId) }, PeerRpcContext.Default.PeerAuthResult), ct);
return new PeerSessionInfo(auth!.PeerId, credentials, remote, auth.Version is { Length: <= 64 } version ? version : null);
}
private static ValueTask SendAsync(TlsFrameTransport transport, RpcMessage message, CancellationToken ct) =>
transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(message, FleetRpcContext.Default.RpcMessage)), ct);
private static bool TryDecode(string text, out byte[] data)
{
try
{
data = Convert.FromBase64String(text);
return data.Length > 0;
}
catch (FormatException)
{
data = [];
return false;
}
}
}
/// <summary>发起点对点会话(Probe 一侧):按 SPKI 指纹固定对端证书,用身份私钥签名,派生同样的数据面凭据。</summary>
/// <summary>点对点会话的套接字:开启 TCP keepalive(空闲 15 秒后每 5 秒探测,连续 3 次无响应就断开,约 30 秒),主机丢失或路径被切断时连接不会一直挂着。</summary>
internal static class PeerSockets
{
public static void EnableKeepAlive(Socket socket)
{
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);
}
catch (Exception ex) when (ex is SocketException or ArgumentException or PlatformNotSupportedException)
{
// 个别平台不支持其中某个选项:退回系统默认
}
}
}
public static class PeerConnector
{
public static async Task<(PeerSessionInfo Info, TlsFrameTransport Transport)> ConnectAsync(
string host, int port, byte[] expectedSpkiSha256, NodeIdentity identity, string peerId, string? version, int maxFrameBytes, TimeProvider time, CancellationToken ct)
{
var client = new TcpClient { NoDelay = true };
PeerSockets.EnableKeepAlive(client.Client);
try
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10), time);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
await client.ConnectAsync(host, port, linked.Token);
var tls = new SslStream(
client.GetStream(), leaveInnerStreamOpen: false,
(_, certificate, _, _) => certificate is not null && SpkiMatches(certificate, expectedSpkiSha256));
await tls.AuthenticateAsClientAsync(new SslClientAuthenticationOptions { TargetHost = host }, linked.Token);
var transport = new TlsFrameTransport(tls, maxFrameBytes);
RpcFrame? first = await transport.ReceiveAsync(linked.Token);
RpcMessage? challengeMessage = first is { Kind: FrameKind.Text } f ? JsonSerializer.Deserialize(f.Data.Span, FleetRpcContext.Default.RpcMessage) : null;
if (challengeMessage?.Method != PeerMethods.Challenge || challengeMessage.Params is null)
{
throw new InvalidOperationException("the agent did not send a challenge");
}
PeerChallengeParams challenge = challengeMessage.Params.Value.Deserialize(PeerRpcContext.Default.PeerChallengeParams)!;
using var ephemeral = new EphemeralKey();
string clientPub = Convert.ToBase64String(ephemeral.PublicKeySpki);
string signature = Convert.ToBase64String(identity.Sign(SignaturePurpose.Peer, peerId, challenge.Nonce, clientPub, challenge.EcdhPublicKey));
RpcMessage request = RpcMessage.Request(1, PeerMethods.Auth, new PeerAuthParams { PeerId = peerId, EcdhPublicKey = clientPub, Signature = signature, Version = version }, PeerRpcContext.Default.PeerAuthParams);
await transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(request, FleetRpcContext.Default.RpcMessage)), linked.Token);
RpcFrame? replyFrame = await transport.ReceiveAsync(linked.Token);
RpcMessage? reply = replyFrame is { Kind: FrameKind.Text } r ? JsonSerializer.Deserialize(r.Data.Span, FleetRpcContext.Default.RpcMessage) : null;
if (reply is null || reply.Error is not null)
{
throw new InvalidOperationException("peer authentication failed: " + reply?.Error?.Data?.Code);
}
SessionCredentials credentials = ephemeral.Derive(Convert.FromBase64String(challenge.EcdhPublicKey), Convert.FromBase64String(challenge.Nonce), ephemeral.PublicKeySpki, Convert.FromBase64String(challenge.EcdhPublicKey));
string announced = reply.Result!.Value.Deserialize(PeerRpcContext.Default.PeerAuthResult)!.SessionId;
if (announced != Convert.ToHexStringLower(credentials.SessionId))
{
throw new InvalidOperationException("session id mismatch: the key agreement did not produce the same credentials");
}
return (new PeerSessionInfo(peerId, credentials, client.Client.RemoteEndPoint), transport);
}
catch
{
client.Dispose();
throw;
}
}
/// <summary>固定证书:只看 SPKI 的 SHA-256 指纹,不看证书链和主机名(自签名证书)。</summary>
private static bool SpkiMatches(X509Certificate certificate, byte[] expected)
{
X509Certificate2 full = certificate as X509Certificate2 ?? X509CertificateLoader.LoadCertificate(certificate.GetRawCertData());
return CryptographicOperations.FixedTimeEquals(NodeCertificate.SpkiFingerprint(full), expected);
}
}
+207
View File
@@ -0,0 +1,207 @@
using System.Buffers.Binary;
using System.Net.WebSockets;
using Fleet.Rpc;
namespace Fleet.Link;
/// <summary>WebSocket 传输(节点 → 服务端):文本帧是 JSON-RPC,二进制帧是发件箱。</summary>
public sealed class WsTransport : IRpcTransport
{
private readonly WebSocket _socket;
private readonly int _maxFrameBytes;
private readonly SemaphoreSlim _sendLock = new(1, 1);
public WsTransport(WebSocket socket, int maxFrameBytes)
{
_socket = socket;
_maxFrameBytes = maxFrameBytes;
}
public async ValueTask SendAsync(RpcFrame frame, CancellationToken ct)
{
await _sendLock.WaitAsync(ct);
try
{
await _socket.SendAsync(frame.Data, frame.Kind == FrameKind.Text ? WebSocketMessageType.Text : WebSocketMessageType.Binary, endOfMessage: true, ct);
}
finally
{
_sendLock.Release();
}
}
public async ValueTask<RpcFrame?> ReceiveAsync(CancellationToken ct)
{
var message = new MemoryStream();
byte[] buffer = new byte[16 * 1024];
while (true)
{
ValueWebSocketReceiveResult result = await _socket.ReceiveAsync(buffer.AsMemory(), ct);
if (result.MessageType == WebSocketMessageType.Close)
{
if (_socket.State == WebSocketState.CloseReceived)
{
// 按协议确认对端发起的关闭
try
{
await _socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "ack", CancellationToken.None);
}
catch (WebSocketException)
{
// 对端已经断开
}
}
return null;
}
message.Write(buffer, 0, result.Count);
if (message.Length > _maxFrameBytes)
{
await CloseAsync("frame too large", CancellationToken.None);
return null;
}
if (result.EndOfMessage)
{
return new RpcFrame(result.MessageType == WebSocketMessageType.Text ? FrameKind.Text : FrameKind.Binary, message.ToArray());
}
}
}
public async ValueTask CloseAsync(string reason, CancellationToken ct)
{
if (_socket.State is WebSocketState.Open or WebSocketState.CloseReceived)
{
try
{
await _socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, reason, ct);
}
catch (WebSocketException)
{
// 对端已经断开
}
}
}
public ValueTask DisposeAsync()
{
_socket.Dispose();
_sendLock.Dispose();
return ValueTask.CompletedTask;
}
}
/// <summary>
/// TLS 帧传输(Probe → Agent):<c>u32 LE 正文长度 | u8 类型(0 文本, 1 二进制) | 正文</c>。
/// 比 WebSocket 轻得多,Agent 不需要内置 HTTP 服务端。
/// </summary>
public sealed class TlsFrameTransport : IRpcTransport
{
private const int HeaderBytes = 5;
private readonly Stream _stream;
private readonly int _maxFrameBytes;
private readonly SemaphoreSlim _sendLock = new(1, 1);
public TlsFrameTransport(Stream stream, int maxFrameBytes)
{
_stream = stream;
_maxFrameBytes = maxFrameBytes;
}
public async ValueTask SendAsync(RpcFrame frame, CancellationToken ct)
{
if (frame.Data.Length > _maxFrameBytes)
{
throw new InvalidOperationException("frame exceeds the maximum size");
}
byte[] header = new byte[HeaderBytes];
BinaryPrimitives.WriteUInt32LittleEndian(header, (uint)frame.Data.Length);
header[4] = (byte)frame.Kind;
await _sendLock.WaitAsync(ct);
try
{
await _stream.WriteAsync(header, ct);
await _stream.WriteAsync(frame.Data, ct);
await _stream.FlushAsync(ct);
}
finally
{
_sendLock.Release();
}
}
public async ValueTask<RpcFrame?> ReceiveAsync(CancellationToken ct)
{
byte[] header = new byte[HeaderBytes];
try
{
if (!await ReadExactlyOrEofAsync(header, ct))
{
return null;
}
uint length = BinaryPrimitives.ReadUInt32LittleEndian(header);
if (length > _maxFrameBytes || header[4] > (byte)FrameKind.Binary)
{
await _stream.DisposeAsync(); // 非法帧:直接断开
return null;
}
byte[] body = new byte[length];
if (!await ReadExactlyOrEofAsync(body, ct))
{
return null;
}
return new RpcFrame((FrameKind)header[4], body);
}
catch (IOException)
{
return null;
}
catch (ObjectDisposedException)
{
return null;
}
}
public async ValueTask CloseAsync(string reason, CancellationToken ct)
{
try
{
await _stream.FlushAsync(ct);
}
catch (Exception ex) when (ex is IOException or ObjectDisposedException)
{
// 对端已经断开
}
await _stream.DisposeAsync();
}
public async ValueTask DisposeAsync()
{
await _stream.DisposeAsync();
_sendLock.Dispose();
}
private async ValueTask<bool> ReadExactlyOrEofAsync(byte[] buffer, CancellationToken ct)
{
int read = 0;
while (read < buffer.Length)
{
int n = await _stream.ReadAsync(buffer.AsMemory(read), ct);
if (n == 0)
{
return false;
}
read += n;
}
return true;
}
}
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>
<ItemGroup>
<AdditionalFiles Include="$(MSBuildThisFileDirectory)../../../BannedSymbols.Node.txt" />
<ProjectReference Include="..\Fleet.Core\Fleet.Core.csproj" />
</ItemGroup>
</Project>
+140
View File
@@ -0,0 +1,140 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Fleet.Rpc;
/// <summary>框架保留的方法名。<c>fleet.*</c> 只属于框架,产品使用自己的前缀。</summary>
public static class FleetMethods
{
public const string Challenge = "fleet.challenge";
public const string Auth = "fleet.auth";
public const string Hello = "fleet.hello";
public const string Heartbeat = "fleet.heartbeat";
public const string StateSync = "fleet.state.sync";
public const string OutboxAck = "fleet.outbox.ack";
public const string Revoked = "fleet.revoked";
}
/// <summary>二进制通道:0–15 保留给框架,产品从 16 开始。</summary>
public static class FleetChannels
{
public const byte FirstProductChannel = 16;
}
public sealed class ChallengeParams
{
[JsonPropertyName("nonce")]
public string Nonce { get; set; } = "";
[JsonPropertyName("expires_at_ms")]
public long ExpiresAtMs { get; set; }
}
public sealed class AuthParams
{
[JsonPropertyName("node_id")]
public string NodeId { get; set; } = "";
/// <summary>对 <c>fleet|v1|auth|{node_id}|{nonce}</c> 的签名(base64)。</summary>
[JsonPropertyName("signature")]
public string Signature { get; set; } = "";
}
public sealed class AuthResult
{
/// <summary>pending | active</summary>
[JsonPropertyName("status")]
public string Status { get; set; } = "";
}
public sealed class HelloParams
{
[JsonPropertyName("node_version")]
public string NodeVersion { get; set; } = "";
[JsonPropertyName("capabilities")]
public List<string> Capabilities { get; set; } = [];
[JsonPropertyName("now_utc_ms")]
public long NowUtcMs { get; set; }
/// <summary>产品自定义的元数据(例如 Monitor 的 tls_spki、probe_ports、local_limits 摘要)。</summary>
[JsonPropertyName("meta")]
public JsonElement? Meta { get; set; }
}
public sealed class HelloResult
{
[JsonPropertyName("server_time_ms")]
public long ServerTimeMs { get; set; }
/// <summary>节点时钟 − 服务端时钟(毫秒,未扣除网络往返)。</summary>
[JsonPropertyName("clock_offset_ms")]
public long ClockOffsetMs { get; set; }
}
public sealed class HeartbeatParams
{
[JsonPropertyName("extra")]
public JsonElement? Extra { get; set; }
}
public sealed class StateSyncParams
{
[JsonPropertyName("type")]
public string Type { get; set; } = "";
[JsonPropertyName("version")]
public long Version { get; set; }
[JsonPropertyName("doc")]
public JsonElement Doc { get; set; }
}
public sealed class StateSyncResult
{
[JsonPropertyName("applied_version")]
public long AppliedVersion { get; set; }
[JsonPropertyName("rejected")]
public List<RejectedItem> Rejected { get; set; } = [];
}
public sealed class RejectedItem
{
[JsonPropertyName("id")]
public string Id { get; set; } = "";
/// <summary>unsupported | budget_exceeded | policy_denied | …</summary>
[JsonPropertyName("reason")]
public string Reason { get; set; } = "";
}
public sealed class OutboxAckParams
{
[JsonPropertyName("ids")]
public List<string> Ids { get; set; } = [];
[JsonPropertyName("duplicates")]
public List<string> Duplicates { get; set; } = [];
}
public sealed class RevokedParams
{
[JsonPropertyName("reason")]
public string Reason { get; set; } = "";
}
[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
[JsonSerializable(typeof(RpcMessage))]
[JsonSerializable(typeof(ChallengeParams))]
[JsonSerializable(typeof(AuthParams))]
[JsonSerializable(typeof(AuthResult))]
[JsonSerializable(typeof(HelloParams))]
[JsonSerializable(typeof(HelloResult))]
[JsonSerializable(typeof(HeartbeatParams))]
[JsonSerializable(typeof(StateSyncParams))]
[JsonSerializable(typeof(StateSyncResult))]
[JsonSerializable(typeof(OutboxAckParams))]
[JsonSerializable(typeof(RevokedParams))]
public sealed partial class FleetRpcContext : JsonSerializerContext;
@@ -0,0 +1,35 @@
namespace Fleet.Rpc;
/// <summary>
/// 发件箱二进制帧:<c>u8 channel | 16 字节 msg_id (UUIDv7) | 正文</c>。
/// 服务端按 msg_id 去重,持久化之后才回复 <c>fleet.outbox.ack</c>。
/// </summary>
public static class OutboxFrame
{
public const int HeaderBytes = 1 + 16;
public static bool TryParse(ReadOnlyMemory<byte> frame, out byte channel, out Guid msgId, out ReadOnlyMemory<byte> payload)
{
if (frame.Length < HeaderBytes)
{
channel = 0;
msgId = Guid.Empty;
payload = default;
return false;
}
channel = frame.Span[0];
msgId = new Guid(frame.Span.Slice(1, 16), bigEndian: true);
payload = frame[HeaderBytes..];
return true;
}
public static byte[] Build(byte channel, Guid msgId, ReadOnlySpan<byte> payload)
{
byte[] frame = new byte[HeaderBytes + payload.Length];
frame[0] = channel;
msgId.TryWriteBytes(frame.AsSpan(1, 16), bigEndian: true, out _);
payload.CopyTo(frame.AsSpan(HeaderBytes));
return frame;
}
}
@@ -0,0 +1,52 @@
using System.Text.Json.Serialization;
namespace Fleet.Rpc;
/// <summary>
/// 节点间点对点会话的握手(Probe → Agent,TLS 帧)。除了签名认证,双方各生成一个临时 ECDH 密钥,
/// 经过 HKDF 派生出数据面使用的 <c>session_id</c> 与 <c>session_key</c>(所以不需要单独的“下发密钥”方法)。
/// </summary>
public static class PeerMethods
{
public const string Challenge = "fleet.peer.challenge";
public const string Auth = "fleet.peer.auth";
}
public sealed class PeerChallengeParams
{
[JsonPropertyName("nonce")]
public string Nonce { get; set; } = "";
/// <summary>服务端(监听方)的临时 ECDH 公钥(SPKI,base64)。</summary>
[JsonPropertyName("ecdh_pub")]
public string EcdhPublicKey { get; set; } = "";
}
public sealed class PeerAuthParams
{
[JsonPropertyName("peer_id")]
public string PeerId { get; set; } = "";
/// <summary>发起方的临时 ECDH 公钥(SPKI,base64)。</summary>
[JsonPropertyName("ecdh_pub")]
public string EcdhPublicKey { get; set; } = "";
/// <summary>对 <c>fleet|v1|peer|{peer_id}|{nonce}|{client_ecdh_pub}|{server_ecdh_pub}</c> 的签名(base64)。</summary>
[JsonPropertyName("signature")]
public string Signature { get; set; } = "";
/// <summary>发起方的程序版本(可选,只用于展示)。</summary>
[JsonPropertyName("version")]
public string? Version { get; set; }
}
public sealed class PeerAuthResult
{
[JsonPropertyName("session_id")]
public string SessionId { get; set; } = "";
}
[JsonSerializable(typeof(PeerChallengeParams))]
[JsonSerializable(typeof(PeerAuthParams))]
[JsonSerializable(typeof(PeerAuthResult))]
public sealed partial class PeerRpcContext : JsonSerializerContext;
+87
View File
@@ -0,0 +1,87 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using Fleet.Core;
namespace Fleet.Rpc;
/// <summary>JSON-RPC 2.0 信封。请求、响应、通知共用一个类型,缺失的字段不序列化。</summary>
public sealed class RpcMessage
{
[JsonPropertyName("jsonrpc")]
public string Jsonrpc { get; set; } = "2.0";
[JsonPropertyName("id")]
public long? Id { get; set; }
[JsonPropertyName("method")]
public string? Method { get; set; }
[JsonPropertyName("params")]
public JsonElement? Params { get; set; }
[JsonPropertyName("result")]
public JsonElement? Result { get; set; }
[JsonPropertyName("error")]
public RpcError? Error { get; set; }
[JsonIgnore]
public bool IsRequest => Method is not null && Id is not null;
[JsonIgnore]
public bool IsNotification => Method is not null && Id is null;
[JsonIgnore]
public bool IsResponse => Method is null && Id is not null;
public static RpcMessage Request<T>(long id, string method, T value, JsonTypeInfo<T> typeInfo) =>
new() { Id = id, Method = method, Params = JsonSerializer.SerializeToElement(value, typeInfo) };
public static RpcMessage Notification<T>(string method, T value, JsonTypeInfo<T> typeInfo) =>
new() { Method = method, Params = JsonSerializer.SerializeToElement(value, typeInfo) };
public static RpcMessage Success<T>(long id, T value, JsonTypeInfo<T> typeInfo) =>
new() { Id = id, Result = JsonSerializer.SerializeToElement(value, typeInfo) };
public static RpcMessage Failure(long? id, FleetError error) =>
new() { Id = id, Error = RpcError.From(error) };
}
public sealed class RpcError
{
public const int MethodNotFound = -32601;
public const int InvalidParams = -32602;
public const int InternalError = -32603;
public const int ApplicationError = -32000;
[JsonPropertyName("code")]
public int Code { get; set; }
[JsonPropertyName("message")]
public string Message { get; set; } = "";
[JsonPropertyName("data")]
public RpcErrorData? Data { get; set; }
public static RpcError From(FleetError error)
{
int code = error.Code == FleetErrors.Unsupported ? MethodNotFound
: error.Code == FleetErrors.InvalidParams ? InvalidParams
: error.Code == FleetErrors.Internal ? InternalError
: ApplicationError;
return new RpcError { Code = code, Message = error.Message, Data = new RpcErrorData { Code = error.Code.Value } };
}
public FleetError ToFleetError() =>
new(new ErrorCode(Data?.Code ?? "fleet_internal"), Message);
}
public sealed class RpcErrorData
{
[JsonPropertyName("code")]
public string Code { get; set; } = "";
[JsonPropertyName("detail")]
public string? Detail { get; set; }
}
+115
View File
@@ -0,0 +1,115 @@
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using Fleet.Core;
namespace Fleet.Rpc;
/// <summary>方法路由,与传输方式无关。<typeparamref name="TContext"/> 由传输层提供(例如调用方节点)。</summary>
public sealed class RpcRouter<TContext>
{
private delegate ValueTask<Result<JsonElement?>> Route(TContext context, JsonElement? parameters, CancellationToken ct);
private static readonly JsonElement s_emptyObject = JsonDocument.Parse("{}").RootElement.Clone();
private readonly Dictionary<string, Route> _routes = new(StringComparer.Ordinal);
private readonly Action<string, Exception>? _onUnhandled;
public RpcRouter(Action<string, Exception>? onUnhandled = null)
{
_onUnhandled = onUnhandled;
}
public bool Has(string method) => _routes.ContainsKey(method);
public IReadOnlyCollection<string> Methods => _routes.Keys;
public RpcRouter<TContext> On<TParams, TResult>(
string method,
JsonTypeInfo<TParams> paramsInfo,
JsonTypeInfo<TResult> resultInfo,
Func<TContext, TParams, CancellationToken, ValueTask<Result<TResult>>> handler)
{
_routes[method] = async (context, raw, ct) =>
{
if (!TryDeserialize(raw, paramsInfo, out TParams? p))
{
return Result<JsonElement?>.Fail(FleetErrors.InvalidParams, "params could not be parsed");
}
Result<TResult> r = await handler(context, p, ct);
return r.TryGet(out TResult? value, out FleetError? error)
? Result<JsonElement?>.Ok(JsonSerializer.SerializeToElement(value, resultInfo))
: Result<JsonElement?>.Fail(error);
};
return this;
}
public RpcRouter<TContext> OnNotify<TParams>(
string method,
JsonTypeInfo<TParams> paramsInfo,
Func<TContext, TParams, CancellationToken, ValueTask> handler)
{
_routes[method] = async (context, raw, ct) =>
{
if (!TryDeserialize(raw, paramsInfo, out TParams? p))
{
return Result<JsonElement?>.Fail(FleetErrors.InvalidParams, "params could not be parsed");
}
await handler(context, p, ct);
return Result<JsonElement?>.Ok(null);
};
return this;
}
/// <summary>
/// 处理一条请求或通知。请求返回响应;通知返回 null(失败时只丢弃)。
/// 处理函数里的异常属于程序缺陷:在这个边界捕获,回复 internal 错误,并交给 <c>onUnhandled</c> 记录。
/// </summary>
public async ValueTask<RpcMessage?> DispatchAsync(TContext context, RpcMessage message, CancellationToken ct)
{
string method = message.Method ?? "";
if (!_routes.TryGetValue(method, out Route? route))
{
return message.Id is null ? null : RpcMessage.Failure(message.Id, new FleetError(FleetErrors.Unsupported, $"unknown method {method}"));
}
Result<JsonElement?> result;
try
{
result = await route(context, message.Params, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
_onUnhandled?.Invoke(method, ex);
result = Result<JsonElement?>.Fail(FleetErrors.Internal, "internal error");
}
if (message.Id is null)
{
return null;
}
return result.TryGet(out JsonElement? value, out FleetError? error)
? new RpcMessage { Id = message.Id, Result = value ?? s_emptyObject }
: RpcMessage.Failure(message.Id, error);
}
private static bool TryDeserialize<T>(JsonElement? raw, JsonTypeInfo<T> info, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out T? value)
{
value = default;
try
{
value = raw is null ? JsonSerializer.Deserialize("{}", info) : raw.Value.Deserialize(info);
return value is not null;
}
catch (JsonException)
{
return false;
}
}
}
+232
View File
@@ -0,0 +1,232 @@
using System.Collections.Concurrent;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using System.Threading.Channels;
using Fleet.Core;
namespace Fleet.Rpc;
public enum FrameKind : byte
{
Text = 0,
Binary = 1,
}
public readonly record struct RpcFrame(FrameKind Kind, ReadOnlyMemory<byte> Data);
/// <summary>
/// 一条双向的、保序的消息通道:文本帧承载 JSON-RPC,二进制帧承载发件箱。
/// 节点到服务端用 WebSocket,Probe 到 Agent 用 TLS 帧;会话引擎对两者一视同仁。
/// </summary>
public interface IRpcTransport : IAsyncDisposable
{
ValueTask SendAsync(RpcFrame frame, CancellationToken ct);
/// <summary>对端关闭时返回 null。</summary>
ValueTask<RpcFrame?> ReceiveAsync(CancellationToken ct);
ValueTask CloseAsync(string reason, CancellationToken ct);
}
public sealed class RpcSessionOptions
{
public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(15);
/// <summary>待处理的入站请求上限;超过时接收循环等待(背压)。</summary>
public int InboundQueue { get; set; } = 256;
}
/// <summary>
/// 传输无关的会话:请求与响应的关联、方法路由、二进制帧。
/// 入站的请求和通知由独立的任务按顺序处理,接收循环只负责读帧和完成响应,
/// 所以处理函数里可以等待向对端发起的请求而不会死锁。
/// </summary>
public sealed class RpcSession<TContext> : IAsyncDisposable
{
private readonly IRpcTransport _transport;
private readonly RpcRouter<TContext> _router;
private readonly TContext _context;
private readonly TimeProvider _time;
private readonly RpcSessionOptions _options;
private readonly ConcurrentDictionary<long, TaskCompletionSource<RpcMessage>> _pending = new();
private readonly Channel<RpcMessage> _inbound;
private readonly CancellationTokenSource _stop = new();
private long _nextId;
private long _lastReceive;
public RpcSession(IRpcTransport transport, RpcRouter<TContext> router, TContext context, TimeProvider time, RpcSessionOptions? options = null)
{
_transport = transport;
_router = router;
_context = context;
_time = time;
_lastReceive = time.GetTimestamp();
_options = options ?? new RpcSessionOptions();
_inbound = Channel.CreateBounded<RpcMessage>(new BoundedChannelOptions(_options.InboundQueue) { SingleReader = true, FullMode = BoundedChannelFullMode.Wait });
}
/// <summary>收到二进制帧:<c>u8 channel | 正文</c>(发件箱帧再往里解析)。</summary>
public Func<ReadOnlyMemory<byte>, CancellationToken, ValueTask>? OnBinary { get; set; }
/// <summary>对端上一次发来任何帧到现在的时间。静默死亡(断电、进程被冻结)的对端不会关闭连接,调用方据此设置空闲超时。</summary>
public TimeSpan IdleFor => _time.GetElapsedTime(Volatile.Read(ref _lastReceive));
/// <summary>读帧并分发,直到连接关闭或被取消。</summary>
public async Task RunAsync(CancellationToken ct)
{
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, _stop.Token);
Task dispatcher = Task.Run(() => DispatchAsync(linked.Token), CancellationToken.None);
try
{
while (!linked.IsCancellationRequested)
{
RpcFrame? frame = await _transport.ReceiveAsync(linked.Token);
if (frame is null)
{
return;
}
Volatile.Write(ref _lastReceive, _time.GetTimestamp());
if (frame.Value.Kind == FrameKind.Binary)
{
if (OnBinary is not null)
{
await OnBinary(frame.Value.Data, linked.Token);
}
continue;
}
RpcMessage? message = Parse(frame.Value.Data);
if (message is null)
{
continue; // 格式不对的帧忽略,连接保持可用
}
if (message.IsResponse)
{
if (_pending.TryGetValue(message.Id!.Value, out TaskCompletionSource<RpcMessage>? waiter))
{
waiter.TrySetResult(message);
}
}
else
{
await _inbound.Writer.WriteAsync(message, linked.Token);
}
}
}
finally
{
_inbound.Writer.TryComplete();
foreach (TaskCompletionSource<RpcMessage> waiter in _pending.Values)
{
waiter.TrySetCanceled(CancellationToken.None);
}
await linked.CancelAsync();
try
{
await dispatcher;
}
catch (OperationCanceledException)
{
// 正常结束
}
}
}
public async ValueTask<Result<JsonElement>> RequestAsync(string method, JsonElement? parameters, CancellationToken ct, TimeSpan? timeout = null)
{
long id = Interlocked.Increment(ref _nextId);
var waiter = new TaskCompletionSource<RpcMessage>(TaskCreationOptions.RunContinuationsAsynchronously);
_pending[id] = waiter;
try
{
await SendAsync(new RpcMessage { Id = id, Method = method, Params = parameters }, ct);
using var timer = new CancellationTokenSource(timeout ?? _options.RequestTimeout, _time);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timer.Token);
RpcMessage reply = await waiter.Task.WaitAsync(linked.Token);
return reply.Error is not null
? Result<JsonElement>.Fail(reply.Error.ToFleetError())
: Result<JsonElement>.Ok(reply.Result ?? default);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
return Result<JsonElement>.Fail(FleetErrors.Timeout, $"{method} timed out or the session closed");
}
finally
{
_pending.TryRemove(id, out _);
}
}
public async ValueTask<Result<TResult>> RequestAsync<TParams, TResult>(
string method, TParams parameters, JsonTypeInfo<TParams> paramsInfo, JsonTypeInfo<TResult> resultInfo, CancellationToken ct, TimeSpan? timeout = null)
{
Result<JsonElement> raw = await RequestAsync(method, JsonSerializer.SerializeToElement(parameters, paramsInfo), ct, timeout);
if (!raw.TryGet(out JsonElement value, out FleetError? error))
{
return Result<TResult>.Fail(error);
}
try
{
TResult? result = value.Deserialize(resultInfo);
return result is null ? Result<TResult>.Fail(FleetErrors.Internal, "empty result") : Result<TResult>.Ok(result);
}
catch (JsonException)
{
return Result<TResult>.Fail(FleetErrors.Internal, "result could not be parsed");
}
}
public ValueTask NotifyAsync<TParams>(string method, TParams parameters, JsonTypeInfo<TParams> paramsInfo, CancellationToken ct) =>
SendAsync(RpcMessage.Notification(method, parameters, paramsInfo), ct);
public ValueTask SendAsync(RpcMessage message, CancellationToken ct) =>
_transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(message, FleetRpcContext.Default.RpcMessage)), ct);
public ValueTask SendBinaryAsync(ReadOnlyMemory<byte> frame, CancellationToken ct) =>
_transport.SendAsync(new RpcFrame(FrameKind.Binary, frame), ct);
public ValueTask CloseAsync(string reason, CancellationToken ct) => _transport.CloseAsync(reason, ct);
public async ValueTask DisposeAsync()
{
await _stop.CancelAsync();
await _transport.DisposeAsync();
_stop.Dispose();
}
private async Task DispatchAsync(CancellationToken ct)
{
try
{
await foreach (RpcMessage message in _inbound.Reader.ReadAllAsync(ct))
{
RpcMessage? response = await _router.DispatchAsync(_context, message, ct);
if (response is not null)
{
await SendAsync(response, ct);
}
}
}
catch (OperationCanceledException)
{
// 会话结束
}
}
private static RpcMessage? Parse(ReadOnlyMemory<byte> data)
{
try
{
return JsonSerializer.Deserialize(data.Span, FleetRpcContext.Default.RpcMessage);
}
catch (JsonException)
{
return null;
}
}
}
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Fleet.Core\Fleet.Core.csproj" />
<ProjectReference Include="..\Fleet.Rpc\Fleet.Rpc.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,15 @@
namespace Fleet.Server;
public sealed class FleetServerOptions
{
/// <summary>节点 ID 的类型前缀,Monitor 使用 <c>agt</c>。</summary>
public string NodeIdPrefix { get; set; } = "node";
public int MaxFrameBytes { get; set; } = 16 << 20;
public TimeSpan ChallengeTtl { get; set; } = TimeSpan.FromSeconds(30);
public TimeSpan HeartbeatTimeout { get; set; } = TimeSpan.FromSeconds(45);
public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(15);
}
@@ -0,0 +1,21 @@
using Fleet.Core;
namespace Fleet.Server;
/// <summary>Fleet 服务端需要的存储。产品可以自己实现(Monitor 把它放进 monitor.db)。</summary>
public interface IFleetStore
{
ValueTask<JoinTokenRecord?> FindJoinTokenAsync(ReadOnlyMemory<byte> tokenSha256, CancellationToken ct);
/// <summary>原子地把令牌使用次数加一。令牌已吊销、过期或用完时返回 false。</summary>
ValueTask<bool> TryConsumeJoinTokenAsync(string tokenId, long nowMs, CancellationToken ct);
ValueTask<Result<NodeRecord>> CreateNodeAsync(NewNode node, CancellationToken ct);
ValueTask<NodeRecord?> GetNodeAsync(NodeId id, CancellationToken ct);
/// <summary>按名称查找状态不是 pending 的节点(用于判断注册时的名称冲突)。</summary>
ValueTask<NodeRecord?> FindEstablishedNodeByNameAsync(string name, CancellationToken ct);
ValueTask UpdatePresenceAsync(NodeId id, Presence presence, CancellationToken ct);
}
@@ -0,0 +1,48 @@
using Fleet.Core;
namespace Fleet.Server;
/// <summary>pending 能连接,但不下发状态、不接收上报;disabled 拒绝连接。</summary>
public enum NodeStatus
{
Pending,
Active,
Disabled,
}
public sealed record NodeRecord(
NodeId Id,
string Name,
NodeStatus Status,
byte[] PublicKey,
byte[] TlsSpki,
IReadOnlyList<string> Labels,
string? MetaJson,
string? NodeVersion,
long CreatedAtMs,
long? LastSeenAtMs);
public sealed record NewNode(
NodeId Id,
string Name,
NodeStatus Status,
byte[] PublicKey,
byte[] TlsSpki,
IReadOnlyList<string> Labels,
string? MetaJson,
string? NodeVersion,
long NowMs);
public sealed record JoinTokenRecord(
string Id,
byte[] TokenSha256,
string? Note,
IReadOnlyList<string> Labels,
bool AutoApprove,
int MaxUses,
int Uses,
long ExpiresAtMs,
long? RevokedAtMs,
long CreatedAtMs);
public sealed record Presence(bool Online, long AtMs, string? NodeVersion, IReadOnlyList<string> Capabilities, string? HelloMetaJson);
@@ -0,0 +1,65 @@
using System.Text.Json;
using Fleet.Core;
using Fleet.Rpc;
namespace Fleet.Server;
public sealed record EnrollmentContext(string? RemoteIp, JsonElement? ProductMeta);
/// <summary>产品挂钩:节点注册成功之后调用。</summary>
public interface IEnrollmentHook
{
ValueTask OnEnrolledAsync(NodeRecord node, EnrollmentContext context, CancellationToken ct);
}
public enum NodeEventKind
{
Online,
Offline,
Hello,
Heartbeat,
}
public sealed record NodeEvent(NodeEventKind Kind, NodeRecord Node, long AtMs, HelloParams? Hello = null, JsonElement? Extra = null);
public interface INodeEventSink
{
ValueTask OnEventAsync(NodeEvent nodeEvent, CancellationToken ct);
}
/// <summary>期望状态提供者:为每个节点计算期望状态的完整快照(天然幂等)。</summary>
public interface IStateProvider
{
/// <summary>状态文档类型,例如 <c>task_set</c>。</summary>
string Type { get; }
ValueTask<StateDocument?> GetAsync(NodeRecord node, CancellationToken ct);
/// <summary>节点应用之后回调,<c>rejected[]</c> 带结构化原因。</summary>
ValueTask OnAppliedAsync(NodeRecord node, long version, StateSyncResult result, CancellationToken ct);
}
public sealed record StateDocument(long Version, JsonElement Doc);
public enum InboxOutcome
{
Stored,
Duplicate,
Rejected,
}
/// <summary>收件箱:持久化之后才返回 Stored,框架随后才回复 ACK。</summary>
public interface IInbox
{
byte Channel { get; }
ValueTask<InboxOutcome> HandleAsync(NodeRecord node, Guid msgId, ReadOnlyMemory<byte> payload, CancellationToken ct);
}
/// <summary>通知 Fleet:某个节点的期望状态变了,需要重新计算并推送。</summary>
public interface IStateNotifier
{
void NotifyChanged(NodeId node);
void NotifyAllChanged();
}
@@ -0,0 +1,66 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Fleet.Server;
public sealed class EnrollRequest
{
[JsonPropertyName("join_token")]
public string JoinToken { get; set; } = "";
[JsonPropertyName("name")]
public string Name { get; set; } = "";
/// <summary>身份公钥(SPKI,base64)。</summary>
[JsonPropertyName("pubkey")]
public string PublicKey { get; set; } = "";
/// <summary>面向对端节点的 TLS 证书指纹(SHA-256,base64)。</summary>
[JsonPropertyName("tls_spki")]
public string TlsSpki { get; set; } = "";
/// <summary>持有证明:对 <c>fleet|v1|enroll|{sha256_hex(join_token)}|{pubkey}</c> 的签名(base64)。</summary>
[JsonPropertyName("proof")]
public string Proof { get; set; } = "";
[JsonPropertyName("node_version")]
public string NodeVersion { get; set; } = "";
[JsonPropertyName("meta")]
public JsonElement? Meta { get; set; }
}
public sealed class EnrollResponse
{
[JsonPropertyName("node_id")]
public string NodeId { get; set; } = "";
/// <summary>pending | active</summary>
[JsonPropertyName("status")]
public string Status { get; set; } = "";
}
/// <summary>RFC 9457 problem+json,带 <c>code</c> 扩展字段。</summary>
public sealed class FleetProblem
{
[JsonPropertyName("type")]
public string Type { get; set; } = "about:blank";
[JsonPropertyName("title")]
public string Title { get; set; } = "";
[JsonPropertyName("status")]
public int Status { get; set; }
[JsonPropertyName("code")]
public string Code { get; set; } = "";
[JsonPropertyName("detail")]
public string? Detail { get; set; }
}
[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
[JsonSerializable(typeof(EnrollRequest))]
[JsonSerializable(typeof(EnrollResponse))]
[JsonSerializable(typeof(FleetProblem))]
internal sealed partial class EnrollmentJsonContext : JsonSerializerContext;
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<OutputType>Library</OutputType>
<IsAotCompatible>true</IsAotCompatible>
<EnableRequestDelegateGenerator>true</EnableRequestDelegateGenerator>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Fleet.Server.Abstractions\Fleet.Server.Abstractions.csproj" />
<ProjectReference Include="..\Fleet.Identity\Fleet.Identity.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,92 @@
using System.Net;
using System.Text.Json;
using Fleet.Core;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
namespace Fleet.Server;
public static class FleetEnrollmentEndpoints
{
private const int MaxBodyBytes = 64 * 1024;
/// <summary>注册端点:<c>POST {path}</c>,使用加入令牌认证。需要在 DI 中注册 <see cref="FleetEnrollmentService"/>。</summary>
public static IEndpointRouteBuilder MapFleetEnrollment(this IEndpointRouteBuilder app, string path)
{
app.MapPost(path, HandleAsync)
.WithName("fleet_enroll")
.WithTags("agent")
.WithSummary("Enroll a node with a join token")
.DisableAntiforgery();
return app;
}
private static async Task<IResult> HandleAsync(HttpContext http, FleetEnrollmentService service)
{
EnrollRequest? request;
try
{
request = await ReadBodyAsync(http, http.RequestAborted);
}
catch (JsonException)
{
request = null;
}
if (request is null)
{
return Problem(StatusCodes.Status400BadRequest, FleetErrors.InvalidParams, "request body is not valid");
}
Result<EnrollResponse> result = await service.EnrollAsync(request, http.Connection.RemoteIpAddress?.ToString(), http.RequestAborted);
if (result.TryGet(out EnrollResponse? response, out FleetError? error))
{
return Results.Json(response, EnrollmentJsonContext.Default.EnrollResponse, statusCode: StatusCodes.Status201Created);
}
int status = error.Code == FleetErrors.JoinTokenInvalid ? StatusCodes.Status401Unauthorized
: error.Code == FleetErrors.JoinTokenExpired ? StatusCodes.Status410Gone
: error.Code == FleetErrors.ProofInvalid ? StatusCodes.Status403Forbidden
: error.Code == FleetErrors.NameConflict ? StatusCodes.Status409Conflict
: error.Code == FleetErrors.InvalidParams ? StatusCodes.Status400BadRequest
: StatusCodes.Status500InternalServerError;
return Problem(status, error.Code, error.Message);
}
private static async Task<EnrollRequest?> ReadBodyAsync(HttpContext http, CancellationToken ct)
{
if (http.Request.ContentLength > MaxBodyBytes)
{
return null;
}
using var buffer = new MemoryStream();
byte[] chunk = new byte[4096];
int read;
while ((read = await http.Request.Body.ReadAsync(chunk, ct)) > 0)
{
buffer.Write(chunk, 0, read);
if (buffer.Length > MaxBodyBytes)
{
return null;
}
}
return JsonSerializer.Deserialize(buffer.ToArray(), EnrollmentJsonContext.Default.EnrollRequest);
}
private static IResult Problem(int status, ErrorCode code, string detail) =>
Results.Json(
new FleetProblem { Title = ((HttpStatusCode)status).ToString(), Status = status, Code = code.Value, Detail = detail },
EnrollmentJsonContext.Default.FleetProblem,
contentType: "application/problem+json",
statusCode: status);
}
public static class FleetEnrollmentServiceCollectionExtensions
{
public static IServiceCollection AddFleetEnrollment(this IServiceCollection services) =>
services.AddSingleton<FleetEnrollmentService>();
}
@@ -0,0 +1,110 @@
using System.Text.RegularExpressions;
using Fleet.Core;
using Fleet.Identity;
namespace Fleet.Server;
/// <summary>注册流程:校验令牌 → 校验持有证明 → 创建节点 → 触发产品挂钩。与 HTTP 无关,便于测试。</summary>
public sealed partial class FleetEnrollmentService
{
private readonly IFleetStore _store;
private readonly IEnumerable<IEnrollmentHook> _hooks;
private readonly FleetServerOptions _options;
private readonly TimeProvider _time;
public FleetEnrollmentService(IFleetStore store, IEnumerable<IEnrollmentHook> hooks, FleetServerOptions options, TimeProvider time)
{
_store = store;
_hooks = hooks;
_options = options;
_time = time;
}
public async ValueTask<Result<EnrollResponse>> EnrollAsync(EnrollRequest request, string? remoteIp, CancellationToken ct)
{
if (string.IsNullOrEmpty(request.JoinToken) || !NamePattern().IsMatch(request.Name))
{
return Result<EnrollResponse>.Fail(FleetErrors.InvalidParams, "join_token and a valid name are required");
}
if (!TryDecode(request.PublicKey, out byte[] publicKey) || !NodeIdentity.IsValidPublicKey(publicKey)
|| !TryDecode(request.TlsSpki, out byte[] tlsSpki) || tlsSpki.Length != 32
|| !TryDecode(request.Proof, out byte[] proof))
{
return Result<EnrollResponse>.Fail(FleetErrors.InvalidParams, "pubkey, tls_spki or proof is malformed");
}
byte[] tokenHash = TokenHash.Sha256(request.JoinToken);
JoinTokenRecord? token = await _store.FindJoinTokenAsync(tokenHash, ct);
long nowMs = _time.GetUtcNow().ToUnixTimeMilliseconds();
if (token is null || token.RevokedAtMs is not null || token.Uses >= token.MaxUses)
{
return Result<EnrollResponse>.Fail(FleetErrors.JoinTokenInvalid, "join token is invalid");
}
if (token.ExpiresAtMs <= nowMs)
{
return Result<EnrollResponse>.Fail(FleetErrors.JoinTokenExpired, "join token has expired");
}
if (!NodeIdentity.Verify(publicKey, proof, SignaturePurpose.Enroll, Convert.ToHexStringLower(tokenHash), request.PublicKey))
{
return Result<EnrollResponse>.Fail(FleetErrors.ProofInvalid, "proof of possession does not verify");
}
if (!await _store.TryConsumeJoinTokenAsync(token.Id, nowMs, ct))
{
return Result<EnrollResponse>.Fail(FleetErrors.JoinTokenInvalid, "join token is invalid");
}
// 同名节点已存在时进入 pending,由管理员选择“接管”或“作为新节点”。
bool nameTaken = await _store.FindEstablishedNodeByNameAsync(request.Name, ct) is not null;
NodeStatus status = token.AutoApprove && !nameTaken ? NodeStatus.Active : NodeStatus.Pending;
var node = new NewNode(
new NodeId(Ulid.NewPrefixed(_options.NodeIdPrefix, _time)),
request.Name,
status,
publicKey,
tlsSpki,
token.Labels,
request.Meta?.GetRawText(),
request.NodeVersion,
nowMs);
Result<NodeRecord> created = await _store.CreateNodeAsync(node, ct);
if (!created.TryGet(out NodeRecord? record, out FleetError? error))
{
return Result<EnrollResponse>.Fail(error);
}
var context = new EnrollmentContext(remoteIp, request.Meta);
foreach (IEnrollmentHook hook in _hooks)
{
await hook.OnEnrolledAsync(record, context, ct);
}
return Result<EnrollResponse>.Ok(new EnrollResponse
{
NodeId = record.Id.Value,
Status = record.Status == NodeStatus.Active ? "active" : "pending",
});
}
private static bool TryDecode(string text, out byte[] data)
{
try
{
data = Convert.FromBase64String(text);
return data.Length > 0;
}
catch (FormatException)
{
data = [];
return false;
}
}
[GeneratedRegex(@"^[\p{L}\p{M}\p{N}._-]{1,64}$")]
private static partial Regex NamePattern();
}
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<OutputType>Library</OutputType>
<IsAotCompatible>true</IsAotCompatible>
<EnableRequestDelegateGenerator>true</EnableRequestDelegateGenerator>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Fleet.Server.Abstractions\Fleet.Server.Abstractions.csproj" />
<ProjectReference Include="..\Fleet.Identity\Fleet.Identity.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,170 @@
using System.Collections.Concurrent;
using System.Net.WebSockets;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using Fleet.Core;
using Fleet.Rpc;
using Microsoft.Extensions.Logging;
namespace Fleet.Server;
/// <summary>
/// 服务端连接宿主:在线表 + 服务端发起的调用 + 状态推送通知。每个 WebSocket 对应一个 <see cref="NodeConnection"/>。
/// </summary>
public sealed class FleetLinkHost : IFleetConnections, IStateNotifier
{
private readonly ConcurrentDictionary<NodeId, NodeConnection> _connections = new();
internal FleetLinkHost(
IFleetStore store,
FleetServerOptions options,
TimeProvider time,
ILogger<FleetLinkHost> logger,
IEnumerable<IStateProvider> stateProviders,
IEnumerable<IInbox> inboxes,
IEnumerable<INodeEventSink> sinks,
FleetRouter router)
{
Store = store;
Options = options;
Time = time;
Logger = logger;
StateProviders = [.. stateProviders];
Inboxes = inboxes.ToDictionary(i => i.Channel);
Sinks = [.. sinks];
Router = router.Inner;
}
internal IFleetStore Store { get; }
internal FleetServerOptions Options { get; }
internal TimeProvider Time { get; }
internal ILogger<FleetLinkHost> Logger { get; }
internal IReadOnlyList<IStateProvider> StateProviders { get; }
internal IReadOnlyDictionary<byte, IInbox> Inboxes { get; }
internal IReadOnlyList<INodeEventSink> Sinks { get; }
internal RpcRouter<NodeCallContext> Router { get; }
public IReadOnlyCollection<NodeId> OnlineNodes => [.. _connections.Keys];
public bool IsOnline(NodeId node) => _connections.ContainsKey(node);
public NodeSessionInfo? GetSession(NodeId node) =>
_connections.TryGetValue(node, out NodeConnection? c) ? c.Session : null;
/// <summary>处理一条 WebSocket 连接,直到连接结束。</summary>
public async Task RunConnectionAsync(WebSocket socket, CancellationToken ct)
{
using var connection = new NodeConnection(this, socket);
await connection.RunAsync(ct);
}
/// <summary>返回被替换掉的旧连接(如果有)。</summary>
internal NodeConnection? Register(NodeConnection connection)
{
NodeConnection? replaced = null;
_connections.AddOrUpdate(
connection.NodeId,
connection,
(_, existing) =>
{
replaced = existing;
return connection;
});
return replaced;
}
internal void Unregister(NodeConnection connection) =>
_connections.TryRemove(new KeyValuePair<NodeId, NodeConnection>(connection.NodeId, connection));
public async ValueTask<Result<TResult>> RequestAsync<TParams, TResult>(
NodeId node,
string method,
TParams parameters,
JsonTypeInfo<TParams> paramsInfo,
JsonTypeInfo<TResult> resultInfo,
CancellationToken ct)
{
if (!_connections.TryGetValue(node, out NodeConnection? connection))
{
return Result<TResult>.Fail(FleetErrors.NodeOffline, "node is not connected");
}
Result<JsonElement> raw = await connection.RequestAsync(method, JsonSerializer.SerializeToElement(parameters, paramsInfo), ct);
if (!raw.TryGet(out JsonElement value, out FleetError? error))
{
return Result<TResult>.Fail(error);
}
try
{
TResult? result = value.Deserialize(resultInfo);
return result is null
? Result<TResult>.Fail(FleetErrors.Internal, "empty result")
: Result<TResult>.Ok(result);
}
catch (JsonException)
{
return Result<TResult>.Fail(FleetErrors.Internal, "result could not be parsed");
}
}
public async ValueTask<bool> NotifyAsync<TParams>(NodeId node, string method, TParams parameters, JsonTypeInfo<TParams> paramsInfo, CancellationToken ct)
{
if (!_connections.TryGetValue(node, out NodeConnection? connection))
{
return false;
}
return await connection.SendAsync(RpcMessage.Notification(method, parameters, paramsInfo), ct);
}
public async ValueTask CloseAllAsync(string reason)
{
foreach (NodeConnection connection in _connections.Values.ToArray())
{
await connection.CloseAsync(reason);
}
}
public async ValueTask DisconnectAsync(NodeId node, string reason, CancellationToken ct)
{
if (_connections.TryGetValue(node, out NodeConnection? connection))
{
await connection.RevokeAsync(reason, ct);
}
}
public void NotifyChanged(NodeId node)
{
if (_connections.TryGetValue(node, out NodeConnection? connection))
{
connection.SignalStateChanged();
}
}
public void NotifyAllChanged()
{
foreach (NodeConnection connection in _connections.Values)
{
connection.SignalStateChanged();
}
}
}
/// <summary>产品注册的 RPC 方法路由。</summary>
public sealed class FleetRouter
{
public FleetRouter(RpcRouter<NodeCallContext> inner)
{
Inner = inner;
}
internal RpcRouter<NodeCallContext> Inner { get; }
}
@@ -0,0 +1,27 @@
using Microsoft.Extensions.Logging;
namespace Fleet.Server;
internal static partial class FleetLog
{
[LoggerMessage(Level = LogLevel.Information, Message = "node connected node={Node} status={Status}")]
public static partial void Connected(ILogger logger, string node, string status);
[LoggerMessage(Level = LogLevel.Information, Message = "node disconnected node={Node} reason={Reason}")]
public static partial void Disconnected(ILogger logger, string node, string reason);
[LoggerMessage(Level = LogLevel.Warning, Message = "node auth rejected err={Err}")]
public static partial void AuthRejected(ILogger logger, string err);
[LoggerMessage(Level = LogLevel.Warning, Message = "inbox failed node={Node} channel={Channel}")]
public static partial void InboxFailed(ILogger logger, Exception exception, string node, byte channel);
[LoggerMessage(Level = LogLevel.Warning, Message = "state push failed node={Node} type={Type} err={Err}")]
public static partial void StatePushFailed(ILogger logger, string node, string type, string err);
[LoggerMessage(Level = LogLevel.Error, Message = "unhandled error in handler method={Method}")]
public static partial void HandlerFailed(ILogger logger, Exception exception, string method);
[LoggerMessage(Level = LogLevel.Error, Message = "event sink failed kind={Kind} node={Node}")]
public static partial void SinkFailed(ILogger logger, Exception exception, string kind, string node);
}
@@ -0,0 +1,112 @@
using System.Diagnostics.CodeAnalysis;
using Fleet.Rpc;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Fleet.Server;
/// <summary>Fleet 服务端的接入点:产品在这里挂上自己的状态、收件箱、事件和 RPC 方法。</summary>
public sealed class FleetServerBuilder
{
private readonly List<Action<RpcRouter<NodeCallContext>, IServiceProvider>> _routeConfigurators = [];
internal FleetServerBuilder(IServiceCollection services)
{
Services = services;
}
public IServiceCollection Services { get; }
internal IReadOnlyList<Action<RpcRouter<NodeCallContext>, IServiceProvider>> RouteConfigurators => _routeConfigurators;
public FleetServerBuilder AddState<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TProvider>()
where TProvider : class, IStateProvider
{
Services.AddSingleton<IStateProvider, TProvider>();
return this;
}
public FleetServerBuilder AddInbox<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TInbox>()
where TInbox : class, IInbox
{
Services.AddSingleton<IInbox, TInbox>();
return this;
}
public FleetServerBuilder OnNodeEvent<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TSink>()
where TSink : class, INodeEventSink
{
Services.AddSingleton<INodeEventSink, TSink>();
return this;
}
public FleetServerBuilder OnEnrolled<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] THook>()
where THook : class, IEnrollmentHook
{
Services.AddSingleton<IEnrollmentHook, THook>();
return this;
}
/// <summary>注册产品的 RPC 方法(<c>monitor.*</c> 等)。处理函数通过 <see cref="IServiceProvider"/> 在调用时解析服务,避免构造期的循环依赖。</summary>
public FleetServerBuilder MapMethods(Action<RpcRouter<NodeCallContext>, IServiceProvider> configure)
{
_routeConfigurators.Add(configure);
return this;
}
}
public static class FleetServerExtensions
{
/// <summary>需要事先在 DI 中注册 <see cref="IFleetStore"/>。</summary>
public static FleetServerBuilder AddFleetServer(this IServiceCollection services, Action<FleetServerOptions>? configure = null)
{
var options = new FleetServerOptions();
configure?.Invoke(options);
services.AddSingleton(options);
var builder = new FleetServerBuilder(services);
services.AddSingleton(provider =>
{
ILogger logger = provider.GetRequiredService<ILoggerFactory>().CreateLogger("Fleet.Rpc");
var router = new RpcRouter<NodeCallContext>((method, ex) => FleetLog.HandlerFailed(logger, ex, method));
foreach (Action<RpcRouter<NodeCallContext>, IServiceProvider> configurator in builder.RouteConfigurators)
{
configurator(router, provider);
}
return new FleetRouter(router);
});
services.AddSingleton(provider => new FleetLinkHost(
provider.GetRequiredService<IFleetStore>(),
provider.GetRequiredService<FleetServerOptions>(),
provider.GetRequiredService<TimeProvider>(),
provider.GetRequiredService<ILogger<FleetLinkHost>>(),
provider.GetServices<IStateProvider>(),
provider.GetServices<IInbox>(),
provider.GetServices<INodeEventSink>(),
provider.GetRequiredService<FleetRouter>()));
services.AddSingleton<IFleetConnections>(provider => provider.GetRequiredService<FleetLinkHost>());
services.AddSingleton<IStateNotifier>(provider => provider.GetRequiredService<FleetLinkHost>());
return builder;
}
/// <summary>映射 WebSocket 端点。调用方需要先 <c>app.UseWebSockets()</c>。</summary>
public static IEndpointRouteBuilder MapFleetLink(this IEndpointRouteBuilder app, string path)
{
app.Map(path, async (HttpContext http) =>
{
if (!http.WebSockets.IsWebSocketRequest)
{
http.Response.StatusCode = StatusCodes.Status400BadRequest;
return;
}
using System.Net.WebSockets.WebSocket socket = await http.WebSockets.AcceptWebSocketAsync();
await http.RequestServices.GetRequiredService<FleetLinkHost>().RunConnectionAsync(socket, http.RequestAborted);
});
return app;
}
}
@@ -0,0 +1,45 @@
using System.Text.Json.Serialization.Metadata;
using Fleet.Core;
namespace Fleet.Server;
/// <summary>节点的会话信息(来自 fleet.hello)。</summary>
public sealed record NodeSessionInfo(
NodeId NodeId,
long ConnectedAtMs,
string? NodeVersion,
IReadOnlyList<string> Capabilities,
string? HelloMetaJson);
/// <summary>在线表,以及服务端向节点发起的调用。</summary>
public interface IFleetConnections
{
IReadOnlyCollection<NodeId> OnlineNodes { get; }
bool IsOnline(NodeId node);
NodeSessionInfo? GetSession(NodeId node);
/// <summary>向节点发起请求。节点不在线时返回 <c>fleet_node_offline</c>;超时返回 <c>fleet_timeout</c>。</summary>
ValueTask<Result<TResult>> RequestAsync<TParams, TResult>(
NodeId node,
string method,
TParams parameters,
JsonTypeInfo<TParams> paramsInfo,
JsonTypeInfo<TResult> resultInfo,
CancellationToken ct);
ValueTask<bool> NotifyAsync<TParams>(NodeId node, string method, TParams parameters, JsonTypeInfo<TParams> paramsInfo, CancellationToken ct);
/// <summary>断开节点的连接(先发送 <c>fleet.revoked</c>)。</summary>
ValueTask DisconnectAsync(NodeId node, string reason, CancellationToken ct);
/// <summary>
/// 服务端要停止了:对所有在线节点做 Close 握手,<b>不</b>发送 <c>fleet.revoked</c>(节点会按退避重连,等服务端回来)。
/// 长连接不主动关闭的话,宿主的有序停止要等满超时(30 秒)才会退出。
/// </summary>
ValueTask CloseAllAsync(string reason);
}
/// <summary>调用方信息,传给产品注册的 RPC 处理函数。</summary>
public sealed record NodeCallContext(NodeRecord Node, NodeSessionInfo Session, IFleetConnections Connections);
@@ -0,0 +1,729 @@
using System.Buffers;
using System.Collections.Concurrent;
using System.Net.WebSockets;
using System.Security.Cryptography;
using System.Threading.Channels;
using System.Text.Json;
using Fleet.Core;
using Fleet.Identity;
using Fleet.Rpc;
namespace Fleet.Server;
/// <summary>
/// 一个节点的 WebSocket 会话:挑战 → 签名认证 → hello → 状态推送 / 收件箱 / 产品 RPC。
/// 收到的消息按顺序处理,所以产品的处理函数不能等待“向同一个节点发起的请求”,否则会死锁。
/// </summary>
internal sealed class NodeConnection : IDisposable
{
private static readonly TimeSpan s_authTimeout = TimeSpan.FromSeconds(15);
private static readonly TimeSpan s_presenceRefresh = TimeSpan.FromSeconds(60);
private static readonly TimeSpan s_statePushRetry = TimeSpan.FromSeconds(5);
private static readonly TimeSpan s_closeTimeout = TimeSpan.FromSeconds(2);
private readonly FleetLinkHost _host;
private readonly WebSocket _socket;
private readonly SemaphoreSlim _sendLock = new(1, 1);
private readonly ConcurrentDictionary<long, TaskCompletionSource<RpcMessage>> _pending = new();
private readonly Channel<bool> _stateSignal = Channel.CreateBounded<bool>(new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropWrite });
private readonly Dictionary<string, long> _lastPushedVersion = new(StringComparer.Ordinal);
private readonly CancellationTokenSource _lifetime = new();
private long _nextRequestId;
private long _lastPresenceTicks;
private NodeRecord? _node;
private NodeSessionInfo? _session;
private string? _closeReason;
private bool _helloDone;
public NodeConnection(FleetLinkHost host, WebSocket socket)
{
_host = host;
_socket = socket;
}
public NodeId NodeId => _node!.Id;
public NodeSessionInfo Session => _session!;
public async Task RunAsync(CancellationToken ct)
{
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, _lifetime.Token);
CancellationToken token = linked.Token;
bool registered = false;
Task? pusher = null;
try
{
if (!await AuthenticateAsync(token))
{
return;
}
NodeConnection? old = _host.Register(this);
registered = true;
if (old is not null)
{
await old.CloseAsync("replaced");
}
string status = StatusName(_node!.Status);
FleetLog.Connected(_host.Logger, _node.Id.Value, status);
await UpdatePresenceAsync(online: true, token);
await FireAsync(NodeEventKind.Online, token);
pusher = Task.Run(() => StatePushLoopAsync(token), CancellationToken.None);
await ReceiveLoopAsync(token);
}
catch (OperationCanceledException)
{
// 关闭原因由触发取消的一方设置
}
catch (WebSocketException)
{
_closeReason = "connection lost";
}
finally
{
await _lifetime.CancelAsync();
if (pusher is not null)
{
try
{
await pusher;
}
catch (OperationCanceledException)
{
// 正常结束
}
}
foreach (TaskCompletionSource<RpcMessage> waiter in _pending.Values)
{
waiter.TrySetCanceled(CancellationToken.None);
}
if (registered)
{
_host.Unregister(this);
FleetLog.Disconnected(_host.Logger, _node!.Id.Value, _closeReason ?? "closed");
// 被新连接替换时,在线状态属于新连接。
if (!_host.IsOnline(_node.Id))
{
await UpdatePresenceAsync(online: false, CancellationToken.None);
await FireAsync(NodeEventKind.Offline, CancellationToken.None);
}
}
if (_socket.State is WebSocketState.Open or WebSocketState.CloseReceived)
{
// 完整的关闭握手:发送 Close 并等待对端确认(有上限),这样对端能收到关闭原因
try
{
using var closeTimeout = new CancellationTokenSource(s_closeTimeout, _host.Time);
await _socket.CloseAsync(WebSocketCloseStatus.NormalClosure, _closeReason ?? "closed", closeTimeout.Token);
}
catch (WebSocketException)
{
// 对端已经断开
}
catch (OperationCanceledException)
{
// 对端没有在时限内确认:不再等待
}
}
}
}
public void Dispose()
{
_lifetime.Dispose();
_sendLock.Dispose();
}
public void SignalStateChanged() => _stateSignal.Writer.TryWrite(true);
/// <summary>
/// 服务端发起的关闭:发送 Close 帧,等对端确认之后接收循环自然结束。
/// 注意不能直接取消接收的令牌——取消 <c>ReceiveAsync</c> 会中止整个 WebSocket,对端拿不到关闭原因;取消只作为超时兜底。
/// </summary>
public async ValueTask CloseAsync(string reason)
{
_closeReason ??= reason;
try
{
await _sendLock.WaitAsync(CancellationToken.None);
}
catch (ObjectDisposedException)
{
return;
}
try
{
if (_socket.State == WebSocketState.Open)
{
await _socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, reason, CancellationToken.None);
}
}
catch (WebSocketException)
{
// 对端已经断开
}
finally
{
_sendLock.Release();
}
_lifetime.CancelAfter(s_closeTimeout);
}
public async ValueTask RevokeAsync(string reason, CancellationToken ct)
{
await SendAsync(RpcMessage.Notification(FleetMethods.Revoked, new RevokedParams { Reason = reason }, FleetRpcContext.Default.RevokedParams), ct);
await CloseAsync(reason);
}
public async ValueTask<bool> SendAsync(RpcMessage message, CancellationToken ct)
{
byte[] bytes = JsonSerializer.SerializeToUtf8Bytes(message, FleetRpcContext.Default.RpcMessage);
try
{
await _sendLock.WaitAsync(ct);
}
catch (ObjectDisposedException)
{
return false;
}
try
{
if (_socket.State != WebSocketState.Open)
{
return false;
}
await _socket.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, ct);
return true;
}
catch (WebSocketException)
{
return false;
}
finally
{
_sendLock.Release();
}
}
public async ValueTask<Result<JsonElement>> RequestAsync(string method, JsonElement parameters, CancellationToken ct)
{
long id = Interlocked.Increment(ref _nextRequestId);
var waiter = new TaskCompletionSource<RpcMessage>(TaskCreationOptions.RunContinuationsAsynchronously);
_pending[id] = waiter;
try
{
var request = new RpcMessage { Id = id, Method = method, Params = parameters };
if (!await SendAsync(request, ct))
{
return Result<JsonElement>.Fail(FleetErrors.NodeOffline, "node is not connected");
}
using var timeout = new CancellationTokenSource(_host.Options.RequestTimeout, _host.Time);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
RpcMessage response = await waiter.Task.WaitAsync(linked.Token);
return response.Error is not null
? Result<JsonElement>.Fail(response.Error.ToFleetError())
: Result<JsonElement>.Ok(response.Result ?? default);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
return Result<JsonElement>.Fail(FleetErrors.Timeout, $"{method} timed out");
}
finally
{
_pending.TryRemove(id, out _);
}
}
// ───────────────────────── 认证 ─────────────────────────
private async Task<bool> AuthenticateAsync(CancellationToken ct)
{
string nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
long expiresAtMs = _host.Time.GetUtcNow().Add(_host.Options.ChallengeTtl).ToUnixTimeMilliseconds();
await SendAsync(
RpcMessage.Notification(FleetMethods.Challenge, new ChallengeParams { Nonce = nonce, ExpiresAtMs = expiresAtMs }, FleetRpcContext.Default.ChallengeParams),
ct);
using var timeout = new CancellationTokenSource(s_authTimeout, _host.Time);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
Frame? frame;
try
{
frame = await ReceiveFrameAsync(linked.Token);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
await RejectAsync(null, "auth timeout", ct);
return false;
}
if (frame is not { IsText: true } || !TryParse(frame.Data, out RpcMessage? message) || message.Method != FleetMethods.Auth || message.Id is null)
{
await RejectAsync(null, "expected fleet.auth", ct);
return false;
}
AuthParams? auth = null;
try
{
auth = message.Params?.Deserialize(FleetRpcContext.Default.AuthParams);
}
catch (JsonException)
{
// 按缺失处理
}
NodeRecord? node = auth is null ? null : await _host.Store.GetNodeAsync(new NodeId(auth.NodeId), ct);
bool signatureOk = node is not null
&& _host.Time.GetUtcNow().ToUnixTimeMilliseconds() <= expiresAtMs
&& TryDecode(auth!.Signature, out byte[] signature)
&& NodeIdentity.Verify(node.PublicKey, signature, SignaturePurpose.Auth, auth.NodeId, nonce);
if (!signatureOk)
{
// 节点不存在与签名错误返回同样的错误,不泄露节点是否存在。
await RejectAsync(message.Id, "authentication failed", ct);
return false;
}
_node = node;
if (node!.Status == NodeStatus.Disabled)
{
await SendAsync(RpcMessage.Failure(message.Id, new FleetError(FleetErrors.NodeDisabled, "node is disabled")), ct);
await RevokeAsync("disabled", ct);
return false;
}
_session = new NodeSessionInfo(node.Id, _host.Time.GetUtcNow().ToUnixTimeMilliseconds(), node.NodeVersion, [], null);
await SendAsync(
RpcMessage.Success(message.Id.Value, new AuthResult { Status = StatusName(node.Status) }, FleetRpcContext.Default.AuthResult),
ct);
return true;
}
private async Task RejectAsync(long? id, string reason, CancellationToken ct)
{
FleetLog.AuthRejected(_host.Logger, reason);
_closeReason ??= reason;
if (id is not null)
{
await SendAsync(RpcMessage.Failure(id, new FleetError(FleetErrors.Unauthorized, "authentication failed")), ct);
}
if (_socket.State == WebSocketState.Open)
{
try
{
await _socket.CloseOutputAsync(WebSocketCloseStatus.PolicyViolation, "unauthorized", ct);
}
catch (WebSocketException)
{
// 对端已经断开
}
}
}
// ───────────────────────── 接收循环 ─────────────────────────
private async Task ReceiveLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
using var idle = new CancellationTokenSource(_host.Options.HeartbeatTimeout, _host.Time);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, idle.Token);
Frame? frame;
try
{
frame = await ReceiveFrameAsync(linked.Token);
}
catch (OperationCanceledException) when (idle.IsCancellationRequested && !ct.IsCancellationRequested)
{
_closeReason ??= "heartbeat timeout";
return;
}
if (frame is null)
{
_closeReason ??= "closed by peer";
return;
}
if (frame.IsText)
{
if (TryParse(frame.Data, out RpcMessage? message))
{
await HandleMessageAsync(message, ct);
}
}
else
{
await HandleBinaryAsync(frame.Data, ct);
}
}
}
private async Task HandleMessageAsync(RpcMessage message, CancellationToken ct)
{
if (message.IsResponse)
{
if (_pending.TryGetValue(message.Id!.Value, out TaskCompletionSource<RpcMessage>? waiter))
{
waiter.TrySetResult(message);
}
return;
}
switch (message.Method)
{
case FleetMethods.Hello:
await HandleHelloAsync(message, ct);
return;
case FleetMethods.Heartbeat:
await HandleHeartbeatAsync(message, ct);
return;
}
if (_node!.Status != NodeStatus.Active)
{
if (message.Id is not null)
{
await SendAsync(RpcMessage.Failure(message.Id, new FleetError(FleetErrors.NodePending, "node is not approved yet")), ct);
}
return;
}
var context = new NodeCallContext(_node, _session!, _host);
RpcMessage? response;
try
{
response = await _host.Router.DispatchAsync(context, message, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
if (response is not null)
{
await SendAsync(response, ct);
}
}
private async Task HandleHelloAsync(RpcMessage message, CancellationToken ct)
{
HelloParams? hello = null;
try
{
hello = message.Params?.Deserialize(FleetRpcContext.Default.HelloParams);
}
catch (JsonException)
{
// 按缺失处理
}
if (hello is null)
{
if (message.Id is not null)
{
await SendAsync(RpcMessage.Failure(message.Id, new FleetError(FleetErrors.InvalidParams, "invalid hello")), ct);
}
return;
}
long nowMs = _host.Time.GetUtcNow().ToUnixTimeMilliseconds();
_session = _session! with
{
NodeVersion = hello.NodeVersion,
Capabilities = hello.Capabilities,
HelloMetaJson = hello.Meta?.GetRawText(),
};
_node = _node! with { NodeVersion = hello.NodeVersion };
_helloDone = true;
await UpdatePresenceAsync(online: true, ct);
await FireAsync(NodeEventKind.Hello, ct, hello);
if (message.Id is not null)
{
await SendAsync(
RpcMessage.Success(
message.Id.Value,
new HelloResult { ServerTimeMs = nowMs, ClockOffsetMs = hello.NowUtcMs - nowMs },
FleetRpcContext.Default.HelloResult),
ct);
}
SignalStateChanged();
}
private async Task HandleHeartbeatAsync(RpcMessage message, CancellationToken ct)
{
JsonElement? extra = null;
try
{
extra = message.Params?.Deserialize(FleetRpcContext.Default.HeartbeatParams)?.Extra;
}
catch (JsonException)
{
// 忽略格式不正确的附加字段
}
long now = _host.Time.GetTimestamp();
if (_host.Time.GetElapsedTime(Interlocked.Read(ref _lastPresenceTicks), now) >= s_presenceRefresh)
{
await UpdatePresenceAsync(online: true, ct);
}
await FireAsync(NodeEventKind.Heartbeat, ct, hello: null, extra);
if (message.Id is not null)
{
await SendAsync(new RpcMessage { Id = message.Id, Result = JsonSerializer.SerializeToElement(new EmptyObject(), FleetEmptyObject.Info) }, ct);
}
}
private async Task HandleBinaryAsync(byte[] data, CancellationToken ct)
{
if (_node!.Status != NodeStatus.Active
|| !OutboxFrame.TryParse(data, out byte channel, out Guid msgId, out ReadOnlyMemory<byte> payload)
|| !_host.Inboxes.TryGetValue(channel, out IInbox? inbox))
{
return;
}
InboxOutcome outcome;
try
{
outcome = await inbox.HandleAsync(_node, msgId, payload, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
// 没有持久化就不确认,节点会重发。
FleetLog.InboxFailed(_host.Logger, ex, _node.Id.Value, channel);
return;
}
if (outcome == InboxOutcome.Rejected)
{
return;
}
var ack = new OutboxAckParams();
string id = msgId.ToString();
if (outcome == InboxOutcome.Duplicate)
{
ack.Duplicates.Add(id);
}
else
{
ack.Ids.Add(id);
}
await SendAsync(RpcMessage.Notification(FleetMethods.OutboxAck, ack, FleetRpcContext.Default.OutboxAckParams), ct);
}
// ───────────────────────── 状态推送 ─────────────────────────
private async Task StatePushLoopAsync(CancellationToken ct)
{
while (await _stateSignal.Reader.WaitToReadAsync(ct))
{
_stateSignal.Reader.TryRead(out _);
bool complete = await PushStateAsync(ct);
if (!complete)
{
await Task.Delay(s_statePushRetry, _host.Time, ct);
SignalStateChanged();
}
}
}
private async Task<bool> PushStateAsync(CancellationToken ct)
{
NodeRecord? fresh = await _host.Store.GetNodeAsync(_node!.Id, ct);
if (fresh is null)
{
return true;
}
_node = fresh with { NodeVersion = _node.NodeVersion ?? fresh.NodeVersion };
if (fresh.Status == NodeStatus.Disabled)
{
await RevokeAsync("disabled", ct);
return true;
}
if (fresh.Status != NodeStatus.Active || !_helloDone)
{
// pending 不下发状态;还没有 hello 的连接等 hello 之后再推送。
return true;
}
bool complete = true;
foreach (IStateProvider provider in _host.StateProviders)
{
StateDocument? document = await provider.GetAsync(_node, ct);
if (document is null || (_lastPushedVersion.TryGetValue(provider.Type, out long last) && last == document.Version))
{
continue;
}
Result<JsonElement> raw = await RequestAsync(
FleetMethods.StateSync,
JsonSerializer.SerializeToElement(
new StateSyncParams { Type = provider.Type, Version = document.Version, Doc = document.Doc },
FleetRpcContext.Default.StateSyncParams),
ct);
if (!raw.TryGet(out JsonElement value, out FleetError? error))
{
FleetLog.StatePushFailed(_host.Logger, _node.Id.Value, provider.Type, error.Code.Value);
complete = false;
continue;
}
StateSyncResult? result = null;
try
{
result = value.Deserialize(FleetRpcContext.Default.StateSyncResult);
}
catch (JsonException)
{
// 按缺失处理
}
if (result is null)
{
complete = false;
continue;
}
_lastPushedVersion[provider.Type] = document.Version;
await provider.OnAppliedAsync(_node, document.Version, result, ct);
}
return complete;
}
// ───────────────────────── 帮助方法 ─────────────────────────
private async Task UpdatePresenceAsync(bool online, CancellationToken ct)
{
Interlocked.Exchange(ref _lastPresenceTicks, _host.Time.GetTimestamp());
await _host.Store.UpdatePresenceAsync(
_node!.Id,
new Presence(online, _host.Time.GetUtcNow().ToUnixTimeMilliseconds(), _session?.NodeVersion, _session?.Capabilities ?? [], _session?.HelloMetaJson),
ct);
}
private async Task FireAsync(NodeEventKind kind, CancellationToken ct, HelloParams? hello = null, JsonElement? extra = null)
{
var nodeEvent = new NodeEvent(kind, _node!, _host.Time.GetUtcNow().ToUnixTimeMilliseconds(), hello, extra);
foreach (INodeEventSink sink in _host.Sinks)
{
try
{
await sink.OnEventAsync(nodeEvent, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
// 产品的事件处理失败不能影响连接。
FleetLog.SinkFailed(_host.Logger, ex, kind.ToString(), _node!.Id.Value);
}
}
}
private async ValueTask<Frame?> ReceiveFrameAsync(CancellationToken ct)
{
var writer = new ArrayBufferWriter<byte>(1024);
while (true)
{
Memory<byte> buffer = writer.GetMemory(4096);
ValueWebSocketReceiveResult result = await _socket.ReceiveAsync(buffer, ct);
if (result.MessageType == WebSocketMessageType.Close)
{
return null;
}
writer.Advance(result.Count);
if (writer.WrittenCount > _host.Options.MaxFrameBytes)
{
_closeReason ??= "frame too large";
await _socket.CloseOutputAsync(WebSocketCloseStatus.MessageTooBig, "frame too large", CancellationToken.None);
return null;
}
if (result.EndOfMessage)
{
return new Frame(result.MessageType == WebSocketMessageType.Text, writer.WrittenSpan.ToArray());
}
}
}
private static bool TryParse(byte[] data, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out RpcMessage? message)
{
try
{
message = JsonSerializer.Deserialize(data, FleetRpcContext.Default.RpcMessage);
return message is not null;
}
catch (JsonException)
{
message = null;
return false;
}
}
private static bool TryDecode(string text, out byte[] data)
{
try
{
data = Convert.FromBase64String(text);
return true;
}
catch (FormatException)
{
data = [];
return false;
}
}
private static string StatusName(NodeStatus status) => status switch
{
NodeStatus.Active => "active",
NodeStatus.Pending => "pending",
_ => "disabled",
};
private sealed record Frame(bool IsText, byte[] Data);
}
[System.Text.Json.Serialization.JsonSerializable(typeof(EmptyObject))]
internal sealed partial class FleetEmptyObjectContext : System.Text.Json.Serialization.JsonSerializerContext;
internal sealed class EmptyObject;
internal static class FleetEmptyObject
{
public static System.Text.Json.Serialization.Metadata.JsonTypeInfo<EmptyObject> Info => FleetEmptyObjectContext.Default.EmptyObject;
}
@@ -0,0 +1,123 @@
using Fleet.Core;
namespace Fleet.Tests;
public sealed class CoreTests
{
[Theory]
[InlineData(0UL)]
[InlineData(1UL)]
[InlineData(127UL)]
[InlineData(128UL)]
[InlineData(300UL)]
[InlineData(ulong.MaxValue)]
public void VarInt_RoundTrips(ulong value)
{
Span<byte> buffer = stackalloc byte[VarInt.MaxBytes];
int written = VarInt.Write(buffer, value);
Assert.True(VarInt.TryRead(buffer[..written], out ulong read, out int consumed));
Assert.Equal(value, read);
Assert.Equal(written, consumed);
}
[Fact]
public void VarInt_RejectsTruncatedAndOverlongInput()
{
Assert.False(VarInt.TryRead([0x80], out _, out _));
Assert.False(VarInt.TryRead([], out _, out _));
byte[] overlong = [.. Enumerable.Repeat((byte)0x80, 11), 0x01];
Assert.False(VarInt.TryRead(overlong, out _, out _));
}
[Theory]
[InlineData(0L)]
[InlineData(-1L)]
[InlineData(1L)]
[InlineData(long.MinValue)]
[InlineData(long.MaxValue)]
public void ZigZag_RoundTrips_AndKeepsSmallMagnitudesSmall(long value)
{
Assert.Equal(value, VarInt.UnZigZag(VarInt.ZigZag(value)));
if (value is >= -63 and <= 63)
{
Assert.True(VarInt.ZigZag(value) < 128);
}
}
[Fact]
public void Ulid_IsTwentySixCharacters_SortsByTime_AndDoesNotRepeat()
{
var time = new FakeClock(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
string a = Ulid.New(time);
time.Advance(TimeSpan.FromMilliseconds(5));
string b = Ulid.New(time);
Assert.Equal(26, a.Length);
Assert.True(string.CompareOrdinal(a, b) < 0);
Assert.Equal(1000, Enumerable.Range(0, 1000).Select(_ => Ulid.New(time)).Distinct().Count());
Assert.StartsWith("agt_", Ulid.NewPrefixed("agt", time));
}
[Fact]
public void Tokens_AreHighEntropy_AndOnlyTheHashIsComparable()
{
string a = TokenHash.NewToken("jt");
string b = TokenHash.NewToken("jt");
Assert.NotEqual(a, b);
Assert.StartsWith("jt_", a);
Assert.Equal(3 + 43, a.Length);
Assert.True(TokenHash.FixedTimeEquals(TokenHash.Sha256(a), TokenHash.Sha256(a)));
Assert.False(TokenHash.FixedTimeEquals(TokenHash.Sha256(a), TokenHash.Sha256(b)));
}
[Fact]
public void Base64Url_RoundTrips_AndRejectsGarbage()
{
byte[] data = [0xFB, 0xFF, 0xFE, 0x00, 0x01];
string text = Base64Url.Encode(data);
Assert.DoesNotContain('+', text);
Assert.DoesNotContain('/', text);
Assert.DoesNotContain('=', text);
Assert.True(Base64Url.TryDecode(text, out byte[] back));
Assert.Equal(data, back);
Assert.False(Base64Url.TryDecode("***", out _));
}
[Fact]
public void Result_CarriesEitherAValueOrAnError_AndRefusesTheWrongAccess()
{
Result<int> ok = Result<int>.Ok(5);
Result<int> failed = Result<int>.Fail(FleetErrors.NotFound, "missing");
Assert.True(ok.IsSuccess);
Assert.Equal(10, ok.Map(v => v * 2).Value);
Assert.False(failed.IsSuccess);
Assert.Equal(FleetErrors.NotFound, failed.Error.Code);
Assert.Throws<InvalidOperationException>(() => failed.Value);
Assert.Throws<InvalidOperationException>(() => ok.Error);
Assert.Equal("fleet_not_found", failed.Map(v => v).Error.Code.Value);
}
[Fact]
public void FrameworkErrorCodes_ArePrefixedAndSnakeCase()
{
foreach (System.Reflection.FieldInfo field in typeof(FleetErrors).GetFields())
{
string value = ((ErrorCode)field.GetValue(null)!).Value;
Assert.StartsWith("fleet_", value);
Assert.Matches("^[a-z]+(_[a-z]+)*$", value);
}
}
private sealed class FakeClock : TimeProvider
{
private DateTimeOffset _now;
public FakeClock(DateTimeOffset now)
{
_now = now;
}
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan by) => _now += by;
}
}
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" />
<ProjectReference Include="..\..\src\Fleet.Core\Fleet.Core.csproj" />
<ProjectReference Include="..\..\src\Fleet.Identity\Fleet.Identity.csproj" />
<ProjectReference Include="..\..\src\Fleet.Rpc\Fleet.Rpc.csproj" />
<ProjectReference Include="..\..\src\Fleet.Link\Fleet.Link.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>
@@ -0,0 +1,162 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Fleet.Core;
using Fleet.Identity;
using Fleet.Rpc;
namespace Fleet.Tests;
public sealed class IdentityAndRpcTests
{
[Fact]
public void Signatures_VerifyOnlyForTheSameKeyPurposeAndFields()
{
using NodeIdentity node = NodeIdentity.Generate();
using NodeIdentity other = NodeIdentity.Generate();
byte[] signature = node.Sign(SignaturePurpose.Auth, "node-1", "nonce-1");
Assert.True(NodeIdentity.Verify(node.PublicKeySpki, signature, SignaturePurpose.Auth, "node-1", "nonce-1"));
Assert.False(NodeIdentity.Verify(other.PublicKeySpki, signature, SignaturePurpose.Auth, "node-1", "nonce-1"), "another key");
Assert.False(NodeIdentity.Verify(node.PublicKeySpki, signature, SignaturePurpose.Peer, "node-1", "nonce-1"), "another purpose: a signature must not be reusable across scenarios");
Assert.False(NodeIdentity.Verify(node.PublicKeySpki, signature, SignaturePurpose.Auth, "node-2", "nonce-1"), "another node id");
Assert.False(NodeIdentity.Verify(node.PublicKeySpki, signature, SignaturePurpose.Auth, "node-1", "nonce-2"), "another nonce");
}
[Fact]
public void Verify_NeverThrowsOnGarbage()
{
using NodeIdentity node = NodeIdentity.Generate();
Assert.False(NodeIdentity.Verify([1, 2, 3], [4, 5, 6], SignaturePurpose.Auth, "x"));
Assert.False(NodeIdentity.Verify(node.PublicKeySpki, [], SignaturePurpose.Auth, "x"));
Assert.False(NodeIdentity.Verify(node.PublicKeySpki, new byte[64], SignaturePurpose.Auth, "x"));
Assert.False(NodeIdentity.IsValidPublicKey([]));
Assert.False(NodeIdentity.IsValidPublicKey(new byte[91]));
Assert.True(NodeIdentity.IsValidPublicKey(node.PublicKeySpki));
}
[Fact]
public void Identity_SurvivesAnExportImportRoundTrip()
{
using NodeIdentity original = NodeIdentity.Generate();
using NodeIdentity restored = NodeIdentity.FromPkcs8(original.ExportPkcs8());
Assert.Equal(original.PublicKeySpki, restored.PublicKeySpki);
Assert.True(NodeIdentity.Verify(original.PublicKeySpki, restored.Sign("t", "a"), "t", "a"));
Assert.Equal(32, NodeIdentity.Fingerprint(original.PublicKeySpki).Length);
}
[Fact]
public void SignatureString_IsPrefixedWithPurpose()
{
Assert.Equal("fleet|v1|auth|a|b", SignatureString.Build("auth", "a", "b"));
}
[Fact]
public void OutboxFrame_RoundTrips_AndRejectsShortFrames()
{
var id = Guid.CreateVersion7();
byte[] frame = OutboxFrame.Build(16, id, [1, 2, 3]);
Assert.True(OutboxFrame.TryParse(frame, out byte channel, out Guid parsed, out ReadOnlyMemory<byte> payload));
Assert.Equal((byte)16, channel);
Assert.Equal(id, parsed);
Assert.Equal(new byte[] { 1, 2, 3 }, payload.ToArray());
Assert.False(OutboxFrame.TryParse(new byte[OutboxFrame.HeaderBytes - 1], out _, out _, out _));
Assert.True(OutboxFrame.TryParse(new byte[OutboxFrame.HeaderBytes], out _, out _, out ReadOnlyMemory<byte> empty));
Assert.Equal(0, empty.Length);
}
[Fact]
public async Task Router_DispatchesTypedRequests_AndReportsStructuredErrors()
{
var router = new RpcRouter<string>()
.On("test.add", EchoContext.Default.AddParams, EchoContext.Default.AddResult,
(caller, p, _) => ValueTask.FromResult(p.A < 0 ? Result<AddResult>.Fail(FleetErrors.Forbidden, "negative") : Result<AddResult>.Ok(new AddResult { Sum = p.A + p.B, Caller = caller })))
.On<AddParams, AddResult>("test.boom", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => throw new InvalidOperationException("secret detail"));
RpcMessage ok = (await router.DispatchAsync("alice", Request(1, "test.add", """{"a":2,"b":3}"""), CancellationToken.None))!;
Assert.Equal(5, ok.Result!.Value.Deserialize(EchoContext.Default.AddResult)!.Sum);
Assert.Equal("alice", ok.Result.Value.Deserialize(EchoContext.Default.AddResult)!.Caller);
RpcMessage denied = (await router.DispatchAsync("alice", Request(2, "test.add", """{"a":-1,"b":3}"""), CancellationToken.None))!;
Assert.Equal("fleet_forbidden", denied.Error!.Data!.Code);
Assert.Equal(RpcError.ApplicationError, denied.Error.Code);
RpcMessage invalid = (await router.DispatchAsync("alice", Request(3, "test.add", """{"a":"x"}"""), CancellationToken.None))!;
Assert.Equal(RpcError.InvalidParams, invalid.Error!.Code);
RpcMessage unknown = (await router.DispatchAsync("alice", Request(4, "test.nope", "{}"), CancellationToken.None))!;
Assert.Equal(RpcError.MethodNotFound, unknown.Error!.Code);
Assert.Equal("fleet_unsupported", unknown.Error.Data!.Code);
}
[Fact]
public async Task Router_HandlerExceptions_AreContained_AndNeverLeakDetails()
{
string? logged = null;
var router = new RpcRouter<string>((method, ex) => logged = method + ":" + ex.Message)
.On<AddParams, AddResult>("test.boom", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => throw new InvalidOperationException("secret detail"));
RpcMessage reply = (await router.DispatchAsync("a", Request(1, "test.boom", """{"a":1,"b":1}"""), CancellationToken.None))!;
Assert.Equal("fleet_internal", reply.Error!.Data!.Code);
Assert.DoesNotContain("secret", reply.Error.Message);
Assert.Equal("test.boom:secret detail", logged);
}
[Fact]
public async Task Router_Notifications_ProduceNoResponse_EvenOnFailure()
{
int calls = 0;
var router = new RpcRouter<string>().OnNotify("test.note", EchoContext.Default.AddParams, (_, _, _) =>
{
calls++;
return ValueTask.CompletedTask;
});
Assert.Null(await router.DispatchAsync("a", Notification("test.note", """{"a":1,"b":2}"""), CancellationToken.None));
Assert.Null(await router.DispatchAsync("a", Notification("test.note", """{"a":"bad"}"""), CancellationToken.None));
Assert.Null(await router.DispatchAsync("a", Notification("test.unknown", "{}"), CancellationToken.None));
Assert.Equal(1, calls);
}
[Fact]
public void Envelope_OmitsAbsentFields_AndRoundTrips()
{
string request = JsonSerializer.Serialize(Request(7, "x.y", """{"k":1}"""), FleetRpcContext.Default.RpcMessage);
Assert.Equal("""{"jsonrpc":"2.0","id":7,"method":"x.y","params":{"k":1}}""", request);
RpcMessage back = JsonSerializer.Deserialize(request, FleetRpcContext.Default.RpcMessage)!;
Assert.True(back.IsRequest);
Assert.False(back.IsResponse);
Assert.True(Notification("a.b", "{}").IsNotification);
string failure = JsonSerializer.Serialize(RpcMessage.Failure(3, new FleetError(FleetErrors.Timeout, "slow")), FleetRpcContext.Default.RpcMessage);
Assert.Contains("\"code\":\"fleet_timeout\"", failure);
Assert.Equal(FleetErrors.Timeout, JsonSerializer.Deserialize(failure, FleetRpcContext.Default.RpcMessage)!.Error!.ToFleetError().Code);
}
private static RpcMessage Request(long id, string method, string json) =>
new() { Id = id, Method = method, Params = JsonDocument.Parse(json).RootElement.Clone() };
private static RpcMessage Notification(string method, string json) =>
new() { Method = method, Params = JsonDocument.Parse(json).RootElement.Clone() };
}
public sealed class AddParams
{
[JsonPropertyName("a")]
public int A { get; set; }
[JsonPropertyName("b")]
public int B { get; set; }
}
public sealed class AddResult
{
[JsonPropertyName("sum")]
public int Sum { get; set; }
[JsonPropertyName("caller")]
public string Caller { get; set; } = "";
}
[JsonSerializable(typeof(AddParams))]
[JsonSerializable(typeof(AddResult))]
internal sealed partial class EchoContext : JsonSerializerContext;
@@ -0,0 +1,431 @@
using System.Net;
using System.Net.Sockets;
using System.Security.Authentication;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text.Json;
using System.Text.Json.Serialization;
using Fleet.Core;
using Fleet.Identity;
using Fleet.Link;
using Fleet.Rpc;
namespace Fleet.Tests;
public sealed class LinkTests
{
// ───────────────────────── 基础件 ─────────────────────────
[Fact]
public void ReplayWindow_AcceptsEachSequenceOnce_AndRejectsStaleOnes()
{
var window = new ReplayWindow();
Assert.True(window.TryAccept(100));
Assert.False(window.TryAccept(100), "duplicate");
Assert.True(window.TryAccept(99), "late but inside the window");
Assert.False(window.TryAccept(99));
Assert.True(window.TryAccept(105));
Assert.True(window.TryAccept(101));
Assert.True(window.TryAccept(100 + 1024), "the window slides");
Assert.False(window.TryAccept(100), "now older than the window");
Assert.True(window.TryAccept(100 + 5000), "a big jump clears everything");
Assert.False(window.TryAccept(100 + 5000));
Assert.True(window.TryAccept(100 + 4999));
}
[Fact]
public void Backoff_GrowsExponentially_IsCappedAndJittered_AndResets()
{
var backoff = new Backoff(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(60));
TimeSpan previousCeiling = TimeSpan.Zero;
for (int i = 0; i < 12; i++)
{
TimeSpan delay = backoff.Next();
double ceiling = Math.Min(60, Math.Pow(2, i));
Assert.InRange(delay.TotalSeconds, ceiling * 0.5 - 1e-9, ceiling + 1e-9);
Assert.True(TimeSpan.FromSeconds(ceiling) >= previousCeiling);
previousCeiling = TimeSpan.FromSeconds(ceiling);
}
backoff.Reset();
Assert.InRange(backoff.Next().TotalSeconds, 0.5, 1.0);
}
[Fact]
public async Task Outbox_DeliversInOrder_UntilAcknowledged_AndResendsAfterAReconnect()
{
var outbox = new Outbox(1 << 20);
Guid a = Guid.CreateVersion7();
Guid b = Guid.CreateVersion7();
outbox.Enqueue(16, a, [1]);
outbox.Enqueue(16, b, [2]);
outbox.Enqueue(16, a, [9]); // 同一个 msg_id 只保留一份
Assert.Equal(2, outbox.PendingCount);
Assert.True(OutboxFrame.TryParse(await outbox.TakeNextAsync(1 << 20, default), out _, out Guid first, out _));
Assert.True(OutboxFrame.TryParse(await outbox.TakeNextAsync(1 << 20, default), out _, out Guid second, out _));
Assert.Equal((a, b), (first, second));
// 连接断了:还没确认的全部重发,顺序不变
outbox.ResetSent();
Assert.True(OutboxFrame.TryParse(await outbox.TakeNextAsync(1 << 20, default), out _, out Guid again, out _));
Assert.Equal(a, again);
outbox.Acknowledge([a, b, Guid.NewGuid()]);
Assert.Equal(0, outbox.PendingCount);
Assert.Equal(0, outbox.PendingBytes);
}
[Fact]
public async Task Outbox_IsBounded_DropsTheOldest_AndReportsTheGap()
{
var outbox = new Outbox(maxBytes: 100);
int gap = 0;
outbox.OnGap = n => gap += n;
for (int i = 0; i < 10; i++)
{
outbox.Enqueue(16, Guid.CreateVersion7(), new byte[40]); // 每条 57 字节
}
Assert.Equal(1, outbox.PendingCount);
Assert.Equal(9, outbox.Dropped);
Assert.Equal(9, gap);
Assert.True(outbox.PendingBytes <= 100);
var counted = new Outbox(1 << 20, maxCount: 3);
for (int i = 0; i < 5; i++)
{
counted.Enqueue(16, Guid.CreateVersion7(), [1]);
}
Assert.Equal(3, counted.PendingCount);
Assert.Equal(2, counted.Dropped);
_ = await counted.TakeNextAsync(1 << 20, default);
}
[Fact]
public async Task Outbox_LimitsBytesInFlight_UntilTheServerAcknowledges()
{
var outbox = new Outbox(1 << 20);
Guid first = Guid.CreateVersion7();
outbox.Enqueue(16, first, new byte[1000]);
outbox.Enqueue(16, Guid.CreateVersion7(), new byte[1000]);
await outbox.TakeNextAsync(1500, default); // 第一条放行
using var cts = new CancellationTokenSource(200);
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () => await outbox.TakeNextAsync(1500, cts.Token)); // 第二条超过在途上限,等 ACK
outbox.Acknowledge([first]);
Assert.NotNull(await outbox.TakeNextAsync(1500, default));
// 单条消息比在途上限还大时也要放行,否则永远卡住
var big = new Outbox(1 << 20);
big.Enqueue(16, Guid.CreateVersion7(), new byte[5000]);
Assert.NotNull(await big.TakeNextAsync(100, default));
}
[Fact]
public void SessionKeys_AgreeOnBothSides_AndBindTheHandshake()
{
using var client = new EphemeralKey();
using var server = new EphemeralKey();
byte[] nonce = RandomNumberGenerator.GetBytes(32);
SessionCredentials c = client.Derive(server.PublicKeySpki, nonce, client.PublicKeySpki, server.PublicKeySpki);
SessionCredentials s = server.Derive(client.PublicKeySpki, nonce, client.PublicKeySpki, server.PublicKeySpki);
Assert.Equal(c.Key, s.Key);
Assert.Equal(c.SessionId, s.SessionId);
Assert.Equal(8, c.SessionId.Length);
Assert.Equal(32, c.Key.Length);
SessionCredentials otherNonce = client.Derive(server.PublicKeySpki, RandomNumberGenerator.GetBytes(32), client.PublicKeySpki, server.PublicKeySpki);
Assert.NotEqual(c.Key, otherNonce.Key);
using var third = new EphemeralKey();
Assert.NotEqual(c.Key, third.Derive(server.PublicKeySpki, nonce, third.PublicKeySpki, server.PublicKeySpki).Key);
}
[Fact]
public void DataPlaneMac_DetectsAnyTampering()
{
byte[] key = RandomNumberGenerator.GetBytes(32);
byte[] data = [1, 2, 3, 4, 5];
byte[] mac = new byte[DataPlaneMac.Bytes];
DataPlaneMac.Compute(key, data, mac);
Assert.True(DataPlaneMac.Verify(key, data, mac));
data[2] ^= 1;
Assert.False(DataPlaneMac.Verify(key, data, mac));
data[2] ^= 1;
mac[0] ^= 1;
Assert.False(DataPlaneMac.Verify(key, data, mac));
mac[0] ^= 1;
Assert.False(DataPlaneMac.Verify(RandomNumberGenerator.GetBytes(32), data, mac));
}
// ───────────────────────── 传输与会话 ─────────────────────────
private static async Task<(TlsFrameTransport Left, TlsFrameTransport Right)> PairAsync(int maxFrame = 1 << 20)
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var client = new TcpClient();
Task<TcpClient> accept = listener.AcceptTcpClientAsync();
await client.ConnectAsync(IPAddress.Loopback, ((IPEndPoint)listener.LocalEndpoint).Port);
TcpClient server = await accept;
listener.Stop();
return (new TlsFrameTransport(client.GetStream(), maxFrame), new TlsFrameTransport(server.GetStream(), maxFrame));
}
[Fact]
public async Task TlsFrames_RoundTripTextAndBinary_AndCloseCleanly()
{
(TlsFrameTransport left, TlsFrameTransport right) = await PairAsync();
await using (left)
await using (right)
{
await left.SendAsync(new RpcFrame(FrameKind.Text, "hello"u8.ToArray()), default);
await left.SendAsync(new RpcFrame(FrameKind.Binary, new byte[] { 1, 2, 3 }), default);
await left.SendAsync(new RpcFrame(FrameKind.Binary, ReadOnlyMemory<byte>.Empty), default);
RpcFrame a = (await right.ReceiveAsync(default))!.Value;
RpcFrame b = (await right.ReceiveAsync(default))!.Value;
RpcFrame c = (await right.ReceiveAsync(default))!.Value;
Assert.Equal((FrameKind.Text, "hello"), (a.Kind, System.Text.Encoding.UTF8.GetString(a.Data.Span)));
Assert.Equal(new byte[] { 1, 2, 3 }, b.Data.ToArray());
Assert.Equal(0, c.Data.Length);
await left.CloseAsync("done", default);
Assert.Null(await right.ReceiveAsync(default));
}
}
[Fact]
public async Task TlsFrames_RefuseOversizedFrames_OnBothSides()
{
(TlsFrameTransport left, TlsFrameTransport right) = await PairAsync(maxFrame: 100);
await using (left)
await using (right)
{
await Assert.ThrowsAsync<InvalidOperationException>(async () => await left.SendAsync(new RpcFrame(FrameKind.Binary, new byte[101]), default));
// 对端绕过长度检查发来超长帧:接收方直接断开
(TlsFrameTransport raw, TlsFrameTransport receiver) = await PairAsync(maxFrame: 100);
await using (raw)
await using (receiver)
{
var permissive = new TlsFrameTransport(await TakeStreamAsync(raw), 1 << 20);
await permissive.SendAsync(new RpcFrame(FrameKind.Binary, new byte[500]), default);
Assert.Null(await receiver.ReceiveAsync(default));
}
}
}
private static Task<Stream> TakeStreamAsync(TlsFrameTransport transport) =>
Task.FromResult((Stream)typeof(TlsFrameTransport).GetField("_stream", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!.GetValue(transport)!);
[Fact]
public async Task Session_CorrelatesRequestsAndResponses_BothWays_WithoutDeadlockingInHandlers()
{
(TlsFrameTransport left, TlsFrameTransport right) = await PairAsync();
RpcSession<string>? rightSession = null;
var leftRouter = new RpcRouter<string>().On("left.double", LinkJson.Default.Number, LinkJson.Default.Number, (_, p, _) => ValueTask.FromResult(Result<Number>.Ok(new Number { Value = p.Value * 2 })));
var rightRouter = new RpcRouter<string>().On(
"right.relay", LinkJson.Default.Number, LinkJson.Default.Number,
async (_, p, ct) =>
{
// 处理函数里等待向请求方发起的请求:接收循环和处理函数在不同的任务里,所以不会死锁
Result<Number> inner = await rightSession!.RequestAsync("left.double", p, LinkJson.Default.Number, LinkJson.Default.Number, ct);
return Result<Number>.Ok(new Number { Value = inner.Value.Value + 1 });
});
await using var leftSession = new RpcSession<string>(left, leftRouter, "left", TimeProvider.System);
rightSession = new RpcSession<string>(right, rightRouter, "right", TimeProvider.System);
await using (rightSession)
{
Task leftRun = leftSession.RunAsync(default);
Task rightRun = rightSession.RunAsync(default);
// left 请求 right.relay;right 的处理函数回头请求 left.double:21 → 42 → +1
Result<Number> result = await leftSession.RequestAsync("right.relay", new Number { Value = 21 }, LinkJson.Default.Number, LinkJson.Default.Number, default);
Assert.Equal(43, result.Value.Value);
Result<JsonElement> unknown = await leftSession.RequestAsync("nope", null, default);
Assert.Equal("fleet_unsupported", unknown.Error.Code.Value);
await leftSession.CloseAsync("bye", default);
await rightRun.WaitAsync(TimeSpan.FromSeconds(5));
await leftRun.WaitAsync(TimeSpan.FromSeconds(5));
}
}
[Fact]
public async Task Session_RequestsFailWhenThePeerNeverAnswers_OrTheSessionCloses()
{
(TlsFrameTransport left, TlsFrameTransport right) = await PairAsync();
await using var silent = right; // 对端不读也不回
await using var session = new RpcSession<string>(left, new RpcRouter<string>(), "x", TimeProvider.System, new RpcSessionOptions { RequestTimeout = TimeSpan.FromMilliseconds(200) });
Task run = session.RunAsync(default);
Result<JsonElement> timedOut = await session.RequestAsync("any", null, default);
Assert.Equal(FleetErrors.Timeout, timedOut.Error.Code);
Task<Result<JsonElement>> pending = session.RequestAsync("any", null, default, TimeSpan.FromSeconds(30)).AsTask();
await silent.CloseAsync("gone", default);
Assert.Equal(FleetErrors.Timeout, (await pending.WaitAsync(TimeSpan.FromSeconds(5))).Error.Code);
await run.WaitAsync(TimeSpan.FromSeconds(5));
}
// ───────────────────────── 点对点握手 ─────────────────────────
private sealed class PeerFixture : IAsyncDisposable
{
private readonly CancellationTokenSource _stop = new();
public PeerFixture(Func<string, byte[]?> lookup, Func<PeerSessionInfo, IRpcTransport, CancellationToken, Task>? onSession = null)
{
Certificate = NodeCertificate.LoadPfx(NodeCertificate.CreateSelfSignedPfx("agent", TimeSpan.FromDays(30), TimeProvider.System));
Listener = new PeerListener(
new PeerListenerOptions { Listen = new IPEndPoint(IPAddress.Loopback, 0), Certificate = Certificate, LookupPeerKey = lookup },
onSession ?? ((_, transport, ct) => KeepOpenAsync(transport, ct)),
NullNodeLog.Instance, TimeProvider.System);
Task = Listener.RunAsync(_stop.Token);
}
public X509Certificate2 Certificate { get; }
public PeerListener Listener { get; }
public Task Task { get; }
public byte[] Spki => NodeCertificate.SpkiFingerprint(Certificate);
public async ValueTask DisposeAsync()
{
await _stop.CancelAsync();
await Task;
Certificate.Dispose();
_stop.Dispose();
}
private static async Task KeepOpenAsync(IRpcTransport transport, CancellationToken ct)
{
while (await transport.ReceiveAsync(ct) is not null)
{
}
}
}
[Fact]
public async Task PeerHandshake_AuthenticatesTheProbe_AndBothSidesDeriveTheSameDataPlaneCredentials()
{
using NodeIdentity probe = NodeIdentity.Generate();
var seen = new TaskCompletionSource<PeerSessionInfo>();
await using var agent = new PeerFixture(id => id == "prb_1" ? probe.PublicKeySpki : null, (info, transport, ct) =>
{
seen.TrySetResult(info);
return Task.Delay(Timeout.Infinite, ct).ContinueWith(_ => { }, CancellationToken.None);
});
await agent.Listener.Started.Task;
(PeerSessionInfo client, TlsFrameTransport transport) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
await using (transport)
{
PeerSessionInfo server = await seen.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal("prb_1", server.PeerId);
Assert.Equal(client.Credentials.Key, server.Credentials.Key);
Assert.Equal(client.Credentials.SessionId, server.Credentials.SessionId);
}
}
[Fact]
public async Task PeerHandshake_RejectsUnknownProbes_AndForgedSignatures_WithTheSameError()
{
using NodeIdentity probe = NodeIdentity.Generate();
using NodeIdentity impostor = NodeIdentity.Generate();
await using var agent = new PeerFixture(id => id == "prb_1" ? probe.PublicKeySpki : null);
await agent.Listener.Started.Task;
InvalidOperationException unknown = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_unknown", null, 1 << 20, TimeProvider.System, default));
InvalidOperationException forged = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, impostor, "prb_1", null, 1 << 20, TimeProvider.System, default));
Assert.Equal(unknown.Message, forged.Message);
Assert.Contains("fleet_unauthorized", forged.Message);
// 正主仍然可以连接
(_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
await ok.DisposeAsync();
}
[Fact]
public async Task PeerHandshake_PinsTheAgentCertificate_ByItsSpkiFingerprint()
{
using NodeIdentity probe = NodeIdentity.Generate();
await using var agent = new PeerFixture(_ => probe.PublicKeySpki);
await agent.Listener.Started.Task;
byte[] wrong = SHA256.HashData("another certificate"u8);
await Assert.ThrowsAnyAsync<AuthenticationException>(async () =>
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, wrong, probe, "prb_1", null, 1 << 20, TimeProvider.System, default));
}
[Fact]
public async Task PeerSession_CarriesRpcBothWays_AfterTheHandshake()
{
using NodeIdentity probe = NodeIdentity.Generate();
var router = new RpcRouter<string>().On("agent.echo", LinkJson.Default.Number, LinkJson.Default.Number, (_, p, _) => ValueTask.FromResult(Result<Number>.Ok(new Number { Value = p.Value + 1 })));
await using var agent = new PeerFixture(
_ => probe.PublicKeySpki,
async (info, transport, ct) =>
{
await using var session = new RpcSession<string>(transport, router, info.PeerId, TimeProvider.System);
await session.RunAsync(ct);
});
await agent.Listener.Started.Task;
(_, TlsFrameTransport transport) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
await using var client = new RpcSession<string>(transport, new RpcRouter<string>(), "probe", TimeProvider.System);
Task run = client.RunAsync(default);
Result<Number> result = await client.RequestAsync("agent.echo", new Number { Value = 41 }, LinkJson.Default.Number, LinkJson.Default.Number, default);
Assert.Equal(42, result.Value.Value);
await client.CloseAsync("bye", default);
await run.WaitAsync(TimeSpan.FromSeconds(5));
}
[Fact]
public async Task PeerListener_LimitsConcurrentSessions_AndSurvivesGarbageConnections()
{
using NodeIdentity probe = NodeIdentity.Generate();
await using var agent = new PeerFixture(_ => probe.PublicKeySpki);
await agent.Listener.Started.Task;
// 不说 TLS 的连接:握手失败,监听继续
using (var garbage = new TcpClient())
{
await garbage.ConnectAsync(IPAddress.Loopback, agent.Listener.Port);
await garbage.GetStream().WriteAsync("GET / HTTP/1.1\r\n\r\n"u8.ToArray());
}
(_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
await ok.DisposeAsync();
}
[Fact]
public void SelfSignedCertificate_IsEcdsaP256_AndHasAStableFingerprint()
{
byte[] pfx = NodeCertificate.CreateSelfSignedPfx("agent", TimeSpan.FromDays(30), TimeProvider.System);
using X509Certificate2 certificate = NodeCertificate.LoadPfx(pfx);
Assert.True(certificate.HasPrivateKey);
Assert.Equal("1.2.840.10045.2.1", certificate.PublicKey.Oid.Value); // id-ecPublicKey
Assert.Equal(NodeCertificate.SpkiFingerprint(certificate), NodeCertificate.SpkiFingerprint(NodeCertificate.LoadPfx(pfx)));
Assert.Equal(32, NodeCertificate.SpkiFingerprint(certificate).Length);
}
}
public sealed class Number
{
[JsonPropertyName("value")]
public int Value { get; set; }
}
[JsonSerializable(typeof(Number))]
internal sealed partial class LinkJson : JsonSerializerContext;
@@ -0,0 +1,43 @@
using System.Text.Json.Nodes;
using Fleet.Core;
using Fleet.Enrollment.Client;
// fleet-enroll --server https://panel --join jt_xxx --name jp-1 --identity-dir /etc/monitor [--meta '{"probe_tcp_port":7443,...}'] [--version 1.0]
// 一次性运行:生成身份、注册、写入身份目录,然后退出。
string? Arg(string name) => args.SkipWhile(a => a != name).Skip(1).FirstOrDefault();
string? server = Arg("--server");
string? join = Arg("--join");
string? name = Arg("--name");
string? directory = Arg("--identity-dir");
if (server is null || join is null || name is null || directory is null)
{
Console.Error.WriteLine("usage: fleet-enroll --server <url> --join <token> --name <node name> --identity-dir <dir> [--meta <json>] [--version <v>]");
return 2;
}
JsonNode? meta = null;
if (Arg("--meta") is { } metaText)
{
try
{
meta = JsonNode.Parse(metaText);
}
catch (System.Text.Json.JsonException ex)
{
Console.Error.WriteLine("--meta is not valid JSON: " + ex.Message);
return 2;
}
}
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
Result<EnrollmentResult> result = await EnrollmentClient.EnrollAsync(
http, new Uri(server), join, name, directory, Arg("--version") ?? "0.0.0", meta, TimeProvider.System, CancellationToken.None);
if (!result.TryGet(out EnrollmentResult? enrolled, out FleetError? error))
{
Console.Error.WriteLine($"enrollment failed ({error.Code}): {error.Message}");
return 1;
}
Console.WriteLine($"enrolled as {enrolled.NodeId} ({enrolled.Status}); identity written to {enrolled.IdentityDirectory}");
return 0;
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<AssemblyName>fleet-enroll</AssemblyName>
<RootNamespace>FleetEnroll</RootNamespace>
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Fleet.Enrollment.Client\Fleet.Enrollment.Client.csproj" />
</ItemGroup>
</Project>
+9
View File
@@ -0,0 +1,9 @@
{
"sdk": {
"version": "10.0.200",
"rollForward": "latestFeature"
},
"test": {
"runner": "Microsoft.Testing.Platform"
}
}
File diff suppressed because it is too large. Load diff
+17
View File
@@ -0,0 +1,17 @@
<Project>
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" />
<PropertyGroup>
<OptimizationPreference>Size</OptimizationPreference>
<UseSystemResourceKeys>true</UseSystemResourceKeys>
<StackTraceSupport>false</StackTraceSupport>
<IlcFoldIdenticalMethodBodies>true</IlcFoldIdenticalMethodBodies>
<EventSourceSupport>false</EventSourceSupport>
<DebuggerSupport>false</DebuggerSupport>
<HttpActivityPropagationSupport>false</HttpActivityPropagationSupport>
<ServerGarbageCollection>false</ServerGarbageCollection>
<ConcurrentGarbageCollection>false</ConcurrentGarbageCollection>
</PropertyGroup>
<ItemGroup>
<AdditionalFiles Include="$(MSBuildThisFileDirectory)../BannedSymbols.Node.txt" />
</ItemGroup>
</Project>
+46
View File
@@ -0,0 +1,46 @@
# Monitor Probe
Probe 是网络测量的发起端,通常运行在家宽或其他需要观测的网络出口。它连接 Agent、执行线路任务,并把本端采集的原始数据交给 Agent 汇总上报。Probe 不直接连接 Backend,也不提供远程命令或文件管理功能。
## 工作流程
```text
Backend ──任务与节点列表──> Agent <──测量会话──> Probe
│ │
└──汇总并上报数据 <───┘
```
Probe 使用面板生成的 `probe.json` 读取自身身份、首次连接用的 Agent 地址和本地测试限制。成功连接后,Agent 会同步可用节点列表;Probe 将列表缓存到同目录的 `agents.json`。`probe.json` 含私钥,请限制文件访问权限,不要提交到版本控制。
## 构建
需要 .NET SDK 10。为应用 `backend/global.json` 的 SDK 设置,请从 `backend/` 目录构建:
```sh
cd backend
dotnet build probe/src/Monitor.Probe -c Release
```
发布 NativeAOT 程序需要对应平台的 C/C++ 工具链;`<RID>` 应与构建环境和目标平台匹配:
```sh
dotnet publish probe/src/Monitor.Probe -c Release -r <RID> -p:PublishAot=true
```
也可在仓库根目录运行 `bash scripts/publish.sh probe <RID>`,再使用 `scripts/package.sh probe <RID>` 生成部署包。支持平台和安装流程见 [`../../docs/deployment.md`](../../docs/deployment.md)。
## 配置与运行
在管理端创建 Probe 并下载一次性身份配置,将文件保存为目标机上的 `/etc/monitor/probe.json`。随后运行:
```sh
monitor-probe --config /etc/monitor/probe.json
```
不传 `--config` 时默认读取 `/etc/monitor/probe.json`。配置中的 `limits` 是本地硬上限,Backend 无法远程放宽;路由器等低性能设备可调低测试码率和包速率,避免 Probe 自身成为测量瓶颈。部署时可使用仓库的 `packaging/` 文件和安装脚本。
## 设计说明
- Probe 只执行编译进程序的测量模块。它不认识 Backend,不落盘测量数据,也不能执行命令或自行更新。
- 测量模块由 `backend/shared/Monitor.Measurement/` 与 Agent 共用;Probe 负责发起连接、调度任务和记录本端数据。
- 测量模型和协议细节见 [`../../docs/measurement.md`](../../docs/measurement.md) 与 [`../../docs/protocol.md`](../../docs/protocol.md);整体架构见 [`../../docs/architecture.md`](../../docs/architecture.md)。
@@ -0,0 +1,428 @@
using System.Collections.Concurrent;
using System.Net;
using System.Net.Sockets;
using System.Text.Json;
using Fleet.Core;
using Fleet.Identity;
using Fleet.Link;
using Fleet.Rpc;
using Monitor.Measurement;
using Monitor.Protocol;
namespace Monitor.Probe;
/// <summary>所有连接共用的东西。</summary>
public sealed record ConnectionEnv(NodeIdentity Identity, string ProbeId, ProbeLimits Limits, AgentDirectory Directory, MonoClock Clock, TimeProvider Time, INodeLog Log);
/// <summary>
/// 与一个 Agent 的点对点会话:认证握手 → 同步时钟 → 取 Agent 列表 → 按 Agent 下发的 <c>line_task_set</c> 运行角色 →
/// 每 10 秒把已完成的行切成列块放进发件箱 → 响应按需测试的阶段。会话断开时所有角色停止(凭据属于会话),按退避重连。
/// 会话内的所有状态(套接字、发件箱、角色)随会话一起创建和释放。
/// </summary>
public sealed class AgentConnection
{
private static readonly TimeSpan s_flushInterval = TimeSpan.FromSeconds(ProtocolLimits.ChunkSeconds);
private static readonly TimeSpan s_clockInterval = TimeSpan.FromSeconds(30);
private static readonly RpcRouter<ConnectionSession> s_router = BuildRouter();
private readonly ConnectionEnv _env;
private volatile ConnectionSession? _session;
public AgentConnection(BootstrapAgent endpoint, ConnectionEnv env)
{
Endpoint = endpoint;
_env = env;
}
public BootstrapAgent Endpoint { get; }
public bool IsConnected => _session is not null;
public int RoleCount => _session?.RoleCount ?? 0;
public long ClockOffsetUs => _session?.Clock.OffsetUs ?? 0;
public async Task RunAsync(CancellationToken ct)
{
var backoff = new Backoff(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(20));
while (!ct.IsCancellationRequested)
{
try
{
string reason = await RunOnceAsync(backoff, ct);
_env.Log.Warn("agent session ended", ("agent", Endpoint.AgentId), ("err", reason));
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
return;
}
catch (Exception ex) when (ex is IOException or System.Net.Sockets.SocketException or InvalidOperationException or System.Security.Authentication.AuthenticationException or OperationCanceledException or FormatException)
{
_env.Log.Warn("agent connection failed", ("agent", Endpoint.AgentId), ("err", ex.Message));
}
try
{
await Task.Delay(backoff.Next(), _env.Time, ct);
}
catch (OperationCanceledException)
{
return;
}
}
}
private async Task<string> RunOnceAsync(Backoff backoff, CancellationToken ct)
{
(PeerSessionInfo info, TlsFrameTransport transport) = await PeerConnector.ConnectAsync(
Endpoint.Host, Endpoint.TcpPort, Convert.FromBase64String(Endpoint.Spki), _env.Identity, _env.ProbeId, ProbeApp.Version, 8 << 20, _env.Time, ct);
var remote = (IPEndPoint)info.Remote!;
var data = new DataSession(info.Credentials.SessionIdValue, info.Credentials.Key);
using var udp = new UdpMux(new SingleSession(data), null, _env.Clock, new DataPlaneCounters(), _env.Log, remote.AddressFamily == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any, []);
udp.Bind();
var outbox = new Outbox(8 << 20);
var clock = new ClockEstimator();
var shipper = new FragmentShipper(outbox, new NodeId(Endpoint.AgentId), new ProbeId(_env.ProbeId), clock, _env.Time);
var blockEnv = new BlockEnv
{
Clock = _env.Clock,
Log = _env.Log,
Session = () => data,
Udp = udp,
PeerAddress = remote.Address,
PeerSpkiSha256 = Convert.FromBase64String(Endpoint.Spki),
Fragments = shipper,
MaxPacketsPerSecond = _env.Limits.MaxTotalPps,
MaxBulkMbps = _env.Limits.MaxTestMbps,
};
var session = new ConnectionSession(blockEnv, clock, outbox, _env);
var rpc = new RpcSession<ConnectionSession>(transport, s_router, session, _env.Time);
rpc.OnBinary = (_, _) => ValueTask.CompletedTask;
using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
Task run = rpc.RunAsync(cts.Token);
Task receive = udp.RunAsync(cts.Token);
Task pump = PumpAsync(rpc, outbox, cts.Token);
try
{
// 先同步时钟(块头里要带偏移),再取 Agent 列表;Agent 随后推送线路任务集
await SyncClockAsync(rpc, clock, rounds: 5, cts.Token);
backoff.Reset();
_session = session;
_env.Log.Info("agent session up", ("agent", Endpoint.AgentId), ("offset_us", clock.OffsetUs));
await RefreshDirectoryAsync(rpc, cts.Token);
Task maintenance = MaintainAsync(rpc, session, clock, cts.Token);
Task finished = await Task.WhenAny(run, receive, pump, maintenance);
await cts.CancelAsync();
await Task.WhenAll(Quiet(run), Quiet(receive), Quiet(pump), Quiet(maintenance));
return finished == run ? "closed by the agent" : "session task failed";
}
finally
{
_session = null;
await cts.CancelAsync();
await session.StopAllAsync();
await Quiet(run);
await Quiet(receive);
await Quiet(pump);
await rpc.DisposeAsync();
}
}
private static async Task Quiet(Task task)
{
try
{
await task;
}
catch (Exception ex) when (ex is OperationCanceledException or IOException or ObjectDisposedException or System.Net.Sockets.SocketException or InvalidOperationException)
{
// 会话结束时的正常噪声
}
}
private static async Task PumpAsync(RpcSession<ConnectionSession> rpc, Outbox outbox, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
byte[] frame = await outbox.TakeNextAsync(2 << 20, ct);
await rpc.SendBinaryAsync(frame, ct);
}
}
private async Task SyncClockAsync(RpcSession<ConnectionSession> rpc, ClockEstimator clock, int rounds, CancellationToken ct)
{
for (int i = 0; i < rounds; i++)
{
long sent = _env.Clock.NowUs();
Result<ClockResult> reply = await rpc.RequestAsync(MonitorMethods.Clock, new ClockParams { ProbeMonoUs = sent }, MonitorJsonContext.Default.ClockParams, MonitorJsonContext.Default.ClockResult, ct);
if (reply.TryGet(out ClockResult? value, out _))
{
clock.Add(sent, value.AgentMonoUs, _env.Clock.NowUs());
}
}
}
private async Task RefreshDirectoryAsync(RpcSession<ConnectionSession> rpc, CancellationToken ct)
{
Result<ProbeAgentsResult> list = await rpc.RequestAsync(
MonitorMethods.ProbeAgents, new ProbeAgentsParams { HaveVersion = _env.Directory.Version }, MonitorJsonContext.Default.ProbeAgentsParams, MonitorJsonContext.Default.ProbeAgentsResult, ct);
if (list.TryGet(out ProbeAgentsResult? agents, out FleetError? error))
{
_env.Directory.Apply(agents);
}
else
{
_env.Log.Warn("could not fetch the agent list", ("agent", Endpoint.AgentId), ("err", error.Code));
}
}
/// <summary>切块(每 10 秒)和时钟同步(每 30 秒)。</summary>
private async Task MaintainAsync(RpcSession<ConnectionSession> rpc, ConnectionSession session, ClockEstimator clock, CancellationToken ct)
{
using var flush = new PeriodicTimer(s_flushInterval, _env.Time);
long lastClockUs = _env.Clock.NowUs();
while (await flush.WaitForNextTickAsync(ct))
{
session.FlushLineBlocks();
if (_env.Clock.NowUs() - lastClockUs >= s_clockInterval.TotalMicroseconds)
{
lastClockUs = _env.Clock.NowUs();
await SyncClockAsync(rpc, clock, rounds: 1, ct);
}
}
}
private static RpcRouter<ConnectionSession> BuildRouter() =>
new RpcRouter<ConnectionSession>()
.On(
FleetMethods.StateSync,
FleetRpcContext.Default.StateSyncParams,
FleetRpcContext.Default.StateSyncResult,
async (session, sync, _) =>
{
var result = new StateSyncResult { AppliedVersion = sync.Version };
if (sync.Type == LineTaskSet.StateType)
{
LineTaskSet? set = sync.Doc.Deserialize(MonitorJsonContext.Default.LineTaskSet);
if (set is null)
{
return Result<StateSyncResult>.Fail(FleetErrors.InvalidParams, "line_task_set could not be parsed");
}
result.Rejected.AddRange(await session.ApplyAsync(set));
}
return Result<StateSyncResult>.Ok(result);
})
.On(
MonitorMethods.TestPhase,
MonitorJsonContext.Default.TestPhaseStartParams,
MonitorJsonContext.Default.TestPhaseStartResult,
(session, start, _) => ValueTask.FromResult(Result<TestPhaseStartResult>.Ok(new TestPhaseStartResult { Accepted = session.StartPhase(start) })))
.On(
MonitorMethods.TestPhaseEnd,
MonitorJsonContext.Default.TestPhaseEndParams,
MonitorJsonContext.Default.TestPhaseEndResult,
async (session, end, ct) => Result<TestPhaseEndResult>.Ok(new TestPhaseEndResult { Fragments = await session.EndPhaseAsync(end, ct) }))
.OnNotify(
MonitorMethods.TestAbort,
MonitorJsonContext.Default.TestAbortParams,
async (session, abort, _) => await session.AbortAsync(abort.TestId))
.OnNotify(
MonitorMethods.ProbeAgentsChanged,
MonitorJsonContext.Default.ProbeAgentsResult,
(session, list, _) =>
{
session.Env.Directory.Apply(list);
return ValueTask.CompletedTask;
})
.OnNotify(
FleetMethods.OutboxAck,
FleetRpcContext.Default.OutboxAckParams,
(session, ack, _) =>
{
session.Outbox.Acknowledge(ack.Ids.Concat(ack.Duplicates).Select(Guid.Parse));
return ValueTask.CompletedTask;
});
}
/// <summary>一次会话内的运行状态:线路任务的积木、按需测试的阶段。方法由路由器在单个入站队列里顺序调用。</summary>
public sealed class ConnectionSession
{
private const int MaxBlocks = 16;
private readonly object _gate = new();
private readonly Dictionary<string, RunningBlock> _lineBlocks = [];
private readonly ConcurrentDictionary<uint, PhaseState> _phases = new();
public ConnectionSession(BlockEnv blockEnv, ClockEstimator clock, Outbox outbox, ConnectionEnv env)
{
BlockEnv = blockEnv;
Clock = clock;
Outbox = outbox;
Env = env;
}
public BlockEnv BlockEnv { get; }
public ClockEstimator Clock { get; }
public Outbox Outbox { get; }
public ConnectionEnv Env { get; }
public int RoleCount
{
get
{
lock (_gate)
{
return _lineBlocks.Count;
}
}
}
/// <summary>调和线路任务:参数没有变化的积木继续运行,其余启动或停止。返回被拒绝的任务(不认识的 Kind、参数非法、超过上限)。</summary>
public async Task<List<RejectedItem>> ApplyAsync(LineTaskSet set)
{
var rejected = new List<RejectedItem>();
var wanted = new Dictionary<string, BlockSpec>();
foreach (LineTask task in set.Tasks)
{
BlockSpec? spec = BlockCatalog.ProbeKinds.Contains(task.Kind)
? BlockSpec.Create(task.Kind, task.Params, task.StreamId, task.Port, new FragmentScope(task.Id, null, null))
: null;
if (spec is null)
{
rejected.Add(new RejectedItem { Id = task.Id.Value, Reason = BlockCatalog.ProbeKinds.Contains(task.Kind) ? "invalid_params" : "unsupported" });
}
else if (wanted.Count >= MaxBlocks)
{
rejected.Add(new RejectedItem { Id = task.Id.Value, Reason = "budget_exceeded" });
}
else
{
wanted[task.Id.Value] = spec;
}
}
var stopped = new List<RunningBlock>();
lock (_gate)
{
foreach ((string id, RunningBlock running) in _lineBlocks.ToList())
{
if (!wanted.TryGetValue(id, out BlockSpec? spec) || spec.Signature != running.Signature)
{
stopped.Add(running);
_lineBlocks.Remove(id);
}
}
foreach ((string id, BlockSpec spec) in wanted)
{
if (!_lineBlocks.ContainsKey(id))
{
_lineBlocks[id] = RunningBlock.Start(BlockCatalog.Find(spec.KindId)!.Dialer(BlockEnv, spec), spec.Signature);
}
}
}
// 被停止的积木把已经稳定的行交出去(刚发出、还在等回复的行不交:它们不是丢失,只是没有等到)
foreach (RunningBlock running in stopped)
{
await running.StopAsync();
running.Block.Flush(FlushMode.Settled);
}
return rejected;
}
public void FlushLineBlocks()
{
List<RunningBlock> blocks;
lock (_gate)
{
blocks = [.. _lineBlocks.Values];
}
foreach (RunningBlock running in blocks)
{
running.Block.Flush(FlushMode.Settled);
}
}
/// <summary>按需测试的一个阶段:起对应的拨号方积木(单向数据流是接收端)。超过本地上限(或不认识)则拒绝。</summary>
public bool StartPhase(TestPhaseStartParams start)
{
string? direction = start.Params.TryGetValue("direction", out System.Text.Json.JsonElement d) ? d.GetString() : null;
if (_phases.ContainsKey(start.StreamId) || !BlockCatalog.ProbeKinds.Contains(start.Kind) || BlockCatalog.Find(start.Kind)!.Supports(direction) is false)
{
return false;
}
if (start.Kind == "udp_train")
{
double mbps = start.Params["mbps"].GetDouble();
long size = start.Params["size"].GetInt64();
long pps = (long)(mbps * 1_000_000 / 8 / Math.Max(1, size));
if (mbps > Env.Limits.MaxTestMbps || pps > Env.Limits.MaxTotalPps)
{
return false;
}
}
BlockSpec? spec = BlockSpec.Create(start.Kind, start.Params, start.StreamId, start.Port, new FragmentScope(null, start.TestId, start.Phase));
if (spec is null)
{
return false;
}
_phases[start.StreamId] = new PhaseState(start.TestId, start.Kind, RunningBlock.Start(BlockCatalog.Find(start.Kind)!.Dialer(BlockEnv, spec), spec.Signature));
return true;
}
/// <summary>阶段结束:收尾、交出列块,返回块数。</summary>
public async Task<int> EndPhaseAsync(TestPhaseEndParams end, CancellationToken ct)
{
if (!_phases.TryRemove(end.StreamId, out PhaseState? phase))
{
return 0;
}
// 最后一批包(单向数据流)、最后几个回显的回复还在途中
await Task.Delay(TimeSpan.FromMilliseconds(phase.Kind == "udp_train" ? 200 : 300), BlockEnv.Time, ct);
await phase.Running.StopAsync();
return phase.Running.Block.Flush(FlushMode.All, end.SentCount);
}
public async Task AbortAsync(TestId test)
{
foreach ((uint stream, PhaseState phase) in _phases.ToList())
{
if (phase.Test == test && _phases.TryRemove(stream, out _))
{
await phase.Running.StopAsync();
}
}
}
public async Task StopAllAsync()
{
List<RunningBlock> blocks;
lock (_gate)
{
blocks = [.. _lineBlocks.Values];
_lineBlocks.Clear();
}
blocks.AddRange(_phases.Values.Select(p => p.Running));
_phases.Clear();
foreach (RunningBlock running in blocks)
{
await running.StopAsync();
}
}
private sealed record PhaseState(TestId Test, string Kind, RunningBlock Running);
}
@@ -0,0 +1,105 @@
using System.Text.Json;
using Fleet.Core;
using Fleet.Hosting;
using Monitor.Protocol;
namespace Monitor.Probe;
/// <summary>
/// Probe 要连接的 Agent 列表。Probe 不认识 Backend:列表先取自 <c>probe.json</c> 的 bootstrap(或上一次保存的 <c>agents.json</c>),
/// 连上任意一个 Agent 之后,由它代为向 Backend 查询(<c>monitor.probe.agents</c>),之后的变化由 Agent 推送(<c>agents_changed</c>)。
/// 列表变化时用 <see cref="ConfigStore"/> 写回 <c>agents.json</c>(这是配置,不是测量数据;只在变化时写,保护路由器的闪存)。
/// </summary>
public sealed class AgentDirectory
{
public const string FileName = "agents.json";
private readonly object _gate = new();
private readonly ConfigStore _store;
private readonly INodeLog _log;
private List<BootstrapAgent> _agents;
private long _version;
public AgentDirectory(ConfigStore store, string configDirectory, ProbeFile file, INodeLog log)
{
_store = store;
_log = log;
_agents = [.. file.Bootstrap];
string cache = Path.Combine(configDirectory, FileName);
if (File.Exists(cache))
{
try
{
AgentsCache saved = NodeConfig.Load(cache, ProbeConfigContext.Default.AgentsCache);
if (saved.Agents.Count > 0)
{
_agents = saved.Agents;
_version = saved.Version;
}
}
catch (Exception ex) when (ex is IOException or InvalidDataException or JsonException or InvalidOperationException)
{
_log.Warn("agents.json is unreadable, using the bootstrap list", ("err", ex.Message));
}
}
}
/// <summary>列表变化(调和连接)。</summary>
public event Action? Changed;
public long Version
{
get
{
lock (_gate)
{
return _version;
}
}
}
public IReadOnlyList<BootstrapAgent> Agents
{
get
{
lock (_gate)
{
return [.. _agents];
}
}
}
/// <summary>应用 Agent 转来的列表。版本不比当前新的忽略(乱序到达的旧推送)。</summary>
public void Apply(ProbeAgentsResult result)
{
var next = result.Agents.Select(a => new BootstrapAgent { AgentId = a.AgentId.Value, Host = a.Host, TcpPort = a.TcpPort, Spki = a.Spki }).ToList();
lock (_gate)
{
if (result.Version < _version || (result.Version == _version && Same(next, _agents)))
{
return;
}
_version = result.Version;
_agents = next;
}
try
{
_store.Write(FileName, JsonSerializer.SerializeToUtf8Bytes(new AgentsCache { Version = result.Version, Agents = next }, ProbeConfigContext.Default.AgentsCache));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_log.Warn("could not save agents.json", ("err", ex.Message));
}
_log.Info("agent list updated", ("version", result.Version), ("agents", next.Count));
Changed?.Invoke();
}
private static bool Same(List<BootstrapAgent> a, List<BootstrapAgent> b) =>
a.Count == b.Count && a.Zip(b).All(p => Equal(p.First, p.Second));
public static bool Equal(BootstrapAgent a, BootstrapAgent b) =>
a.AgentId == b.AgentId && a.Host == b.Host && a.TcpPort == b.TcpPort && a.Spki == b.Spki;
}
@@ -0,0 +1,87 @@
using System.Text.Json;
using Fleet.Core;
using Fleet.Link;
using Monitor.Measurement;
using Monitor.Protocol;
namespace Monitor.Probe;
/// <summary>
/// 估计 Agent 单调时钟 − Probe 单调时钟:每次 <c>monitor.clock</c> 往返取中点,在最近 8 次里取往返最短的一次
/// (往返越短,对称性假设的误差越小)。Probe 的列块头里带着这个偏移,Backend 据此对齐两端的时间戳。
/// </summary>
public sealed class ClockEstimator
{
private const int Window = 8;
private readonly object _gate = new();
private readonly Queue<(long RoundTripUs, long OffsetUs)> _samples = new();
public long OffsetUs { get; private set; }
public bool HasEstimate { get; private set; }
public void Add(long sendUs, long agentUs, long receiveUs)
{
lock (_gate)
{
_samples.Enqueue((receiveUs - sendUs, agentUs - ((sendUs + receiveUs) / 2)));
while (_samples.Count > Window)
{
_samples.Dequeue();
}
OffsetUs = _samples.MinBy(s => s.RoundTripUs).OffsetUs;
HasEstimate = true;
}
}
}
/// <summary>
/// 把 Probe 一侧的行交给这个 Agent:补全块头 → 编码 → 放进会话的发件箱(通道 17,至少一次,有界)。
/// 发件箱的内容在连接断开重连之后重发;发件箱满了丢弃最旧的。
/// </summary>
public sealed class FragmentShipper : IFragmentSink
{
private readonly Outbox _outbox;
private readonly NodeId _agent;
private readonly ProbeId _probe;
private readonly ClockEstimator _clock;
private readonly TimeProvider _time;
public FragmentShipper(Outbox outbox, NodeId agent, ProbeId probe, ClockEstimator clock, TimeProvider time)
{
_outbox = outbox;
_agent = agent;
_probe = probe;
_clock = clock;
_time = time;
}
public void Ship(string kind, FragmentScope scope, IReadOnlyList<RawStream> streams, IReadOnlyDictionary<string, JsonElement>? parameters, long startUtcMs, int durationMs, int port)
{
var header = new RawChunkHeader
{
ChunkId = ChunkId.New(_time),
Kind = kind,
TaskId = scope.Task,
TestId = scope.Test,
Phase = scope.Phase,
AgentId = _agent,
ProbeId = _probe,
Port = port,
StartUtcMs = startUtcMs,
DurationMs = Math.Max(1, durationMs),
Clock = new ChunkClock { ProbeOffsetUs = _clock.OffsetUs },
};
if (parameters is not null)
{
foreach ((string key, JsonElement value) in parameters)
{
header.Params[key] = value;
}
}
_outbox.Enqueue(MonitorChannels.ColumnBlock, header.ChunkId.Value, RawChunkCodec.Encode(new RawChunk(header, streams)));
}
}
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<AssemblyName>monitor-probe</AssemblyName>
<RootNamespace>Monitor.Probe</RootNamespace>
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="Monitor.Backend.Tests" />
<ProjectReference Include="..\..\..\fleet\src\Fleet.Link\Fleet.Link.csproj" />
<ProjectReference Include="..\..\..\fleet\src\Fleet.Hosting\Fleet.Hosting.csproj" />
<ProjectReference Include="..\..\..\shared\Monitor.Measurement\Monitor.Measurement.csproj" />
</ItemGroup>
</Project>
+154
View File
@@ -0,0 +1,154 @@
using Fleet.Core;
using Fleet.Hosting;
using Fleet.Identity;
using Monitor.Measurement;
namespace Monitor.Probe;
/// <summary>
/// Probe 的组装点:Agent 列表 → 每个 Agent 一个连接(认证、重连、角色)。没有全局状态,测试可以在同一进程里启动多个。
/// 列表变化时只调和连接:新增的建立,移除的断开,地址或证书指纹变化的重建,其余不受影响。
/// </summary>
public sealed class ProbeApp : IDisposable
{
public const string Version = "0.1.0";
private readonly ConnectionEnv _env;
private readonly Dictionary<string, Running> _connections = [];
private readonly SemaphoreSlim _changed = new(0);
private readonly INodeLog _log;
public ProbeApp(ProbeFile file, string configDirectory, TimeProvider time, INodeLog log)
{
_log = log;
var directory = new AgentDirectory(new ConfigStore(configDirectory), configDirectory, file, log);
NodeIdentity identity = NodeIdentity.FromPkcs8(Convert.FromBase64String(file.PrivateKey));
_env = new ConnectionEnv(identity, file.ProbeId, file.Limits, directory, new MonoClock(time), time, log);
directory.Changed += () => _changed.Release();
}
public AgentDirectory Directory => _env.Directory;
/// <summary>某个 Agent 的会话是否已经建立(测试和状态查看用)。</summary>
public bool IsConnected(string agentId)
{
lock (_connections)
{
return _connections.TryGetValue(agentId, out Running? running) && running.Connection.IsConnected;
}
}
public AgentConnection? Connection(string agentId)
{
lock (_connections)
{
return _connections.TryGetValue(agentId, out Running? running) ? running.Connection : null;
}
}
public async Task RunAsync(CancellationToken ct)
{
if (_env.Directory.Agents.Count == 0)
{
_log.Warn("no agents known yet: probe.json has an empty bootstrap list");
}
try
{
while (!ct.IsCancellationRequested)
{
await ReconcileAsync(_env.Directory.Agents, ct);
await _changed.WaitAsync(ct);
}
}
catch (OperationCanceledException)
{
// 停止
}
finally
{
await ReconcileAsync([], CancellationToken.None);
}
}
public void Dispose()
{
_env.Identity.Dispose();
_changed.Dispose();
}
private async Task ReconcileAsync(IReadOnlyList<BootstrapAgent> agents, CancellationToken ct)
{
var wanted = agents.ToDictionary(a => a.AgentId);
var stopped = new List<Running>();
lock (_connections)
{
foreach ((string id, Running running) in _connections.ToList())
{
if (!wanted.TryGetValue(id, out BootstrapAgent? endpoint) || !AgentDirectory.Equal(endpoint, running.Connection.Endpoint))
{
stopped.Add(running);
_connections.Remove(id);
}
}
}
foreach (Running running in stopped)
{
await running.StopAsync();
}
if (ct.IsCancellationRequested)
{
return;
}
lock (_connections)
{
foreach ((string id, BootstrapAgent endpoint) in wanted)
{
if (!_connections.ContainsKey(id))
{
_connections[id] = Running.Start(new AgentConnection(endpoint, _env));
}
}
}
}
private sealed class Running : IDisposable
{
private readonly CancellationTokenSource _cts = new();
private Task _run = Task.CompletedTask;
private Running(AgentConnection connection)
{
Connection = connection;
}
public AgentConnection Connection { get; }
public static Running Start(AgentConnection connection)
{
var running = new Running(connection);
running._run = Task.Run(() => connection.RunAsync(running._cts.Token), CancellationToken.None);
return running;
}
public async Task StopAsync()
{
await _cts.CancelAsync();
try
{
await _run.WaitAsync(TimeSpan.FromSeconds(10));
}
catch (Exception ex) when (ex is OperationCanceledException or TimeoutException)
{
// 没有及时退出:不再等
}
Dispose();
}
public void Dispose() => _cts.Dispose();
}
}
@@ -0,0 +1,65 @@
using System.Text.Json.Serialization;
namespace Monitor.Probe;
/// <summary>
/// <c>probe.json</c>:面板 <c>POST /api/admin/probe/identity</c> 生成的文件(只能下载一次)。
/// <c>limits</c> 是本地硬上限,面板无法修改;在路由器上建议调低,避免 Probe 自己的 CPU 成为瓶颈、反过来影响测量。
/// </summary>
public sealed class ProbeFile
{
[JsonPropertyName("probe_id")]
public string ProbeId { get; set; } = "";
/// <summary>PKCS#8,base64。</summary>
[JsonPropertyName("private_key")]
public string PrivateKey { get; set; } = "";
/// <summary>首次连接用的 Agent;连上之后以 <c>agents.json</c>(最新的列表)为准。</summary>
[JsonPropertyName("bootstrap")]
public List<BootstrapAgent> Bootstrap { get; set; } = [];
[JsonPropertyName("limits")]
public ProbeLimits Limits { get; set; } = new();
}
public sealed class BootstrapAgent
{
[JsonPropertyName("agent_id")]
public string AgentId { get; set; } = "";
[JsonPropertyName("host")]
public string Host { get; set; } = "";
[JsonPropertyName("tcp_port")]
public int TcpPort { get; set; }
/// <summary>Agent 的 TLS 证书指纹(SHA-256,base64)。</summary>
[JsonPropertyName("spki")]
public string Spki { get; set; } = "";
}
public sealed class ProbeLimits
{
/// <summary>按需测试里单个阶段允许的最大码率(Mbit/s)。</summary>
[JsonPropertyName("max_test_mbps")]
public double MaxTestMbps { get; set; } = 100;
/// <summary>按需测试里单个阶段允许的最大包速率。</summary>
[JsonPropertyName("max_total_pps")]
public long MaxTotalPps { get; set; } = 20_000;
}
[JsonSerializable(typeof(ProbeFile))]
[JsonSerializable(typeof(AgentsCache))]
public sealed partial class ProbeConfigContext : JsonSerializerContext;
/// <summary><c>agents.json</c>:最近一次从 Agent 同步到的列表(配置,不是测量数据)。</summary>
public sealed class AgentsCache
{
[JsonPropertyName("version")]
public long Version { get; set; }
[JsonPropertyName("agents")]
public List<BootstrapAgent> Agents { get; set; } = [];
}
@@ -0,0 +1,50 @@
using Fleet.Core;
using Fleet.Hosting;
using Monitor.Probe;
TimeProvider time = TimeProvider.System;
INodeLog log = new ConsoleNodeLog(time);
if (PlatformCheck.VerifyCrypto() is { } problem)
{
log.Error("startup failed", ("err", problem));
return 1;
}
string configPath = "/etc/monitor/probe.json";
for (int i = 0; i < args.Length - 1; i++)
{
if (args[i] == "--config")
{
configPath = args[i + 1];
}
}
ProbeFile file;
try
{
file = NodeConfig.Load(configPath, ProbeConfigContext.Default.ProbeFile);
if (file.ProbeId.Length == 0 || file.PrivateKey.Length == 0)
{
throw new InvalidOperationException("probe.json needs probe_id and private_key (download it from the panel: POST /api/admin/probe/identity)");
}
}
catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or System.Text.Json.JsonException)
{
log.Error("startup failed", ("err", ex.Message));
return 1;
}
string directory = Path.GetDirectoryName(Path.GetFullPath(configPath))!;
try
{
using var app = new ProbeApp(file, directory, time, log);
await ServiceShell.RunAsync(app.RunAsync, log);
}
catch (Exception ex) when (ex is FormatException or System.Security.Cryptography.CryptographicException)
{
log.Error("startup failed", ("err", "private_key is not a valid PKCS#8 key: " + ex.Message));
return 1;
}
return 0;
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" />
<ProjectReference Include="..\..\src\Monitor.Probe\Monitor.Probe.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>
@@ -0,0 +1,87 @@
using Fleet.Core;
using Fleet.Hosting;
using Monitor.Protocol;
namespace Monitor.Probe.Tests;
public sealed class ProbeTests
{
[Fact]
public void ClockEstimator_UsesTheSampleWithTheShortestRoundTrip()
{
var clock = new ClockEstimator();
Assert.False(clock.HasEstimate);
// Offset 1000 with a 50 ms round trip, offset 5000 with a 2 ms round trip (symmetry assumption is far better here)
clock.Add(sendUs: 0, agentUs: 25_000 + 1000, receiveUs: 50_000);
clock.Add(sendUs: 100_000, agentUs: 101_000 + 5000, receiveUs: 102_000);
Assert.True(clock.HasEstimate);
Assert.Equal(5000, clock.OffsetUs);
// Slow samples do not displace the good one
clock.Add(sendUs: 200_000, agentUs: 300_000, receiveUs: 400_000);
Assert.Equal(5000, clock.OffsetUs);
}
private static string NewDirectory()
{
string path = Path.Combine(Path.GetTempPath(), "probe-tests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(path);
return path;
}
private static ProbeAgentsResult List(long version, params string[] ids) => new()
{
Version = version,
Agents = [.. ids.Select(id => new AgentEndpoint { AgentId = new NodeId(id), Host = id + ".example", TcpPort = 7443, Spki = "c3BraQ==" })],
};
[Fact]
public void AgentDirectory_StartsFromBootstrap_IgnoresStaleLists_PersistsChanges_AndReloadsThem()
{
string directory = NewDirectory();
try
{
var file = new ProbeFile { ProbeId = "prb_x", Bootstrap = [new BootstrapAgent { AgentId = "agt_boot", Host = "boot.example", TcpPort = 7443, Spki = "c3BraQ==" }] };
var directoryA = new AgentDirectory(new ConfigStore(directory), directory, file, NullNodeLog.Instance);
int changes = 0;
directoryA.Changed += () => changes++;
Assert.Equal(["agt_boot"], directoryA.Agents.Select(a => a.AgentId));
directoryA.Apply(List(5, "agt_1", "agt_2"));
Assert.Equal(1, changes);
Assert.Equal(["agt_1", "agt_2"], directoryA.Agents.Select(a => a.AgentId));
directoryA.Apply(List(4, "agt_old")); // out-of-order older push
directoryA.Apply(List(5, "agt_1", "agt_2")); // same version and content
Assert.Equal(1, changes);
Assert.Equal(5, directoryA.Version);
// The next start uses the saved list, not the bootstrap one
var directoryB = new AgentDirectory(new ConfigStore(directory), directory, file, NullNodeLog.Instance);
Assert.Equal(["agt_1", "agt_2"], directoryB.Agents.Select(a => a.AgentId));
Assert.Equal(5, directoryB.Version);
}
finally
{
Directory.Delete(directory, recursive: true);
}
}
[Fact]
public void AgentDirectory_FallsBackToBootstrap_WhenTheSavedFileIsCorrupt()
{
string directory = NewDirectory();
try
{
new ConfigStore(directory).Write(AgentDirectory.FileName, "{ not json"u8.ToArray());
var file = new ProbeFile { Bootstrap = [new BootstrapAgent { AgentId = "agt_boot", Host = "h", TcpPort = 1, Spki = "AA==" }] };
var loaded = new AgentDirectory(new ConfigStore(directory), directory, file, NullNodeLog.Instance);
Assert.Equal(["agt_boot"], loaded.Agents.Select(a => a.AgentId));
}
finally
{
Directory.Delete(directory, recursive: true);
}
}
}
+3
View File
@@ -0,0 +1,3 @@
<Project>
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" />
</Project>
@@ -0,0 +1,105 @@
using System.Net;
using Monitor.Protocol;
namespace Monitor.Measurement;
/// <summary>积木的公共部分:环境、参数、把行切成列块交出去。</summary>
public abstract class BlockBase : IBlock
{
protected BlockBase(BlockEnv env, BlockSpec spec)
{
Env = env;
Spec = spec;
}
public virtual long Produced => 0;
protected BlockEnv Env { get; }
protected BlockSpec Spec { get; }
public abstract Task RunAsync(CancellationToken ct);
public virtual int Flush(FlushMode mode, long peerProduced = 0) => 0;
public virtual RowStore? Records(string streamName) => null;
protected IPEndPoint PeerEndpoint(int port) => new(Env.PeerAddress!, port);
/// <summary>交出一个流的行;起点和时长取自时间列 <paramref name="timeColumn"/> 的首尾(本侧的单调时钟,微秒)。</summary>
protected void Ship(RawStream stream, string timeColumn, IReadOnlyList<RawStream>? extra = null, IReadOnlyDictionary<string, System.Text.Json.JsonElement>? parameters = null)
{
RawColumn? time = stream.Find(timeColumn);
long first = stream.Count > 0 && time is not null ? time.Values[0] : Env.Clock.NowUs();
long last = stream.Count > 0 && time is not null ? time.Values[stream.Count - 1] : first;
List<RawStream> streams = [stream];
if (extra is not null)
{
streams.AddRange(extra);
}
Env.Fragments!.Ship(Spec.KindId, Spec.Scope, streams, parameters ?? Spec.Params.Values, Env.Clock.UtcMsAt(first), (int)((last - first) / 1000), Spec.Port);
}
/// <summary>什么也不做、一直等到取消(监听方里不需要自己运行逻辑的积木)。</summary>
protected async Task WaitUntilCancelledAsync(CancellationToken ct)
{
try
{
await Task.Delay(Timeout.InfiniteTimeSpan, Env.Time, ct);
}
catch (OperationCanceledException)
{
// 被停止
}
}
}
/// <summary>
/// 拨号方作为行的主导方时记录的行:行按键(序号)追加,回复到达时按键回填列,到点把已经稳定的行从游标处切下来交出去。
/// 环形缓冲被覆盖的行(交出太慢)直接跳过。
/// </summary>
internal sealed class DriverLog
{
private readonly object _gate = new();
private long _cursor;
public DriverLog(StreamSpec spec, Side driver, int capacity)
{
Store = new RowStore(spec, driver, capacity);
Spec = spec;
}
public RowStore Store { get; }
public StreamSpec Spec { get; }
public long Count => Store.NextKey;
/// <summary>取出游标之后已经稳定的行:<paramref name="settled"/> 为 null 或 <paramref name="all"/> 时取全部。</summary>
public RawStream? Take(Func<long, bool>? settled, bool all, int maxRows = int.MaxValue)
{
long from;
long to;
lock (_gate)
{
from = _cursor;
to = from;
long end = Store.NextKey;
while (to < end && to - from < maxRows && (all || settled is null || !Store.Contains(to) || settled(to)))
{
to++;
}
if (to == from)
{
return null;
}
_cursor = to;
}
RawStream stream = Store.Cut(from, to, includeKey: Spec.Key is not null);
return stream.Count == 0 ? null : stream;
}
}
@@ -0,0 +1,45 @@
using Monitor.Protocol;
namespace Monitor.Measurement;
/// <summary>
/// 一种采集方法(Kind)的两个角色:拨号方(Probe)和监听方(Agent),以及它能不能作为目标任务(Agent 自己探测一个地址,没有对端)运行。
/// 积木只按机制命名、只产生原始列,不知道上层的方案或协议。
/// </summary>
public sealed record BlockPair(
string Kind,
Func<BlockEnv, BlockSpec, IBlock> Dialer,
Func<BlockEnv, BlockSpec, IBlock>? Listener,
bool RunsAsTarget = false,
bool OnDemandOnly = false,
IReadOnlyList<string>? Directions = null)
{
/// <summary>这个 Kind 支持 <paramref name="direction"/> 吗(没有 <c>direction</c> 参数的 Kind 传 null)。</summary>
public bool Supports(string? direction) => Directions is null || direction is null || Directions.Contains(direction);
}
/// <summary>
/// 积木目录:新增一种采集方法 = 在 <see cref="Kinds"/> 里加一条声明,在这里加一行,实现它的两个角色。Probe 和 Agent 同时获得。
/// hello 里上报的 capabilities、本地预算检查的支持列表都从这里推出来,不再手写。
/// </summary>
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("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"]),
new("connect", (e, s) => new ConnectDialer(e, s), (e, s) => new IdleListener(e, s), RunsAsTarget: true),
new("hold", (e, s) => new HoldDialer(e, s), (e, s) => new TcpEchoListener(e, s, record: false)),
new("icmp_echo", (e, s) => new IcmpEchoSolo(e, s), null, RunsAsTarget: true),
];
public static BlockPair? Find(string kind) => All.FirstOrDefault(p => p.Kind == kind);
/// <summary>这个构建的 Agent 能运行的 Kind(监听方,或目标任务)。</summary>
public static IReadOnlyList<string> AgentKinds { get; } = [.. All.Where(p => p.Listener is not null || p.RunsAsTarget).Select(p => p.Kind)];
/// <summary>这个构建的 Probe 能运行的 Kind(有监听方可以配对的拨号方)。</summary>
public static IReadOnlyList<string> ProbeKinds { get; } = [.. All.Where(p => p.Listener is not null).Select(p => p.Kind)];
}
@@ -0,0 +1,277 @@
using System.Net.Sockets;
using Monitor.Protocol;
namespace Monitor.Measurement;
/// <summary>批量传输的收发循环(两端共用)。</summary>
internal static class BulkIo
{
private const int ChunkBytes = 16 * 1024;
/// <summary>
/// 发送:尽可能快,但不超过 <paramref name="mbps"/>(本地硬上限,防止把小机器的 CPU 和链路打满)。
/// 发送的数据是随机字节(避免被链路上的压缩或重复数据消除"优化"掉)。
/// </summary>
public static async Task SendAsync(Stream stream, double mbps, Action<int> onBytes, TimeProvider time, CancellationToken ct)
{
byte[] buffer = new byte[ChunkBytes];
Random.Shared.NextBytes(buffer);
double bytesPerSecond = mbps * 1_000_000 / 8;
long start = time.GetTimestamp();
long sent = 0;
while (!ct.IsCancellationRequested)
{
await stream.WriteAsync(buffer, ct);
sent += buffer.Length;
onBytes(buffer.Length);
if (bytesPerSecond > 0)
{
double ahead = (sent / bytesPerSecond) - time.GetElapsedTime(start).TotalSeconds;
if (ahead > 0.002)
{
await Task.Delay(TimeSpan.FromSeconds(Math.Min(ahead, 0.05)), time, ct);
}
}
}
}
/// <summary>接收并丢弃,直到对端关闭或取消。</summary>
public static async Task ReceiveAsync(Stream stream, Action<int> onBytes, CancellationToken ct)
{
byte[] buffer = new byte[4 * ChunkBytes];
while (!ct.IsCancellationRequested)
{
int read = await stream.ReadAsync(buffer, ct);
if (read <= 0)
{
return;
}
onBytes(read);
}
}
public static bool IsExpected(Exception ex) =>
ex is OperationCanceledException or IOException or SocketException or ObjectDisposedException or System.Security.Authentication.AuthenticationException or InvalidOperationException;
}
/// <summary>
/// 批量传输,拨号方(Probe):开 <c>streams</c> 条 TCP 连接(可选 TLS),按 <c>direction</c> 发送(fwd)或接收(rev),持续 <c>duration_s</c>。
/// 记录每个采样间隔每条连接的<b>累计字节数</b>(<c>bytes</c> 流,Probe 的时钟),Backend 据此求每个间隔的吞吐;
/// 同时采样第一条连接自己这一侧的内核 <c>tcp_info</c>(多条连接的累计计数器不能混在同一个序列里求差)。
/// 发送速率不超过本地上限 <see cref="BlockEnv.MaxBulkMbps"/>。
/// </summary>
public sealed class TcpBulkDialer : BlockBase
{
private const int MaxRowsPerFragment = 40_000;
private readonly DriverLog _log;
private readonly TcpInfoRecorder _info;
private readonly int _ts;
private readonly int _conn;
private readonly int _bytes;
private long[] _counters = [];
public TcpBulkDialer(BlockEnv env, BlockSpec spec)
: base(env, spec)
{
long expectedRows = spec.Int("streams") * spec.Int("duration_s") * Math.Max(1, 1000 / Math.Max(1, spec.Int("sample_ms")));
_log = new DriverLog(spec.Kind.FindStream("bytes")!, Side.Probe, (int)Math.Clamp(expectedRows + 256, 1024, 262_144));
_info = new TcpInfoRecorder(spec.Kind.FindStream("tcp_info")!, Side.Probe, env.Clock);
_ts = _log.Store.ColumnIndex("ts");
_conn = _log.Store.ColumnIndex("conn");
_bytes = _log.Store.ColumnIndex("bytes");
}
public override long Produced => _log.Count;
public override async Task RunAsync(CancellationToken ct)
{
int streams = (int)Spec.Int("streams");
bool send = Spec.Params.GetString("direction") == "fwd";
TimeSpan duration = TimeSpan.FromSeconds(Spec.Int("duration_s"));
TimeSpan sampleEvery = TimeSpan.FromMilliseconds(Spec.Int("sample_ms"));
_counters = new long[streams];
var links = new TcpLink?[streams];
try
{
await Task.WhenAll(Enumerable.Range(0, streams).Select(async i =>
{
try
{
links[i] = await TcpLink.OpenAsync(Env, Spec.Port, Spec.Bool("tls"), Spec.StreamId, sendAuth: true, ct);
}
catch (Exception ex) when (BulkIo.IsExpected(ex))
{
// 这条连接没有建起来:它的字节数一直是 0
}
}));
using var timeout = new CancellationTokenSource(duration, Env.Time);
using var run = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
var work = new List<Task>();
double perStream = Env.MaxBulkMbps / streams;
for (int i = 0; i < streams; i++)
{
if (links[i] is not { } link)
{
continue;
}
int index = i;
work.Add(RunConnectionAsync(link.Stream, index, send, perStream, run.Token));
}
if (links.FirstOrDefault(l => l is not null) is { } first)
{
work.Add(_info.RunAsync(first.Socket, sampleEvery, run.Token));
}
Task sampler = SampleAsync(sampleEvery, run.Token);
await Task.WhenAll(work);
await run.CancelAsync();
await sampler;
Sample(); // 最后一个采样点
}
finally
{
foreach (TcpLink? link in links)
{
link?.Dispose();
}
}
}
public override int Flush(FlushMode mode, long peerProduced = 0)
{
int fragments = 0;
while (_log.Take(null, all: true, MaxRowsPerFragment) is { } stream)
{
RawStream kernel = _info.Rows.Drain(includeKey: false);
Ship(stream, "ts", kernel.Count > 0 ? [kernel] : null);
fragments++;
}
if (fragments == 0 && _info.Rows.Drain(includeKey: false) is { Count: > 0 } onlyKernel)
{
Ship(onlyKernel, "ts");
fragments++;
}
return fragments;
}
private async Task RunConnectionAsync(Stream stream, int index, bool send, double mbps, CancellationToken ct)
{
try
{
if (send)
{
await BulkIo.SendAsync(stream, mbps, n => Interlocked.Add(ref _counters[index], n), Env.Time, ct);
}
else
{
await BulkIo.ReceiveAsync(stream, n => Interlocked.Add(ref _counters[index], n), ct);
}
}
catch (Exception ex) when (BulkIo.IsExpected(ex))
{
// 时间到或者连接断开:计数停在这里
}
}
private async Task SampleAsync(TimeSpan every, CancellationToken ct)
{
using var timer = new PeriodicTimer(every, Env.Time);
try
{
while (await timer.WaitForNextTickAsync(ct))
{
Sample();
}
}
catch (OperationCanceledException)
{
// 阶段结束
}
}
private void Sample()
{
long now = Env.Clock.NowUs();
for (int i = 0; i < _counters.Length; i++)
{
long key = _log.Store.Append();
_log.Store.Set(key, _ts, now);
_log.Store.Set(key, _conn, i);
_log.Store.Set(key, _bytes, Interlocked.Read(ref _counters[i]));
}
}
}
/// <summary>
/// 批量传输,监听方(Agent):接受连接,<c>rev</c> 时向 Probe 发送(受本地码率上限约束),<c>fwd</c> 时接收并丢弃;
/// 采样第一条连接自己这一侧的内核 <c>tcp_info</c>(Agent 的重传反映下行方向,代理场景里大流量走这个方向)。
/// 连接在 <c>duration_s</c> 之后(加几秒余量)或阶段结束时关闭。
/// </summary>
public sealed class TcpBulkListener : BlockBase
{
private readonly TcpInfoRecorder _info;
private CancellationToken _stop;
private int _sampled;
public TcpBulkListener(BlockEnv env, BlockSpec spec)
: base(env, spec)
{
_info = new TcpInfoRecorder(spec.Kind.FindStream("tcp_info")!, Side.Agent, env.Clock);
}
public override RowStore? Records(string streamName) => streamName == "tcp_info" ? _info.Rows : null;
public override async Task RunAsync(CancellationToken ct)
{
_stop = ct;
Env.TcpListen!.Register(Spec.StreamId, HandleAsync);
try
{
await WaitUntilCancelledAsync(ct);
}
finally
{
Env.TcpListen.Unregister(Spec.StreamId);
}
}
private async Task HandleAsync(TcpConnection connection, CancellationToken acceptorToken)
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(Spec.Int("duration_s") + 3), Env.Time);
using var linked = CancellationTokenSource.CreateLinkedTokenSource(acceptorToken, _stop, timeout.Token);
Task sampler = Task.CompletedTask;
if (Interlocked.CompareExchange(ref _sampled, 1, 0) == 0)
{
sampler = _info.RunAsync(connection.Socket, TimeSpan.FromMilliseconds(Spec.Int("sample_ms")), linked.Token);
}
try
{
if (Spec.Params.GetString("direction") == "rev")
{
await BulkIo.SendAsync(connection.Stream, Env.MaxBulkMbps / Math.Max(1, Spec.Int("streams")), _ => Env.Counters.PacketOut(), Env.Time, linked.Token);
}
else
{
await BulkIo.ReceiveAsync(connection.Stream, _ => Env.Counters.PacketIn(), linked.Token);
}
}
catch (Exception ex) when (BulkIo.IsExpected(ex))
{
// 时间到、阶段结束或对端关闭
}
finally
{
await linked.CancelAsync();
await sampler;
}
}
}
@@ -0,0 +1,383 @@
using System.Net;
using System.Net.Sockets;
using Monitor.Protocol;
namespace Monitor.Measurement;
/// <summary>
/// 回显积木的行表(拨号方,行的主导方):发出时记 size、t1,回复到达时按序号回填 t4,到点把已经稳定的行切成列块。
/// UDP 回显和 TCP 回显共用,列由 Kind 的 <c>msg</c> 流声明决定。
/// </summary>
internal sealed class EchoRows
{
/// <summary>UDP:没有在这段时间内回来的包就是丢了。</summary>
public const long UdpGraceUs = 2_000_000;
/// <summary>
/// TCP 不会丢包,只会延迟(重传、队头阻塞):一次几秒的中断之后,积压的回显会陆续回来,RTT 就是那几秒。
/// 如果像 UDP 那样等 2 秒就把没有回复的行当作丢失交出去,最严重的长尾反而成了"丢包",RTT 里一点也看不到。
/// 所以 TCP 的行要等回复(或者连接真的断了——30 秒之后仍然没有回复才交出,t4 缺失)。
/// </summary>
public const long TcpGraceUs = 30_000_000;
private readonly DriverLog _log;
private readonly int _size;
private readonly int _t1;
private readonly int _t4;
private readonly long _graceUs;
private readonly bool _settleOnReply;
public EchoRows(KindSpec kind, int capacity, long graceUs, bool settleOnReply)
{
_graceUs = graceUs;
_settleOnReply = settleOnReply;
StreamSpec spec = kind.FindStream("msg")!;
_log = new DriverLog(spec, Side.Probe, capacity);
_size = _log.Store.ColumnIndex("size");
_t1 = _log.Store.ColumnIndex("t1");
_t4 = _log.Store.ColumnIndex("t4");
}
public long Count => _log.Count;
public uint Add(int size, long t1Us)
{
long key = _log.Store.Append();
_log.Store.Set(key, _size, size);
_log.Store.Set(key, _t1, t1Us);
return (uint)key;
}
public void Reply(uint seq, long t4Us)
{
if (_log.Store.Contains(seq) && !_log.Store.IsSet(seq, _t4))
{
_log.Store.Set(seq, _t4, t4Us);
}
}
public RawStream? Take(long nowUs, bool all) =>
_log.Take(
key => (_settleOnReply && _log.Store.IsSet(key, _t4)) || (_log.Store.TryGet(key, _t1, out long t1) && t1 <= nowUs - _graceUs),
all);
}
/// <summary>
/// UDP 回显,拨号方(Probe):按声明的速率发包,t1 在发出时记录,回复到达时回填 t4。回复丢失的行 t4 缺失(Agent 一侧再补 t2/t3)。
/// </summary>
public sealed class UdpEchoDialer : BlockBase
{
private readonly EchoRows _rows;
public UdpEchoDialer(BlockEnv env, BlockSpec spec)
: base(env, spec)
{
_rows = new EchoRows(spec.Kind, 16384, EchoRows.UdpGraceUs, settleOnReply: false);
}
public override long Produced => _rows.Count;
public override async Task RunAsync(CancellationToken ct)
{
UdpMux udp = Env.Udp!;
DataSession session = Env.Session() ?? throw new BlockFaultException("no data session");
IPEndPoint target = PeerEndpoint(Spec.Port);
int size = (int)Spec.Int("size");
byte[] packet = new byte[size];
udp.Register(Spec.StreamId, OnPacket);
try
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1.0 / Spec.Int("rate_hz")), Env.Time);
while (await timer.WaitForNextTickAsync(ct))
{
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);
}
}
catch (OperationCanceledException)
{
// 积木被停止
}
finally
{
udp.Unregister(Spec.StreamId);
}
}
public override int Flush(FlushMode mode, long peerProduced = 0)
{
RawStream? stream = _rows.Take(Env.Clock.NowUs(), mode == FlushMode.All);
if (stream is null)
{
return 0;
}
Ship(stream, "t1");
return 1;
}
private void OnPacket(in UdpDelivery delivery)
{
if (delivery.Header.Type == UdpPacketType.EchoReply)
{
_rows.Reply(delivery.Header.Seq, delivery.ReceiveUs);
}
}
}
/// <summary>UDP 回显,监听方(Agent):校验后立即回复一个等长的包,记录 t2(到达)和 t3(回复发出),按序号留给合并。</summary>
public sealed class UdpEchoListener : BlockBase
{
private readonly RowStore _records;
public UdpEchoListener(BlockEnv env, BlockSpec spec)
: base(env, spec)
{
_records = new RowStore(spec.Kind.FindStream("msg")!, Side.Agent, 32768);
}
public override RowStore? Records(string streamName) => streamName == "msg" ? _records : null;
public override async Task RunAsync(CancellationToken ct)
{
Env.Udp!.Register(Spec.StreamId, OnPacket);
try
{
await WaitUntilCancelledAsync(ct);
}
finally
{
Env.Udp.Unregister(Spec.StreamId);
}
}
private void OnPacket(in UdpDelivery delivery)
{
if (delivery.Header.Type != UdpPacketType.EchoRequest)
{
return; // Data:探活包,只用来保持 NAT 映射和让准入策略学到源地址
}
long t2 = delivery.ReceiveUs;
delivery.ReplyAs(UdpPacketType.EchoReply);
Span<long> row = [t2, Env.Clock.NowUs()];
_records.SetRow(delivery.Header.Seq, row);
}
}
/// <summary>
/// TCP 回显,拨号方:一条常驻连接(可选 TLS)上按速率发小消息,回复按序到达。连接断开期间不发消息(没有连接不是丢包),
/// 之后按退避重连;断开和重连本身由 connect/hold 记录。
/// </summary>
public sealed class TcpEchoDialer : BlockBase
{
private readonly EchoRows _rows;
private readonly TcpInfoRecorder _info;
public TcpEchoDialer(BlockEnv env, BlockSpec spec)
: base(env, spec)
{
_rows = new EchoRows(spec.Kind, 16384, EchoRows.TcpGraceUs, settleOnReply: true);
_info = new TcpInfoRecorder(spec.Kind.FindStream("tcp_info")!, Side.Probe, env.Clock);
}
public override long Produced => _rows.Count;
public override async Task RunAsync(CancellationToken ct)
{
var backoff = new Fleet.Core.Backoff(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(30));
while (!ct.IsCancellationRequested)
{
try
{
using TcpLink link = await TcpLink.OpenAsync(Env, Spec.Port, Spec.Bool("tls"), Spec.StreamId, sendAuth: true, ct);
backoff.Reset();
await ExchangeAsync(link, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
return;
}
catch (Exception ex) when (ex is SocketException or IOException or System.Security.Authentication.AuthenticationException or OperationCanceledException or InvalidDataException or InvalidOperationException)
{
// 连接失败或断开:退避之后重连
}
try
{
await Task.Delay(backoff.Next(), Env.Time, ct);
}
catch (OperationCanceledException)
{
return;
}
}
}
public override int Flush(FlushMode mode, long peerProduced = 0)
{
RawStream? stream = _rows.Take(Env.Clock.NowUs(), mode == FlushMode.All);
RawStream kernel = _info.Rows.Drain(includeKey: false);
if (stream is null && kernel.Count == 0)
{
return 0;
}
// 内核采样(tcp_info)和回显行一起交出;只有采样没有回显行(例如连接刚断开)时单独交出
if (stream is null)
{
Ship(kernel, "ts");
}
else
{
Ship(stream, "t1", kernel.Count > 0 ? [kernel] : null);
}
return 1;
}
private async Task ExchangeAsync(TcpLink link, CancellationToken ct)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
Task reader = ReadRepliesAsync(link.Stream, cts.Token);
Task writer = WriteRequestsAsync(link.Stream, cts.Token);
Task sampler = _info.RunAsync(link.Socket, TimeSpan.FromMilliseconds(Spec.Int("tcp_info_ms")), cts.Token);
await Task.WhenAny(reader, writer);
await cts.CancelAsync();
foreach (Task task in new[] { reader, writer, sampler })
{
try
{
await task;
}
catch (Exception ex) when (ex is OperationCanceledException or IOException or SocketException or ObjectDisposedException)
{
// 连接已经断开
}
}
}
private async Task WriteRequestsAsync(Stream stream, CancellationToken ct)
{
int min = (int)Spec.Int("min");
int max = (int)Spec.Int("max");
byte[] body = new byte[Math.Max(max, min)];
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1.0 / Spec.Int("rate_hz")), Env.Time);
while (await timer.WaitForNextTickAsync(ct))
{
int size = Math.Max(TcpWire.MinEchoBody, max > min ? Random.Shared.Next(min, max + 1) : min);
long t1 = Env.Clock.NowUs();
uint seq = _rows.Add(size, t1);
body.AsSpan(0, size).Clear();
TcpWire.WriteEchoBody(body, seq, t1);
await TcpWire.WriteMessageAsync(stream, body.AsMemory(0, size), ct);
await stream.FlushAsync(ct);
}
}
private async Task ReadRepliesAsync(Stream stream, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
byte[]? reply = await TcpWire.ReadMessageAsync(stream, ct);
if (reply is null)
{
return; // 对端关闭
}
long now = Env.Clock.NowUs();
if (TcpWire.TryReadEchoBody(reply, out uint seq, out _))
{
_rows.Reply(seq, now);
}
}
}
}
/// <summary>TCP 回显,监听方:逐条回显并记录 t2(读到)和 t3(回复发出)。保持(hold)的监听方也用它,只是不记录。</summary>
public sealed class TcpEchoListener : BlockBase
{
private readonly RowStore? _records;
private readonly TcpInfoRecorder? _info;
public TcpEchoListener(BlockEnv env, BlockSpec spec, bool record = true)
: base(env, spec)
{
_records = record ? new RowStore(spec.Kind.FindStream("msg")!, Side.Agent, 32768) : null;
_info = record ? new TcpInfoRecorder(spec.Kind.FindStream("tcp_info")!, Side.Agent, env.Clock) : null;
}
public override RowStore? Records(string streamName) => streamName switch
{
"msg" => _records,
"tcp_info" => _info?.Rows,
_ => null,
};
public override async Task RunAsync(CancellationToken ct)
{
Env.TcpListen!.Register(Spec.StreamId, EchoAsync);
try
{
await WaitUntilCancelledAsync(ct);
}
finally
{
Env.TcpListen.Unregister(Spec.StreamId);
}
}
private async Task EchoAsync(TcpConnection connection, CancellationToken ct)
{
// 这条连接上 Agent 一侧套接字的内核采样(下行方向的重传、cwnd、srtt)
using var samplerCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
Task sampler = _info is null ? Task.CompletedTask : _info.RunAsync(connection.Socket, TimeSpan.FromMilliseconds(Spec.Int("tcp_info_ms")), samplerCts.Token);
try
{
await EchoLoopAsync(connection, ct);
}
finally
{
await samplerCts.CancelAsync();
await sampler;
}
}
private async Task EchoLoopAsync(TcpConnection connection, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
byte[]? body = await TcpWire.ReadMessageAsync(connection.Stream, ct);
if (body is null)
{
return;
}
long t2 = Env.Clock.NowUs();
Env.Counters.PacketIn();
if (!TcpWire.TryReadEchoBody(body, out uint seq, out _))
{
Env.Counters.DropInvalid();
return; // 协议错乱:关闭连接
}
if (Env.Admission is { } admission && !admission.AdmitMessage(connection.Session, connection.StreamId, t2))
{
Env.Counters.DropRateLimited();
continue;
}
long t3 = Env.Clock.NowUs();
await TcpWire.WriteMessageAsync(connection.Stream, body, ct);
await connection.Stream.FlushAsync(ct);
Env.Counters.PacketOut();
if (_records is not null)
{
Span<long> row = [t2, t3];
_records.SetRow(seq, row);
}
}
}
}
@@ -0,0 +1,362 @@
using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Security.Authentication;
using Monitor.Protocol;
namespace Monitor.Measurement;
/// <summary>什么也不做的监听方:connect 的另一端就是 TCP 监听器本身(接受连接、认证失败就关闭),不需要积木,但需要占一个流编号。</summary>
public sealed class IdleListener : BlockBase
{
public IdleListener(BlockEnv env, BlockSpec spec)
: base(env, spec)
{
}
public override Task RunAsync(CancellationToken ct) => WaitUntilCancelledAsync(ct);
}
/// <summary>
/// 反复建立连接(TCP,可选 TLS)并记录每个阶段的耗时和结果。结果编码与 Backend 的 <c>ConnectOutcome</c> 一致:
/// 0 成功、1 超时、2 被重置或拒绝、3 TLS 告警、4 其他。拨号方是 Probe 时对端是 Agent(按指纹固定证书,建好连接即关闭);
/// 作为目标任务在 Agent 上运行时对端是任意地址(不校验证书,只测握手耗时)。
/// </summary>
public sealed class ConnectDialer : BlockBase
{
private readonly DriverLog _log;
private readonly int _attempt;
private readonly int _ts;
private readonly int _tcp;
private readonly int _tls;
private readonly int _outcome;
public ConnectDialer(BlockEnv env, BlockSpec spec)
: base(env, spec)
{
_log = new DriverLog(spec.Kind.FindStream("conn")!, Side.Probe, 1024);
_attempt = _log.Store.ColumnIndex("attempt");
_ts = _log.Store.ColumnIndex("ts");
_tcp = _log.Store.ColumnIndex("tcp_us");
_tls = _log.Store.ColumnIndex("tls_us");
_outcome = _log.Store.ColumnIndex("outcome");
}
public override long Produced => _log.Count;
public override async Task RunAsync(CancellationToken ct)
{
TimeSpan interval = TimeSpan.FromSeconds(60.0 / Spec.Int("per_minute"));
TimeSpan timeout = TimeSpan.FromMilliseconds(Spec.Int("timeout_ms"));
bool tls = Spec.Bool("tls");
try
{
while (!ct.IsCancellationRequested)
{
long start = Env.Clock.NowUs();
await AttemptAsync(tls, timeout, start, ct);
TimeSpan wait = interval - TimeSpan.FromMicroseconds(Env.Clock.NowUs() - start);
if (wait > TimeSpan.Zero)
{
await Task.Delay(wait, Env.Time, ct);
}
}
}
catch (OperationCanceledException)
{
// 积木被停止
}
}
public override int Flush(FlushMode mode, long peerProduced = 0)
{
RawStream? stream = _log.Take(null, all: true);
if (stream is null)
{
return 0;
}
Ship(stream, "ts");
return 1;
}
private async Task AttemptAsync(bool tls, TimeSpan timeout, long startUs, CancellationToken ct)
{
using var timeoutCts = new CancellationTokenSource(timeout, Env.Time);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token);
long tcpUs = 0;
long? tlsUs = null;
int outcome;
try
{
// 对端不需要认证首帧:只测量建立连接
using TcpLink link = await TcpLink.OpenAsync(Env, Spec.Port, tls, Spec.StreamId, sendAuth: false, linked.Token);
tcpUs = link.TcpUs;
tlsUs = link.TlsUs;
outcome = 0;
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
outcome = 1;
}
catch (SocketException ex) when (ex.SocketErrorCode is SocketError.ConnectionRefused or SocketError.ConnectionReset)
{
outcome = 2;
}
catch (Exception ex) when (ex is AuthenticationException || ex.InnerException is AuthenticationException)
{
outcome = 3;
}
catch (Exception ex) when (ex is SocketException or IOException)
{
outcome = ex is IOException { InnerException: SocketException { SocketErrorCode: SocketError.ConnectionReset } } ? 2 : 4;
}
// 失败时 tcp_us/tls_us 缺失;成功时没有 TLS 则 tls_us 缺失
long key = _log.Store.Append();
_log.Store.Set(key, _attempt, key);
_log.Store.Set(key, _ts, startUs);
_log.Store.Set(key, _outcome, outcome);
if (outcome == 0)
{
_log.Store.Set(key, _tcp, tcpUs);
if (tlsUs is { } t)
{
_log.Store.Set(key, _tls, t);
}
}
}
}
/// <summary>
/// 保持,拨号方:一条长时间保持的 TLS 连接,每隔 <c>heartbeat_s</c> 发一个心跳并等回复。记录打开、心跳、重置(连接意外中断)和关闭,
/// 每一行带着连接已经存活的时间。连接中断之后按退避重连。监听方是不记录的 <see cref="TcpEchoListener"/>。
/// </summary>
public sealed class HoldDialer : BlockBase
{
private const int Open = 0;
private const int Heartbeat = 1;
private const int Reset = 2;
private const int Close = 3;
private readonly DriverLog _log;
private readonly int _ts;
private readonly int _event;
private readonly int _lifetime;
public HoldDialer(BlockEnv env, BlockSpec spec)
: base(env, spec)
{
_log = new DriverLog(spec.Kind.FindStream("hold")!, Side.Probe, 1024);
_ts = _log.Store.ColumnIndex("ts");
_event = _log.Store.ColumnIndex("event");
_lifetime = _log.Store.ColumnIndex("lifetime_ms");
}
public override long Produced => _log.Count;
public override async Task RunAsync(CancellationToken ct)
{
var backoff = new Fleet.Core.Backoff(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(30));
while (!ct.IsCancellationRequested)
{
long openedUs = 0;
bool opened = false;
try
{
using TcpLink link = await TcpLink.OpenAsync(Env, Spec.Port, tls: true, Spec.StreamId, sendAuth: true, ct);
openedUs = Env.Clock.NowUs();
opened = true;
backoff.Reset();
Record(Open, openedUs, openedUs);
await HoldAsync(link.Stream, openedUs, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
if (opened)
{
Record(Close, Env.Clock.NowUs(), openedUs);
}
return;
}
catch (Exception ex) when (ex is SocketException or IOException or AuthenticationException or OperationCanceledException or InvalidDataException or InvalidOperationException)
{
if (opened)
{
Record(Reset, Env.Clock.NowUs(), openedUs);
}
}
try
{
await Task.Delay(backoff.Next(), Env.Time, ct);
}
catch (OperationCanceledException)
{
return;
}
}
}
public override int Flush(FlushMode mode, long peerProduced = 0)
{
RawStream? stream = _log.Take(null, all: true);
if (stream is null)
{
return 0;
}
Ship(stream, "ts");
return 1;
}
private async Task HoldAsync(Stream stream, long openedUs, CancellationToken ct)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
Task reader = ReadAsync(stream, openedUs, cts.Token);
Task writer = WriteAsync(stream, cts.Token);
Task finished = await Task.WhenAny(reader, writer);
await cts.CancelAsync();
try
{
await finished; // 连接中断时这里抛出,由调用方记录 Reset
if (!ct.IsCancellationRequested)
{
throw new IOException("the peer closed the connection");
}
}
finally
{
foreach (Task task in new[] { reader, writer })
{
try
{
await task;
}
catch (Exception ex) when (ex is OperationCanceledException or IOException or SocketException or ObjectDisposedException)
{
// 已经处理过
}
}
}
}
private async Task WriteAsync(Stream stream, CancellationToken ct)
{
byte[] body = new byte[TcpWire.MinEchoBody];
uint seq = 0;
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(Spec.Int("heartbeat_s")), Env.Time);
while (await timer.WaitForNextTickAsync(ct))
{
TcpWire.WriteEchoBody(body, seq++, Env.Clock.NowUs());
await TcpWire.WriteMessageAsync(stream, body, ct);
await stream.FlushAsync(ct);
}
}
private async Task ReadAsync(Stream stream, long openedUs, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
byte[]? reply = await TcpWire.ReadMessageAsync(stream, ct);
if (reply is null)
{
return;
}
Record(Heartbeat, Env.Clock.NowUs(), openedUs);
}
}
private void Record(int eventCode, long nowUs, long openedUs)
{
long key = _log.Store.Append();
_log.Store.Set(key, _ts, nowUs);
_log.Store.Set(key, _event, eventCode);
_log.Store.Set(key, _lifetime, Math.Max(0, (nowUs - openedUs) / 1000));
}
}
/// <summary>
/// ICMP 回显(只有一侧:Agent 对目标地址):按速率发 ICMP 并记录发出和收到的时刻(本地时钟);超时没有回复的行 t4 缺失。
/// 没有对端,所以没有监听方;行交给 <see cref="BlockEnv.Fragments"/>(Agent 自己盖章上传)。
/// </summary>
public sealed class IcmpEchoSolo : BlockBase
{
private readonly DriverLog _log;
private readonly int _seq;
private readonly int _size;
private readonly int _t1;
private readonly int _t4;
public IcmpEchoSolo(BlockEnv env, BlockSpec spec)
: base(env, spec)
{
_log = new DriverLog(spec.Kind.FindStream("msg")!, Side.Probe, 1024);
_seq = _log.Store.ColumnIndex("seq");
_size = _log.Store.ColumnIndex("size");
_t1 = _log.Store.ColumnIndex("t1");
_t4 = _log.Store.ColumnIndex("t4");
}
public override long Produced => _log.Count;
public override async Task RunAsync(CancellationToken ct)
{
TimeSpan interval = TimeSpan.FromSeconds(1.0 / Spec.Int("rate_hz"));
int size = (int)Spec.Int("size");
byte[] payload = new byte[Math.Max(0, size - 28)];
using var ping = new Ping();
try
{
while (!ct.IsCancellationRequested)
{
long t1 = Env.Clock.NowUs();
long? t4 = null;
try
{
PingReply reply = await ping.SendPingAsync(Env.PeerAddress!, TimeSpan.FromSeconds(2), payload, null, ct);
if (reply.Status == IPStatus.Success)
{
t4 = Env.Clock.NowUs();
}
}
catch (PingException)
{
// 发送失败(例如本机没有路由):记为丢失
}
long key = _log.Store.Append();
_log.Store.Set(key, _seq, key);
_log.Store.Set(key, _size, size);
_log.Store.Set(key, _t1, t1);
if (t4 is { } received)
{
_log.Store.Set(key, _t4, received);
}
TimeSpan wait = interval - TimeSpan.FromMicroseconds(Env.Clock.NowUs() - t1);
if (wait > TimeSpan.Zero)
{
await Task.Delay(wait, Env.Time, ct);
}
}
}
catch (OperationCanceledException)
{
// 积木被停止
}
}
public override int Flush(FlushMode mode, long peerProduced = 0)
{
RawStream? stream = _log.Take(null, all: true);
if (stream is null)
{
return 0;
}
Ship(stream, "t1");
return 1;
}
}
@@ -0,0 +1,239 @@
using System.Diagnostics;
using System.Net;
using Monitor.Protocol;
namespace Monitor.Measurement;
/// <summary>
/// 单向数据流(UDP train),监听方(Agent,行的主导方):按声明的码率均匀发包,发出的每个包 (seq, size, t_send) 记在按序号的记录里,
/// Probe 的接收时刻之后按序号合并(没有到达的就是丢失)。发送在专用线程上完成,不占用线程池。
/// 等不到对端的地址(5 秒内没有任何认证过的包)就以 <see cref="BlockFaultException"/> 结束。
/// </summary>
public sealed class TrainListener : BlockBase
{
private static readonly TimeSpan s_endpointWait = TimeSpan.FromSeconds(5);
private readonly RowStore _records;
private long _sent;
public TrainListener(BlockEnv env, BlockSpec spec)
: base(env, spec)
{
double pps = spec.Params.GetNumber("mbps") * 1_000_000 / 8 / Math.Max(1, spec.Int("size"));
_records = new RowStore(spec.Kind.FindStream("pkt")!, Side.Agent, (int)Math.Clamp(pps * spec.Int("duration_s") * 1.25, 1024, 4_000_000));
}
public override long Produced => Interlocked.Read(ref _sent);
public override RowStore? Records(string streamName) => streamName == "pkt" ? _records : null;
public override async Task RunAsync(CancellationToken ct)
{
// Probe 开始前发来的探活包(Data)要被接受,准入策略才能据此学到它当前的源地址(NAT 映射);它们本身没有内容
Env.Udp!.Register(Spec.StreamId, static (in UdpDelivery _) => { });
try
{
DataSession session = await WaitForPeerAsync(ct);
IPEndPoint target = session.UdpEndpoint!;
TimeSpan duration = TimeSpan.FromSeconds(Spec.Int("duration_s"));
var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var thread = new Thread(() =>
{
try
{
Send(session, target, duration, ct);
done.SetResult();
}
catch (Exception ex)
{
done.SetException(ex);
}
})
{
IsBackground = true,
Name = "udp-train",
};
thread.Start();
await done.Task;
}
finally
{
Env.Udp.Unregister(Spec.StreamId);
}
}
private async Task<DataSession> WaitForPeerAsync(CancellationToken ct)
{
DateTimeOffset deadline = Env.Time.GetUtcNow() + s_endpointWait;
while (true)
{
DataSession? session = Env.Session();
if (session?.UdpEndpoint is not null)
{
return session;
}
if (Env.Time.GetUtcNow() >= deadline)
{
throw new BlockFaultException("the peer's address is unknown");
}
await Task.Delay(TimeSpan.FromMilliseconds(50), Env.Time, ct);
}
}
private void Send(DataSession session, IPEndPoint target, TimeSpan duration, CancellationToken ct)
{
double packetsPerSecond = Spec.Params.GetNumber("mbps") * 1_000_000 / 8 / Spec.Int("size");
int size = (int)Spec.Int("size");
byte[] packet = new byte[size];
using var pause = new ManualResetEventSlim(false);
uint seq = 0;
long startTicks = Stopwatch.GetTimestamp();
long endTicks = startTicks + (long)(duration.TotalSeconds * Stopwatch.Frequency);
while (!ct.IsCancellationRequested)
{
long now = Stopwatch.GetTimestamp();
if (now >= endTicks)
{
break;
}
// 到目前为止应该发出的包数(均匀):落后了就追,领先了就等;追赶的上限是 50 ms 的量,避免长时间停顿之后的突发
double elapsed = (now - startTicks) / (double)Stopwatch.Frequency;
long due = Math.Min((long)(elapsed * packetsPerSecond) + 1, seq + Math.Max(1, (long)(packetsPerSecond * 0.05)));
if (seq >= due)
{
// 离下一个包还早就让出 CPU(至少 1 ms 的粒度),临近了再自旋,保证发送间隔均匀
if ((seq / packetsPerSecond) - elapsed > 0.002)
{
pause.Wait(1, ct);
}
else
{
Thread.SpinWait(50);
}
continue;
}
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);
Span<long> row = [size, sendUs];
_records.SetRow(seq, row);
Interlocked.Exchange(ref _sent, seq + 1L);
}
}
}
}
/// <summary>
/// 单向数据流,拨号方(Probe,行的跟随方):接收,记录每个序号第一次到达的时刻(按序号索引,热路径不分配);
/// 序号超过本地包速率上限推算出的范围时忽略。阶段结束时按序号范围切成多个列块交给 Agent,每块不超过 <see cref="ProtocolLimits.MaxRecordsPerChunk"/> 的记录。
/// 开始时先发几个探活包,让 Agent 学到我们当前的源地址(NAT 映射)再开始发送。
/// </summary>
public sealed class TrainDialer : BlockBase
{
private const int PerFragment = 40_000;
private readonly RowStore _received;
private readonly RowStore _step;
private readonly int _tRecv;
private readonly long _maxSeq;
private long _startUs;
private long _endUs;
public TrainDialer(BlockEnv env, BlockSpec spec)
: base(env, spec)
{
double pps = spec.Params.GetNumber("mbps") * 1_000_000 / 8 / Math.Max(1, spec.Int("size"));
long seconds = Math.Max(1, spec.Int("duration_s"));
_maxSeq = Math.Max(1024, Math.Min((long)(pps * seconds * 1.25), env.MaxPacketsPerSecond * seconds * 3 / 2));
_received = new RowStore(spec.Kind.FindStream("pkt")!, Side.Probe, (int)_maxSeq);
_tRecv = _received.ColumnIndex("t_recv");
_step = new RowStore(spec.Kind.FindStream("step")!, Side.Probe, 16);
}
public override async Task RunAsync(CancellationToken ct)
{
UdpMux udp = Env.Udp!;
DataSession session = Env.Session() ?? throw new BlockFaultException("no data session");
_startUs = Env.Clock.NowUs();
udp.Register(Spec.StreamId, OnPacket);
try
{
byte[] hello = new byte[UdpPacket.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);
}
await WaitUntilCancelledAsync(ct);
}
finally
{
_endUs = Env.Clock.NowUs();
udp.Unregister(Spec.StreamId);
}
}
public override int Flush(FlushMode mode, long peerProduced = 0)
{
if (mode != FlushMode.All)
{
return 0;
}
long end = _endUs == 0 ? Env.Clock.NowUs() : _endUs;
long step = _step.Append();
_step.Set(step, _step.ColumnIndex("index"), 0);
_step.Set(step, _step.ColumnIndex("mbps"), (long)Spec.Params.GetNumber("mbps"));
_step.Set(step, _step.ColumnIndex("start_ts"), _startUs);
_step.Set(step, _step.ColumnIndex("end_ts"), end);
long total = Math.Min(peerProduced, _maxSeq);
int fragments = 0;
for (long from = 0; from < total || fragments == 0; from += PerFragment)
{
long to = Math.Min(from + PerFragment, total);
RawStream pkt = _received.Cut(from, to, includeKey: true);
var parameters = new Dictionary<string, System.Text.Json.JsonElement>(Spec.Params.Values)
{
[FragmentKeys.SeqFrom] = Number(from),
[FragmentKeys.SeqTo] = Number(to),
};
Env.Fragments!.Ship(
"udp_train",
Spec.Scope,
fragments == 0 ? [pkt, _step.Cut(0, 1, includeKey: false)] : [pkt],
parameters,
Env.Clock.UtcMsAt(_startUs),
(int)((end - _startUs) / 1000),
Spec.Port);
fragments++;
if (total == 0)
{
break;
}
}
return fragments;
}
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)
{
long seq = delivery.Header.Seq;
if (delivery.Header.Type == UdpPacketType.Data && seq < _maxSeq && !_received.IsSet(seq, _tRecv))
{
_received.Set(seq, _tRecv, delivery.ReceiveUs); // 只记第一次到达(重复的包不覆盖)
}
}
}
@@ -0,0 +1,248 @@
using System.Net;
using System.Text.Json;
using Fleet.Core;
using Monitor.Protocol;
namespace Monitor.Measurement;
/// <summary>
/// 数据面会话:<c>session_id</c> 和密钥(由点对点握手里的 ECDH 派生)。两端的数据面包都用它认证。
/// Agent 的会话还带重放窗口等状态,所以是可以继承的。
/// </summary>
public class DataSession
{
public DataSession(long sessionId, byte[] key)
{
SessionId = sessionId;
Key = key;
}
public long SessionId { get; }
public byte[] Key { get; }
/// <summary>对端最近一次通过认证的 UDP 源地址(监听方把单向数据发往这里;拨号方不需要)。</summary>
public virtual IPEndPoint? UdpEndpoint => null;
/// <summary>TCP 首帧的随机数只能用一次。拨号方不接受连接,所以默认放行。</summary>
public virtual bool UseNonce(ReadOnlySpan<byte> nonce) => true;
}
public interface IDataSessions
{
DataSession? Find(long sessionId);
}
/// <summary>只有一个会话(拨号方:Probe 与某个 Agent 的会话)。</summary>
public sealed class SingleSession : IDataSessions
{
private readonly DataSession _session;
public SingleSession(DataSession session)
{
_session = session;
}
public DataSession? Find(long sessionId) => sessionId == _session.SessionId ? _session : null;
}
public enum Admission
{
Accepted,
Invalid,
RateLimited,
}
/// <summary>
/// 监听方的准入策略(安全边界):重放窗口、令牌桶限速、源地址学习。积木本身不含任何策略;
/// 拨号方不需要,只做 MAC 校验。任何一步失败都是静默丢弃并计数,不回复。
/// </summary>
public interface IAdmission
{
/// <summary>UDP 包:<paramref name="source"/> 是认证通过的源地址(实现可以据此学习 NAT 重绑定)。</summary>
Admission AdmitPacket(DataSession session, in UdpHeader header, SocketAddress source, long nowUs);
/// <summary>TCP 回显的一条消息(只限速)。</summary>
bool AdmitMessage(DataSession session, uint streamId, long nowUs);
}
/// <summary>数据面计数(只有计数,没有主机信息):用于心跳和面板上的节点状态。</summary>
public sealed class DataPlaneCounters
{
private long _packetsIn;
private long _packetsOut;
private long _invalid;
private long _rateLimited;
public long PacketsIn => Interlocked.Read(ref _packetsIn);
public long PacketsOut => Interlocked.Read(ref _packetsOut);
public long DroppedInvalid => Interlocked.Read(ref _invalid);
public long DroppedRateLimited => Interlocked.Read(ref _rateLimited);
public void PacketIn() => Interlocked.Increment(ref _packetsIn);
public void PacketOut() => Interlocked.Increment(ref _packetsOut);
public void DropInvalid() => Interlocked.Increment(ref _invalid);
public void DropRateLimited() => Interlocked.Increment(ref _rateLimited);
}
/// <summary>一个列块属于线路任务还是按需测试的某个阶段。</summary>
public sealed record FragmentScope(TaskId? Task, TestId? Test, int? Phase);
/// <summary>
/// 积木交出一侧的行的地方。Probe:放进发给 Agent 的发件箱(列块);Agent 自己运行的积木(目标任务):直接盖章上传。
/// </summary>
public interface IFragmentSink
{
void Ship(string kind, FragmentScope scope, IReadOnlyList<RawStream> streams, IReadOnlyDictionary<string, JsonElement>? parameters, long startUtcMs, int durationMs, int port);
}
public enum FlushMode
{
/// <summary>只交出已经稳定的行(回显类等过宽限期的)。</summary>
Settled,
/// <summary>交出全部(阶段结束或角色停止)。</summary>
All,
}
/// <summary>积木无法继续(例如监听方等不到对端的地址):由编排者决定整个测试怎么结束。</summary>
public sealed class BlockFaultException : Exception
{
public BlockFaultException(string message)
: base(message)
{
}
}
/// <summary>
/// 一个运行中的积木角色。同一种采集方法的两端是一对角色(拨号方 = Probe,监听方 = Agent),写在同一个文件里,
/// 共用包的构造、校验、行的记录;它们只产生<b>原始列</b>,不做任何统计,也不知道上层的方案或协议。
/// </summary>
public interface IBlock
{
/// <summary>运行到取消(回显、保持)或到自己结束(单向数据流的发送端)。</summary>
Task RunAsync(CancellationToken ct);
/// <summary>
/// 拨号方把已完成的行切成列块交给 <see cref="IFragmentSink"/>,返回块数;监听方返回 0(它的行留在 <see cref="Records"/> 里等合并)。
/// <paramref name="peerProduced"/>:对端是行的主导方时,对端产生的行数(单向数据流的发送包数)。
/// </summary>
int Flush(FlushMode mode, long peerProduced = 0);
/// <summary>这一侧作为主导方产生的行数(发出的包数)。</summary>
long Produced { get; }
/// <summary>这一侧为合并保留的按键记录;没有则为 null。</summary>
RowStore? Records(string streamName);
}
/// <summary>运行一个积木所需的全部参数:Kind、已经校验并补全默认值的参数、流编号、端口、列块归属。</summary>
public sealed class BlockSpec
{
public BlockSpec(KindSpec kind, ParamValues parameters, uint streamId, int port, FragmentScope scope)
{
Kind = kind;
Params = parameters;
StreamId = streamId;
Port = port;
Scope = scope;
}
public KindSpec Kind { get; }
public ParamValues Params { get; }
public uint StreamId { get; }
public int Port { get; }
public FragmentScope Scope { get; }
public string KindId => Kind.Id;
public long Int(string name) => Params.GetInt(name);
public bool Bool(string name) => Params.GetBool(name);
/// <summary>参数或端口变化时调和器重启这个积木(不含流编号:Agent 的流编号每次重建都会重新分配)。</summary>
public string ConfigSignature => KindId + "|" + Port + "|" +
string.Join(',', Params.Values.OrderBy(p => p.Key, StringComparer.Ordinal).Select(p => p.Key + "=" + p.Value.GetRawText()));
/// <summary>参数、端口或流编号变化时重启:Probe 据此判断 Agent 下发的任务有没有变(Agent 重启后流编号会变)。</summary>
public string Signature => StreamId + "|" + ConfigSignature;
/// <summary>校验参数(补全默认值)后构造;Kind 不存在或参数非法返回 null。</summary>
public static BlockSpec? Create(string kindId, IReadOnlyDictionary<string, JsonElement> parameters, uint streamId, int port, FragmentScope scope)
{
KindSpec? kind = Kinds.Find(kindId);
if (kind is null)
{
return null;
}
ParamResolution resolved = ParamResolver.Resolve(kind.Params, parameters);
return resolved.IsValid ? new BlockSpec(kind, resolved.Values!, streamId, port, scope) : null;
}
}
/// <summary>积木运行的环境:时钟、套接字、对端地址、列块出口。拨号方和监听方各自组装自己用得到的部分。</summary>
public sealed class BlockEnv
{
public required MonoClock Clock { get; init; }
public required INodeLog Log { get; init; }
/// <summary>当前的数据面会话(拨号方固定;监听方是 Agent 当前的 Probe 会话,可能没有)。</summary>
public required Func<DataSession?> Session { get; init; }
public UdpMux? Udp { get; init; }
/// <summary>监听方的准入策略(限速等);拨号方为 null。</summary>
public IAdmission? Admission { get; init; }
public DataPlaneCounters Counters { get; init; } = new();
/// <summary>监听方:接受 TCP 数据连接。</summary>
public TcpAcceptor? TcpListen { get; init; }
/// <summary>拨号方:对端(Agent,或目标任务的目标地址)。</summary>
public IPAddress? PeerAddress { get; init; }
/// <summary>拨号方:对端 TLS 证书的 SPKI 指纹;null 表示不校验证书(目标任务,目标是任意地址)。</summary>
public byte[]? PeerSpkiSha256 { get; init; }
/// <summary>拨号方:列块出口。</summary>
public IFragmentSink? Fragments { get; init; }
/// <summary>本地硬上限:批量传输(tcp_bulk)发送端的码率(Mbit/s)。批量传输会尽可能占满链路,这是防止把小机器打满的保险。</summary>
public double MaxBulkMbps { get; init; } = 100;
/// <summary>本地硬上限:单向数据流接收缓冲的包速率上界。</summary>
public long MaxPacketsPerSecond { get; init; } = 20_000;
public TimeProvider Time => Clock.Time;
/// <summary>同一套环境,但绑定到一个固定的数据面会话(Agent 为每个 Probe 会话各起一组监听方积木)。</summary>
public BlockEnv WithSession(DataSession session) =>
new()
{
Clock = Clock,
Log = Log,
Session = () => session,
Udp = Udp,
Admission = Admission,
Counters = Counters,
TcpListen = TcpListen,
PeerAddress = PeerAddress,
PeerSpkiSha256 = PeerSpkiSha256,
Fragments = Fragments,
MaxBulkMbps = MaxBulkMbps,
MaxPacketsPerSecond = MaxPacketsPerSecond,
};
}
@@ -0,0 +1,26 @@
namespace Monitor.Measurement;
/// <summary>节点的单调时钟(微秒):t1…t4、t_send、t_recv 都用它;Probe 和 Agent 各自一个,块头里的时钟锚点把它对应到 UTC。</summary>
public sealed class MonoClock
{
private readonly TimeProvider _time;
public MonoClock(TimeProvider time)
{
_time = time;
}
public TimeProvider Time => _time;
public long NowUs()
{
long ticks = _time.GetTimestamp();
// 先除后乘会丢精度,先乘再除在 64 位内不会溢出(频率通常是 1e9 或 1e7)
return (ticks / _time.TimestampFrequency * 1_000_000) + (ticks % _time.TimestampFrequency * 1_000_000 / _time.TimestampFrequency);
}
public ClockAnchor Anchor() => new(NowUs(), _time.GetUtcNow().ToUnixTimeMilliseconds());
/// <summary>单调时钟读数 <paramref name="thenUs"/> 对应的 UTC 毫秒(只用于块的粗略起点,精确时间由块头的时钟锚点换算)。</summary>
public long UtcMsAt(long thenUs) => _time.GetUtcNow().ToUnixTimeMilliseconds() - (Math.Max(0, NowUs() - thenUs) / 1000);
}
@@ -0,0 +1,239 @@
using Monitor.Protocol;
namespace Monitor.Measurement;
/// <summary>
/// 一侧记录的行:只含这一侧拥有的列(见 <see cref="StreamSpec.ColumnsOf"/>),按连接键(通常是 <c>seq</c>)索引的环形缓冲,
/// 每列一个 long 数组,热路径不分配。Agent 用它记录 t2/t3 或发送行,等 Probe 的列块到达后按键合并;
/// Probe 用它记录 t1/t4 或 t_recv,到点切成列块交出去。行被后来的键覆盖(环绕)就丢弃,这只会发生在一侧长时间没有交出行的时候。
/// </summary>
public sealed class RowStore
{
private readonly object _gate = new();
private readonly StreamSpec _spec;
private readonly IReadOnlyList<ColumnSpec> _columns;
private readonly ColumnSpec? _keyColumn;
private readonly int _capacity;
private readonly long[] _keys;
private readonly long[][] _values;
private readonly bool[]?[] _valid;
private long _next;
private long _drained;
public RowStore(StreamSpec spec, Side owner, int capacity)
{
_spec = spec;
_columns = spec.ColumnsOf(owner);
_keyColumn = spec.Key is null ? null : spec.Columns.First(c => c.Name == spec.Key);
_capacity = Math.Max(16, capacity);
_keys = new long[_capacity];
Array.Fill(_keys, -1);
_values = new long[_columns.Count][];
_valid = new bool[]?[_columns.Count];
for (int i = 0; i < _columns.Count; i++)
{
_values[i] = new long[_capacity];
_valid[i] = _columns[i].Nullable ? new bool[_capacity] : null;
}
}
public string Name => _spec.Name;
/// <summary>下一个自动分配的键(也是已经追加的行数)。</summary>
public long NextKey
{
get
{
lock (_gate)
{
return _next;
}
}
}
public int ColumnIndex(string name)
{
for (int i = 0; i < _columns.Count; i++)
{
if (_columns[i].Name == name)
{
return i;
}
}
throw new ArgumentException($"column {name} is not recorded on this side of stream {_spec.Name}", nameof(name));
}
/// <summary>
/// 清空所有行。一个新的数据面会话开始时,监听方必须清掉上一个会话留下的记录:Probe 重启之后序号从 0 重新开始,
/// 旧会话里同一个序号的 t2/t3 还留着的话,会被当成新会话里这个包"上行已经到达",真正的上行丢失就被算成了下行丢失。
/// </summary>
public void Clear()
{
lock (_gate)
{
Array.Fill(_keys, -1);
_next = 0;
_drained = 0;
}
}
/// <summary>取出上次取出之后追加的所有行(只用于两侧各自记录完整行的 union 流:每一行只交出一次)。</summary>
public RawStream Drain(bool includeKey)
{
lock (_gate)
{
RawStream rows = Cut(_drained, _next, includeKey);
_drained = _next;
return rows;
}
}
/// <summary>追加一行,键自动递增(从 0 开始,与 <c>seq</c> 一致)。</summary>
public long Append()
{
lock (_gate)
{
long key = _next++;
Claim(key);
return key;
}
}
public void Set(long key, int column, long value)
{
lock (_gate)
{
int slot = Claim(key);
_values[column][slot] = value;
if (_valid[column] is { } valid)
{
valid[slot] = true;
}
}
}
/// <summary>一次写入这一侧的所有列(顺序同 <see cref="StreamSpec.ColumnsOf"/>)。</summary>
public void SetRow(long key, ReadOnlySpan<long> values)
{
lock (_gate)
{
int slot = Claim(key);
for (int i = 0; i < _columns.Count; i++)
{
_values[i][slot] = values[i];
if (_valid[i] is { } valid)
{
valid[slot] = true;
}
}
}
}
public bool Contains(long key)
{
lock (_gate)
{
return key >= 0 && _keys[(int)(key % _capacity)] == key;
}
}
/// <summary>读一个值;行不存在或该列没有设置(可空列)返回 false。</summary>
public bool TryGet(long key, int column, out long value)
{
lock (_gate)
{
int slot = (int)(key % _capacity);
value = 0;
if (key < 0 || _keys[slot] != key || (_valid[column] is { } valid && !valid[slot]))
{
return false;
}
value = _values[column][slot];
return true;
}
}
public bool IsSet(long key, int column) => TryGet(key, column, out _);
/// <summary>
/// 取出键在 [<paramref name="fromKey"/>, <paramref name="toKey"/>) 内、仍然存在的行(按键升序),不删除。
/// 列按声明顺序排列;<paramref name="includeKey"/> 时带上连接键那一列。
/// </summary>
public RawStream Cut(long fromKey, long toKey, bool includeKey)
{
lock (_gate)
{
var slots = new List<int>();
var keys = new List<long>();
for (long key = Math.Max(0, fromKey); key < toKey; key++)
{
int slot = (int)(key % _capacity);
if (_keys[slot] == key)
{
slots.Add(slot);
keys.Add(key);
}
}
var columns = new List<RawColumn>();
foreach (ColumnSpec spec in _spec.Columns)
{
if (includeKey && _keyColumn is not null && spec.Name == _keyColumn.Name)
{
columns.Add(new RawColumn(spec.Name, spec.Type, spec.Enc, spec.Nullable, spec.Role, [.. keys], null));
continue;
}
int index = -1;
for (int i = 0; i < _columns.Count; i++)
{
if (_columns[i].Name == spec.Name)
{
index = i;
}
}
if (index < 0)
{
continue;
}
long[] values = new long[slots.Count];
bool[]? valid = spec.Nullable ? new bool[slots.Count] : null;
for (int r = 0; r < slots.Count; r++)
{
values[r] = _values[index][slots[r]];
if (valid is not null)
{
valid[r] = _valid[index]![slots[r]];
}
}
columns.Add(new RawColumn(spec.Name, spec.Type, spec.Enc, spec.Nullable, spec.Role, values, valid));
}
return new RawStream(_spec.Name, slots.Count, columns);
}
}
private int Claim(long key)
{
int slot = (int)(key % _capacity);
if (_keys[slot] != key)
{
_keys[slot] = key;
for (int i = 0; i < _columns.Count; i++)
{
_values[i][slot] = 0;
if (_valid[i] is { } valid)
{
valid[slot] = false;
}
}
}
return slot;
}
}
@@ -0,0 +1,47 @@
namespace Monitor.Measurement;
/// <summary>一个运行中的积木:自己的取消令牌和后台任务。Probe 和 Agent 共用。</summary>
public sealed class RunningBlock : IDisposable
{
private readonly CancellationTokenSource _cts = new();
private RunningBlock(IBlock block, string signature)
{
Block = block;
Signature = signature;
Completion = Task.CompletedTask;
}
public IBlock Block { get; }
/// <summary>参数、端口或流编号变化时调和器据此重启积木。</summary>
public string Signature { get; }
/// <summary>积木自己结束(单向数据流的发送端)或失败(<see cref="BlockFaultException"/>)时完成;被 <see cref="StopAsync"/> 停止时正常完成。</summary>
public Task Completion { get; private set; }
public static RunningBlock Start(IBlock block, string signature)
{
var running = new RunningBlock(block, signature);
running.Completion = Task.Run(() => block.RunAsync(running._cts.Token), CancellationToken.None);
return running;
}
/// <summary>停止积木并等它退出(最多 5 秒)。</summary>
public async Task StopAsync()
{
await _cts.CancelAsync();
try
{
await Completion.WaitAsync(TimeSpan.FromSeconds(5));
}
catch (Exception ex) when (ex is OperationCanceledException or TimeoutException or BlockFaultException)
{
// 没有及时退出或者已经失败:不再等
}
Dispose();
}
public void Dispose() => _cts.Dispose();
}
@@ -0,0 +1,193 @@
using System.Net;
using System.Text.Json;
using Monitor.Protocol;
namespace Monitor.Measurement;
/// <summary>节点的本地硬上限(写在本机配置里,Backend 无法修改)。</summary>
public sealed class NodeLimits
{
public double MaxTotalMbps { get; init; } = 1200;
public long MaxTotalPps { get; init; } = 20_000;
/// <summary>目标任务允许访问的网段;默认为空,即不开放任何目标任务。</summary>
public IReadOnlyList<string> AllowedTargetCidrs { get; init; } = [];
public int MaxConcurrentTests { get; init; } = 1;
/// <summary>任务参数里的 <c>port</c> 只能取这些端口(本机配置的 listen 列表)。</summary>
public IReadOnlyList<int> AllowedUdpPorts { get; init; } = [7443];
public IReadOnlyList<int> AllowedTcpPorts { get; init; } = [7444];
}
/// <summary>被拒绝的原因,用于 <c>rejected[]</c>:<c>unsupported</c>、<c>invalid_params</c>、<c>budget_exceeded</c>、<c>policy_denied</c>。</summary>
public static class RejectReasons
{
public const string Unsupported = "unsupported";
public const string InvalidParams = "invalid_params";
public const string BudgetExceeded = "budget_exceeded";
public const string PolicyDenied = "policy_denied";
}
public sealed record TaskDecision(bool Accepted, string? Reason, ParamValues? Resolved, KindSpec? Kind)
{
public static TaskDecision Reject(string reason) => new(false, reason, null, null);
}
/// <summary>
/// 本地硬上限检查:Backend 下发的任何参数都要先经过 <see cref="ParamResolver"/> 校验,再经过这里的本地上限检查,两道都不能省略。
/// 持续任务按顺序累计码率和包速率,超出总预算的任务被拒绝(并给出原因),其余照常运行。
/// </summary>
public sealed class BudgetGuard
{
private readonly NodeLimits _limits;
private readonly IReadOnlyCollection<string> _supportedKinds;
private readonly List<(IPAddress Network, int Prefix)> _cidrs = [];
public BudgetGuard(NodeLimits limits, IReadOnlyCollection<string> supportedKinds)
{
_limits = limits;
_supportedKinds = supportedKinds;
foreach (string cidr in limits.AllowedTargetCidrs)
{
if (TryParseCidr(cidr, out IPAddress? network, out int prefix))
{
_cidrs.Add((network, prefix));
}
}
}
/// <summary>检查一个任务;<paramref name="usedMbps"/> 和 <paramref name="usedPps"/> 是已经接受的任务占用的预算,接受之后会累加。</summary>
public TaskDecision Check(TaskSpec task, ref double usedMbps, ref long usedPps)
{
KindSpec? kind = Kinds.Find(task.Kind);
if (kind is null || !_supportedKinds.Contains(task.Kind))
{
return TaskDecision.Reject(RejectReasons.Unsupported);
}
ParamResolution resolved = ParamResolver.Resolve(kind.Params, task.Params);
if (!resolved.IsValid)
{
return TaskDecision.Reject(RejectReasons.InvalidParams);
}
if (task.Scope == "target")
{
if (!TargetAllowed(task.Target))
{
return TaskDecision.Reject(RejectReasons.PolicyDenied);
}
}
else if (!PortAllowed(kind, resolved.Values!))
{
return TaskDecision.Reject(RejectReasons.PolicyDenied);
}
double mbps = kind.BudgetBitsPerSecond(resolved.Values!) / 1_000_000.0;
long pps = EstimatePps(kind.Id, resolved.Values!);
if (usedMbps + mbps > _limits.MaxTotalMbps || usedPps + pps > _limits.MaxTotalPps)
{
return TaskDecision.Reject(RejectReasons.BudgetExceeded);
}
usedMbps += mbps;
usedPps += pps;
return new TaskDecision(true, null, resolved.Values, kind);
}
/// <summary>按需测试的一个阶段是否在本地上限之内(单独运行,不和持续任务累计)。</summary>
public TaskDecision CheckPhase(string kindId, IReadOnlyDictionary<string, JsonElement> parameters)
{
double mbps = 0;
long pps = 0;
return Check(new TaskSpec { Id = new TaskId("tsk_phase"), Scope = "line", Kind = kindId, Params = new Dictionary<string, JsonElement>(parameters) }, ref mbps, ref pps);
}
public bool TargetAllowed(string? target)
{
if (target is null)
{
return false;
}
string host = target;
if (IPEndPoint.TryParse(target, out IPEndPoint? endpoint))
{
host = endpoint.Address.ToString();
}
if (!IPAddress.TryParse(host, out IPAddress? address))
{
return false;
}
foreach ((IPAddress network, int prefix) in _cidrs)
{
if (InCidr(address, network, prefix))
{
return true;
}
}
return false;
}
private bool PortAllowed(KindSpec kind, ParamValues values)
{
if (!kind.Params.Any(p => p.Name == "port"))
{
return true;
}
int port = (int)values.GetInt("port");
return kind.Blocks.Transport == "udp" ? _limits.AllowedUdpPorts.Contains(port) : _limits.AllowedTcpPorts.Contains(port);
}
private static long EstimatePps(string kindId, ParamValues values) => kindId switch
{
"udp_echo" or "tcp_echo" => values.GetInt("rate_hz") * 2,
"icmp_echo" => values.GetInt("rate_hz") * 2,
"udp_train" => (long)(values.GetNumber("mbps") * 1_000_000 / 8 / Math.Max(1, values.GetInt("size"))),
"connect" => values.GetInt("per_minute") * 6 / 60,
_ => 1,
};
private static bool TryParseCidr(string text, out IPAddress network, out int prefix)
{
network = IPAddress.None;
prefix = 0;
int slash = text.IndexOf('/', StringComparison.Ordinal);
if (slash < 0 || !IPAddress.TryParse(text.AsSpan(0, slash), out IPAddress? parsed) || !int.TryParse(text.AsSpan(slash + 1), out prefix))
{
return false;
}
network = parsed;
return prefix >= 0 && prefix <= parsed.GetAddressBytes().Length * 8;
}
private static bool InCidr(IPAddress address, IPAddress network, int prefix)
{
byte[] a = address.GetAddressBytes();
byte[] n = network.GetAddressBytes();
if (a.Length != n.Length)
{
return false;
}
int bits = prefix;
for (int i = 0; i < a.Length && bits > 0; i++, bits -= 8)
{
int mask = bits >= 8 ? 0xFF : (0xFF << (8 - bits)) & 0xFF;
if ((a[i] & mask) != (n[i] & mask))
{
return false;
}
}
return true;
}
}
@@ -0,0 +1,90 @@
using System.Text.Json;
using Fleet.Core;
using Monitor.Protocol;
namespace Monitor.Measurement;
/// <summary>Agent 单调时钟(微秒)与 UTC(毫秒)的对应关系:块头里的时钟锚点。</summary>
public readonly record struct ClockAnchor(long AgentMonoUs, long UtcMs);
/// <summary>
/// 给合并好的流盖章:生成完整的块头(任务/测试、参数快照、端口、时钟锚点、起止时间)。
/// 起止时间由数据本身的时间戳换算得出,而不是取收到列块的时刻,所以不受 Probe 交付延迟的影响。
/// </summary>
public static class ChunkFinalizer
{
public const int MaxDurationMs = 600_000;
public static RawChunk Finalize(
KindSpec spec,
RawChunk fragment,
IReadOnlyList<RawStream> streams,
NodeId agent,
ClockAnchor anchor,
IReadOnlyDictionary<string, JsonElement> resolvedParams,
int port,
ChunkId id)
{
RawChunkHeader source = fragment.Header;
long offsetUs = source.Clock.ProbeOffsetUs;
long min = long.MaxValue;
long max = long.MinValue;
foreach (RawStream stream in streams)
{
if (stream.Name == "tcp_info")
{
continue; // 这个流的时钟取决于 side 列,不参与起止时间的计算
}
foreach (RawColumn column in stream.Columns)
{
if (column.Role == ColumnRole.None)
{
continue;
}
for (int i = 0; i < stream.Count; i++)
{
if (!column.IsValid(i))
{
continue;
}
long utc = ToUtcMs(column.Values[i], column.Role, anchor, offsetUs);
min = Math.Min(min, utc);
max = Math.Max(max, utc);
}
}
}
long start = min == long.MaxValue ? anchor.UtcMs - source.DurationMs : min;
int duration = (int)Math.Clamp(Math.Max(source.DurationMs, min == long.MaxValue ? 0 : max - start), 1, MaxDurationMs);
var header = new RawChunkHeader
{
ChunkId = id,
Kind = spec.Id,
TaskId = source.TaskId,
TestId = source.TestId,
Phase = source.Phase,
AgentId = agent,
ProbeId = source.ProbeId,
Target = null,
Params = new Dictionary<string, JsonElement>(resolvedParams),
Port = port,
Direction = source.Direction,
StartUtcMs = start,
DurationMs = duration,
Partial = false,
Clock = new ChunkClock { AgentMonoUs = anchor.AgentMonoUs, UtcMs = anchor.UtcMs, ProbeOffsetUs = offsetUs },
};
return new RawChunk(header, streams);
}
public static long ToUtcMs(long value, ColumnRole role, ClockAnchor anchor, long probeOffsetUs)
{
long agentUs = role == ColumnRole.TsProbe ? value + probeOffsetUs : value;
long delta = agentUs - anchor.AgentMonoUs;
return anchor.UtcMs + (delta >= 0 ? delta / 1000 : -((-delta + 999) / 1000));
}
}
@@ -0,0 +1,225 @@
using System.Text.Json;
using Fleet.Core;
using Monitor.Protocol;
namespace Monitor.Measurement;
/// <summary>Agent 一侧为合并保留的按键记录(由正在运行的积木提供)。</summary>
public interface IRecordSource
{
RowStore? Records(uint streamId, string streamName);
}
/// <summary>
/// 把 Probe 一侧的列块(一个只带 Probe 列的 RawChunk)与 Agent 一侧的记录合并成完整的行。合并完全由 Kind 的 <see cref="StreamSpec"/> 声明驱动:
/// <list type="bullet">
/// <item><description>声明了连接键、两侧都有列:按键连接。主导方是 Probe(回显)时行取自列块,Agent 的列按键补上,缺失就是缺失(上行丢失);
/// 主导方是 Agent(单向数据流)时行取自 Agent 的记录(按列块头里的序号范围),Probe 的列按键补上,缺失就是缺失(丢失)。</description></item>
/// <item><description>没有连接键,或者只有主导方一侧有列:原样透传,只按声明的列顺序重排。</description></item>
/// </list>
/// 合并之后的流按声明的列顺序排列,所以 Backend 的分析层不需要知道这些列是谁记录的;新增一种记录流不需要写合并代码。
/// </summary>
public static class FragmentMerger
{
public static Result<IReadOnlyList<RawStream>> Merge(KindSpec spec, RawChunk fragment, uint streamId, IRecordSource records)
{
var streams = new List<RawStream>();
foreach (RawStream stream in fragment.Streams)
{
StreamSpec? streamSpec = spec.FindStream(stream.Name);
if (streamSpec is null)
{
return Result<IReadOnlyList<RawStream>>.Fail(MonitorErrors.ValidationFailed, $"stream {stream.Name} is not declared by {spec.Id}");
}
Result<RawStream> merged = Join(streamSpec, stream, fragment.Header, records.Records(streamId, stream.Name));
if (!merged.TryGet(out RawStream? value, out FleetError? error))
{
return Result<IReadOnlyList<RawStream>>.Fail(error);
}
streams.Add(value);
}
// 两侧各自记录完整行的流:Probe 的列块里没有它(例如 Probe 在不支持内核采样的平台上)时,Agent 自己的行也要交出去
foreach (StreamSpec union in spec.Streams.Where(s => s.Union && fragment.Find(s.Name) is null))
{
RawStream? own = records.Records(streamId, union.Name)?.Drain(includeKey: false);
if (own is { Count: > 0 })
{
streams.Add(Conform(union, own));
}
}
return Result<IReadOnlyList<RawStream>>.Ok(streams);
}
private static Result<RawStream> Join(StreamSpec spec, RawStream probe, RawChunkHeader header, RowStore? agent)
{
if (spec.Union)
{
return Result<RawStream>.Ok(Concatenate(spec, probe, agent));
}
IReadOnlyList<ColumnSpec> probeColumns = spec.ColumnsOf(Side.Probe);
IReadOnlyList<ColumnSpec> agentColumns = spec.ColumnsOf(Side.Agent);
if (spec.Key is null || (spec.Driver == Side.Probe && agentColumns.Count == 0))
{
return Result<RawStream>.Ok(Conform(spec, probe));
}
RawColumn? key = probe.Find(spec.Key);
if (key is null || probeColumns.Any(c => probe.Find(c.Name) is null))
{
return Result<RawStream>.Fail(MonitorErrors.ValidationFailed, $"a fragment of stream {spec.Name} needs the columns {string.Join(", ", new[] { spec.Key }.Concat(probeColumns.Select(c => c.Name)))}");
}
return spec.Driver == Side.Probe ? JoinOnProbeRows(spec, probe, key, agent) : JoinOnAgentRows(spec, probe, key, header, agent);
}
private static Result<RawStream> JoinOnProbeRows(StreamSpec spec, RawStream probe, RawColumn key, RowStore? agent)
{
int n = probe.Count;
var columns = new List<RawColumn>(spec.Columns.Count);
foreach (ColumnSpec column in spec.Columns)
{
long[] values = new long[n];
bool[] valid = new bool[n];
if (column.Name == spec.Key)
{
key.Values.AsSpan(0, n).CopyTo(values);
Array.Fill(valid, true);
}
else if (spec.OwnerOf(column) == Side.Probe)
{
RawColumn source = probe.Find(column.Name)!;
for (int i = 0; i < n; i++)
{
values[i] = source.Values[i];
valid[i] = source.IsValid(i);
}
}
else if (agent is not null)
{
int index = agent.ColumnIndex(column.Name);
for (int i = 0; i < n; i++)
{
valid[i] = agent.TryGet(key.Values[i], index, out values[i]);
}
}
columns.Add(new RawColumn(column.Name, column.Type, column.Enc, column.Nullable, column.Role, values, column.Nullable ? valid : null));
}
return Result<RawStream>.Ok(new RawStream(spec.Name, n, columns));
}
private static Result<RawStream> JoinOnAgentRows(StreamSpec spec, RawStream probe, RawColumn key, RawChunkHeader header, RowStore? agent)
{
if (!TryGetRange(header, out uint from, out uint to))
{
return Result<RawStream>.Fail(MonitorErrors.ValidationFailed, $"a fragment of stream {spec.Name} must declare its sequence range");
}
// 行取自 Agent 的记录:范围内发出过的每一个包都有一行,Probe 没有报告到达的就是丢失
RawStream rows = agent?.Cut(from, to, includeKey: true) ?? new RawStream(spec.Name, 0, []);
var arrived = new Dictionary<long, int>();
for (int i = 0; i < probe.Count; i++)
{
arrived.TryAdd(key.Values[i], i); // 重复的包只算第一次
}
int n = rows.Count;
var columns = new List<RawColumn>(spec.Columns.Count);
foreach (ColumnSpec column in spec.Columns)
{
long[] values = new long[n];
bool[] valid = new bool[n];
RawColumn? own = rows.Find(column.Name);
if (own is not null)
{
own.Values.AsSpan(0, n).CopyTo(values);
for (int i = 0; i < n; i++)
{
valid[i] = own.IsValid(i);
}
}
else if (probe.Find(column.Name) is { } source)
{
RawColumn keys = rows.Find(spec.Key!)!;
for (int i = 0; i < n; i++)
{
if (arrived.TryGetValue(keys.Values[i], out int row) && source.IsValid(row))
{
values[i] = source.Values[row];
valid[i] = true;
}
}
}
columns.Add(new RawColumn(column.Name, column.Type, column.Enc, column.Nullable, column.Role, values, column.Nullable ? valid : null));
}
return Result<RawStream>.Ok(new RawStream(spec.Name, n, columns));
}
/// <summary>
/// 两侧各自记录完整行的流(例如各自套接字的内核采样):Probe 的行(列块里)加上 Agent 自己新记录的行,按声明的列顺序拼接。
/// 每一行里有一列标明它属于哪一侧,所以不需要连接。
/// </summary>
private static RawStream Concatenate(StreamSpec spec, RawStream probe, RowStore? agent)
{
RawStream own = Conform(spec, probe);
RawStream? extra = agent?.Drain(includeKey: false);
if (extra is null || extra.Count == 0)
{
return own;
}
int n = own.Count + extra.Count;
var columns = new List<RawColumn>(spec.Columns.Count);
foreach (ColumnSpec column in spec.Columns)
{
RawColumn a = own.Find(column.Name)!;
RawColumn b = extra.Find(column.Name)!;
long[] values = new long[n];
bool[] valid = new bool[n];
for (int i = 0; i < own.Count; i++)
{
values[i] = a.Values[i];
valid[i] = a.IsValid(i);
}
for (int i = 0; i < extra.Count; i++)
{
values[own.Count + i] = b.Values[i];
valid[own.Count + i] = b.IsValid(i);
}
columns.Add(new RawColumn(column.Name, column.Type, column.Enc, column.Nullable, column.Role, values, column.Nullable ? valid : null));
}
return new RawStream(spec.Name, n, columns);
}
/// <summary>按声明的列顺序重排;Probe 一侧已经是完整的行时不改变内容。</summary>
private static RawStream Conform(StreamSpec spec, RawStream stream)
{
var columns = new List<RawColumn>(spec.Columns.Count);
foreach (ColumnSpec column in spec.Columns)
{
RawColumn? existing = stream.Find(column.Name);
columns.Add(existing ?? new RawColumn(column.Name, column.Type, column.Enc, column.Nullable, column.Role, new long[stream.Count], column.Nullable ? new bool[stream.Count] : null));
}
return new RawStream(stream.Name, stream.Count, columns);
}
public static bool TryGetRange(RawChunkHeader header, out uint from, out uint to)
{
from = 0;
to = 0;
return header.Params.TryGetValue(FragmentKeys.SeqFrom, out JsonElement f) && f.TryGetUInt32(out from)
&& header.Params.TryGetValue(FragmentKeys.SeqTo, out JsonElement t) && t.TryGetUInt32(out to) && to >= from;
}
}
Loaded 100 of 347 files, more files were not shown because too many files have changed in this diff. Show more