chore: initial project import

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

No files matched your search

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