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

+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>