chore: initial project import
This commit is contained in:
commit
26620707a0
347 files changed
+56172
No files matched your search
+18
@@ -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
|
||||
@@ -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.
|
||||
@@ -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)。
|
||||
@@ -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
|
||||
@@ -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 完成)
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)。
|
||||
@@ -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>
|
||||
@@ -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)。
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<Project>
|
||||
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" />
|
||||
</Project>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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<T></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>
|
||||
@@ -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)
|
||||
{
|
||||
// 会话结束时的正常异常
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user