chore: initial project import
This commit is contained in:
commit
3fc4e5ca53
347 files changed
+56172
No files matched your search
@@ -0,0 +1,3 @@
|
||||
<Project>
|
||||
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" />
|
||||
</Project>
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace Fleet.Core;
|
||||
|
||||
/// <summary>
|
||||
/// 错误码:snake_case 字符串。框架的错误码以 <c>fleet_</c> 开头,产品可以扩展自己的错误码。
|
||||
/// </summary>
|
||||
public readonly record struct ErrorCode(string Value)
|
||||
{
|
||||
public override string ToString() => Value;
|
||||
}
|
||||
|
||||
public static class FleetErrors
|
||||
{
|
||||
public static readonly ErrorCode Unauthorized = new("fleet_unauthorized");
|
||||
public static readonly ErrorCode Forbidden = new("fleet_forbidden");
|
||||
public static readonly ErrorCode Unsupported = new("fleet_unsupported");
|
||||
public static readonly ErrorCode InvalidParams = new("fleet_invalid_params");
|
||||
public static readonly ErrorCode NotFound = new("fleet_not_found");
|
||||
public static readonly ErrorCode Internal = new("fleet_internal");
|
||||
public static readonly ErrorCode Timeout = new("fleet_timeout");
|
||||
public static readonly ErrorCode NodeOffline = new("fleet_node_offline");
|
||||
public static readonly ErrorCode NodePending = new("fleet_node_pending");
|
||||
public static readonly ErrorCode NodeDisabled = new("fleet_node_disabled");
|
||||
public static readonly ErrorCode NameConflict = new("fleet_name_conflict");
|
||||
public static readonly ErrorCode JoinTokenInvalid = new("fleet_join_token_invalid");
|
||||
public static readonly ErrorCode JoinTokenExpired = new("fleet_join_token_expired");
|
||||
public static readonly ErrorCode ProofInvalid = new("fleet_proof_invalid");
|
||||
public static readonly ErrorCode FrameTooLarge = new("fleet_frame_too_large");
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsAotCompatible>true</IsAotCompatible>
|
||||
<RootNamespace>Fleet.Core</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<AdditionalFiles Include="$(MSBuildThisFileDirectory)../../../BannedSymbols.Node.txt" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,119 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace Fleet.Core;
|
||||
|
||||
public enum NodeLogLevel
|
||||
{
|
||||
Info,
|
||||
Warn,
|
||||
Error,
|
||||
}
|
||||
|
||||
/// <summary>节点日志:字段名统一为 logfmt(<c>ts level msg agent probe session task result err</c>)。逐包事件不记录,只累加计数。</summary>
|
||||
public interface INodeLog
|
||||
{
|
||||
void Write(NodeLogLevel level, string message, params (string Key, object? Value)[] fields);
|
||||
}
|
||||
|
||||
public sealed class NullNodeLog : INodeLog
|
||||
{
|
||||
public static NullNodeLog Instance { get; } = new();
|
||||
|
||||
public void Write(NodeLogLevel level, string message, params (string Key, object? Value)[] fields)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public static class NodeLogExtensions
|
||||
{
|
||||
public static void Info(this INodeLog log, string message, params (string Key, object? Value)[] fields) => log.Write(NodeLogLevel.Info, message, fields);
|
||||
|
||||
public static void Warn(this INodeLog log, string message, params (string Key, object? Value)[] fields) => log.Write(NodeLogLevel.Warn, message, fields);
|
||||
|
||||
public static void Error(this INodeLog log, string message, params (string Key, object? Value)[] fields) => log.Write(NodeLogLevel.Error, message, fields);
|
||||
}
|
||||
|
||||
/// <summary>指数退避(带随机抖动):1 s → 20 s(对端恢复之后最多等 20 秒;节点数量不大,重连风暴不是问题)。连接成功之后调用 <see cref="Reset"/>。</summary>
|
||||
public sealed class Backoff
|
||||
{
|
||||
private readonly TimeSpan _initial;
|
||||
private readonly TimeSpan _max;
|
||||
private int _attempt;
|
||||
|
||||
public Backoff(TimeSpan? initial = null, TimeSpan? max = null)
|
||||
{
|
||||
_initial = initial ?? TimeSpan.FromSeconds(1);
|
||||
_max = max ?? TimeSpan.FromSeconds(20);
|
||||
}
|
||||
|
||||
public TimeSpan Next()
|
||||
{
|
||||
double baseMs = Math.Min(_max.TotalMilliseconds, _initial.TotalMilliseconds * Math.Pow(2, Math.Min(_attempt, 20)));
|
||||
_attempt++;
|
||||
// 抖动:0.5 ~ 1.0 倍,避免所有节点同时重连
|
||||
double jitter = 0.5 + (RandomNumberGenerator.GetInt32(0, 1000) / 2000.0);
|
||||
return TimeSpan.FromMilliseconds(baseMs * jitter);
|
||||
}
|
||||
|
||||
public void Reset() => _attempt = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 序号重放窗口(滑动 1024 位):同一个序号只接受一次;比窗口还旧的序号视为重放。
|
||||
/// 用于数据面的 UDP 包,每个 (session, stream) 一个。
|
||||
/// </summary>
|
||||
public sealed class ReplayWindow
|
||||
{
|
||||
private const int Size = 1024;
|
||||
private readonly ulong[] _bits = new ulong[Size / 64];
|
||||
private long _highest = -1;
|
||||
|
||||
public bool TryAccept(uint seq)
|
||||
{
|
||||
if (_highest < 0)
|
||||
{
|
||||
_highest = seq;
|
||||
Set(seq);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (seq > _highest)
|
||||
{
|
||||
long shift = seq - _highest;
|
||||
if (shift >= Size)
|
||||
{
|
||||
Array.Clear(_bits);
|
||||
}
|
||||
else
|
||||
{
|
||||
for (long s = _highest + 1; s <= seq; s++)
|
||||
{
|
||||
Clear((uint)s);
|
||||
}
|
||||
}
|
||||
|
||||
_highest = seq;
|
||||
Set(seq);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_highest - seq >= Size)
|
||||
{
|
||||
return false; // 太旧
|
||||
}
|
||||
|
||||
if (IsSet(seq))
|
||||
{
|
||||
return false; // 重复
|
||||
}
|
||||
|
||||
Set(seq);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void Set(uint seq) => _bits[(int)(seq % Size) / 64] |= 1UL << (int)(seq % 64);
|
||||
|
||||
private void Clear(uint seq) => _bits[(int)(seq % Size) / 64] &= ~(1UL << (int)(seq % 64));
|
||||
|
||||
private bool IsSet(uint seq) => (_bits[(int)(seq % Size) / 64] & (1UL << (int)(seq % 64))) != 0;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
namespace Fleet.Core;
|
||||
|
||||
public record FleetError(ErrorCode Code, string Message)
|
||||
{
|
||||
public override string ToString() => $"{Code}: {Message}";
|
||||
}
|
||||
|
||||
/// <summary>预期内的失败用结果表示,不抛异常。</summary>
|
||||
public readonly struct Result<T>
|
||||
{
|
||||
private readonly T? _value;
|
||||
private readonly FleetError? _error;
|
||||
|
||||
private Result(T? value, FleetError? error)
|
||||
{
|
||||
_value = value;
|
||||
_error = error;
|
||||
}
|
||||
|
||||
public bool IsSuccess => _error is null;
|
||||
|
||||
public T Value => _error is null ? _value! : throw new InvalidOperationException(_error.ToString());
|
||||
|
||||
public FleetError Error => _error ?? throw new InvalidOperationException("Result is successful.");
|
||||
|
||||
public static Result<T> Ok(T value) => new(value, null);
|
||||
|
||||
public static Result<T> Fail(FleetError error) => new(default, error);
|
||||
|
||||
public static Result<T> Fail(ErrorCode code, string message) => new(default, new FleetError(code, message));
|
||||
|
||||
public bool TryGet(out T value, out FleetError error)
|
||||
{
|
||||
value = _value!;
|
||||
error = _error!;
|
||||
return _error is null;
|
||||
}
|
||||
|
||||
public Result<TOut> Map<TOut>(Func<T, TOut> map) =>
|
||||
_error is null ? Result<TOut>.Ok(map(_value!)) : Result<TOut>.Fail(_error);
|
||||
|
||||
public static implicit operator Result<T>(FleetError error) => Fail(error);
|
||||
}
|
||||
|
||||
public static class Result
|
||||
{
|
||||
public static Result<T> Ok<T>(T value) => Result<T>.Ok(value);
|
||||
|
||||
public static Result<T> Fail<T>(ErrorCode code, string message) => Result<T>.Fail(code, message);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Fleet.Core;
|
||||
|
||||
/// <summary>节点 ID(带类型前缀的 ULID)。Monitor 中的 AgentId 就是 NodeId。</summary>
|
||||
[JsonConverter(typeof(NodeIdJsonConverter))]
|
||||
public readonly record struct NodeId(string Value)
|
||||
{
|
||||
public override string ToString() => Value;
|
||||
}
|
||||
|
||||
/// <summary>强类型字符串 ID 的 JSON 转换器基类(源生成友好,不使用反射)。</summary>
|
||||
public abstract class StringIdJsonConverter<TId> : JsonConverter<TId>
|
||||
{
|
||||
protected abstract TId Create(string value);
|
||||
|
||||
protected abstract string GetValue(TId id);
|
||||
|
||||
public sealed override TId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
|
||||
Create(reader.GetString() ?? throw new JsonException("id must be a string"));
|
||||
|
||||
public sealed override void Write(Utf8JsonWriter writer, TId value, JsonSerializerOptions options) =>
|
||||
writer.WriteStringValue(GetValue(value));
|
||||
|
||||
public sealed override TId ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
|
||||
Create(reader.GetString() ?? throw new JsonException("id must be a string"));
|
||||
|
||||
public sealed override void WriteAsPropertyName(Utf8JsonWriter writer, TId value, JsonSerializerOptions options) =>
|
||||
writer.WritePropertyName(GetValue(value));
|
||||
}
|
||||
|
||||
public sealed class NodeIdJsonConverter : StringIdJsonConverter<NodeId>
|
||||
{
|
||||
protected override NodeId Create(string value) => new(value);
|
||||
|
||||
protected override string GetValue(NodeId id) => id.Value;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Fleet.Core;
|
||||
|
||||
/// <summary>令牌的生成与哈希:服务端只保存 SHA-256,明文只在创建时返回一次。</summary>
|
||||
public static class TokenHash
|
||||
{
|
||||
public static byte[] Sha256(string token) => SHA256.HashData(Encoding.UTF8.GetBytes(token));
|
||||
|
||||
/// <summary>生成高熵令牌:<c>{prefix}_{43 个 base64url 字符}</c>。</summary>
|
||||
public static string NewToken(string prefix)
|
||||
{
|
||||
Span<byte> random = stackalloc byte[32];
|
||||
RandomNumberGenerator.Fill(random);
|
||||
return prefix + "_" + Base64Url.Encode(random);
|
||||
}
|
||||
|
||||
public static bool FixedTimeEquals(ReadOnlySpan<byte> a, ReadOnlySpan<byte> b) =>
|
||||
CryptographicOperations.FixedTimeEquals(a, b);
|
||||
}
|
||||
|
||||
public static class Base64Url
|
||||
{
|
||||
public static string Encode(ReadOnlySpan<byte> data) =>
|
||||
Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
|
||||
public static bool TryDecode(string text, out byte[] data)
|
||||
{
|
||||
string s = text.Replace('-', '+').Replace('_', '/');
|
||||
s = s.PadRight(s.Length + ((4 - (s.Length % 4)) % 4), '=');
|
||||
try
|
||||
{
|
||||
data = Convert.FromBase64String(s);
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
data = [];
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace Fleet.Core;
|
||||
|
||||
/// <summary>26 个字符的 ULID(Crockford base32,前 48 位为毫秒时间戳),按时间可排序。</summary>
|
||||
public static class Ulid
|
||||
{
|
||||
private const string Alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
||||
|
||||
public static string New(TimeProvider time)
|
||||
{
|
||||
long ms = time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
Span<byte> random = stackalloc byte[10];
|
||||
RandomNumberGenerator.Fill(random);
|
||||
|
||||
Span<char> chars = stackalloc char[26];
|
||||
for (int i = 9; i >= 0; i--)
|
||||
{
|
||||
chars[i] = Alphabet[(int)(ms & 31)];
|
||||
ms >>= 5;
|
||||
}
|
||||
|
||||
// 80 位随机数 → 16 个 base32 字符
|
||||
UInt128 r = 0;
|
||||
foreach (byte b in random)
|
||||
{
|
||||
r = (r << 8) | b;
|
||||
}
|
||||
|
||||
for (int i = 25; i >= 10; i--)
|
||||
{
|
||||
chars[i] = Alphabet[(int)(r & 31)];
|
||||
r >>= 5;
|
||||
}
|
||||
|
||||
return new string(chars);
|
||||
}
|
||||
|
||||
/// <summary>带类型前缀的 ID,例如 <c>agt_01J…</c>。</summary>
|
||||
public static string NewPrefixed(string prefix, TimeProvider time) => prefix + "_" + New(time);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace Fleet.Core;
|
||||
|
||||
/// <summary>LEB128 变长整数(无符号),以及 zigzag 有符号映射。</summary>
|
||||
public static class VarInt
|
||||
{
|
||||
public const int MaxBytes = 10;
|
||||
|
||||
public static ulong ZigZag(long value) => (ulong)((value << 1) ^ (value >> 63));
|
||||
|
||||
public static long UnZigZag(ulong value) => (long)(value >> 1) ^ -(long)(value & 1);
|
||||
|
||||
public static int Write(Span<byte> destination, ulong value)
|
||||
{
|
||||
int i = 0;
|
||||
while (value >= 0x80)
|
||||
{
|
||||
destination[i++] = (byte)(value | 0x80);
|
||||
value >>= 7;
|
||||
}
|
||||
|
||||
destination[i++] = (byte)value;
|
||||
return i;
|
||||
}
|
||||
|
||||
public static void Write(Stream destination, ulong value)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[MaxBytes];
|
||||
int n = Write(buffer, value);
|
||||
destination.Write(buffer[..n]);
|
||||
}
|
||||
|
||||
/// <summary>读取失败(数据被截断或超过 10 字节)时返回 false。</summary>
|
||||
public static bool TryRead(ReadOnlySpan<byte> source, out ulong value, out int consumed)
|
||||
{
|
||||
value = 0;
|
||||
consumed = 0;
|
||||
int shift = 0;
|
||||
while (consumed < source.Length && consumed < MaxBytes)
|
||||
{
|
||||
byte b = source[consumed++];
|
||||
value |= (ulong)(b & 0x7F) << shift;
|
||||
if ((b & 0x80) == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
shift += 7;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool TryReadUInt32LittleEndian(ReadOnlySpan<byte> source, out uint value)
|
||||
{
|
||||
if (source.Length < 4)
|
||||
{
|
||||
value = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
value = BinaryPrimitives.ReadUInt32LittleEndian(source);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Fleet.Core;
|
||||
using Fleet.Hosting;
|
||||
using Fleet.Identity;
|
||||
|
||||
namespace Fleet.Enrollment.Client;
|
||||
|
||||
public sealed record EnrollmentResult(string NodeId, string Status, string IdentityDirectory);
|
||||
|
||||
/// <summary>
|
||||
/// 用加入令牌注册(供 <c>fleet-enroll</c> 使用,也可以嵌入产品自己的安装工具):
|
||||
/// 生成身份密钥和 TLS 证书 → 用身份私钥签名持有证明 → 调用注册端点 → 写入身份目录。
|
||||
/// 注册逻辑只存在于这里,常驻的业务进程不包含注册代码,也不使用 HttpClient。
|
||||
/// </summary>
|
||||
public static class EnrollmentClient
|
||||
{
|
||||
public static async Task<Result<EnrollmentResult>> EnrollAsync(
|
||||
HttpClient http, Uri server, string joinToken, string name, string identityDirectory, string nodeVersion, JsonNode? productMeta, TimeProvider time, CancellationToken ct)
|
||||
{
|
||||
string nodeFile = Path.Combine(identityDirectory, LoadedNodeIdentity.NodeFileName);
|
||||
if (File.Exists(nodeFile))
|
||||
{
|
||||
return Result<EnrollmentResult>.Fail(FleetErrors.NameConflict, $"{nodeFile} already exists: this node is already enrolled (remove the directory to enroll again)");
|
||||
}
|
||||
|
||||
using NodeIdentity identity = NodeIdentity.Generate();
|
||||
byte[] pfx = NodeCertificate.CreateSelfSignedPfx(name, TimeSpan.FromDays(3650), time);
|
||||
byte[] spki;
|
||||
using (var certificate = NodeCertificate.LoadPfx(pfx))
|
||||
{
|
||||
spki = NodeCertificate.SpkiFingerprint(certificate);
|
||||
}
|
||||
|
||||
string pubkey = Convert.ToBase64String(identity.PublicKeySpki);
|
||||
string tokenHash = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(joinToken)));
|
||||
var body = new JsonObject
|
||||
{
|
||||
["join_token"] = joinToken,
|
||||
["name"] = name,
|
||||
["pubkey"] = pubkey,
|
||||
["tls_spki"] = Convert.ToBase64String(spki),
|
||||
["proof"] = Convert.ToBase64String(identity.Sign(SignaturePurpose.Enroll, tokenHash, pubkey)),
|
||||
["node_version"] = nodeVersion,
|
||||
["meta"] = productMeta,
|
||||
};
|
||||
|
||||
using var content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json");
|
||||
using HttpResponseMessage response = await http.PostAsync(new Uri(server, "/api/agent/v1/enroll"), content, ct);
|
||||
string text = await response.Content.ReadAsStringAsync(ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
string code = "fleet_internal";
|
||||
string detail = text;
|
||||
try
|
||||
{
|
||||
JsonNode? problem = JsonNode.Parse(text);
|
||||
code = problem?["code"]?.GetValue<string>() ?? code;
|
||||
detail = problem?["detail"]?.GetValue<string>() ?? detail;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// 不是 problem+json:原样返回
|
||||
}
|
||||
|
||||
return Result<EnrollmentResult>.Fail(new ErrorCode(code), $"{(int)response.StatusCode}: {detail}");
|
||||
}
|
||||
|
||||
JsonNode result = JsonNode.Parse(text)!;
|
||||
string nodeId = result["node_id"]!.GetValue<string>();
|
||||
var store = new ConfigStore(identityDirectory);
|
||||
store.Write(LoadedNodeIdentity.KeyFileName, identity.ExportPkcs8());
|
||||
store.Write(LoadedNodeIdentity.TlsFileName, pfx);
|
||||
// node.json 最后写:它存在就表示注册完整完成
|
||||
store.Write(
|
||||
LoadedNodeIdentity.NodeFileName,
|
||||
JsonSerializer.SerializeToUtf8Bytes(new NodeFile { NodeId = nodeId, Server = server.ToString().TrimEnd('/'), TlsSpki = Convert.ToBase64String(spki) }, NodeFileContext.Default.NodeFile));
|
||||
return Result<EnrollmentResult>.Ok(new EnrollmentResult(nodeId, result["status"]!.GetValue<string>(), identityDirectory));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsAotCompatible>true</IsAotCompatible>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Fleet.Core\Fleet.Core.csproj" />
|
||||
<ProjectReference Include="..\Fleet.Identity\Fleet.Identity.csproj" />
|
||||
<ProjectReference Include="..\Fleet.Hosting\Fleet.Hosting.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsAotCompatible>true</IsAotCompatible>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<AdditionalFiles Include="$(MSBuildThisFileDirectory)../../../BannedSymbols.Node.txt" />
|
||||
<ProjectReference Include="..\Fleet.Core\Fleet.Core.csproj" />
|
||||
<ProjectReference Include="..\Fleet.Identity\Fleet.Identity.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,317 @@
|
||||
using System.Globalization;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using Fleet.Core;
|
||||
using Fleet.Identity;
|
||||
|
||||
namespace Fleet.Hosting;
|
||||
|
||||
/// <summary>logfmt 日志:<c>ts=… level=info msg="…" key=value</c>。令牌、会话密钥和 MAC 不能出现在日志里。</summary>
|
||||
public sealed class ConsoleNodeLog : INodeLog
|
||||
{
|
||||
private readonly TimeProvider _time;
|
||||
private readonly TextWriter _writer;
|
||||
private readonly object _gate = new();
|
||||
|
||||
public ConsoleNodeLog(TimeProvider time, TextWriter? writer = null)
|
||||
{
|
||||
_time = time;
|
||||
_writer = writer ?? Console.Error;
|
||||
}
|
||||
|
||||
public void Write(NodeLogLevel level, string message, params (string Key, object? Value)[] fields)
|
||||
{
|
||||
var line = new StringBuilder();
|
||||
line.Append("ts=").Append(_time.GetUtcNow().ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture));
|
||||
line.Append(" level=").Append(level.ToString().ToLowerInvariant());
|
||||
line.Append(" msg=").Append(Quote(message));
|
||||
foreach ((string key, object? value) in fields)
|
||||
{
|
||||
line.Append(' ').Append(key).Append('=').Append(Quote(Convert.ToString(value, CultureInfo.InvariantCulture) ?? ""));
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
_writer.WriteLine(line.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
private static string Quote(string text) =>
|
||||
text.Length > 0 && text.All(c => char.IsLetterOrDigit(c) || c is '_' or '-' or '.' or ':' or '/') ? text : "\"" + text.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal) + "\"";
|
||||
}
|
||||
|
||||
/// <summary>可以独立运行的功能模块(连接、数据面、切块……)。</summary>
|
||||
public interface IModule
|
||||
{
|
||||
string Name { get; }
|
||||
|
||||
Task RunAsync(CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模块宿主:同时运行所有模块,隔离故障(一个模块崩溃只重启它自己,指数退避),收到取消之后等所有模块退出。
|
||||
/// 模块之间通过构造函数传入的接口或 <c>Channel<T></c> 通信,不使用静态全局状态。
|
||||
/// </summary>
|
||||
public sealed class ModuleHost
|
||||
{
|
||||
private readonly List<IModule> _modules = [];
|
||||
private readonly INodeLog _log;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public ModuleHost(INodeLog log, TimeProvider time)
|
||||
{
|
||||
_log = log;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
public ModuleHost Add(IModule module)
|
||||
{
|
||||
_modules.Add(module);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Task RunAsync(CancellationToken ct) => Task.WhenAll(_modules.Select(m => RunModuleAsync(m, ct)));
|
||||
|
||||
private async Task RunModuleAsync(IModule module, CancellationToken ct)
|
||||
{
|
||||
var backoff = new Backoff(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(30));
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await module.RunAsync(ct);
|
||||
return; // 模块正常结束
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 模块边界:一个模块的故障不能带倒整个进程
|
||||
TimeSpan delay = backoff.Next();
|
||||
_log.Error("module crashed", ("module", module.Name), ("err", ex.GetType().Name + ": " + ex.Message), ("retry_ms", (long)delay.TotalMilliseconds));
|
||||
await Task.Delay(delay, _time, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 启动自检:Linux 上 .NET 的加密和 TLS 依赖系统的 OpenSSL(<c>libssl.so.3</c>)。OpenWrt 默认用 mbedTLS/wolfSSL,需要另外安装 <c>libopenssl3</c>。
|
||||
/// 缺少时在启动的第一步明确报错,而不是在第一次握手时抛出一个难以理解的异常。
|
||||
/// </summary>
|
||||
public static class PlatformCheck
|
||||
{
|
||||
/// <summary>返回 null 表示正常,否则是给人看的错误说明。</summary>
|
||||
public static string? VerifyCrypto()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var key = System.Security.Cryptography.ECDsa.Create(System.Security.Cryptography.ECCurve.NamedCurves.nistP256);
|
||||
_ = key.SignData("check"u8, System.Security.Cryptography.HashAlgorithmName.SHA256);
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex) when (ex is DllNotFoundException or TypeInitializationException or System.Security.Cryptography.CryptographicException or PlatformNotSupportedException or InvalidOperationException)
|
||||
{
|
||||
return $"cannot use the system cryptography library ({ex.GetType().Name}: {ex.Message}). Install OpenSSL 3: Debian/Ubuntu `apt install libssl3`, Alpine `apk add libssl3`, OpenWrt `opkg install libopenssl3`";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>处理 Ctrl+C 和 SIGTERM:取消令牌,让各个模块有序退出。</summary>
|
||||
public static class ServiceShell
|
||||
{
|
||||
public static async Task RunAsync(Func<CancellationToken, Task> run, INodeLog log)
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
using PosixSignalRegistration term = PosixSignalRegistration.Create(PosixSignal.SIGTERM, context =>
|
||||
{
|
||||
context.Cancel = true;
|
||||
log.Info("stopping", ("result", "SIGTERM"));
|
||||
cts.Cancel();
|
||||
});
|
||||
using PosixSignalRegistration interrupt = PosixSignalRegistration.Create(PosixSignal.SIGINT, context =>
|
||||
{
|
||||
context.Cancel = true;
|
||||
log.Info("stopping", ("result", "SIGINT"));
|
||||
cts.Cancel();
|
||||
});
|
||||
// Windows 上 Ctrl+Break 对应 SIGQUIT(没有控制台窗口的后台运行方式用它来请求有序退出)
|
||||
using PosixSignalRegistration quit = PosixSignalRegistration.Create(PosixSignal.SIGQUIT, context =>
|
||||
{
|
||||
context.Cancel = true;
|
||||
log.Info("stopping", ("result", "SIGQUIT"));
|
||||
cts.Cancel();
|
||||
});
|
||||
// Windows 上关闭控制台窗口 / taskkill(不带 /F)对应 SIGHUP
|
||||
using PosixSignalRegistration hangup = PosixSignalRegistration.Create(PosixSignal.SIGHUP, context =>
|
||||
{
|
||||
context.Cancel = true;
|
||||
log.Info("stopping", ("result", "SIGHUP"));
|
||||
cts.Cancel();
|
||||
});
|
||||
Task trimmer = TrimHeapAsync(cts.Token);
|
||||
try
|
||||
{
|
||||
await run(cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (cts.IsCancellationRequested)
|
||||
{
|
||||
// 正常退出
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
await trimmer;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 节点进程的托管堆只有几百 KB,但工作站 GC 的第 0 代预算很大,按默认设置要攒很多垃圾才回收一次,
|
||||
/// 在 1 GB 内存的小机器上表现为常驻内存缓慢爬升(不是泄漏,强制回收后曲线是平的)。
|
||||
/// 每 30 秒回收一次:堆这么小,一次完整回收不到 1 ms。
|
||||
/// </summary>
|
||||
private static async Task TrimHeapAsync(CancellationToken ct)
|
||||
{
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30));
|
||||
try
|
||||
{
|
||||
while (await timer.WaitForNextTickAsync(ct))
|
||||
{
|
||||
GC.Collect(2, GCCollectionMode.Forced, blocking: true, compacting: false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 退出
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>读取 JSON 配置(源生成,不使用反射)。解析失败时给出明确的错误,而不是带着默认值悄悄运行。</summary>
|
||||
public static class NodeConfig
|
||||
{
|
||||
public static T Load<T>(string path, JsonTypeInfo<T> typeInfo)
|
||||
where T : class
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
throw new InvalidDataException($"config file {path} does not exist");
|
||||
}
|
||||
|
||||
byte[] bytes = File.ReadAllBytes(path);
|
||||
ReadOnlySpan<byte> json = bytes;
|
||||
if (json.StartsWith(""u8)) // Windows 编辑器常带 UTF-8 BOM,System.Text.Json 不接受
|
||||
{
|
||||
json = json[3..];
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize(json, typeInfo) ?? throw new InvalidDataException($"{path} is empty");
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new InvalidDataException($"{path} is not valid: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 常驻业务进程里唯一允许写文件的地方,而且只能写配置/身份目录(原子替换,类 Unix 上权限 0600)。
|
||||
/// 例如 Probe 把最新的 Agent 列表写回 bootstrap。测量数据本来就不落盘。
|
||||
/// </summary>
|
||||
public sealed class ConfigStore
|
||||
{
|
||||
private readonly string _directory;
|
||||
|
||||
public ConfigStore(string directory)
|
||||
{
|
||||
_directory = Path.GetFullPath(directory);
|
||||
}
|
||||
|
||||
public void Write(string fileName, byte[] content)
|
||||
{
|
||||
string path = Path.GetFullPath(Path.Combine(_directory, fileName));
|
||||
if (!path.StartsWith(_directory, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("config files must stay inside the config directory");
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(_directory);
|
||||
string temp = path + ".tmp";
|
||||
#pragma warning disable RS0030 // 纯采集边界的唯一豁免点:ConfigStore 只写配置目录,先写临时文件再原子替换
|
||||
File.WriteAllBytes(temp, content);
|
||||
#pragma warning restore RS0030
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
File.SetUnixFileMode(temp, UnixFileMode.UserRead | UnixFileMode.UserWrite);
|
||||
}
|
||||
|
||||
File.Move(temp, path, overwrite: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary><c>node.json</c>:<c>fleet-enroll</c> 写入,节点只读。</summary>
|
||||
public sealed class NodeFile
|
||||
{
|
||||
[JsonPropertyName("node_id")]
|
||||
public string NodeId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("server")]
|
||||
public string Server { get; set; } = "";
|
||||
|
||||
/// <summary>本节点 TLS 证书的 SPKI 指纹(base64),注册时上报。</summary>
|
||||
[JsonPropertyName("tls_spki")]
|
||||
public string TlsSpki { get; set; } = "";
|
||||
}
|
||||
|
||||
[JsonSerializable(typeof(NodeFile))]
|
||||
public sealed partial class NodeFileContext : JsonSerializerContext;
|
||||
|
||||
/// <summary>身份目录里的文件(<c>fleet-enroll</c> 生成):<c>node.json</c>、<c>node.key</c>(PKCS#8)、<c>tls.pfx</c>。</summary>
|
||||
public sealed class LoadedNodeIdentity : IDisposable
|
||||
{
|
||||
public const string NodeFileName = "node.json";
|
||||
public const string KeyFileName = "node.key";
|
||||
public const string TlsFileName = "tls.pfx";
|
||||
|
||||
public LoadedNodeIdentity(NodeFile file, NodeIdentity identity, X509Certificate2 certificate)
|
||||
{
|
||||
File = file;
|
||||
Identity = identity;
|
||||
Certificate = certificate;
|
||||
}
|
||||
|
||||
public NodeFile File { get; }
|
||||
|
||||
public NodeId NodeId => new(File.NodeId);
|
||||
|
||||
public NodeIdentity Identity { get; }
|
||||
|
||||
public X509Certificate2 Certificate { get; }
|
||||
|
||||
public static LoadedNodeIdentity Load(string directory)
|
||||
{
|
||||
if (!System.IO.File.Exists(Path.Combine(directory, KeyFileName)) || !System.IO.File.Exists(Path.Combine(directory, TlsFileName)))
|
||||
{
|
||||
throw new InvalidDataException($"no node identity in {directory}: run fleet-enroll first");
|
||||
}
|
||||
|
||||
NodeFile file = NodeConfig.Load(Path.Combine(directory, NodeFileName), NodeFileContext.Default.NodeFile);
|
||||
NodeIdentity identity = NodeIdentity.FromPkcs8(System.IO.File.ReadAllBytes(Path.Combine(directory, KeyFileName)));
|
||||
X509Certificate2 certificate = NodeCertificate.LoadPfx(System.IO.File.ReadAllBytes(Path.Combine(directory, TlsFileName)));
|
||||
return new LoadedNodeIdentity(file, identity, certificate);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Identity.Dispose();
|
||||
Certificate.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsAotCompatible>true</IsAotCompatible>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<AdditionalFiles Include="$(MSBuildThisFileDirectory)../../../BannedSymbols.Node.txt" />
|
||||
<ProjectReference Include="..\Fleet.Core\Fleet.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace Fleet.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// 点对点会话的 TLS 证书:自签名的 ECDSA P-256(和身份密钥是两把不同的密钥)。
|
||||
/// 对端不按证书链校验,而是按 SPKI 的 SHA-256 指纹校验(指纹由服务端经 Agent 列表下发)。
|
||||
/// </summary>
|
||||
public static class NodeCertificate
|
||||
{
|
||||
/// <summary>生成自签名证书,返回无口令的 PFX 字节(文件权限由写入方设为 0600)。</summary>
|
||||
public static byte[] CreateSelfSignedPfx(string commonName, TimeSpan validity, TimeProvider time)
|
||||
{
|
||||
using ECDsa key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var request = new CertificateRequest("CN=" + commonName, key, HashAlgorithmName.SHA256);
|
||||
request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, critical: true));
|
||||
request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(
|
||||
[new Oid("1.3.6.1.5.5.7.3.1"), new Oid("1.3.6.1.5.5.7.3.2")], critical: false));
|
||||
DateTimeOffset now = time.GetUtcNow();
|
||||
using X509Certificate2 certificate = request.CreateSelfSigned(now.AddDays(-1), now.Add(validity));
|
||||
return certificate.Export(X509ContentType.Pfx);
|
||||
}
|
||||
|
||||
public static X509Certificate2 LoadPfx(byte[] pfx) => X509CertificateLoader.LoadPkcs12(pfx, null);
|
||||
|
||||
/// <summary>证书公钥(SPKI)的 SHA-256:对端用它来固定这张证书。</summary>
|
||||
public static byte[] SpkiFingerprint(X509Certificate2 certificate) =>
|
||||
SHA256.HashData(certificate.PublicKey.ExportSubjectPublicKeyInfo());
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Fleet.Identity;
|
||||
|
||||
/// <summary>签名用途前缀:同一份签名不能在不同场景下复用。产品可以追加自己的用途,但不能占用 fleet 保留的这些。</summary>
|
||||
public static class SignaturePurpose
|
||||
{
|
||||
public const string Auth = "auth";
|
||||
public const string Peer = "peer";
|
||||
public const string Enroll = "enroll";
|
||||
}
|
||||
|
||||
public static class SignatureString
|
||||
{
|
||||
public static string Build(string purpose, params string[] fields) =>
|
||||
"fleet|v1|" + purpose + "|" + string.Join('|', fields);
|
||||
}
|
||||
|
||||
/// <summary>节点身份:ECDSA P-256 密钥对。服务端只保存公钥(SPKI)。</summary>
|
||||
public sealed class NodeIdentity : IDisposable
|
||||
{
|
||||
private readonly ECDsa _key;
|
||||
|
||||
private NodeIdentity(ECDsa key)
|
||||
{
|
||||
_key = key;
|
||||
PublicKeySpki = key.ExportSubjectPublicKeyInfo();
|
||||
}
|
||||
|
||||
public byte[] PublicKeySpki { get; }
|
||||
|
||||
public static NodeIdentity Generate() => new(ECDsa.Create(ECCurve.NamedCurves.nistP256));
|
||||
|
||||
public static NodeIdentity FromPkcs8(ReadOnlySpan<byte> pkcs8)
|
||||
{
|
||||
ECDsa key = ECDsa.Create();
|
||||
key.ImportPkcs8PrivateKey(pkcs8, out _);
|
||||
return new NodeIdentity(key);
|
||||
}
|
||||
|
||||
public byte[] ExportPkcs8() => _key.ExportPkcs8PrivateKey();
|
||||
|
||||
public byte[] Sign(string purpose, params string[] fields) =>
|
||||
_key.SignData(Encoding.UTF8.GetBytes(SignatureString.Build(purpose, fields)), HashAlgorithmName.SHA256,
|
||||
DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
|
||||
|
||||
/// <summary>验证签名;公钥或签名格式不合法时返回 false,不抛异常。</summary>
|
||||
public static bool Verify(ReadOnlySpan<byte> publicKeySpki, ReadOnlySpan<byte> signature, string purpose, params string[] fields)
|
||||
{
|
||||
try
|
||||
{
|
||||
using ECDsa key = ECDsa.Create();
|
||||
key.ImportSubjectPublicKeyInfo(publicKeySpki, out _);
|
||||
return key.VerifyData(Encoding.UTF8.GetBytes(SignatureString.Build(purpose, fields)), signature,
|
||||
HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsValidPublicKey(ReadOnlySpan<byte> publicKeySpki)
|
||||
{
|
||||
try
|
||||
{
|
||||
using ECDsa key = ECDsa.Create();
|
||||
key.ImportSubjectPublicKeyInfo(publicKeySpki, out _);
|
||||
return key.KeySize == 256;
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[] Fingerprint(ReadOnlySpan<byte> spki) => SHA256.HashData(spki);
|
||||
|
||||
public void Dispose() => _key.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Fleet.Identity;
|
||||
|
||||
/// <summary>点对点会话派生出的数据面凭据。<c>SessionId</c> 8 字节,<c>Key</c> 32 字节(HMAC-SHA256 的密钥)。</summary>
|
||||
public sealed record SessionCredentials(byte[] SessionId, byte[] Key)
|
||||
{
|
||||
public long SessionIdValue => BitConverter.ToInt64(SessionId, 0);
|
||||
}
|
||||
|
||||
/// <summary>临时 ECDH 密钥:每个会话各生成一个,用完即弃,所以会话密钥具有前向保密。</summary>
|
||||
public sealed class EphemeralKey : IDisposable
|
||||
{
|
||||
private readonly ECDiffieHellman _key = ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256);
|
||||
|
||||
public byte[] PublicKeySpki => _key.ExportSubjectPublicKeyInfo();
|
||||
|
||||
/// <summary>
|
||||
/// 与对端的临时公钥协商,再经 HKDF-SHA256 派生会话凭据。
|
||||
/// 盐是服务端的随机数,info 绑定双方的临时公钥,防止凭据被挪用到别的握手上。
|
||||
/// </summary>
|
||||
public SessionCredentials Derive(ReadOnlySpan<byte> remotePublicKeySpki, ReadOnlySpan<byte> nonce, ReadOnlySpan<byte> clientPublic, ReadOnlySpan<byte> serverPublic)
|
||||
{
|
||||
using ECDiffieHellman remote = ECDiffieHellman.Create();
|
||||
remote.ImportSubjectPublicKeyInfo(remotePublicKeySpki, out _);
|
||||
byte[] secret = _key.DeriveRawSecretAgreement(remote.PublicKey);
|
||||
byte[] info = [.. Encoding.UTF8.GetBytes("fleet|v1|session|"), .. SHA256.HashData(clientPublic), .. SHA256.HashData(serverPublic)];
|
||||
byte[] material = HKDF.DeriveKey(HashAlgorithmName.SHA256, secret, 40, nonce.ToArray(), info);
|
||||
CryptographicOperations.ZeroMemory(secret);
|
||||
return new SessionCredentials(material[..8], material[8..]);
|
||||
}
|
||||
|
||||
public void Dispose() => _key.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>数据面的消息认证:HMAC-SHA256 截断到 16 字节。</summary>
|
||||
public static class DataPlaneMac
|
||||
{
|
||||
public const int Bytes = 16;
|
||||
|
||||
public static void Compute(ReadOnlySpan<byte> key, ReadOnlySpan<byte> data, Span<byte> destination)
|
||||
{
|
||||
Span<byte> full = stackalloc byte[32];
|
||||
HMACSHA256.HashData(key, data, full);
|
||||
full[..Bytes].CopyTo(destination);
|
||||
}
|
||||
|
||||
public static bool Verify(ReadOnlySpan<byte> key, ReadOnlySpan<byte> data, ReadOnlySpan<byte> mac)
|
||||
{
|
||||
Span<byte> expected = stackalloc byte[Bytes];
|
||||
Compute(key, data, expected);
|
||||
return CryptographicOperations.FixedTimeEquals(expected, mac);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsAotCompatible>true</IsAotCompatible>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<AdditionalFiles Include="$(MSBuildThisFileDirectory)../../../BannedSymbols.Node.txt" />
|
||||
<ProjectReference Include="..\Fleet.Core\Fleet.Core.csproj" />
|
||||
<ProjectReference Include="..\Fleet.Identity\Fleet.Identity.csproj" />
|
||||
<ProjectReference Include="..\Fleet.Rpc\Fleet.Rpc.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,317 @@
|
||||
using System.Net.WebSockets;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using Fleet.Core;
|
||||
using Fleet.Identity;
|
||||
using Fleet.Rpc;
|
||||
|
||||
namespace Fleet.Link;
|
||||
|
||||
public sealed record LinkContext(FleetLink Link);
|
||||
|
||||
/// <summary>期望状态的处理者:应用完整快照,返回已应用的版本和被拒绝的条目(带结构化原因)。</summary>
|
||||
public interface IStateHandler
|
||||
{
|
||||
string Type { get; }
|
||||
|
||||
ValueTask<StateSyncResult> ApplyAsync(long version, JsonElement document, CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed class StateHandler<TDoc> : IStateHandler
|
||||
{
|
||||
private readonly JsonTypeInfo<TDoc> _info;
|
||||
private readonly Func<long, TDoc, CancellationToken, ValueTask<StateSyncResult>> _apply;
|
||||
|
||||
public StateHandler(string type, JsonTypeInfo<TDoc> info, Func<long, TDoc, CancellationToken, ValueTask<StateSyncResult>> apply)
|
||||
{
|
||||
Type = type;
|
||||
_info = info;
|
||||
_apply = apply;
|
||||
}
|
||||
|
||||
public string Type { get; }
|
||||
|
||||
public async ValueTask<StateSyncResult> ApplyAsync(long version, JsonElement document, CancellationToken ct)
|
||||
{
|
||||
TDoc? doc = document.Deserialize(_info);
|
||||
return doc is null ? new StateSyncResult { AppliedVersion = 0 } : await _apply(version, doc, ct);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class FleetLinkOptions
|
||||
{
|
||||
/// <summary>面板的地址(http 或 https),连接路径固定为 <c>/api/agent/v1/rpc</c>。</summary>
|
||||
public required Uri Server { get; init; }
|
||||
|
||||
public required NodeIdentity Identity { get; init; }
|
||||
|
||||
public required NodeId NodeId { get; init; }
|
||||
|
||||
public string NodeVersion { get; init; } = "0.0.0";
|
||||
|
||||
/// <summary>框架之外的能力,例如 <c>monitor.v1</c> 与 <c>monitor.kinds:udp_echo,tcp_echo</c>。</summary>
|
||||
public IReadOnlyList<string> Capabilities { get; init; } = [];
|
||||
|
||||
/// <summary>附加到 <c>fleet.hello</c> 的产品元数据。</summary>
|
||||
public Func<JsonElement?>? HelloMeta { get; init; }
|
||||
|
||||
/// <summary>附加到 <c>fleet.heartbeat</c> 的进程内计数(不能包含主机信息)。</summary>
|
||||
public Func<JsonElement?>? HeartbeatExtra { get; init; }
|
||||
|
||||
public TimeSpan HeartbeatInterval { get; init; } = TimeSpan.FromSeconds(10);
|
||||
|
||||
public int MaxFrameBytes { get; init; } = 16 << 20;
|
||||
|
||||
/// <summary>发件箱在途字节上限(服务端慢时不会把内存撑爆)。</summary>
|
||||
public long MaxInflightBytes { get; init; } = 4 << 20;
|
||||
|
||||
public Backoff Backoff { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 节点到服务端的连接:挑战 → 签名认证 → hello → 状态调和 / 可靠发件箱 / 心跳,断线之后指数退避重连。
|
||||
/// 被服务端撤销(<c>fleet.revoked</c>)或禁用时停止重试。
|
||||
/// </summary>
|
||||
public sealed class FleetLink
|
||||
{
|
||||
private const string RpcPath = "/api/agent/v1/rpc";
|
||||
|
||||
private readonly FleetLinkOptions _options;
|
||||
private readonly RpcRouter<LinkContext> _router;
|
||||
private readonly Dictionary<string, IStateHandler> _handlers;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly INodeLog _log;
|
||||
private volatile RpcSession<LinkContext>? _session;
|
||||
private volatile bool _stopped;
|
||||
|
||||
public FleetLink(FleetLinkOptions options, RpcRouter<LinkContext> router, IEnumerable<IStateHandler> handlers, Outbox outbox, TimeProvider time, INodeLog log)
|
||||
{
|
||||
_options = options;
|
||||
_router = router;
|
||||
_handlers = handlers.ToDictionary(h => h.Type, StringComparer.Ordinal);
|
||||
Outbox = outbox;
|
||||
_time = time;
|
||||
_log = log;
|
||||
|
||||
_router
|
||||
.On(FleetMethods.StateSync, FleetRpcContext.Default.StateSyncParams, FleetRpcContext.Default.StateSyncResult, HandleStateSyncAsync)
|
||||
.OnNotify(FleetMethods.OutboxAck, FleetRpcContext.Default.OutboxAckParams, HandleAckAsync)
|
||||
.OnNotify(FleetMethods.Revoked, FleetRpcContext.Default.RevokedParams, HandleRevokedAsync);
|
||||
}
|
||||
|
||||
public Outbox Outbox { get; }
|
||||
|
||||
public bool IsConnected => _session is not null;
|
||||
|
||||
public RpcSession<LinkContext>? CurrentSession => _session;
|
||||
|
||||
/// <summary>被服务端撤销或禁用:不会再重连。</summary>
|
||||
public bool Revoked => _stopped;
|
||||
|
||||
/// <summary>每次连接并完成 hello 之后调用。</summary>
|
||||
public Func<RpcSession<LinkContext>, CancellationToken, ValueTask>? OnConnected { get; set; }
|
||||
|
||||
public Action<string>? OnDisconnected { get; set; }
|
||||
|
||||
public async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested && !_stopped)
|
||||
{
|
||||
string reason;
|
||||
try
|
||||
{
|
||||
reason = await RunOnceAsync(ct);
|
||||
_options.Backoff.Reset();
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (Exception ex) when (ex is WebSocketException or IOException or HttpRequestException or InvalidOperationException or TimeoutException)
|
||||
{
|
||||
reason = ex.Message;
|
||||
}
|
||||
|
||||
OnDisconnected?.Invoke(reason);
|
||||
if (ct.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_stopped)
|
||||
{
|
||||
_log.Warn("link stopped", ("err", reason));
|
||||
return;
|
||||
}
|
||||
|
||||
TimeSpan delay = _options.Backoff.Next();
|
||||
_log.Warn("link down, retrying", ("err", reason), ("retry_ms", (long)delay.TotalMilliseconds));
|
||||
await Task.Delay(delay, _time, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> RunOnceAsync(CancellationToken ct)
|
||||
{
|
||||
using var socket = new ClientWebSocket();
|
||||
socket.Options.KeepAliveInterval = TimeSpan.FromSeconds(15);
|
||||
socket.Options.KeepAliveTimeout = TimeSpan.FromSeconds(20); // 对端 20 秒没有回 Pong 就判定连接已死
|
||||
var builder = new UriBuilder(_options.Server) { Scheme = _options.Server.Scheme == "https" ? "wss" : "ws", Path = RpcPath };
|
||||
await socket.ConnectAsync(builder.Uri, ct);
|
||||
await using var transport = new WsTransport(socket, _options.MaxFrameBytes);
|
||||
|
||||
string? failure = await AuthenticateAsync(transport, ct);
|
||||
if (failure is not null)
|
||||
{
|
||||
return failure;
|
||||
}
|
||||
|
||||
var session = new RpcSession<LinkContext>(transport, _router, new LinkContext(this), _time);
|
||||
using CancellationTokenSource sessionCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
Task run = session.RunAsync(sessionCts.Token);
|
||||
try
|
||||
{
|
||||
var hello = new HelloParams
|
||||
{
|
||||
NodeVersion = _options.NodeVersion,
|
||||
Capabilities = [.. _options.Capabilities],
|
||||
NowUtcMs = _time.GetUtcNow().ToUnixTimeMilliseconds(),
|
||||
Meta = _options.HelloMeta?.Invoke(),
|
||||
};
|
||||
Result<HelloResult> helloResult = await session.RequestAsync(FleetMethods.Hello, hello, FleetRpcContext.Default.HelloParams, FleetRpcContext.Default.HelloResult, sessionCts.Token);
|
||||
if (!helloResult.IsSuccess)
|
||||
{
|
||||
return "hello failed: " + helloResult.Error.Code;
|
||||
}
|
||||
|
||||
Outbox.ResetSent();
|
||||
_session = session;
|
||||
_log.Info("link up", ("result", "connected"));
|
||||
Task pump = PumpAsync(session, sessionCts.Token);
|
||||
Task heartbeat = HeartbeatAsync(session, sessionCts.Token);
|
||||
if (OnConnected is not null)
|
||||
{
|
||||
await OnConnected(session, sessionCts.Token);
|
||||
}
|
||||
|
||||
Task finished = await Task.WhenAny(run, pump, heartbeat);
|
||||
await sessionCts.CancelAsync();
|
||||
await Task.WhenAll(Quiet(run), Quiet(pump), Quiet(heartbeat));
|
||||
return finished == run ? "closed by server" : "link task failed";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_session = null;
|
||||
await sessionCts.CancelAsync();
|
||||
await Quiet(run);
|
||||
await session.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>挑战 → 签名 → 认证结果。失败返回原因;服务端禁用该节点时不再重试。</summary>
|
||||
private async Task<string?> AuthenticateAsync(WsTransport transport, CancellationToken ct)
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15), _time);
|
||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
|
||||
RpcMessage? challenge = await ReceiveMessageAsync(transport, linked.Token);
|
||||
if (challenge?.Method != FleetMethods.Challenge || challenge.Params is null)
|
||||
{
|
||||
return "no challenge from the server";
|
||||
}
|
||||
|
||||
ChallengeParams nonce = challenge.Params.Value.Deserialize(FleetRpcContext.Default.ChallengeParams)!;
|
||||
string signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.Auth, _options.NodeId.Value, nonce.Nonce));
|
||||
RpcMessage request = RpcMessage.Request(1, FleetMethods.Auth, new AuthParams { NodeId = _options.NodeId.Value, Signature = signature }, FleetRpcContext.Default.AuthParams);
|
||||
await transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(request, FleetRpcContext.Default.RpcMessage)), linked.Token);
|
||||
|
||||
while (true)
|
||||
{
|
||||
RpcMessage? reply = await ReceiveMessageAsync(transport, linked.Token);
|
||||
if (reply is null)
|
||||
{
|
||||
return "connection closed during authentication";
|
||||
}
|
||||
|
||||
if (reply.Method == FleetMethods.Revoked)
|
||||
{
|
||||
_stopped = true;
|
||||
return "revoked";
|
||||
}
|
||||
|
||||
if (!reply.IsResponse)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (reply.Error is { } error)
|
||||
{
|
||||
if (error.Data?.Code is "fleet_node_disabled")
|
||||
{
|
||||
_stopped = true;
|
||||
}
|
||||
|
||||
return "authentication failed: " + error.Data?.Code;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<RpcMessage?> ReceiveMessageAsync(WsTransport transport, CancellationToken ct)
|
||||
{
|
||||
RpcFrame? frame = await transport.ReceiveAsync(ct);
|
||||
return frame is { Kind: FrameKind.Text } f ? JsonSerializer.Deserialize(f.Data.Span, FleetRpcContext.Default.RpcMessage) : null;
|
||||
}
|
||||
|
||||
private async Task PumpAsync(RpcSession<LinkContext> session, CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
byte[] frame = await Outbox.TakeNextAsync(_options.MaxInflightBytes, ct);
|
||||
await session.SendBinaryAsync(frame, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HeartbeatAsync(RpcSession<LinkContext> session, CancellationToken ct)
|
||||
{
|
||||
using var timer = new PeriodicTimer(_options.HeartbeatInterval, _time);
|
||||
while (await timer.WaitForNextTickAsync(ct))
|
||||
{
|
||||
await session.NotifyAsync(FleetMethods.Heartbeat, new HeartbeatParams { Extra = _options.HeartbeatExtra?.Invoke() }, FleetRpcContext.Default.HeartbeatParams, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask<Result<StateSyncResult>> HandleStateSyncAsync(LinkContext context, StateSyncParams parameters, CancellationToken ct)
|
||||
{
|
||||
if (!_handlers.TryGetValue(parameters.Type, out IStateHandler? handler))
|
||||
{
|
||||
return Result<StateSyncResult>.Fail(FleetErrors.Unsupported, $"unknown state type {parameters.Type}");
|
||||
}
|
||||
|
||||
return Result<StateSyncResult>.Ok(await handler.ApplyAsync(parameters.Version, parameters.Doc, ct));
|
||||
}
|
||||
|
||||
private ValueTask HandleAckAsync(LinkContext context, OutboxAckParams parameters, CancellationToken ct)
|
||||
{
|
||||
Outbox.Acknowledge(parameters.Ids.Concat(parameters.Duplicates).Select(id => Guid.TryParse(id, out Guid g) ? g : Guid.Empty));
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private ValueTask HandleRevokedAsync(LinkContext context, RevokedParams parameters, CancellationToken ct)
|
||||
{
|
||||
_stopped = true;
|
||||
_log.Warn("revoked by the server", ("err", parameters.Reason));
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private static async Task Quiet(Task task)
|
||||
{
|
||||
try
|
||||
{
|
||||
await task;
|
||||
}
|
||||
catch (Exception ex) when (ex is OperationCanceledException or WebSocketException or IOException or ObjectDisposedException)
|
||||
{
|
||||
// 会话结束时的正常异常
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
using Fleet.Rpc;
|
||||
|
||||
namespace Fleet.Link;
|
||||
|
||||
/// <summary>
|
||||
/// 可靠发件箱:至少一次投递。消息带 msg_id(UUIDv7),服务端按它去重,持久化之后才回复 ACK。
|
||||
/// 有界(按字节和条数):满了丢弃最旧的,并累计缺口数通知产品。默认只在内存里,节点重启会丢失未上传的数据。
|
||||
/// 重新连接之后,所有还没确认的消息按原顺序重发。
|
||||
/// </summary>
|
||||
public sealed class Outbox
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly LinkedList<Entry> _entries = [];
|
||||
private readonly Dictionary<Guid, LinkedListNode<Entry>> _byId = [];
|
||||
private readonly long _maxBytes;
|
||||
private readonly int _maxCount;
|
||||
private TaskCompletionSource _signal = NewSignal();
|
||||
private long _bytes;
|
||||
private long _inflightBytes;
|
||||
private long _dropped;
|
||||
|
||||
public Outbox(long maxBytes, int maxCount = int.MaxValue)
|
||||
{
|
||||
_maxBytes = maxBytes;
|
||||
_maxCount = maxCount;
|
||||
}
|
||||
|
||||
/// <summary>因队列已满而丢弃的消息数(缺口)。</summary>
|
||||
public long Dropped => Interlocked.Read(ref _dropped);
|
||||
|
||||
public long PendingBytes
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _bytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int PendingCount
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _entries.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>每丢弃一批消息调用一次(参数是这一批的条数)。</summary>
|
||||
public Action<int>? OnGap { get; set; }
|
||||
|
||||
public void Enqueue(byte channel, Guid msgId, ReadOnlySpan<byte> payload)
|
||||
{
|
||||
byte[] frame = OutboxFrame.Build(channel, msgId, payload);
|
||||
int dropped = 0;
|
||||
lock (_gate)
|
||||
{
|
||||
if (_byId.ContainsKey(msgId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
while (_entries.Count > 0 && (_bytes + frame.Length > _maxBytes || _entries.Count >= _maxCount))
|
||||
{
|
||||
Remove(_entries.First!);
|
||||
dropped++;
|
||||
}
|
||||
|
||||
_byId[msgId] = _entries.AddLast(new Entry(msgId, frame));
|
||||
_bytes += frame.Length;
|
||||
Wake();
|
||||
}
|
||||
|
||||
if (dropped > 0)
|
||||
{
|
||||
Interlocked.Add(ref _dropped, dropped);
|
||||
OnGap?.Invoke(dropped);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>连接断开之后:所有消息都视为没有发出,重连时重发。</summary>
|
||||
public void ResetSent()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
foreach (Entry entry in _entries)
|
||||
{
|
||||
entry.Sent = false;
|
||||
}
|
||||
|
||||
_inflightBytes = 0;
|
||||
Wake();
|
||||
}
|
||||
}
|
||||
|
||||
public void Acknowledge(IEnumerable<Guid> ids)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
foreach (Guid id in ids)
|
||||
{
|
||||
if (_byId.TryGetValue(id, out LinkedListNode<Entry>? node))
|
||||
{
|
||||
Remove(node);
|
||||
}
|
||||
}
|
||||
|
||||
Wake();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 取下一条还没发出的消息;在途字节达到 <paramref name="maxInflightBytes"/> 时等待 ACK(服务端慢时不会把内存撑爆)。
|
||||
/// </summary>
|
||||
public async ValueTask<byte[]> TakeNextAsync(long maxInflightBytes, CancellationToken ct)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
Task wait;
|
||||
lock (_gate)
|
||||
{
|
||||
foreach (Entry entry in _entries)
|
||||
{
|
||||
if (!entry.Sent)
|
||||
{
|
||||
// 一条消息比在途上限还大时也要放行,否则会永远卡住
|
||||
if (_inflightBytes == 0 || _inflightBytes + entry.Frame.Length <= maxInflightBytes)
|
||||
{
|
||||
entry.Sent = true;
|
||||
_inflightBytes += entry.Frame.Length;
|
||||
return entry.Frame;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
wait = _signal.Task;
|
||||
}
|
||||
|
||||
await wait.WaitAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
private void Remove(LinkedListNode<Entry> node)
|
||||
{
|
||||
_entries.Remove(node);
|
||||
_byId.Remove(node.Value.Id);
|
||||
_bytes -= node.Value.Frame.Length;
|
||||
if (node.Value.Sent)
|
||||
{
|
||||
_inflightBytes = Math.Max(0, _inflightBytes - node.Value.Frame.Length);
|
||||
}
|
||||
}
|
||||
|
||||
private void Wake()
|
||||
{
|
||||
_signal.TrySetResult();
|
||||
_signal = NewSignal();
|
||||
}
|
||||
|
||||
private static TaskCompletionSource NewSignal() => new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
private sealed class Entry
|
||||
{
|
||||
public Entry(Guid id, byte[] frame)
|
||||
{
|
||||
Id = id;
|
||||
Frame = frame;
|
||||
}
|
||||
|
||||
public Guid Id { get; }
|
||||
|
||||
public byte[] Frame { get; }
|
||||
|
||||
public bool Sent { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
using System.Net;
|
||||
using System.Net.Security;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text.Json;
|
||||
using Fleet.Core;
|
||||
using Fleet.Identity;
|
||||
using Fleet.Rpc;
|
||||
|
||||
namespace Fleet.Link;
|
||||
|
||||
/// <summary>一个已经认证的点对点会话:对端是谁、数据面使用的会话凭据、对端地址。</summary>
|
||||
public sealed record PeerSessionInfo(string PeerId, SessionCredentials Credentials, EndPoint? Remote, string? Version = null);
|
||||
|
||||
public sealed class PeerListenerOptions
|
||||
{
|
||||
public required IPEndPoint Listen { get; init; }
|
||||
|
||||
public required X509Certificate2 Certificate { get; init; }
|
||||
|
||||
/// <summary>按对端 ID 查找允许连接的公钥(SPKI)。不在列表里的对端一律拒绝(期望状态里的 peers)。</summary>
|
||||
public required Func<string, byte[]?> LookupPeerKey { get; init; }
|
||||
|
||||
public int MaxFrameBytes { get; init; } = 8 << 20;
|
||||
|
||||
public int MaxSessions { get; init; } = 8;
|
||||
|
||||
public TimeSpan HandshakeTimeout { get; init; } = TimeSpan.FromSeconds(10);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 监听点对点会话(Agent 一侧):TLS(自签名证书,对端按 SPKI 指纹固定)→ 挑战 → 对端用身份私钥签名(purpose = peer)
|
||||
/// → 临时 ECDH 派生数据面凭据。认证通过之后把会话交给产品代码,由它挂上自己的路由。
|
||||
/// </summary>
|
||||
public sealed class PeerListener
|
||||
{
|
||||
private readonly PeerListenerOptions _options;
|
||||
private readonly Func<PeerSessionInfo, IRpcTransport, CancellationToken, Task> _onSession;
|
||||
private readonly INodeLog _log;
|
||||
private readonly TimeProvider _time;
|
||||
private int _sessions;
|
||||
|
||||
public PeerListener(PeerListenerOptions options, Func<PeerSessionInfo, IRpcTransport, CancellationToken, Task> onSession, INodeLog log, TimeProvider time)
|
||||
{
|
||||
_options = options;
|
||||
_onSession = onSession;
|
||||
_log = log;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
/// <summary>实际监听的端口(配置为 0 时由系统分配);<see cref="RunAsync"/> 开始监听之后有效。</summary>
|
||||
public int Port { get; private set; }
|
||||
|
||||
public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
var listener = new TcpListener(_options.Listen);
|
||||
listener.Start();
|
||||
Port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
Started.TrySetResult();
|
||||
try
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
TcpClient client = await listener.AcceptTcpClientAsync(ct);
|
||||
if (Interlocked.Increment(ref _sessions) > _options.MaxSessions)
|
||||
{
|
||||
Interlocked.Decrement(ref _sessions);
|
||||
client.Dispose();
|
||||
continue;
|
||||
}
|
||||
|
||||
_ = Task.Run(() => HandleAsync(client, ct), CancellationToken.None);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 停止监听
|
||||
}
|
||||
finally
|
||||
{
|
||||
listener.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleAsync(TcpClient client, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
client.NoDelay = true;
|
||||
PeerSockets.EnableKeepAlive(client.Client);
|
||||
using var timeout = new CancellationTokenSource(_options.HandshakeTimeout, _time);
|
||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
|
||||
var tls = new SslStream(client.GetStream(), leaveInnerStreamOpen: false);
|
||||
await tls.AuthenticateAsServerAsync(
|
||||
new SslServerAuthenticationOptions { ServerCertificate = _options.Certificate, ClientCertificateRequired = false, EnabledSslProtocols = System.Security.Authentication.SslProtocols.Tls12 | System.Security.Authentication.SslProtocols.Tls13 },
|
||||
linked.Token);
|
||||
var transport = new TlsFrameTransport(tls, _options.MaxFrameBytes);
|
||||
PeerSessionInfo? info = await HandshakeAsync(transport, client.Client.RemoteEndPoint, linked.Token);
|
||||
if (info is null)
|
||||
{
|
||||
await transport.DisposeAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
_log.Info("peer session", ("probe", info.PeerId), ("result", "established"));
|
||||
await using (transport)
|
||||
{
|
||||
await _onSession(info, transport, ct);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or System.Security.Authentication.AuthenticationException or OperationCanceledException or SocketException or ObjectDisposedException)
|
||||
{
|
||||
// 握手失败或连接断开:这个会话结束,监听继续
|
||||
}
|
||||
finally
|
||||
{
|
||||
client.Dispose();
|
||||
Interlocked.Decrement(ref _sessions);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<PeerSessionInfo?> HandshakeAsync(TlsFrameTransport transport, EndPoint? remote, CancellationToken ct)
|
||||
{
|
||||
using var ephemeral = new EphemeralKey();
|
||||
byte[] nonceBytes = RandomNumberGenerator.GetBytes(32);
|
||||
string nonce = Convert.ToBase64String(nonceBytes);
|
||||
string serverPub = Convert.ToBase64String(ephemeral.PublicKeySpki);
|
||||
await SendAsync(transport, RpcMessage.Notification(PeerMethods.Challenge, new PeerChallengeParams { Nonce = nonce, EcdhPublicKey = serverPub }, PeerRpcContext.Default.PeerChallengeParams), ct);
|
||||
|
||||
RpcFrame? frame = await transport.ReceiveAsync(ct);
|
||||
RpcMessage? message = frame is { Kind: FrameKind.Text } f ? JsonSerializer.Deserialize(f.Data.Span, FleetRpcContext.Default.RpcMessage) : null;
|
||||
if (message?.Method != PeerMethods.Auth || message.Id is null || message.Params is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
PeerAuthParams? auth = null;
|
||||
try
|
||||
{
|
||||
auth = message.Params.Value.Deserialize(PeerRpcContext.Default.PeerAuthParams);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// 按缺失处理
|
||||
}
|
||||
|
||||
byte[]? key = auth is null ? null : _options.LookupPeerKey(auth.PeerId);
|
||||
byte[] clientPublic = [];
|
||||
bool ok = key is not null
|
||||
&& TryDecode(auth!.Signature, out byte[] signature)
|
||||
&& TryDecode(auth.EcdhPublicKey, out clientPublic)
|
||||
&& NodeIdentity.Verify(key, signature, SignaturePurpose.Peer, auth.PeerId, nonce, auth.EcdhPublicKey, serverPub);
|
||||
if (!ok)
|
||||
{
|
||||
// 对端不在允许列表里和签名错误返回同样的错误
|
||||
await SendAsync(transport, RpcMessage.Failure(message.Id, new FleetError(FleetErrors.Unauthorized, "peer authentication failed")), ct);
|
||||
_log.Warn("peer rejected", ("result", "unauthorized"));
|
||||
return null;
|
||||
}
|
||||
|
||||
SessionCredentials credentials;
|
||||
try
|
||||
{
|
||||
credentials = ephemeral.Derive(clientPublic, nonceBytes, clientPublic, ephemeral.PublicKeySpki);
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
await SendAsync(transport, RpcMessage.Failure(message.Id, new FleetError(FleetErrors.InvalidParams, "invalid ecdh key")), ct);
|
||||
return null;
|
||||
}
|
||||
|
||||
await SendAsync(transport, RpcMessage.Success(message.Id.Value, new PeerAuthResult { SessionId = Convert.ToHexStringLower(credentials.SessionId) }, PeerRpcContext.Default.PeerAuthResult), ct);
|
||||
return new PeerSessionInfo(auth!.PeerId, credentials, remote, auth.Version is { Length: <= 64 } version ? version : null);
|
||||
}
|
||||
|
||||
private static ValueTask SendAsync(TlsFrameTransport transport, RpcMessage message, CancellationToken ct) =>
|
||||
transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(message, FleetRpcContext.Default.RpcMessage)), ct);
|
||||
|
||||
private static bool TryDecode(string text, out byte[] data)
|
||||
{
|
||||
try
|
||||
{
|
||||
data = Convert.FromBase64String(text);
|
||||
return data.Length > 0;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
data = [];
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>发起点对点会话(Probe 一侧):按 SPKI 指纹固定对端证书,用身份私钥签名,派生同样的数据面凭据。</summary>
|
||||
/// <summary>点对点会话的套接字:开启 TCP keepalive(空闲 15 秒后每 5 秒探测,连续 3 次无响应就断开,约 30 秒),主机丢失或路径被切断时连接不会一直挂着。</summary>
|
||||
internal static class PeerSockets
|
||||
{
|
||||
public static void EnableKeepAlive(Socket socket)
|
||||
{
|
||||
try
|
||||
{
|
||||
socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true);
|
||||
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, 15);
|
||||
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, 5);
|
||||
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, 3);
|
||||
}
|
||||
catch (Exception ex) when (ex is SocketException or ArgumentException or PlatformNotSupportedException)
|
||||
{
|
||||
// 个别平台不支持其中某个选项:退回系统默认
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static class PeerConnector
|
||||
{
|
||||
public static async Task<(PeerSessionInfo Info, TlsFrameTransport Transport)> ConnectAsync(
|
||||
string host, int port, byte[] expectedSpkiSha256, NodeIdentity identity, string peerId, string? version, int maxFrameBytes, TimeProvider time, CancellationToken ct)
|
||||
{
|
||||
var client = new TcpClient { NoDelay = true };
|
||||
PeerSockets.EnableKeepAlive(client.Client);
|
||||
try
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10), time);
|
||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
|
||||
await client.ConnectAsync(host, port, linked.Token);
|
||||
var tls = new SslStream(
|
||||
client.GetStream(), leaveInnerStreamOpen: false,
|
||||
(_, certificate, _, _) => certificate is not null && SpkiMatches(certificate, expectedSpkiSha256));
|
||||
await tls.AuthenticateAsClientAsync(new SslClientAuthenticationOptions { TargetHost = host }, linked.Token);
|
||||
var transport = new TlsFrameTransport(tls, maxFrameBytes);
|
||||
|
||||
RpcFrame? first = await transport.ReceiveAsync(linked.Token);
|
||||
RpcMessage? challengeMessage = first is { Kind: FrameKind.Text } f ? JsonSerializer.Deserialize(f.Data.Span, FleetRpcContext.Default.RpcMessage) : null;
|
||||
if (challengeMessage?.Method != PeerMethods.Challenge || challengeMessage.Params is null)
|
||||
{
|
||||
throw new InvalidOperationException("the agent did not send a challenge");
|
||||
}
|
||||
|
||||
PeerChallengeParams challenge = challengeMessage.Params.Value.Deserialize(PeerRpcContext.Default.PeerChallengeParams)!;
|
||||
using var ephemeral = new EphemeralKey();
|
||||
string clientPub = Convert.ToBase64String(ephemeral.PublicKeySpki);
|
||||
string signature = Convert.ToBase64String(identity.Sign(SignaturePurpose.Peer, peerId, challenge.Nonce, clientPub, challenge.EcdhPublicKey));
|
||||
RpcMessage request = RpcMessage.Request(1, PeerMethods.Auth, new PeerAuthParams { PeerId = peerId, EcdhPublicKey = clientPub, Signature = signature, Version = version }, PeerRpcContext.Default.PeerAuthParams);
|
||||
await transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(request, FleetRpcContext.Default.RpcMessage)), linked.Token);
|
||||
|
||||
RpcFrame? replyFrame = await transport.ReceiveAsync(linked.Token);
|
||||
RpcMessage? reply = replyFrame is { Kind: FrameKind.Text } r ? JsonSerializer.Deserialize(r.Data.Span, FleetRpcContext.Default.RpcMessage) : null;
|
||||
if (reply is null || reply.Error is not null)
|
||||
{
|
||||
throw new InvalidOperationException("peer authentication failed: " + reply?.Error?.Data?.Code);
|
||||
}
|
||||
|
||||
SessionCredentials credentials = ephemeral.Derive(Convert.FromBase64String(challenge.EcdhPublicKey), Convert.FromBase64String(challenge.Nonce), ephemeral.PublicKeySpki, Convert.FromBase64String(challenge.EcdhPublicKey));
|
||||
string announced = reply.Result!.Value.Deserialize(PeerRpcContext.Default.PeerAuthResult)!.SessionId;
|
||||
if (announced != Convert.ToHexStringLower(credentials.SessionId))
|
||||
{
|
||||
throw new InvalidOperationException("session id mismatch: the key agreement did not produce the same credentials");
|
||||
}
|
||||
|
||||
return (new PeerSessionInfo(peerId, credentials, client.Client.RemoteEndPoint), transport);
|
||||
}
|
||||
catch
|
||||
{
|
||||
client.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>固定证书:只看 SPKI 的 SHA-256 指纹,不看证书链和主机名(自签名证书)。</summary>
|
||||
private static bool SpkiMatches(X509Certificate certificate, byte[] expected)
|
||||
{
|
||||
X509Certificate2 full = certificate as X509Certificate2 ?? X509CertificateLoader.LoadCertificate(certificate.GetRawCertData());
|
||||
return CryptographicOperations.FixedTimeEquals(NodeCertificate.SpkiFingerprint(full), expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Net.WebSockets;
|
||||
using Fleet.Rpc;
|
||||
|
||||
namespace Fleet.Link;
|
||||
|
||||
/// <summary>WebSocket 传输(节点 → 服务端):文本帧是 JSON-RPC,二进制帧是发件箱。</summary>
|
||||
public sealed class WsTransport : IRpcTransport
|
||||
{
|
||||
private readonly WebSocket _socket;
|
||||
private readonly int _maxFrameBytes;
|
||||
private readonly SemaphoreSlim _sendLock = new(1, 1);
|
||||
|
||||
public WsTransport(WebSocket socket, int maxFrameBytes)
|
||||
{
|
||||
_socket = socket;
|
||||
_maxFrameBytes = maxFrameBytes;
|
||||
}
|
||||
|
||||
public async ValueTask SendAsync(RpcFrame frame, CancellationToken ct)
|
||||
{
|
||||
await _sendLock.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
await _socket.SendAsync(frame.Data, frame.Kind == FrameKind.Text ? WebSocketMessageType.Text : WebSocketMessageType.Binary, endOfMessage: true, ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_sendLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<RpcFrame?> ReceiveAsync(CancellationToken ct)
|
||||
{
|
||||
var message = new MemoryStream();
|
||||
byte[] buffer = new byte[16 * 1024];
|
||||
while (true)
|
||||
{
|
||||
ValueWebSocketReceiveResult result = await _socket.ReceiveAsync(buffer.AsMemory(), ct);
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
if (_socket.State == WebSocketState.CloseReceived)
|
||||
{
|
||||
// 按协议确认对端发起的关闭
|
||||
try
|
||||
{
|
||||
await _socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "ack", CancellationToken.None);
|
||||
}
|
||||
catch (WebSocketException)
|
||||
{
|
||||
// 对端已经断开
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
message.Write(buffer, 0, result.Count);
|
||||
if (message.Length > _maxFrameBytes)
|
||||
{
|
||||
await CloseAsync("frame too large", CancellationToken.None);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (result.EndOfMessage)
|
||||
{
|
||||
return new RpcFrame(result.MessageType == WebSocketMessageType.Text ? FrameKind.Text : FrameKind.Binary, message.ToArray());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask CloseAsync(string reason, CancellationToken ct)
|
||||
{
|
||||
if (_socket.State is WebSocketState.Open or WebSocketState.CloseReceived)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, reason, ct);
|
||||
}
|
||||
catch (WebSocketException)
|
||||
{
|
||||
// 对端已经断开
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
_socket.Dispose();
|
||||
_sendLock.Dispose();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TLS 帧传输(Probe → Agent):<c>u32 LE 正文长度 | u8 类型(0 文本, 1 二进制) | 正文</c>。
|
||||
/// 比 WebSocket 轻得多,Agent 不需要内置 HTTP 服务端。
|
||||
/// </summary>
|
||||
public sealed class TlsFrameTransport : IRpcTransport
|
||||
{
|
||||
private const int HeaderBytes = 5;
|
||||
|
||||
private readonly Stream _stream;
|
||||
private readonly int _maxFrameBytes;
|
||||
private readonly SemaphoreSlim _sendLock = new(1, 1);
|
||||
|
||||
public TlsFrameTransport(Stream stream, int maxFrameBytes)
|
||||
{
|
||||
_stream = stream;
|
||||
_maxFrameBytes = maxFrameBytes;
|
||||
}
|
||||
|
||||
public async ValueTask SendAsync(RpcFrame frame, CancellationToken ct)
|
||||
{
|
||||
if (frame.Data.Length > _maxFrameBytes)
|
||||
{
|
||||
throw new InvalidOperationException("frame exceeds the maximum size");
|
||||
}
|
||||
|
||||
byte[] header = new byte[HeaderBytes];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(header, (uint)frame.Data.Length);
|
||||
header[4] = (byte)frame.Kind;
|
||||
await _sendLock.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
await _stream.WriteAsync(header, ct);
|
||||
await _stream.WriteAsync(frame.Data, ct);
|
||||
await _stream.FlushAsync(ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_sendLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<RpcFrame?> ReceiveAsync(CancellationToken ct)
|
||||
{
|
||||
byte[] header = new byte[HeaderBytes];
|
||||
try
|
||||
{
|
||||
if (!await ReadExactlyOrEofAsync(header, ct))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
uint length = BinaryPrimitives.ReadUInt32LittleEndian(header);
|
||||
if (length > _maxFrameBytes || header[4] > (byte)FrameKind.Binary)
|
||||
{
|
||||
await _stream.DisposeAsync(); // 非法帧:直接断开
|
||||
return null;
|
||||
}
|
||||
|
||||
byte[] body = new byte[length];
|
||||
if (!await ReadExactlyOrEofAsync(body, ct))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new RpcFrame((FrameKind)header[4], body);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask CloseAsync(string reason, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _stream.FlushAsync(ct);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or ObjectDisposedException)
|
||||
{
|
||||
// 对端已经断开
|
||||
}
|
||||
|
||||
await _stream.DisposeAsync();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _stream.DisposeAsync();
|
||||
_sendLock.Dispose();
|
||||
}
|
||||
|
||||
private async ValueTask<bool> ReadExactlyOrEofAsync(byte[] buffer, CancellationToken ct)
|
||||
{
|
||||
int read = 0;
|
||||
while (read < buffer.Length)
|
||||
{
|
||||
int n = await _stream.ReadAsync(buffer.AsMemory(read), ct);
|
||||
if (n == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
read += n;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsAotCompatible>true</IsAotCompatible>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<AdditionalFiles Include="$(MSBuildThisFileDirectory)../../../BannedSymbols.Node.txt" />
|
||||
<ProjectReference Include="..\Fleet.Core\Fleet.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,140 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Fleet.Rpc;
|
||||
|
||||
/// <summary>框架保留的方法名。<c>fleet.*</c> 只属于框架,产品使用自己的前缀。</summary>
|
||||
public static class FleetMethods
|
||||
{
|
||||
public const string Challenge = "fleet.challenge";
|
||||
public const string Auth = "fleet.auth";
|
||||
public const string Hello = "fleet.hello";
|
||||
public const string Heartbeat = "fleet.heartbeat";
|
||||
public const string StateSync = "fleet.state.sync";
|
||||
public const string OutboxAck = "fleet.outbox.ack";
|
||||
public const string Revoked = "fleet.revoked";
|
||||
}
|
||||
|
||||
/// <summary>二进制通道:0–15 保留给框架,产品从 16 开始。</summary>
|
||||
public static class FleetChannels
|
||||
{
|
||||
public const byte FirstProductChannel = 16;
|
||||
}
|
||||
|
||||
public sealed class ChallengeParams
|
||||
{
|
||||
[JsonPropertyName("nonce")]
|
||||
public string Nonce { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("expires_at_ms")]
|
||||
public long ExpiresAtMs { get; set; }
|
||||
}
|
||||
|
||||
public sealed class AuthParams
|
||||
{
|
||||
[JsonPropertyName("node_id")]
|
||||
public string NodeId { get; set; } = "";
|
||||
|
||||
/// <summary>对 <c>fleet|v1|auth|{node_id}|{nonce}</c> 的签名(base64)。</summary>
|
||||
[JsonPropertyName("signature")]
|
||||
public string Signature { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class AuthResult
|
||||
{
|
||||
/// <summary>pending | active</summary>
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class HelloParams
|
||||
{
|
||||
[JsonPropertyName("node_version")]
|
||||
public string NodeVersion { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("capabilities")]
|
||||
public List<string> Capabilities { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("now_utc_ms")]
|
||||
public long NowUtcMs { get; set; }
|
||||
|
||||
/// <summary>产品自定义的元数据(例如 Monitor 的 tls_spki、probe_ports、local_limits 摘要)。</summary>
|
||||
[JsonPropertyName("meta")]
|
||||
public JsonElement? Meta { get; set; }
|
||||
}
|
||||
|
||||
public sealed class HelloResult
|
||||
{
|
||||
[JsonPropertyName("server_time_ms")]
|
||||
public long ServerTimeMs { get; set; }
|
||||
|
||||
/// <summary>节点时钟 − 服务端时钟(毫秒,未扣除网络往返)。</summary>
|
||||
[JsonPropertyName("clock_offset_ms")]
|
||||
public long ClockOffsetMs { get; set; }
|
||||
}
|
||||
|
||||
public sealed class HeartbeatParams
|
||||
{
|
||||
[JsonPropertyName("extra")]
|
||||
public JsonElement? Extra { get; set; }
|
||||
}
|
||||
|
||||
public sealed class StateSyncParams
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public long Version { get; set; }
|
||||
|
||||
[JsonPropertyName("doc")]
|
||||
public JsonElement Doc { get; set; }
|
||||
}
|
||||
|
||||
public sealed class StateSyncResult
|
||||
{
|
||||
[JsonPropertyName("applied_version")]
|
||||
public long AppliedVersion { get; set; }
|
||||
|
||||
[JsonPropertyName("rejected")]
|
||||
public List<RejectedItem> Rejected { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class RejectedItem
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
/// <summary>unsupported | budget_exceeded | policy_denied | …</summary>
|
||||
[JsonPropertyName("reason")]
|
||||
public string Reason { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class OutboxAckParams
|
||||
{
|
||||
[JsonPropertyName("ids")]
|
||||
public List<string> Ids { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("duplicates")]
|
||||
public List<string> Duplicates { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class RevokedParams
|
||||
{
|
||||
[JsonPropertyName("reason")]
|
||||
public string Reason { get; set; } = "";
|
||||
}
|
||||
|
||||
[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
[JsonSerializable(typeof(RpcMessage))]
|
||||
[JsonSerializable(typeof(ChallengeParams))]
|
||||
[JsonSerializable(typeof(AuthParams))]
|
||||
[JsonSerializable(typeof(AuthResult))]
|
||||
[JsonSerializable(typeof(HelloParams))]
|
||||
[JsonSerializable(typeof(HelloResult))]
|
||||
[JsonSerializable(typeof(HeartbeatParams))]
|
||||
[JsonSerializable(typeof(StateSyncParams))]
|
||||
[JsonSerializable(typeof(StateSyncResult))]
|
||||
[JsonSerializable(typeof(OutboxAckParams))]
|
||||
[JsonSerializable(typeof(RevokedParams))]
|
||||
public sealed partial class FleetRpcContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace Fleet.Rpc;
|
||||
|
||||
/// <summary>
|
||||
/// 发件箱二进制帧:<c>u8 channel | 16 字节 msg_id (UUIDv7) | 正文</c>。
|
||||
/// 服务端按 msg_id 去重,持久化之后才回复 <c>fleet.outbox.ack</c>。
|
||||
/// </summary>
|
||||
public static class OutboxFrame
|
||||
{
|
||||
public const int HeaderBytes = 1 + 16;
|
||||
|
||||
public static bool TryParse(ReadOnlyMemory<byte> frame, out byte channel, out Guid msgId, out ReadOnlyMemory<byte> payload)
|
||||
{
|
||||
if (frame.Length < HeaderBytes)
|
||||
{
|
||||
channel = 0;
|
||||
msgId = Guid.Empty;
|
||||
payload = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
channel = frame.Span[0];
|
||||
msgId = new Guid(frame.Span.Slice(1, 16), bigEndian: true);
|
||||
payload = frame[HeaderBytes..];
|
||||
return true;
|
||||
}
|
||||
|
||||
public static byte[] Build(byte channel, Guid msgId, ReadOnlySpan<byte> payload)
|
||||
{
|
||||
byte[] frame = new byte[HeaderBytes + payload.Length];
|
||||
frame[0] = channel;
|
||||
msgId.TryWriteBytes(frame.AsSpan(1, 16), bigEndian: true, out _);
|
||||
payload.CopyTo(frame.AsSpan(HeaderBytes));
|
||||
return frame;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Fleet.Rpc;
|
||||
|
||||
/// <summary>
|
||||
/// 节点间点对点会话的握手(Probe → Agent,TLS 帧)。除了签名认证,双方各生成一个临时 ECDH 密钥,
|
||||
/// 经过 HKDF 派生出数据面使用的 <c>session_id</c> 与 <c>session_key</c>(所以不需要单独的“下发密钥”方法)。
|
||||
/// </summary>
|
||||
public static class PeerMethods
|
||||
{
|
||||
public const string Challenge = "fleet.peer.challenge";
|
||||
public const string Auth = "fleet.peer.auth";
|
||||
}
|
||||
|
||||
public sealed class PeerChallengeParams
|
||||
{
|
||||
[JsonPropertyName("nonce")]
|
||||
public string Nonce { get; set; } = "";
|
||||
|
||||
/// <summary>服务端(监听方)的临时 ECDH 公钥(SPKI,base64)。</summary>
|
||||
[JsonPropertyName("ecdh_pub")]
|
||||
public string EcdhPublicKey { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class PeerAuthParams
|
||||
{
|
||||
[JsonPropertyName("peer_id")]
|
||||
public string PeerId { get; set; } = "";
|
||||
|
||||
/// <summary>发起方的临时 ECDH 公钥(SPKI,base64)。</summary>
|
||||
[JsonPropertyName("ecdh_pub")]
|
||||
public string EcdhPublicKey { get; set; } = "";
|
||||
|
||||
/// <summary>对 <c>fleet|v1|peer|{peer_id}|{nonce}|{client_ecdh_pub}|{server_ecdh_pub}</c> 的签名(base64)。</summary>
|
||||
[JsonPropertyName("signature")]
|
||||
public string Signature { get; set; } = "";
|
||||
|
||||
/// <summary>发起方的程序版本(可选,只用于展示)。</summary>
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; set; }
|
||||
}
|
||||
|
||||
public sealed class PeerAuthResult
|
||||
{
|
||||
[JsonPropertyName("session_id")]
|
||||
public string SessionId { get; set; } = "";
|
||||
}
|
||||
|
||||
[JsonSerializable(typeof(PeerChallengeParams))]
|
||||
[JsonSerializable(typeof(PeerAuthParams))]
|
||||
[JsonSerializable(typeof(PeerAuthResult))]
|
||||
public sealed partial class PeerRpcContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using Fleet.Core;
|
||||
|
||||
namespace Fleet.Rpc;
|
||||
|
||||
/// <summary>JSON-RPC 2.0 信封。请求、响应、通知共用一个类型,缺失的字段不序列化。</summary>
|
||||
public sealed class RpcMessage
|
||||
{
|
||||
[JsonPropertyName("jsonrpc")]
|
||||
public string Jsonrpc { get; set; } = "2.0";
|
||||
|
||||
[JsonPropertyName("id")]
|
||||
public long? Id { get; set; }
|
||||
|
||||
[JsonPropertyName("method")]
|
||||
public string? Method { get; set; }
|
||||
|
||||
[JsonPropertyName("params")]
|
||||
public JsonElement? Params { get; set; }
|
||||
|
||||
[JsonPropertyName("result")]
|
||||
public JsonElement? Result { get; set; }
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public RpcError? Error { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public bool IsRequest => Method is not null && Id is not null;
|
||||
|
||||
[JsonIgnore]
|
||||
public bool IsNotification => Method is not null && Id is null;
|
||||
|
||||
[JsonIgnore]
|
||||
public bool IsResponse => Method is null && Id is not null;
|
||||
|
||||
public static RpcMessage Request<T>(long id, string method, T value, JsonTypeInfo<T> typeInfo) =>
|
||||
new() { Id = id, Method = method, Params = JsonSerializer.SerializeToElement(value, typeInfo) };
|
||||
|
||||
public static RpcMessage Notification<T>(string method, T value, JsonTypeInfo<T> typeInfo) =>
|
||||
new() { Method = method, Params = JsonSerializer.SerializeToElement(value, typeInfo) };
|
||||
|
||||
public static RpcMessage Success<T>(long id, T value, JsonTypeInfo<T> typeInfo) =>
|
||||
new() { Id = id, Result = JsonSerializer.SerializeToElement(value, typeInfo) };
|
||||
|
||||
public static RpcMessage Failure(long? id, FleetError error) =>
|
||||
new() { Id = id, Error = RpcError.From(error) };
|
||||
}
|
||||
|
||||
public sealed class RpcError
|
||||
{
|
||||
public const int MethodNotFound = -32601;
|
||||
public const int InvalidParams = -32602;
|
||||
public const int InternalError = -32603;
|
||||
public const int ApplicationError = -32000;
|
||||
|
||||
[JsonPropertyName("code")]
|
||||
public int Code { get; set; }
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string Message { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("data")]
|
||||
public RpcErrorData? Data { get; set; }
|
||||
|
||||
public static RpcError From(FleetError error)
|
||||
{
|
||||
int code = error.Code == FleetErrors.Unsupported ? MethodNotFound
|
||||
: error.Code == FleetErrors.InvalidParams ? InvalidParams
|
||||
: error.Code == FleetErrors.Internal ? InternalError
|
||||
: ApplicationError;
|
||||
return new RpcError { Code = code, Message = error.Message, Data = new RpcErrorData { Code = error.Code.Value } };
|
||||
}
|
||||
|
||||
public FleetError ToFleetError() =>
|
||||
new(new ErrorCode(Data?.Code ?? "fleet_internal"), Message);
|
||||
}
|
||||
|
||||
public sealed class RpcErrorData
|
||||
{
|
||||
[JsonPropertyName("code")]
|
||||
public string Code { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("detail")]
|
||||
public string? Detail { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using Fleet.Core;
|
||||
|
||||
namespace Fleet.Rpc;
|
||||
|
||||
/// <summary>方法路由,与传输方式无关。<typeparamref name="TContext"/> 由传输层提供(例如调用方节点)。</summary>
|
||||
public sealed class RpcRouter<TContext>
|
||||
{
|
||||
private delegate ValueTask<Result<JsonElement?>> Route(TContext context, JsonElement? parameters, CancellationToken ct);
|
||||
|
||||
private static readonly JsonElement s_emptyObject = JsonDocument.Parse("{}").RootElement.Clone();
|
||||
|
||||
private readonly Dictionary<string, Route> _routes = new(StringComparer.Ordinal);
|
||||
private readonly Action<string, Exception>? _onUnhandled;
|
||||
|
||||
public RpcRouter(Action<string, Exception>? onUnhandled = null)
|
||||
{
|
||||
_onUnhandled = onUnhandled;
|
||||
}
|
||||
|
||||
public bool Has(string method) => _routes.ContainsKey(method);
|
||||
|
||||
public IReadOnlyCollection<string> Methods => _routes.Keys;
|
||||
|
||||
public RpcRouter<TContext> On<TParams, TResult>(
|
||||
string method,
|
||||
JsonTypeInfo<TParams> paramsInfo,
|
||||
JsonTypeInfo<TResult> resultInfo,
|
||||
Func<TContext, TParams, CancellationToken, ValueTask<Result<TResult>>> handler)
|
||||
{
|
||||
_routes[method] = async (context, raw, ct) =>
|
||||
{
|
||||
if (!TryDeserialize(raw, paramsInfo, out TParams? p))
|
||||
{
|
||||
return Result<JsonElement?>.Fail(FleetErrors.InvalidParams, "params could not be parsed");
|
||||
}
|
||||
|
||||
Result<TResult> r = await handler(context, p, ct);
|
||||
return r.TryGet(out TResult? value, out FleetError? error)
|
||||
? Result<JsonElement?>.Ok(JsonSerializer.SerializeToElement(value, resultInfo))
|
||||
: Result<JsonElement?>.Fail(error);
|
||||
};
|
||||
return this;
|
||||
}
|
||||
|
||||
public RpcRouter<TContext> OnNotify<TParams>(
|
||||
string method,
|
||||
JsonTypeInfo<TParams> paramsInfo,
|
||||
Func<TContext, TParams, CancellationToken, ValueTask> handler)
|
||||
{
|
||||
_routes[method] = async (context, raw, ct) =>
|
||||
{
|
||||
if (!TryDeserialize(raw, paramsInfo, out TParams? p))
|
||||
{
|
||||
return Result<JsonElement?>.Fail(FleetErrors.InvalidParams, "params could not be parsed");
|
||||
}
|
||||
|
||||
await handler(context, p, ct);
|
||||
return Result<JsonElement?>.Ok(null);
|
||||
};
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 处理一条请求或通知。请求返回响应;通知返回 null(失败时只丢弃)。
|
||||
/// 处理函数里的异常属于程序缺陷:在这个边界捕获,回复 internal 错误,并交给 <c>onUnhandled</c> 记录。
|
||||
/// </summary>
|
||||
public async ValueTask<RpcMessage?> DispatchAsync(TContext context, RpcMessage message, CancellationToken ct)
|
||||
{
|
||||
string method = message.Method ?? "";
|
||||
if (!_routes.TryGetValue(method, out Route? route))
|
||||
{
|
||||
return message.Id is null ? null : RpcMessage.Failure(message.Id, new FleetError(FleetErrors.Unsupported, $"unknown method {method}"));
|
||||
}
|
||||
|
||||
Result<JsonElement?> result;
|
||||
try
|
||||
{
|
||||
result = await route(context, message.Params, ct);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_onUnhandled?.Invoke(method, ex);
|
||||
result = Result<JsonElement?>.Fail(FleetErrors.Internal, "internal error");
|
||||
}
|
||||
|
||||
if (message.Id is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.TryGet(out JsonElement? value, out FleetError? error)
|
||||
? new RpcMessage { Id = message.Id, Result = value ?? s_emptyObject }
|
||||
: RpcMessage.Failure(message.Id, error);
|
||||
}
|
||||
|
||||
private static bool TryDeserialize<T>(JsonElement? raw, JsonTypeInfo<T> info, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out T? value)
|
||||
{
|
||||
value = default;
|
||||
try
|
||||
{
|
||||
value = raw is null ? JsonSerializer.Deserialize("{}", info) : raw.Value.Deserialize(info);
|
||||
return value is not null;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using System.Threading.Channels;
|
||||
using Fleet.Core;
|
||||
|
||||
namespace Fleet.Rpc;
|
||||
|
||||
public enum FrameKind : byte
|
||||
{
|
||||
Text = 0,
|
||||
Binary = 1,
|
||||
}
|
||||
|
||||
public readonly record struct RpcFrame(FrameKind Kind, ReadOnlyMemory<byte> Data);
|
||||
|
||||
/// <summary>
|
||||
/// 一条双向的、保序的消息通道:文本帧承载 JSON-RPC,二进制帧承载发件箱。
|
||||
/// 节点到服务端用 WebSocket,Probe 到 Agent 用 TLS 帧;会话引擎对两者一视同仁。
|
||||
/// </summary>
|
||||
public interface IRpcTransport : IAsyncDisposable
|
||||
{
|
||||
ValueTask SendAsync(RpcFrame frame, CancellationToken ct);
|
||||
|
||||
/// <summary>对端关闭时返回 null。</summary>
|
||||
ValueTask<RpcFrame?> ReceiveAsync(CancellationToken ct);
|
||||
|
||||
ValueTask CloseAsync(string reason, CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed class RpcSessionOptions
|
||||
{
|
||||
public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(15);
|
||||
|
||||
/// <summary>待处理的入站请求上限;超过时接收循环等待(背压)。</summary>
|
||||
public int InboundQueue { get; set; } = 256;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 传输无关的会话:请求与响应的关联、方法路由、二进制帧。
|
||||
/// 入站的请求和通知由独立的任务按顺序处理,接收循环只负责读帧和完成响应,
|
||||
/// 所以处理函数里可以等待向对端发起的请求而不会死锁。
|
||||
/// </summary>
|
||||
public sealed class RpcSession<TContext> : IAsyncDisposable
|
||||
{
|
||||
private readonly IRpcTransport _transport;
|
||||
private readonly RpcRouter<TContext> _router;
|
||||
private readonly TContext _context;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly RpcSessionOptions _options;
|
||||
private readonly ConcurrentDictionary<long, TaskCompletionSource<RpcMessage>> _pending = new();
|
||||
private readonly Channel<RpcMessage> _inbound;
|
||||
private readonly CancellationTokenSource _stop = new();
|
||||
private long _nextId;
|
||||
private long _lastReceive;
|
||||
|
||||
public RpcSession(IRpcTransport transport, RpcRouter<TContext> router, TContext context, TimeProvider time, RpcSessionOptions? options = null)
|
||||
{
|
||||
_transport = transport;
|
||||
_router = router;
|
||||
_context = context;
|
||||
_time = time;
|
||||
_lastReceive = time.GetTimestamp();
|
||||
_options = options ?? new RpcSessionOptions();
|
||||
_inbound = Channel.CreateBounded<RpcMessage>(new BoundedChannelOptions(_options.InboundQueue) { SingleReader = true, FullMode = BoundedChannelFullMode.Wait });
|
||||
}
|
||||
|
||||
/// <summary>收到二进制帧:<c>u8 channel | 正文</c>(发件箱帧再往里解析)。</summary>
|
||||
public Func<ReadOnlyMemory<byte>, CancellationToken, ValueTask>? OnBinary { get; set; }
|
||||
|
||||
/// <summary>对端上一次发来任何帧到现在的时间。静默死亡(断电、进程被冻结)的对端不会关闭连接,调用方据此设置空闲超时。</summary>
|
||||
public TimeSpan IdleFor => _time.GetElapsedTime(Volatile.Read(ref _lastReceive));
|
||||
|
||||
/// <summary>读帧并分发,直到连接关闭或被取消。</summary>
|
||||
public async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, _stop.Token);
|
||||
Task dispatcher = Task.Run(() => DispatchAsync(linked.Token), CancellationToken.None);
|
||||
try
|
||||
{
|
||||
while (!linked.IsCancellationRequested)
|
||||
{
|
||||
RpcFrame? frame = await _transport.ReceiveAsync(linked.Token);
|
||||
if (frame is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Volatile.Write(ref _lastReceive, _time.GetTimestamp());
|
||||
if (frame.Value.Kind == FrameKind.Binary)
|
||||
{
|
||||
if (OnBinary is not null)
|
||||
{
|
||||
await OnBinary(frame.Value.Data, linked.Token);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
RpcMessage? message = Parse(frame.Value.Data);
|
||||
if (message is null)
|
||||
{
|
||||
continue; // 格式不对的帧忽略,连接保持可用
|
||||
}
|
||||
|
||||
if (message.IsResponse)
|
||||
{
|
||||
if (_pending.TryGetValue(message.Id!.Value, out TaskCompletionSource<RpcMessage>? waiter))
|
||||
{
|
||||
waiter.TrySetResult(message);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await _inbound.Writer.WriteAsync(message, linked.Token);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_inbound.Writer.TryComplete();
|
||||
foreach (TaskCompletionSource<RpcMessage> waiter in _pending.Values)
|
||||
{
|
||||
waiter.TrySetCanceled(CancellationToken.None);
|
||||
}
|
||||
|
||||
await linked.CancelAsync();
|
||||
try
|
||||
{
|
||||
await dispatcher;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 正常结束
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<Result<JsonElement>> RequestAsync(string method, JsonElement? parameters, CancellationToken ct, TimeSpan? timeout = null)
|
||||
{
|
||||
long id = Interlocked.Increment(ref _nextId);
|
||||
var waiter = new TaskCompletionSource<RpcMessage>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_pending[id] = waiter;
|
||||
try
|
||||
{
|
||||
await SendAsync(new RpcMessage { Id = id, Method = method, Params = parameters }, ct);
|
||||
using var timer = new CancellationTokenSource(timeout ?? _options.RequestTimeout, _time);
|
||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timer.Token);
|
||||
RpcMessage reply = await waiter.Task.WaitAsync(linked.Token);
|
||||
return reply.Error is not null
|
||||
? Result<JsonElement>.Fail(reply.Error.ToFleetError())
|
||||
: Result<JsonElement>.Ok(reply.Result ?? default);
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
return Result<JsonElement>.Fail(FleetErrors.Timeout, $"{method} timed out or the session closed");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_pending.TryRemove(id, out _);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<Result<TResult>> RequestAsync<TParams, TResult>(
|
||||
string method, TParams parameters, JsonTypeInfo<TParams> paramsInfo, JsonTypeInfo<TResult> resultInfo, CancellationToken ct, TimeSpan? timeout = null)
|
||||
{
|
||||
Result<JsonElement> raw = await RequestAsync(method, JsonSerializer.SerializeToElement(parameters, paramsInfo), ct, timeout);
|
||||
if (!raw.TryGet(out JsonElement value, out FleetError? error))
|
||||
{
|
||||
return Result<TResult>.Fail(error);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
TResult? result = value.Deserialize(resultInfo);
|
||||
return result is null ? Result<TResult>.Fail(FleetErrors.Internal, "empty result") : Result<TResult>.Ok(result);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return Result<TResult>.Fail(FleetErrors.Internal, "result could not be parsed");
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask NotifyAsync<TParams>(string method, TParams parameters, JsonTypeInfo<TParams> paramsInfo, CancellationToken ct) =>
|
||||
SendAsync(RpcMessage.Notification(method, parameters, paramsInfo), ct);
|
||||
|
||||
public ValueTask SendAsync(RpcMessage message, CancellationToken ct) =>
|
||||
_transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(message, FleetRpcContext.Default.RpcMessage)), ct);
|
||||
|
||||
public ValueTask SendBinaryAsync(ReadOnlyMemory<byte> frame, CancellationToken ct) =>
|
||||
_transport.SendAsync(new RpcFrame(FrameKind.Binary, frame), ct);
|
||||
|
||||
public ValueTask CloseAsync(string reason, CancellationToken ct) => _transport.CloseAsync(reason, ct);
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _stop.CancelAsync();
|
||||
await _transport.DisposeAsync();
|
||||
_stop.Dispose();
|
||||
}
|
||||
|
||||
private async Task DispatchAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await foreach (RpcMessage message in _inbound.Reader.ReadAllAsync(ct))
|
||||
{
|
||||
RpcMessage? response = await _router.DispatchAsync(_context, message, ct);
|
||||
if (response is not null)
|
||||
{
|
||||
await SendAsync(response, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 会话结束
|
||||
}
|
||||
}
|
||||
|
||||
private static RpcMessage? Parse(ReadOnlyMemory<byte> data)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize(data.Span, FleetRpcContext.Default.RpcMessage);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsAotCompatible>true</IsAotCompatible>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Fleet.Core\Fleet.Core.csproj" />
|
||||
<ProjectReference Include="..\Fleet.Rpc\Fleet.Rpc.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Fleet.Server;
|
||||
|
||||
public sealed class FleetServerOptions
|
||||
{
|
||||
/// <summary>节点 ID 的类型前缀,Monitor 使用 <c>agt</c>。</summary>
|
||||
public string NodeIdPrefix { get; set; } = "node";
|
||||
|
||||
public int MaxFrameBytes { get; set; } = 16 << 20;
|
||||
|
||||
public TimeSpan ChallengeTtl { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
public TimeSpan HeartbeatTimeout { get; set; } = TimeSpan.FromSeconds(45);
|
||||
|
||||
public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(15);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Fleet.Core;
|
||||
|
||||
namespace Fleet.Server;
|
||||
|
||||
/// <summary>Fleet 服务端需要的存储。产品可以自己实现(Monitor 把它放进 monitor.db)。</summary>
|
||||
public interface IFleetStore
|
||||
{
|
||||
ValueTask<JoinTokenRecord?> FindJoinTokenAsync(ReadOnlyMemory<byte> tokenSha256, CancellationToken ct);
|
||||
|
||||
/// <summary>原子地把令牌使用次数加一。令牌已吊销、过期或用完时返回 false。</summary>
|
||||
ValueTask<bool> TryConsumeJoinTokenAsync(string tokenId, long nowMs, CancellationToken ct);
|
||||
|
||||
ValueTask<Result<NodeRecord>> CreateNodeAsync(NewNode node, CancellationToken ct);
|
||||
|
||||
ValueTask<NodeRecord?> GetNodeAsync(NodeId id, CancellationToken ct);
|
||||
|
||||
/// <summary>按名称查找状态不是 pending 的节点(用于判断注册时的名称冲突)。</summary>
|
||||
ValueTask<NodeRecord?> FindEstablishedNodeByNameAsync(string name, CancellationToken ct);
|
||||
|
||||
ValueTask UpdatePresenceAsync(NodeId id, Presence presence, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using Fleet.Core;
|
||||
|
||||
namespace Fleet.Server;
|
||||
|
||||
/// <summary>pending 能连接,但不下发状态、不接收上报;disabled 拒绝连接。</summary>
|
||||
public enum NodeStatus
|
||||
{
|
||||
Pending,
|
||||
Active,
|
||||
Disabled,
|
||||
}
|
||||
|
||||
public sealed record NodeRecord(
|
||||
NodeId Id,
|
||||
string Name,
|
||||
NodeStatus Status,
|
||||
byte[] PublicKey,
|
||||
byte[] TlsSpki,
|
||||
IReadOnlyList<string> Labels,
|
||||
string? MetaJson,
|
||||
string? NodeVersion,
|
||||
long CreatedAtMs,
|
||||
long? LastSeenAtMs);
|
||||
|
||||
public sealed record NewNode(
|
||||
NodeId Id,
|
||||
string Name,
|
||||
NodeStatus Status,
|
||||
byte[] PublicKey,
|
||||
byte[] TlsSpki,
|
||||
IReadOnlyList<string> Labels,
|
||||
string? MetaJson,
|
||||
string? NodeVersion,
|
||||
long NowMs);
|
||||
|
||||
public sealed record JoinTokenRecord(
|
||||
string Id,
|
||||
byte[] TokenSha256,
|
||||
string? Note,
|
||||
IReadOnlyList<string> Labels,
|
||||
bool AutoApprove,
|
||||
int MaxUses,
|
||||
int Uses,
|
||||
long ExpiresAtMs,
|
||||
long? RevokedAtMs,
|
||||
long CreatedAtMs);
|
||||
|
||||
public sealed record Presence(bool Online, long AtMs, string? NodeVersion, IReadOnlyList<string> Capabilities, string? HelloMetaJson);
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Text.Json;
|
||||
using Fleet.Core;
|
||||
using Fleet.Rpc;
|
||||
|
||||
namespace Fleet.Server;
|
||||
|
||||
public sealed record EnrollmentContext(string? RemoteIp, JsonElement? ProductMeta);
|
||||
|
||||
/// <summary>产品挂钩:节点注册成功之后调用。</summary>
|
||||
public interface IEnrollmentHook
|
||||
{
|
||||
ValueTask OnEnrolledAsync(NodeRecord node, EnrollmentContext context, CancellationToken ct);
|
||||
}
|
||||
|
||||
public enum NodeEventKind
|
||||
{
|
||||
Online,
|
||||
Offline,
|
||||
Hello,
|
||||
Heartbeat,
|
||||
}
|
||||
|
||||
public sealed record NodeEvent(NodeEventKind Kind, NodeRecord Node, long AtMs, HelloParams? Hello = null, JsonElement? Extra = null);
|
||||
|
||||
public interface INodeEventSink
|
||||
{
|
||||
ValueTask OnEventAsync(NodeEvent nodeEvent, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>期望状态提供者:为每个节点计算期望状态的完整快照(天然幂等)。</summary>
|
||||
public interface IStateProvider
|
||||
{
|
||||
/// <summary>状态文档类型,例如 <c>task_set</c>。</summary>
|
||||
string Type { get; }
|
||||
|
||||
ValueTask<StateDocument?> GetAsync(NodeRecord node, CancellationToken ct);
|
||||
|
||||
/// <summary>节点应用之后回调,<c>rejected[]</c> 带结构化原因。</summary>
|
||||
ValueTask OnAppliedAsync(NodeRecord node, long version, StateSyncResult result, CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed record StateDocument(long Version, JsonElement Doc);
|
||||
|
||||
public enum InboxOutcome
|
||||
{
|
||||
Stored,
|
||||
Duplicate,
|
||||
Rejected,
|
||||
}
|
||||
|
||||
/// <summary>收件箱:持久化之后才返回 Stored,框架随后才回复 ACK。</summary>
|
||||
public interface IInbox
|
||||
{
|
||||
byte Channel { get; }
|
||||
|
||||
ValueTask<InboxOutcome> HandleAsync(NodeRecord node, Guid msgId, ReadOnlyMemory<byte> payload, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>通知 Fleet:某个节点的期望状态变了,需要重新计算并推送。</summary>
|
||||
public interface IStateNotifier
|
||||
{
|
||||
void NotifyChanged(NodeId node);
|
||||
|
||||
void NotifyAllChanged();
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Fleet.Server;
|
||||
|
||||
public sealed class EnrollRequest
|
||||
{
|
||||
[JsonPropertyName("join_token")]
|
||||
public string JoinToken { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
/// <summary>身份公钥(SPKI,base64)。</summary>
|
||||
[JsonPropertyName("pubkey")]
|
||||
public string PublicKey { get; set; } = "";
|
||||
|
||||
/// <summary>面向对端节点的 TLS 证书指纹(SHA-256,base64)。</summary>
|
||||
[JsonPropertyName("tls_spki")]
|
||||
public string TlsSpki { get; set; } = "";
|
||||
|
||||
/// <summary>持有证明:对 <c>fleet|v1|enroll|{sha256_hex(join_token)}|{pubkey}</c> 的签名(base64)。</summary>
|
||||
[JsonPropertyName("proof")]
|
||||
public string Proof { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("node_version")]
|
||||
public string NodeVersion { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("meta")]
|
||||
public JsonElement? Meta { get; set; }
|
||||
}
|
||||
|
||||
public sealed class EnrollResponse
|
||||
{
|
||||
[JsonPropertyName("node_id")]
|
||||
public string NodeId { get; set; } = "";
|
||||
|
||||
/// <summary>pending | active</summary>
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>RFC 9457 problem+json,带 <c>code</c> 扩展字段。</summary>
|
||||
public sealed class FleetProblem
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; set; } = "about:blank";
|
||||
|
||||
[JsonPropertyName("title")]
|
||||
public string Title { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public int Status { get; set; }
|
||||
|
||||
[JsonPropertyName("code")]
|
||||
public string Code { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("detail")]
|
||||
public string? Detail { get; set; }
|
||||
}
|
||||
|
||||
[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
[JsonSerializable(typeof(EnrollRequest))]
|
||||
[JsonSerializable(typeof(EnrollResponse))]
|
||||
[JsonSerializable(typeof(FleetProblem))]
|
||||
internal sealed partial class EnrollmentJsonContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<OutputType>Library</OutputType>
|
||||
<IsAotCompatible>true</IsAotCompatible>
|
||||
<EnableRequestDelegateGenerator>true</EnableRequestDelegateGenerator>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Fleet.Server.Abstractions\Fleet.Server.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\Fleet.Identity\Fleet.Identity.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,92 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Fleet.Core;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Fleet.Server;
|
||||
|
||||
public static class FleetEnrollmentEndpoints
|
||||
{
|
||||
private const int MaxBodyBytes = 64 * 1024;
|
||||
|
||||
/// <summary>注册端点:<c>POST {path}</c>,使用加入令牌认证。需要在 DI 中注册 <see cref="FleetEnrollmentService"/>。</summary>
|
||||
public static IEndpointRouteBuilder MapFleetEnrollment(this IEndpointRouteBuilder app, string path)
|
||||
{
|
||||
app.MapPost(path, HandleAsync)
|
||||
.WithName("fleet_enroll")
|
||||
.WithTags("agent")
|
||||
.WithSummary("Enroll a node with a join token")
|
||||
.DisableAntiforgery();
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleAsync(HttpContext http, FleetEnrollmentService service)
|
||||
{
|
||||
EnrollRequest? request;
|
||||
try
|
||||
{
|
||||
request = await ReadBodyAsync(http, http.RequestAborted);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
request = null;
|
||||
}
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
return Problem(StatusCodes.Status400BadRequest, FleetErrors.InvalidParams, "request body is not valid");
|
||||
}
|
||||
|
||||
Result<EnrollResponse> result = await service.EnrollAsync(request, http.Connection.RemoteIpAddress?.ToString(), http.RequestAborted);
|
||||
if (result.TryGet(out EnrollResponse? response, out FleetError? error))
|
||||
{
|
||||
return Results.Json(response, EnrollmentJsonContext.Default.EnrollResponse, statusCode: StatusCodes.Status201Created);
|
||||
}
|
||||
|
||||
int status = error.Code == FleetErrors.JoinTokenInvalid ? StatusCodes.Status401Unauthorized
|
||||
: error.Code == FleetErrors.JoinTokenExpired ? StatusCodes.Status410Gone
|
||||
: error.Code == FleetErrors.ProofInvalid ? StatusCodes.Status403Forbidden
|
||||
: error.Code == FleetErrors.NameConflict ? StatusCodes.Status409Conflict
|
||||
: error.Code == FleetErrors.InvalidParams ? StatusCodes.Status400BadRequest
|
||||
: StatusCodes.Status500InternalServerError;
|
||||
return Problem(status, error.Code, error.Message);
|
||||
}
|
||||
|
||||
private static async Task<EnrollRequest?> ReadBodyAsync(HttpContext http, CancellationToken ct)
|
||||
{
|
||||
if (http.Request.ContentLength > MaxBodyBytes)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var buffer = new MemoryStream();
|
||||
byte[] chunk = new byte[4096];
|
||||
int read;
|
||||
while ((read = await http.Request.Body.ReadAsync(chunk, ct)) > 0)
|
||||
{
|
||||
buffer.Write(chunk, 0, read);
|
||||
if (buffer.Length > MaxBodyBytes)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize(buffer.ToArray(), EnrollmentJsonContext.Default.EnrollRequest);
|
||||
}
|
||||
|
||||
private static IResult Problem(int status, ErrorCode code, string detail) =>
|
||||
Results.Json(
|
||||
new FleetProblem { Title = ((HttpStatusCode)status).ToString(), Status = status, Code = code.Value, Detail = detail },
|
||||
EnrollmentJsonContext.Default.FleetProblem,
|
||||
contentType: "application/problem+json",
|
||||
statusCode: status);
|
||||
}
|
||||
|
||||
public static class FleetEnrollmentServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddFleetEnrollment(this IServiceCollection services) =>
|
||||
services.AddSingleton<FleetEnrollmentService>();
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Fleet.Core;
|
||||
using Fleet.Identity;
|
||||
|
||||
namespace Fleet.Server;
|
||||
|
||||
/// <summary>注册流程:校验令牌 → 校验持有证明 → 创建节点 → 触发产品挂钩。与 HTTP 无关,便于测试。</summary>
|
||||
public sealed partial class FleetEnrollmentService
|
||||
{
|
||||
private readonly IFleetStore _store;
|
||||
private readonly IEnumerable<IEnrollmentHook> _hooks;
|
||||
private readonly FleetServerOptions _options;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public FleetEnrollmentService(IFleetStore store, IEnumerable<IEnrollmentHook> hooks, FleetServerOptions options, TimeProvider time)
|
||||
{
|
||||
_store = store;
|
||||
_hooks = hooks;
|
||||
_options = options;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
public async ValueTask<Result<EnrollResponse>> EnrollAsync(EnrollRequest request, string? remoteIp, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrEmpty(request.JoinToken) || !NamePattern().IsMatch(request.Name))
|
||||
{
|
||||
return Result<EnrollResponse>.Fail(FleetErrors.InvalidParams, "join_token and a valid name are required");
|
||||
}
|
||||
|
||||
if (!TryDecode(request.PublicKey, out byte[] publicKey) || !NodeIdentity.IsValidPublicKey(publicKey)
|
||||
|| !TryDecode(request.TlsSpki, out byte[] tlsSpki) || tlsSpki.Length != 32
|
||||
|| !TryDecode(request.Proof, out byte[] proof))
|
||||
{
|
||||
return Result<EnrollResponse>.Fail(FleetErrors.InvalidParams, "pubkey, tls_spki or proof is malformed");
|
||||
}
|
||||
|
||||
byte[] tokenHash = TokenHash.Sha256(request.JoinToken);
|
||||
JoinTokenRecord? token = await _store.FindJoinTokenAsync(tokenHash, ct);
|
||||
long nowMs = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
if (token is null || token.RevokedAtMs is not null || token.Uses >= token.MaxUses)
|
||||
{
|
||||
return Result<EnrollResponse>.Fail(FleetErrors.JoinTokenInvalid, "join token is invalid");
|
||||
}
|
||||
|
||||
if (token.ExpiresAtMs <= nowMs)
|
||||
{
|
||||
return Result<EnrollResponse>.Fail(FleetErrors.JoinTokenExpired, "join token has expired");
|
||||
}
|
||||
|
||||
if (!NodeIdentity.Verify(publicKey, proof, SignaturePurpose.Enroll, Convert.ToHexStringLower(tokenHash), request.PublicKey))
|
||||
{
|
||||
return Result<EnrollResponse>.Fail(FleetErrors.ProofInvalid, "proof of possession does not verify");
|
||||
}
|
||||
|
||||
if (!await _store.TryConsumeJoinTokenAsync(token.Id, nowMs, ct))
|
||||
{
|
||||
return Result<EnrollResponse>.Fail(FleetErrors.JoinTokenInvalid, "join token is invalid");
|
||||
}
|
||||
|
||||
// 同名节点已存在时进入 pending,由管理员选择“接管”或“作为新节点”。
|
||||
bool nameTaken = await _store.FindEstablishedNodeByNameAsync(request.Name, ct) is not null;
|
||||
NodeStatus status = token.AutoApprove && !nameTaken ? NodeStatus.Active : NodeStatus.Pending;
|
||||
|
||||
var node = new NewNode(
|
||||
new NodeId(Ulid.NewPrefixed(_options.NodeIdPrefix, _time)),
|
||||
request.Name,
|
||||
status,
|
||||
publicKey,
|
||||
tlsSpki,
|
||||
token.Labels,
|
||||
request.Meta?.GetRawText(),
|
||||
request.NodeVersion,
|
||||
nowMs);
|
||||
|
||||
Result<NodeRecord> created = await _store.CreateNodeAsync(node, ct);
|
||||
if (!created.TryGet(out NodeRecord? record, out FleetError? error))
|
||||
{
|
||||
return Result<EnrollResponse>.Fail(error);
|
||||
}
|
||||
|
||||
var context = new EnrollmentContext(remoteIp, request.Meta);
|
||||
foreach (IEnrollmentHook hook in _hooks)
|
||||
{
|
||||
await hook.OnEnrolledAsync(record, context, ct);
|
||||
}
|
||||
|
||||
return Result<EnrollResponse>.Ok(new EnrollResponse
|
||||
{
|
||||
NodeId = record.Id.Value,
|
||||
Status = record.Status == NodeStatus.Active ? "active" : "pending",
|
||||
});
|
||||
}
|
||||
|
||||
private static bool TryDecode(string text, out byte[] data)
|
||||
{
|
||||
try
|
||||
{
|
||||
data = Convert.FromBase64String(text);
|
||||
return data.Length > 0;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
data = [];
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^[\p{L}\p{M}\p{N}._-]{1,64}$")]
|
||||
private static partial Regex NamePattern();
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<OutputType>Library</OutputType>
|
||||
<IsAotCompatible>true</IsAotCompatible>
|
||||
<EnableRequestDelegateGenerator>true</EnableRequestDelegateGenerator>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Fleet.Server.Abstractions\Fleet.Server.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\Fleet.Identity\Fleet.Identity.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,170 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using Fleet.Core;
|
||||
using Fleet.Rpc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Fleet.Server;
|
||||
|
||||
/// <summary>
|
||||
/// 服务端连接宿主:在线表 + 服务端发起的调用 + 状态推送通知。每个 WebSocket 对应一个 <see cref="NodeConnection"/>。
|
||||
/// </summary>
|
||||
public sealed class FleetLinkHost : IFleetConnections, IStateNotifier
|
||||
{
|
||||
private readonly ConcurrentDictionary<NodeId, NodeConnection> _connections = new();
|
||||
|
||||
internal FleetLinkHost(
|
||||
IFleetStore store,
|
||||
FleetServerOptions options,
|
||||
TimeProvider time,
|
||||
ILogger<FleetLinkHost> logger,
|
||||
IEnumerable<IStateProvider> stateProviders,
|
||||
IEnumerable<IInbox> inboxes,
|
||||
IEnumerable<INodeEventSink> sinks,
|
||||
FleetRouter router)
|
||||
{
|
||||
Store = store;
|
||||
Options = options;
|
||||
Time = time;
|
||||
Logger = logger;
|
||||
StateProviders = [.. stateProviders];
|
||||
Inboxes = inboxes.ToDictionary(i => i.Channel);
|
||||
Sinks = [.. sinks];
|
||||
Router = router.Inner;
|
||||
}
|
||||
|
||||
internal IFleetStore Store { get; }
|
||||
|
||||
internal FleetServerOptions Options { get; }
|
||||
|
||||
internal TimeProvider Time { get; }
|
||||
|
||||
internal ILogger<FleetLinkHost> Logger { get; }
|
||||
|
||||
internal IReadOnlyList<IStateProvider> StateProviders { get; }
|
||||
|
||||
internal IReadOnlyDictionary<byte, IInbox> Inboxes { get; }
|
||||
|
||||
internal IReadOnlyList<INodeEventSink> Sinks { get; }
|
||||
|
||||
internal RpcRouter<NodeCallContext> Router { get; }
|
||||
|
||||
public IReadOnlyCollection<NodeId> OnlineNodes => [.. _connections.Keys];
|
||||
|
||||
public bool IsOnline(NodeId node) => _connections.ContainsKey(node);
|
||||
|
||||
public NodeSessionInfo? GetSession(NodeId node) =>
|
||||
_connections.TryGetValue(node, out NodeConnection? c) ? c.Session : null;
|
||||
|
||||
/// <summary>处理一条 WebSocket 连接,直到连接结束。</summary>
|
||||
public async Task RunConnectionAsync(WebSocket socket, CancellationToken ct)
|
||||
{
|
||||
using var connection = new NodeConnection(this, socket);
|
||||
await connection.RunAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>返回被替换掉的旧连接(如果有)。</summary>
|
||||
internal NodeConnection? Register(NodeConnection connection)
|
||||
{
|
||||
NodeConnection? replaced = null;
|
||||
_connections.AddOrUpdate(
|
||||
connection.NodeId,
|
||||
connection,
|
||||
(_, existing) =>
|
||||
{
|
||||
replaced = existing;
|
||||
return connection;
|
||||
});
|
||||
return replaced;
|
||||
}
|
||||
|
||||
internal void Unregister(NodeConnection connection) =>
|
||||
_connections.TryRemove(new KeyValuePair<NodeId, NodeConnection>(connection.NodeId, connection));
|
||||
|
||||
public async ValueTask<Result<TResult>> RequestAsync<TParams, TResult>(
|
||||
NodeId node,
|
||||
string method,
|
||||
TParams parameters,
|
||||
JsonTypeInfo<TParams> paramsInfo,
|
||||
JsonTypeInfo<TResult> resultInfo,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!_connections.TryGetValue(node, out NodeConnection? connection))
|
||||
{
|
||||
return Result<TResult>.Fail(FleetErrors.NodeOffline, "node is not connected");
|
||||
}
|
||||
|
||||
Result<JsonElement> raw = await connection.RequestAsync(method, JsonSerializer.SerializeToElement(parameters, paramsInfo), ct);
|
||||
if (!raw.TryGet(out JsonElement value, out FleetError? error))
|
||||
{
|
||||
return Result<TResult>.Fail(error);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
TResult? result = value.Deserialize(resultInfo);
|
||||
return result is null
|
||||
? Result<TResult>.Fail(FleetErrors.Internal, "empty result")
|
||||
: Result<TResult>.Ok(result);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return Result<TResult>.Fail(FleetErrors.Internal, "result could not be parsed");
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<bool> NotifyAsync<TParams>(NodeId node, string method, TParams parameters, JsonTypeInfo<TParams> paramsInfo, CancellationToken ct)
|
||||
{
|
||||
if (!_connections.TryGetValue(node, out NodeConnection? connection))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return await connection.SendAsync(RpcMessage.Notification(method, parameters, paramsInfo), ct);
|
||||
}
|
||||
|
||||
public async ValueTask CloseAllAsync(string reason)
|
||||
{
|
||||
foreach (NodeConnection connection in _connections.Values.ToArray())
|
||||
{
|
||||
await connection.CloseAsync(reason);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisconnectAsync(NodeId node, string reason, CancellationToken ct)
|
||||
{
|
||||
if (_connections.TryGetValue(node, out NodeConnection? connection))
|
||||
{
|
||||
await connection.RevokeAsync(reason, ct);
|
||||
}
|
||||
}
|
||||
|
||||
public void NotifyChanged(NodeId node)
|
||||
{
|
||||
if (_connections.TryGetValue(node, out NodeConnection? connection))
|
||||
{
|
||||
connection.SignalStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public void NotifyAllChanged()
|
||||
{
|
||||
foreach (NodeConnection connection in _connections.Values)
|
||||
{
|
||||
connection.SignalStateChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>产品注册的 RPC 方法路由。</summary>
|
||||
public sealed class FleetRouter
|
||||
{
|
||||
public FleetRouter(RpcRouter<NodeCallContext> inner)
|
||||
{
|
||||
Inner = inner;
|
||||
}
|
||||
|
||||
internal RpcRouter<NodeCallContext> Inner { get; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Fleet.Server;
|
||||
|
||||
internal static partial class FleetLog
|
||||
{
|
||||
[LoggerMessage(Level = LogLevel.Information, Message = "node connected node={Node} status={Status}")]
|
||||
public static partial void Connected(ILogger logger, string node, string status);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Information, Message = "node disconnected node={Node} reason={Reason}")]
|
||||
public static partial void Disconnected(ILogger logger, string node, string reason);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Warning, Message = "node auth rejected err={Err}")]
|
||||
public static partial void AuthRejected(ILogger logger, string err);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Warning, Message = "inbox failed node={Node} channel={Channel}")]
|
||||
public static partial void InboxFailed(ILogger logger, Exception exception, string node, byte channel);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Warning, Message = "state push failed node={Node} type={Type} err={Err}")]
|
||||
public static partial void StatePushFailed(ILogger logger, string node, string type, string err);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Error, Message = "unhandled error in handler method={Method}")]
|
||||
public static partial void HandlerFailed(ILogger logger, Exception exception, string method);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Error, Message = "event sink failed kind={Kind} node={Node}")]
|
||||
public static partial void SinkFailed(ILogger logger, Exception exception, string kind, string node);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Fleet.Rpc;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Fleet.Server;
|
||||
|
||||
/// <summary>Fleet 服务端的接入点:产品在这里挂上自己的状态、收件箱、事件和 RPC 方法。</summary>
|
||||
public sealed class FleetServerBuilder
|
||||
{
|
||||
private readonly List<Action<RpcRouter<NodeCallContext>, IServiceProvider>> _routeConfigurators = [];
|
||||
|
||||
internal FleetServerBuilder(IServiceCollection services)
|
||||
{
|
||||
Services = services;
|
||||
}
|
||||
|
||||
public IServiceCollection Services { get; }
|
||||
|
||||
internal IReadOnlyList<Action<RpcRouter<NodeCallContext>, IServiceProvider>> RouteConfigurators => _routeConfigurators;
|
||||
|
||||
public FleetServerBuilder AddState<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TProvider>()
|
||||
where TProvider : class, IStateProvider
|
||||
{
|
||||
Services.AddSingleton<IStateProvider, TProvider>();
|
||||
return this;
|
||||
}
|
||||
|
||||
public FleetServerBuilder AddInbox<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TInbox>()
|
||||
where TInbox : class, IInbox
|
||||
{
|
||||
Services.AddSingleton<IInbox, TInbox>();
|
||||
return this;
|
||||
}
|
||||
|
||||
public FleetServerBuilder OnNodeEvent<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TSink>()
|
||||
where TSink : class, INodeEventSink
|
||||
{
|
||||
Services.AddSingleton<INodeEventSink, TSink>();
|
||||
return this;
|
||||
}
|
||||
|
||||
public FleetServerBuilder OnEnrolled<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] THook>()
|
||||
where THook : class, IEnrollmentHook
|
||||
{
|
||||
Services.AddSingleton<IEnrollmentHook, THook>();
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>注册产品的 RPC 方法(<c>monitor.*</c> 等)。处理函数通过 <see cref="IServiceProvider"/> 在调用时解析服务,避免构造期的循环依赖。</summary>
|
||||
public FleetServerBuilder MapMethods(Action<RpcRouter<NodeCallContext>, IServiceProvider> configure)
|
||||
{
|
||||
_routeConfigurators.Add(configure);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
public static class FleetServerExtensions
|
||||
{
|
||||
/// <summary>需要事先在 DI 中注册 <see cref="IFleetStore"/>。</summary>
|
||||
public static FleetServerBuilder AddFleetServer(this IServiceCollection services, Action<FleetServerOptions>? configure = null)
|
||||
{
|
||||
var options = new FleetServerOptions();
|
||||
configure?.Invoke(options);
|
||||
services.AddSingleton(options);
|
||||
|
||||
var builder = new FleetServerBuilder(services);
|
||||
services.AddSingleton(provider =>
|
||||
{
|
||||
ILogger logger = provider.GetRequiredService<ILoggerFactory>().CreateLogger("Fleet.Rpc");
|
||||
var router = new RpcRouter<NodeCallContext>((method, ex) => FleetLog.HandlerFailed(logger, ex, method));
|
||||
foreach (Action<RpcRouter<NodeCallContext>, IServiceProvider> configurator in builder.RouteConfigurators)
|
||||
{
|
||||
configurator(router, provider);
|
||||
}
|
||||
|
||||
return new FleetRouter(router);
|
||||
});
|
||||
services.AddSingleton(provider => new FleetLinkHost(
|
||||
provider.GetRequiredService<IFleetStore>(),
|
||||
provider.GetRequiredService<FleetServerOptions>(),
|
||||
provider.GetRequiredService<TimeProvider>(),
|
||||
provider.GetRequiredService<ILogger<FleetLinkHost>>(),
|
||||
provider.GetServices<IStateProvider>(),
|
||||
provider.GetServices<IInbox>(),
|
||||
provider.GetServices<INodeEventSink>(),
|
||||
provider.GetRequiredService<FleetRouter>()));
|
||||
services.AddSingleton<IFleetConnections>(provider => provider.GetRequiredService<FleetLinkHost>());
|
||||
services.AddSingleton<IStateNotifier>(provider => provider.GetRequiredService<FleetLinkHost>());
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>映射 WebSocket 端点。调用方需要先 <c>app.UseWebSockets()</c>。</summary>
|
||||
public static IEndpointRouteBuilder MapFleetLink(this IEndpointRouteBuilder app, string path)
|
||||
{
|
||||
app.Map(path, async (HttpContext http) =>
|
||||
{
|
||||
if (!http.WebSockets.IsWebSocketRequest)
|
||||
{
|
||||
http.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
return;
|
||||
}
|
||||
|
||||
using System.Net.WebSockets.WebSocket socket = await http.WebSockets.AcceptWebSocketAsync();
|
||||
await http.RequestServices.GetRequiredService<FleetLinkHost>().RunConnectionAsync(socket, http.RequestAborted);
|
||||
});
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using Fleet.Core;
|
||||
|
||||
namespace Fleet.Server;
|
||||
|
||||
/// <summary>节点的会话信息(来自 fleet.hello)。</summary>
|
||||
public sealed record NodeSessionInfo(
|
||||
NodeId NodeId,
|
||||
long ConnectedAtMs,
|
||||
string? NodeVersion,
|
||||
IReadOnlyList<string> Capabilities,
|
||||
string? HelloMetaJson);
|
||||
|
||||
/// <summary>在线表,以及服务端向节点发起的调用。</summary>
|
||||
public interface IFleetConnections
|
||||
{
|
||||
IReadOnlyCollection<NodeId> OnlineNodes { get; }
|
||||
|
||||
bool IsOnline(NodeId node);
|
||||
|
||||
NodeSessionInfo? GetSession(NodeId node);
|
||||
|
||||
/// <summary>向节点发起请求。节点不在线时返回 <c>fleet_node_offline</c>;超时返回 <c>fleet_timeout</c>。</summary>
|
||||
ValueTask<Result<TResult>> RequestAsync<TParams, TResult>(
|
||||
NodeId node,
|
||||
string method,
|
||||
TParams parameters,
|
||||
JsonTypeInfo<TParams> paramsInfo,
|
||||
JsonTypeInfo<TResult> resultInfo,
|
||||
CancellationToken ct);
|
||||
|
||||
ValueTask<bool> NotifyAsync<TParams>(NodeId node, string method, TParams parameters, JsonTypeInfo<TParams> paramsInfo, CancellationToken ct);
|
||||
|
||||
/// <summary>断开节点的连接(先发送 <c>fleet.revoked</c>)。</summary>
|
||||
ValueTask DisconnectAsync(NodeId node, string reason, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// 服务端要停止了:对所有在线节点做 Close 握手,<b>不</b>发送 <c>fleet.revoked</c>(节点会按退避重连,等服务端回来)。
|
||||
/// 长连接不主动关闭的话,宿主的有序停止要等满超时(30 秒)才会退出。
|
||||
/// </summary>
|
||||
ValueTask CloseAllAsync(string reason);
|
||||
}
|
||||
|
||||
/// <summary>调用方信息,传给产品注册的 RPC 处理函数。</summary>
|
||||
public sealed record NodeCallContext(NodeRecord Node, NodeSessionInfo Session, IFleetConnections Connections);
|
||||
@@ -0,0 +1,729 @@
|
||||
using System.Buffers;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.WebSockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading.Channels;
|
||||
using System.Text.Json;
|
||||
using Fleet.Core;
|
||||
using Fleet.Identity;
|
||||
using Fleet.Rpc;
|
||||
|
||||
namespace Fleet.Server;
|
||||
|
||||
/// <summary>
|
||||
/// 一个节点的 WebSocket 会话:挑战 → 签名认证 → hello → 状态推送 / 收件箱 / 产品 RPC。
|
||||
/// 收到的消息按顺序处理,所以产品的处理函数不能等待“向同一个节点发起的请求”,否则会死锁。
|
||||
/// </summary>
|
||||
internal sealed class NodeConnection : IDisposable
|
||||
{
|
||||
private static readonly TimeSpan s_authTimeout = TimeSpan.FromSeconds(15);
|
||||
private static readonly TimeSpan s_presenceRefresh = TimeSpan.FromSeconds(60);
|
||||
private static readonly TimeSpan s_statePushRetry = TimeSpan.FromSeconds(5);
|
||||
private static readonly TimeSpan s_closeTimeout = TimeSpan.FromSeconds(2);
|
||||
|
||||
private readonly FleetLinkHost _host;
|
||||
private readonly WebSocket _socket;
|
||||
private readonly SemaphoreSlim _sendLock = new(1, 1);
|
||||
private readonly ConcurrentDictionary<long, TaskCompletionSource<RpcMessage>> _pending = new();
|
||||
private readonly Channel<bool> _stateSignal = Channel.CreateBounded<bool>(new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropWrite });
|
||||
private readonly Dictionary<string, long> _lastPushedVersion = new(StringComparer.Ordinal);
|
||||
private readonly CancellationTokenSource _lifetime = new();
|
||||
private long _nextRequestId;
|
||||
private long _lastPresenceTicks;
|
||||
private NodeRecord? _node;
|
||||
private NodeSessionInfo? _session;
|
||||
private string? _closeReason;
|
||||
private bool _helloDone;
|
||||
|
||||
public NodeConnection(FleetLinkHost host, WebSocket socket)
|
||||
{
|
||||
_host = host;
|
||||
_socket = socket;
|
||||
}
|
||||
|
||||
public NodeId NodeId => _node!.Id;
|
||||
|
||||
public NodeSessionInfo Session => _session!;
|
||||
|
||||
public async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, _lifetime.Token);
|
||||
CancellationToken token = linked.Token;
|
||||
bool registered = false;
|
||||
Task? pusher = null;
|
||||
try
|
||||
{
|
||||
if (!await AuthenticateAsync(token))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
NodeConnection? old = _host.Register(this);
|
||||
registered = true;
|
||||
if (old is not null)
|
||||
{
|
||||
await old.CloseAsync("replaced");
|
||||
}
|
||||
|
||||
string status = StatusName(_node!.Status);
|
||||
FleetLog.Connected(_host.Logger, _node.Id.Value, status);
|
||||
await UpdatePresenceAsync(online: true, token);
|
||||
await FireAsync(NodeEventKind.Online, token);
|
||||
|
||||
pusher = Task.Run(() => StatePushLoopAsync(token), CancellationToken.None);
|
||||
await ReceiveLoopAsync(token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 关闭原因由触发取消的一方设置
|
||||
}
|
||||
catch (WebSocketException)
|
||||
{
|
||||
_closeReason = "connection lost";
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _lifetime.CancelAsync();
|
||||
if (pusher is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await pusher;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 正常结束
|
||||
}
|
||||
}
|
||||
|
||||
foreach (TaskCompletionSource<RpcMessage> waiter in _pending.Values)
|
||||
{
|
||||
waiter.TrySetCanceled(CancellationToken.None);
|
||||
}
|
||||
|
||||
if (registered)
|
||||
{
|
||||
_host.Unregister(this);
|
||||
FleetLog.Disconnected(_host.Logger, _node!.Id.Value, _closeReason ?? "closed");
|
||||
|
||||
// 被新连接替换时,在线状态属于新连接。
|
||||
if (!_host.IsOnline(_node.Id))
|
||||
{
|
||||
await UpdatePresenceAsync(online: false, CancellationToken.None);
|
||||
await FireAsync(NodeEventKind.Offline, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
if (_socket.State is WebSocketState.Open or WebSocketState.CloseReceived)
|
||||
{
|
||||
// 完整的关闭握手:发送 Close 并等待对端确认(有上限),这样对端能收到关闭原因
|
||||
try
|
||||
{
|
||||
using var closeTimeout = new CancellationTokenSource(s_closeTimeout, _host.Time);
|
||||
await _socket.CloseAsync(WebSocketCloseStatus.NormalClosure, _closeReason ?? "closed", closeTimeout.Token);
|
||||
}
|
||||
catch (WebSocketException)
|
||||
{
|
||||
// 对端已经断开
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 对端没有在时限内确认:不再等待
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_lifetime.Dispose();
|
||||
_sendLock.Dispose();
|
||||
}
|
||||
|
||||
public void SignalStateChanged() => _stateSignal.Writer.TryWrite(true);
|
||||
|
||||
/// <summary>
|
||||
/// 服务端发起的关闭:发送 Close 帧,等对端确认之后接收循环自然结束。
|
||||
/// 注意不能直接取消接收的令牌——取消 <c>ReceiveAsync</c> 会中止整个 WebSocket,对端拿不到关闭原因;取消只作为超时兜底。
|
||||
/// </summary>
|
||||
public async ValueTask CloseAsync(string reason)
|
||||
{
|
||||
_closeReason ??= reason;
|
||||
try
|
||||
{
|
||||
await _sendLock.WaitAsync(CancellationToken.None);
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_socket.State == WebSocketState.Open)
|
||||
{
|
||||
await _socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, reason, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
catch (WebSocketException)
|
||||
{
|
||||
// 对端已经断开
|
||||
}
|
||||
finally
|
||||
{
|
||||
_sendLock.Release();
|
||||
}
|
||||
|
||||
_lifetime.CancelAfter(s_closeTimeout);
|
||||
}
|
||||
|
||||
public async ValueTask RevokeAsync(string reason, CancellationToken ct)
|
||||
{
|
||||
await SendAsync(RpcMessage.Notification(FleetMethods.Revoked, new RevokedParams { Reason = reason }, FleetRpcContext.Default.RevokedParams), ct);
|
||||
await CloseAsync(reason);
|
||||
}
|
||||
|
||||
public async ValueTask<bool> SendAsync(RpcMessage message, CancellationToken ct)
|
||||
{
|
||||
byte[] bytes = JsonSerializer.SerializeToUtf8Bytes(message, FleetRpcContext.Default.RpcMessage);
|
||||
try
|
||||
{
|
||||
await _sendLock.WaitAsync(ct);
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_socket.State != WebSocketState.Open)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
await _socket.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, ct);
|
||||
return true;
|
||||
}
|
||||
catch (WebSocketException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_sendLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<Result<JsonElement>> RequestAsync(string method, JsonElement parameters, CancellationToken ct)
|
||||
{
|
||||
long id = Interlocked.Increment(ref _nextRequestId);
|
||||
var waiter = new TaskCompletionSource<RpcMessage>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_pending[id] = waiter;
|
||||
try
|
||||
{
|
||||
var request = new RpcMessage { Id = id, Method = method, Params = parameters };
|
||||
if (!await SendAsync(request, ct))
|
||||
{
|
||||
return Result<JsonElement>.Fail(FleetErrors.NodeOffline, "node is not connected");
|
||||
}
|
||||
|
||||
using var timeout = new CancellationTokenSource(_host.Options.RequestTimeout, _host.Time);
|
||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
|
||||
RpcMessage response = await waiter.Task.WaitAsync(linked.Token);
|
||||
return response.Error is not null
|
||||
? Result<JsonElement>.Fail(response.Error.ToFleetError())
|
||||
: Result<JsonElement>.Ok(response.Result ?? default);
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
return Result<JsonElement>.Fail(FleetErrors.Timeout, $"{method} timed out");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_pending.TryRemove(id, out _);
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────── 认证 ─────────────────────────
|
||||
|
||||
private async Task<bool> AuthenticateAsync(CancellationToken ct)
|
||||
{
|
||||
string nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||
long expiresAtMs = _host.Time.GetUtcNow().Add(_host.Options.ChallengeTtl).ToUnixTimeMilliseconds();
|
||||
await SendAsync(
|
||||
RpcMessage.Notification(FleetMethods.Challenge, new ChallengeParams { Nonce = nonce, ExpiresAtMs = expiresAtMs }, FleetRpcContext.Default.ChallengeParams),
|
||||
ct);
|
||||
|
||||
using var timeout = new CancellationTokenSource(s_authTimeout, _host.Time);
|
||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
|
||||
Frame? frame;
|
||||
try
|
||||
{
|
||||
frame = await ReceiveFrameAsync(linked.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
await RejectAsync(null, "auth timeout", ct);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (frame is not { IsText: true } || !TryParse(frame.Data, out RpcMessage? message) || message.Method != FleetMethods.Auth || message.Id is null)
|
||||
{
|
||||
await RejectAsync(null, "expected fleet.auth", ct);
|
||||
return false;
|
||||
}
|
||||
|
||||
AuthParams? auth = null;
|
||||
try
|
||||
{
|
||||
auth = message.Params?.Deserialize(FleetRpcContext.Default.AuthParams);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// 按缺失处理
|
||||
}
|
||||
|
||||
NodeRecord? node = auth is null ? null : await _host.Store.GetNodeAsync(new NodeId(auth.NodeId), ct);
|
||||
bool signatureOk = node is not null
|
||||
&& _host.Time.GetUtcNow().ToUnixTimeMilliseconds() <= expiresAtMs
|
||||
&& TryDecode(auth!.Signature, out byte[] signature)
|
||||
&& NodeIdentity.Verify(node.PublicKey, signature, SignaturePurpose.Auth, auth.NodeId, nonce);
|
||||
if (!signatureOk)
|
||||
{
|
||||
// 节点不存在与签名错误返回同样的错误,不泄露节点是否存在。
|
||||
await RejectAsync(message.Id, "authentication failed", ct);
|
||||
return false;
|
||||
}
|
||||
|
||||
_node = node;
|
||||
if (node!.Status == NodeStatus.Disabled)
|
||||
{
|
||||
await SendAsync(RpcMessage.Failure(message.Id, new FleetError(FleetErrors.NodeDisabled, "node is disabled")), ct);
|
||||
await RevokeAsync("disabled", ct);
|
||||
return false;
|
||||
}
|
||||
|
||||
_session = new NodeSessionInfo(node.Id, _host.Time.GetUtcNow().ToUnixTimeMilliseconds(), node.NodeVersion, [], null);
|
||||
await SendAsync(
|
||||
RpcMessage.Success(message.Id.Value, new AuthResult { Status = StatusName(node.Status) }, FleetRpcContext.Default.AuthResult),
|
||||
ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task RejectAsync(long? id, string reason, CancellationToken ct)
|
||||
{
|
||||
FleetLog.AuthRejected(_host.Logger, reason);
|
||||
_closeReason ??= reason;
|
||||
if (id is not null)
|
||||
{
|
||||
await SendAsync(RpcMessage.Failure(id, new FleetError(FleetErrors.Unauthorized, "authentication failed")), ct);
|
||||
}
|
||||
|
||||
if (_socket.State == WebSocketState.Open)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _socket.CloseOutputAsync(WebSocketCloseStatus.PolicyViolation, "unauthorized", ct);
|
||||
}
|
||||
catch (WebSocketException)
|
||||
{
|
||||
// 对端已经断开
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────── 接收循环 ─────────────────────────
|
||||
|
||||
private async Task ReceiveLoopAsync(CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
using var idle = new CancellationTokenSource(_host.Options.HeartbeatTimeout, _host.Time);
|
||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, idle.Token);
|
||||
Frame? frame;
|
||||
try
|
||||
{
|
||||
frame = await ReceiveFrameAsync(linked.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (idle.IsCancellationRequested && !ct.IsCancellationRequested)
|
||||
{
|
||||
_closeReason ??= "heartbeat timeout";
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame is null)
|
||||
{
|
||||
_closeReason ??= "closed by peer";
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.IsText)
|
||||
{
|
||||
if (TryParse(frame.Data, out RpcMessage? message))
|
||||
{
|
||||
await HandleMessageAsync(message, ct);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await HandleBinaryAsync(frame.Data, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleMessageAsync(RpcMessage message, CancellationToken ct)
|
||||
{
|
||||
if (message.IsResponse)
|
||||
{
|
||||
if (_pending.TryGetValue(message.Id!.Value, out TaskCompletionSource<RpcMessage>? waiter))
|
||||
{
|
||||
waiter.TrySetResult(message);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
switch (message.Method)
|
||||
{
|
||||
case FleetMethods.Hello:
|
||||
await HandleHelloAsync(message, ct);
|
||||
return;
|
||||
case FleetMethods.Heartbeat:
|
||||
await HandleHeartbeatAsync(message, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_node!.Status != NodeStatus.Active)
|
||||
{
|
||||
if (message.Id is not null)
|
||||
{
|
||||
await SendAsync(RpcMessage.Failure(message.Id, new FleetError(FleetErrors.NodePending, "node is not approved yet")), ct);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var context = new NodeCallContext(_node, _session!, _host);
|
||||
RpcMessage? response;
|
||||
try
|
||||
{
|
||||
response = await _host.Router.DispatchAsync(context, message, ct);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
|
||||
if (response is not null)
|
||||
{
|
||||
await SendAsync(response, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleHelloAsync(RpcMessage message, CancellationToken ct)
|
||||
{
|
||||
HelloParams? hello = null;
|
||||
try
|
||||
{
|
||||
hello = message.Params?.Deserialize(FleetRpcContext.Default.HelloParams);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// 按缺失处理
|
||||
}
|
||||
|
||||
if (hello is null)
|
||||
{
|
||||
if (message.Id is not null)
|
||||
{
|
||||
await SendAsync(RpcMessage.Failure(message.Id, new FleetError(FleetErrors.InvalidParams, "invalid hello")), ct);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
long nowMs = _host.Time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
_session = _session! with
|
||||
{
|
||||
NodeVersion = hello.NodeVersion,
|
||||
Capabilities = hello.Capabilities,
|
||||
HelloMetaJson = hello.Meta?.GetRawText(),
|
||||
};
|
||||
_node = _node! with { NodeVersion = hello.NodeVersion };
|
||||
_helloDone = true;
|
||||
|
||||
await UpdatePresenceAsync(online: true, ct);
|
||||
await FireAsync(NodeEventKind.Hello, ct, hello);
|
||||
|
||||
if (message.Id is not null)
|
||||
{
|
||||
await SendAsync(
|
||||
RpcMessage.Success(
|
||||
message.Id.Value,
|
||||
new HelloResult { ServerTimeMs = nowMs, ClockOffsetMs = hello.NowUtcMs - nowMs },
|
||||
FleetRpcContext.Default.HelloResult),
|
||||
ct);
|
||||
}
|
||||
|
||||
SignalStateChanged();
|
||||
}
|
||||
|
||||
private async Task HandleHeartbeatAsync(RpcMessage message, CancellationToken ct)
|
||||
{
|
||||
JsonElement? extra = null;
|
||||
try
|
||||
{
|
||||
extra = message.Params?.Deserialize(FleetRpcContext.Default.HeartbeatParams)?.Extra;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// 忽略格式不正确的附加字段
|
||||
}
|
||||
|
||||
long now = _host.Time.GetTimestamp();
|
||||
if (_host.Time.GetElapsedTime(Interlocked.Read(ref _lastPresenceTicks), now) >= s_presenceRefresh)
|
||||
{
|
||||
await UpdatePresenceAsync(online: true, ct);
|
||||
}
|
||||
|
||||
await FireAsync(NodeEventKind.Heartbeat, ct, hello: null, extra);
|
||||
if (message.Id is not null)
|
||||
{
|
||||
await SendAsync(new RpcMessage { Id = message.Id, Result = JsonSerializer.SerializeToElement(new EmptyObject(), FleetEmptyObject.Info) }, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleBinaryAsync(byte[] data, CancellationToken ct)
|
||||
{
|
||||
if (_node!.Status != NodeStatus.Active
|
||||
|| !OutboxFrame.TryParse(data, out byte channel, out Guid msgId, out ReadOnlyMemory<byte> payload)
|
||||
|| !_host.Inboxes.TryGetValue(channel, out IInbox? inbox))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
InboxOutcome outcome;
|
||||
try
|
||||
{
|
||||
outcome = await inbox.HandleAsync(_node, msgId, payload, ct);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 没有持久化就不确认,节点会重发。
|
||||
FleetLog.InboxFailed(_host.Logger, ex, _node.Id.Value, channel);
|
||||
return;
|
||||
}
|
||||
|
||||
if (outcome == InboxOutcome.Rejected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var ack = new OutboxAckParams();
|
||||
string id = msgId.ToString();
|
||||
if (outcome == InboxOutcome.Duplicate)
|
||||
{
|
||||
ack.Duplicates.Add(id);
|
||||
}
|
||||
else
|
||||
{
|
||||
ack.Ids.Add(id);
|
||||
}
|
||||
|
||||
await SendAsync(RpcMessage.Notification(FleetMethods.OutboxAck, ack, FleetRpcContext.Default.OutboxAckParams), ct);
|
||||
}
|
||||
|
||||
// ───────────────────────── 状态推送 ─────────────────────────
|
||||
|
||||
private async Task StatePushLoopAsync(CancellationToken ct)
|
||||
{
|
||||
while (await _stateSignal.Reader.WaitToReadAsync(ct))
|
||||
{
|
||||
_stateSignal.Reader.TryRead(out _);
|
||||
bool complete = await PushStateAsync(ct);
|
||||
if (!complete)
|
||||
{
|
||||
await Task.Delay(s_statePushRetry, _host.Time, ct);
|
||||
SignalStateChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> PushStateAsync(CancellationToken ct)
|
||||
{
|
||||
NodeRecord? fresh = await _host.Store.GetNodeAsync(_node!.Id, ct);
|
||||
if (fresh is null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
_node = fresh with { NodeVersion = _node.NodeVersion ?? fresh.NodeVersion };
|
||||
if (fresh.Status == NodeStatus.Disabled)
|
||||
{
|
||||
await RevokeAsync("disabled", ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (fresh.Status != NodeStatus.Active || !_helloDone)
|
||||
{
|
||||
// pending 不下发状态;还没有 hello 的连接等 hello 之后再推送。
|
||||
return true;
|
||||
}
|
||||
|
||||
bool complete = true;
|
||||
foreach (IStateProvider provider in _host.StateProviders)
|
||||
{
|
||||
StateDocument? document = await provider.GetAsync(_node, ct);
|
||||
if (document is null || (_lastPushedVersion.TryGetValue(provider.Type, out long last) && last == document.Version))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Result<JsonElement> raw = await RequestAsync(
|
||||
FleetMethods.StateSync,
|
||||
JsonSerializer.SerializeToElement(
|
||||
new StateSyncParams { Type = provider.Type, Version = document.Version, Doc = document.Doc },
|
||||
FleetRpcContext.Default.StateSyncParams),
|
||||
ct);
|
||||
if (!raw.TryGet(out JsonElement value, out FleetError? error))
|
||||
{
|
||||
FleetLog.StatePushFailed(_host.Logger, _node.Id.Value, provider.Type, error.Code.Value);
|
||||
complete = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
StateSyncResult? result = null;
|
||||
try
|
||||
{
|
||||
result = value.Deserialize(FleetRpcContext.Default.StateSyncResult);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// 按缺失处理
|
||||
}
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
complete = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
_lastPushedVersion[provider.Type] = document.Version;
|
||||
await provider.OnAppliedAsync(_node, document.Version, result, ct);
|
||||
}
|
||||
|
||||
return complete;
|
||||
}
|
||||
|
||||
// ───────────────────────── 帮助方法 ─────────────────────────
|
||||
|
||||
private async Task UpdatePresenceAsync(bool online, CancellationToken ct)
|
||||
{
|
||||
Interlocked.Exchange(ref _lastPresenceTicks, _host.Time.GetTimestamp());
|
||||
await _host.Store.UpdatePresenceAsync(
|
||||
_node!.Id,
|
||||
new Presence(online, _host.Time.GetUtcNow().ToUnixTimeMilliseconds(), _session?.NodeVersion, _session?.Capabilities ?? [], _session?.HelloMetaJson),
|
||||
ct);
|
||||
}
|
||||
|
||||
private async Task FireAsync(NodeEventKind kind, CancellationToken ct, HelloParams? hello = null, JsonElement? extra = null)
|
||||
{
|
||||
var nodeEvent = new NodeEvent(kind, _node!, _host.Time.GetUtcNow().ToUnixTimeMilliseconds(), hello, extra);
|
||||
foreach (INodeEventSink sink in _host.Sinks)
|
||||
{
|
||||
try
|
||||
{
|
||||
await sink.OnEventAsync(nodeEvent, ct);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 产品的事件处理失败不能影响连接。
|
||||
FleetLog.SinkFailed(_host.Logger, ex, kind.ToString(), _node!.Id.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask<Frame?> ReceiveFrameAsync(CancellationToken ct)
|
||||
{
|
||||
var writer = new ArrayBufferWriter<byte>(1024);
|
||||
while (true)
|
||||
{
|
||||
Memory<byte> buffer = writer.GetMemory(4096);
|
||||
ValueWebSocketReceiveResult result = await _socket.ReceiveAsync(buffer, ct);
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
writer.Advance(result.Count);
|
||||
if (writer.WrittenCount > _host.Options.MaxFrameBytes)
|
||||
{
|
||||
_closeReason ??= "frame too large";
|
||||
await _socket.CloseOutputAsync(WebSocketCloseStatus.MessageTooBig, "frame too large", CancellationToken.None);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (result.EndOfMessage)
|
||||
{
|
||||
return new Frame(result.MessageType == WebSocketMessageType.Text, writer.WrittenSpan.ToArray());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParse(byte[] data, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out RpcMessage? message)
|
||||
{
|
||||
try
|
||||
{
|
||||
message = JsonSerializer.Deserialize(data, FleetRpcContext.Default.RpcMessage);
|
||||
return message is not null;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
message = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryDecode(string text, out byte[] data)
|
||||
{
|
||||
try
|
||||
{
|
||||
data = Convert.FromBase64String(text);
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
data = [];
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string StatusName(NodeStatus status) => status switch
|
||||
{
|
||||
NodeStatus.Active => "active",
|
||||
NodeStatus.Pending => "pending",
|
||||
_ => "disabled",
|
||||
};
|
||||
|
||||
private sealed record Frame(bool IsText, byte[] Data);
|
||||
}
|
||||
|
||||
[System.Text.Json.Serialization.JsonSerializable(typeof(EmptyObject))]
|
||||
internal sealed partial class FleetEmptyObjectContext : System.Text.Json.Serialization.JsonSerializerContext;
|
||||
|
||||
internal sealed class EmptyObject;
|
||||
|
||||
internal static class FleetEmptyObject
|
||||
{
|
||||
public static System.Text.Json.Serialization.Metadata.JsonTypeInfo<EmptyObject> Info => FleetEmptyObjectContext.Default.EmptyObject;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using Fleet.Core;
|
||||
|
||||
namespace Fleet.Tests;
|
||||
|
||||
public sealed class CoreTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(0UL)]
|
||||
[InlineData(1UL)]
|
||||
[InlineData(127UL)]
|
||||
[InlineData(128UL)]
|
||||
[InlineData(300UL)]
|
||||
[InlineData(ulong.MaxValue)]
|
||||
public void VarInt_RoundTrips(ulong value)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[VarInt.MaxBytes];
|
||||
int written = VarInt.Write(buffer, value);
|
||||
Assert.True(VarInt.TryRead(buffer[..written], out ulong read, out int consumed));
|
||||
Assert.Equal(value, read);
|
||||
Assert.Equal(written, consumed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VarInt_RejectsTruncatedAndOverlongInput()
|
||||
{
|
||||
Assert.False(VarInt.TryRead([0x80], out _, out _));
|
||||
Assert.False(VarInt.TryRead([], out _, out _));
|
||||
byte[] overlong = [.. Enumerable.Repeat((byte)0x80, 11), 0x01];
|
||||
Assert.False(VarInt.TryRead(overlong, out _, out _));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0L)]
|
||||
[InlineData(-1L)]
|
||||
[InlineData(1L)]
|
||||
[InlineData(long.MinValue)]
|
||||
[InlineData(long.MaxValue)]
|
||||
public void ZigZag_RoundTrips_AndKeepsSmallMagnitudesSmall(long value)
|
||||
{
|
||||
Assert.Equal(value, VarInt.UnZigZag(VarInt.ZigZag(value)));
|
||||
if (value is >= -63 and <= 63)
|
||||
{
|
||||
Assert.True(VarInt.ZigZag(value) < 128);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ulid_IsTwentySixCharacters_SortsByTime_AndDoesNotRepeat()
|
||||
{
|
||||
var time = new FakeClock(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
string a = Ulid.New(time);
|
||||
time.Advance(TimeSpan.FromMilliseconds(5));
|
||||
string b = Ulid.New(time);
|
||||
Assert.Equal(26, a.Length);
|
||||
Assert.True(string.CompareOrdinal(a, b) < 0);
|
||||
Assert.Equal(1000, Enumerable.Range(0, 1000).Select(_ => Ulid.New(time)).Distinct().Count());
|
||||
Assert.StartsWith("agt_", Ulid.NewPrefixed("agt", time));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tokens_AreHighEntropy_AndOnlyTheHashIsComparable()
|
||||
{
|
||||
string a = TokenHash.NewToken("jt");
|
||||
string b = TokenHash.NewToken("jt");
|
||||
Assert.NotEqual(a, b);
|
||||
Assert.StartsWith("jt_", a);
|
||||
Assert.Equal(3 + 43, a.Length);
|
||||
Assert.True(TokenHash.FixedTimeEquals(TokenHash.Sha256(a), TokenHash.Sha256(a)));
|
||||
Assert.False(TokenHash.FixedTimeEquals(TokenHash.Sha256(a), TokenHash.Sha256(b)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Base64Url_RoundTrips_AndRejectsGarbage()
|
||||
{
|
||||
byte[] data = [0xFB, 0xFF, 0xFE, 0x00, 0x01];
|
||||
string text = Base64Url.Encode(data);
|
||||
Assert.DoesNotContain('+', text);
|
||||
Assert.DoesNotContain('/', text);
|
||||
Assert.DoesNotContain('=', text);
|
||||
Assert.True(Base64Url.TryDecode(text, out byte[] back));
|
||||
Assert.Equal(data, back);
|
||||
Assert.False(Base64Url.TryDecode("***", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Result_CarriesEitherAValueOrAnError_AndRefusesTheWrongAccess()
|
||||
{
|
||||
Result<int> ok = Result<int>.Ok(5);
|
||||
Result<int> failed = Result<int>.Fail(FleetErrors.NotFound, "missing");
|
||||
Assert.True(ok.IsSuccess);
|
||||
Assert.Equal(10, ok.Map(v => v * 2).Value);
|
||||
Assert.False(failed.IsSuccess);
|
||||
Assert.Equal(FleetErrors.NotFound, failed.Error.Code);
|
||||
Assert.Throws<InvalidOperationException>(() => failed.Value);
|
||||
Assert.Throws<InvalidOperationException>(() => ok.Error);
|
||||
Assert.Equal("fleet_not_found", failed.Map(v => v).Error.Code.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FrameworkErrorCodes_ArePrefixedAndSnakeCase()
|
||||
{
|
||||
foreach (System.Reflection.FieldInfo field in typeof(FleetErrors).GetFields())
|
||||
{
|
||||
string value = ((ErrorCode)field.GetValue(null)!).Value;
|
||||
Assert.StartsWith("fleet_", value);
|
||||
Assert.Matches("^[a-z]+(_[a-z]+)*$", value);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeClock : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now;
|
||||
|
||||
public FakeClock(DateTimeOffset now)
|
||||
{
|
||||
_now = now;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
|
||||
public void Advance(TimeSpan by) => _now += by;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" />
|
||||
<ProjectReference Include="..\..\src\Fleet.Core\Fleet.Core.csproj" />
|
||||
<ProjectReference Include="..\..\src\Fleet.Identity\Fleet.Identity.csproj" />
|
||||
<ProjectReference Include="..\..\src\Fleet.Rpc\Fleet.Rpc.csproj" />
|
||||
<ProjectReference Include="..\..\src\Fleet.Link\Fleet.Link.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,162 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Fleet.Core;
|
||||
using Fleet.Identity;
|
||||
using Fleet.Rpc;
|
||||
|
||||
namespace Fleet.Tests;
|
||||
|
||||
public sealed class IdentityAndRpcTests
|
||||
{
|
||||
[Fact]
|
||||
public void Signatures_VerifyOnlyForTheSameKeyPurposeAndFields()
|
||||
{
|
||||
using NodeIdentity node = NodeIdentity.Generate();
|
||||
using NodeIdentity other = NodeIdentity.Generate();
|
||||
byte[] signature = node.Sign(SignaturePurpose.Auth, "node-1", "nonce-1");
|
||||
|
||||
Assert.True(NodeIdentity.Verify(node.PublicKeySpki, signature, SignaturePurpose.Auth, "node-1", "nonce-1"));
|
||||
Assert.False(NodeIdentity.Verify(other.PublicKeySpki, signature, SignaturePurpose.Auth, "node-1", "nonce-1"), "another key");
|
||||
Assert.False(NodeIdentity.Verify(node.PublicKeySpki, signature, SignaturePurpose.Peer, "node-1", "nonce-1"), "another purpose: a signature must not be reusable across scenarios");
|
||||
Assert.False(NodeIdentity.Verify(node.PublicKeySpki, signature, SignaturePurpose.Auth, "node-2", "nonce-1"), "another node id");
|
||||
Assert.False(NodeIdentity.Verify(node.PublicKeySpki, signature, SignaturePurpose.Auth, "node-1", "nonce-2"), "another nonce");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_NeverThrowsOnGarbage()
|
||||
{
|
||||
using NodeIdentity node = NodeIdentity.Generate();
|
||||
Assert.False(NodeIdentity.Verify([1, 2, 3], [4, 5, 6], SignaturePurpose.Auth, "x"));
|
||||
Assert.False(NodeIdentity.Verify(node.PublicKeySpki, [], SignaturePurpose.Auth, "x"));
|
||||
Assert.False(NodeIdentity.Verify(node.PublicKeySpki, new byte[64], SignaturePurpose.Auth, "x"));
|
||||
Assert.False(NodeIdentity.IsValidPublicKey([]));
|
||||
Assert.False(NodeIdentity.IsValidPublicKey(new byte[91]));
|
||||
Assert.True(NodeIdentity.IsValidPublicKey(node.PublicKeySpki));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Identity_SurvivesAnExportImportRoundTrip()
|
||||
{
|
||||
using NodeIdentity original = NodeIdentity.Generate();
|
||||
using NodeIdentity restored = NodeIdentity.FromPkcs8(original.ExportPkcs8());
|
||||
Assert.Equal(original.PublicKeySpki, restored.PublicKeySpki);
|
||||
Assert.True(NodeIdentity.Verify(original.PublicKeySpki, restored.Sign("t", "a"), "t", "a"));
|
||||
Assert.Equal(32, NodeIdentity.Fingerprint(original.PublicKeySpki).Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SignatureString_IsPrefixedWithPurpose()
|
||||
{
|
||||
Assert.Equal("fleet|v1|auth|a|b", SignatureString.Build("auth", "a", "b"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OutboxFrame_RoundTrips_AndRejectsShortFrames()
|
||||
{
|
||||
var id = Guid.CreateVersion7();
|
||||
byte[] frame = OutboxFrame.Build(16, id, [1, 2, 3]);
|
||||
Assert.True(OutboxFrame.TryParse(frame, out byte channel, out Guid parsed, out ReadOnlyMemory<byte> payload));
|
||||
Assert.Equal((byte)16, channel);
|
||||
Assert.Equal(id, parsed);
|
||||
Assert.Equal(new byte[] { 1, 2, 3 }, payload.ToArray());
|
||||
Assert.False(OutboxFrame.TryParse(new byte[OutboxFrame.HeaderBytes - 1], out _, out _, out _));
|
||||
Assert.True(OutboxFrame.TryParse(new byte[OutboxFrame.HeaderBytes], out _, out _, out ReadOnlyMemory<byte> empty));
|
||||
Assert.Equal(0, empty.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Router_DispatchesTypedRequests_AndReportsStructuredErrors()
|
||||
{
|
||||
var router = new RpcRouter<string>()
|
||||
.On("test.add", EchoContext.Default.AddParams, EchoContext.Default.AddResult,
|
||||
(caller, p, _) => ValueTask.FromResult(p.A < 0 ? Result<AddResult>.Fail(FleetErrors.Forbidden, "negative") : Result<AddResult>.Ok(new AddResult { Sum = p.A + p.B, Caller = caller })))
|
||||
.On<AddParams, AddResult>("test.boom", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => throw new InvalidOperationException("secret detail"));
|
||||
|
||||
RpcMessage ok = (await router.DispatchAsync("alice", Request(1, "test.add", """{"a":2,"b":3}"""), CancellationToken.None))!;
|
||||
Assert.Equal(5, ok.Result!.Value.Deserialize(EchoContext.Default.AddResult)!.Sum);
|
||||
Assert.Equal("alice", ok.Result.Value.Deserialize(EchoContext.Default.AddResult)!.Caller);
|
||||
|
||||
RpcMessage denied = (await router.DispatchAsync("alice", Request(2, "test.add", """{"a":-1,"b":3}"""), CancellationToken.None))!;
|
||||
Assert.Equal("fleet_forbidden", denied.Error!.Data!.Code);
|
||||
Assert.Equal(RpcError.ApplicationError, denied.Error.Code);
|
||||
|
||||
RpcMessage invalid = (await router.DispatchAsync("alice", Request(3, "test.add", """{"a":"x"}"""), CancellationToken.None))!;
|
||||
Assert.Equal(RpcError.InvalidParams, invalid.Error!.Code);
|
||||
|
||||
RpcMessage unknown = (await router.DispatchAsync("alice", Request(4, "test.nope", "{}"), CancellationToken.None))!;
|
||||
Assert.Equal(RpcError.MethodNotFound, unknown.Error!.Code);
|
||||
Assert.Equal("fleet_unsupported", unknown.Error.Data!.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Router_HandlerExceptions_AreContained_AndNeverLeakDetails()
|
||||
{
|
||||
string? logged = null;
|
||||
var router = new RpcRouter<string>((method, ex) => logged = method + ":" + ex.Message)
|
||||
.On<AddParams, AddResult>("test.boom", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => throw new InvalidOperationException("secret detail"));
|
||||
|
||||
RpcMessage reply = (await router.DispatchAsync("a", Request(1, "test.boom", """{"a":1,"b":1}"""), CancellationToken.None))!;
|
||||
Assert.Equal("fleet_internal", reply.Error!.Data!.Code);
|
||||
Assert.DoesNotContain("secret", reply.Error.Message);
|
||||
Assert.Equal("test.boom:secret detail", logged);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Router_Notifications_ProduceNoResponse_EvenOnFailure()
|
||||
{
|
||||
int calls = 0;
|
||||
var router = new RpcRouter<string>().OnNotify("test.note", EchoContext.Default.AddParams, (_, _, _) =>
|
||||
{
|
||||
calls++;
|
||||
return ValueTask.CompletedTask;
|
||||
});
|
||||
|
||||
Assert.Null(await router.DispatchAsync("a", Notification("test.note", """{"a":1,"b":2}"""), CancellationToken.None));
|
||||
Assert.Null(await router.DispatchAsync("a", Notification("test.note", """{"a":"bad"}"""), CancellationToken.None));
|
||||
Assert.Null(await router.DispatchAsync("a", Notification("test.unknown", "{}"), CancellationToken.None));
|
||||
Assert.Equal(1, calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Envelope_OmitsAbsentFields_AndRoundTrips()
|
||||
{
|
||||
string request = JsonSerializer.Serialize(Request(7, "x.y", """{"k":1}"""), FleetRpcContext.Default.RpcMessage);
|
||||
Assert.Equal("""{"jsonrpc":"2.0","id":7,"method":"x.y","params":{"k":1}}""", request);
|
||||
RpcMessage back = JsonSerializer.Deserialize(request, FleetRpcContext.Default.RpcMessage)!;
|
||||
Assert.True(back.IsRequest);
|
||||
Assert.False(back.IsResponse);
|
||||
Assert.True(Notification("a.b", "{}").IsNotification);
|
||||
|
||||
string failure = JsonSerializer.Serialize(RpcMessage.Failure(3, new FleetError(FleetErrors.Timeout, "slow")), FleetRpcContext.Default.RpcMessage);
|
||||
Assert.Contains("\"code\":\"fleet_timeout\"", failure);
|
||||
Assert.Equal(FleetErrors.Timeout, JsonSerializer.Deserialize(failure, FleetRpcContext.Default.RpcMessage)!.Error!.ToFleetError().Code);
|
||||
}
|
||||
|
||||
private static RpcMessage Request(long id, string method, string json) =>
|
||||
new() { Id = id, Method = method, Params = JsonDocument.Parse(json).RootElement.Clone() };
|
||||
|
||||
private static RpcMessage Notification(string method, string json) =>
|
||||
new() { Method = method, Params = JsonDocument.Parse(json).RootElement.Clone() };
|
||||
}
|
||||
|
||||
public sealed class AddParams
|
||||
{
|
||||
[JsonPropertyName("a")]
|
||||
public int A { get; set; }
|
||||
|
||||
[JsonPropertyName("b")]
|
||||
public int B { get; set; }
|
||||
}
|
||||
|
||||
public sealed class AddResult
|
||||
{
|
||||
[JsonPropertyName("sum")]
|
||||
public int Sum { get; set; }
|
||||
|
||||
[JsonPropertyName("caller")]
|
||||
public string Caller { get; set; } = "";
|
||||
}
|
||||
|
||||
[JsonSerializable(typeof(AddParams))]
|
||||
[JsonSerializable(typeof(AddResult))]
|
||||
internal sealed partial class EchoContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,431 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Authentication;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Fleet.Core;
|
||||
using Fleet.Identity;
|
||||
using Fleet.Link;
|
||||
using Fleet.Rpc;
|
||||
|
||||
namespace Fleet.Tests;
|
||||
|
||||
public sealed class LinkTests
|
||||
{
|
||||
// ───────────────────────── 基础件 ─────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ReplayWindow_AcceptsEachSequenceOnce_AndRejectsStaleOnes()
|
||||
{
|
||||
var window = new ReplayWindow();
|
||||
Assert.True(window.TryAccept(100));
|
||||
Assert.False(window.TryAccept(100), "duplicate");
|
||||
Assert.True(window.TryAccept(99), "late but inside the window");
|
||||
Assert.False(window.TryAccept(99));
|
||||
Assert.True(window.TryAccept(105));
|
||||
Assert.True(window.TryAccept(101));
|
||||
Assert.True(window.TryAccept(100 + 1024), "the window slides");
|
||||
Assert.False(window.TryAccept(100), "now older than the window");
|
||||
Assert.True(window.TryAccept(100 + 5000), "a big jump clears everything");
|
||||
Assert.False(window.TryAccept(100 + 5000));
|
||||
Assert.True(window.TryAccept(100 + 4999));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Backoff_GrowsExponentially_IsCappedAndJittered_AndResets()
|
||||
{
|
||||
var backoff = new Backoff(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(60));
|
||||
TimeSpan previousCeiling = TimeSpan.Zero;
|
||||
for (int i = 0; i < 12; i++)
|
||||
{
|
||||
TimeSpan delay = backoff.Next();
|
||||
double ceiling = Math.Min(60, Math.Pow(2, i));
|
||||
Assert.InRange(delay.TotalSeconds, ceiling * 0.5 - 1e-9, ceiling + 1e-9);
|
||||
Assert.True(TimeSpan.FromSeconds(ceiling) >= previousCeiling);
|
||||
previousCeiling = TimeSpan.FromSeconds(ceiling);
|
||||
}
|
||||
|
||||
backoff.Reset();
|
||||
Assert.InRange(backoff.Next().TotalSeconds, 0.5, 1.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Outbox_DeliversInOrder_UntilAcknowledged_AndResendsAfterAReconnect()
|
||||
{
|
||||
var outbox = new Outbox(1 << 20);
|
||||
Guid a = Guid.CreateVersion7();
|
||||
Guid b = Guid.CreateVersion7();
|
||||
outbox.Enqueue(16, a, [1]);
|
||||
outbox.Enqueue(16, b, [2]);
|
||||
outbox.Enqueue(16, a, [9]); // 同一个 msg_id 只保留一份
|
||||
|
||||
Assert.Equal(2, outbox.PendingCount);
|
||||
Assert.True(OutboxFrame.TryParse(await outbox.TakeNextAsync(1 << 20, default), out _, out Guid first, out _));
|
||||
Assert.True(OutboxFrame.TryParse(await outbox.TakeNextAsync(1 << 20, default), out _, out Guid second, out _));
|
||||
Assert.Equal((a, b), (first, second));
|
||||
|
||||
// 连接断了:还没确认的全部重发,顺序不变
|
||||
outbox.ResetSent();
|
||||
Assert.True(OutboxFrame.TryParse(await outbox.TakeNextAsync(1 << 20, default), out _, out Guid again, out _));
|
||||
Assert.Equal(a, again);
|
||||
|
||||
outbox.Acknowledge([a, b, Guid.NewGuid()]);
|
||||
Assert.Equal(0, outbox.PendingCount);
|
||||
Assert.Equal(0, outbox.PendingBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Outbox_IsBounded_DropsTheOldest_AndReportsTheGap()
|
||||
{
|
||||
var outbox = new Outbox(maxBytes: 100);
|
||||
int gap = 0;
|
||||
outbox.OnGap = n => gap += n;
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
outbox.Enqueue(16, Guid.CreateVersion7(), new byte[40]); // 每条 57 字节
|
||||
}
|
||||
|
||||
Assert.Equal(1, outbox.PendingCount);
|
||||
Assert.Equal(9, outbox.Dropped);
|
||||
Assert.Equal(9, gap);
|
||||
Assert.True(outbox.PendingBytes <= 100);
|
||||
|
||||
var counted = new Outbox(1 << 20, maxCount: 3);
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
counted.Enqueue(16, Guid.CreateVersion7(), [1]);
|
||||
}
|
||||
|
||||
Assert.Equal(3, counted.PendingCount);
|
||||
Assert.Equal(2, counted.Dropped);
|
||||
_ = await counted.TakeNextAsync(1 << 20, default);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Outbox_LimitsBytesInFlight_UntilTheServerAcknowledges()
|
||||
{
|
||||
var outbox = new Outbox(1 << 20);
|
||||
Guid first = Guid.CreateVersion7();
|
||||
outbox.Enqueue(16, first, new byte[1000]);
|
||||
outbox.Enqueue(16, Guid.CreateVersion7(), new byte[1000]);
|
||||
|
||||
await outbox.TakeNextAsync(1500, default); // 第一条放行
|
||||
using var cts = new CancellationTokenSource(200);
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () => await outbox.TakeNextAsync(1500, cts.Token)); // 第二条超过在途上限,等 ACK
|
||||
|
||||
outbox.Acknowledge([first]);
|
||||
Assert.NotNull(await outbox.TakeNextAsync(1500, default));
|
||||
|
||||
// 单条消息比在途上限还大时也要放行,否则永远卡住
|
||||
var big = new Outbox(1 << 20);
|
||||
big.Enqueue(16, Guid.CreateVersion7(), new byte[5000]);
|
||||
Assert.NotNull(await big.TakeNextAsync(100, default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionKeys_AgreeOnBothSides_AndBindTheHandshake()
|
||||
{
|
||||
using var client = new EphemeralKey();
|
||||
using var server = new EphemeralKey();
|
||||
byte[] nonce = RandomNumberGenerator.GetBytes(32);
|
||||
|
||||
SessionCredentials c = client.Derive(server.PublicKeySpki, nonce, client.PublicKeySpki, server.PublicKeySpki);
|
||||
SessionCredentials s = server.Derive(client.PublicKeySpki, nonce, client.PublicKeySpki, server.PublicKeySpki);
|
||||
Assert.Equal(c.Key, s.Key);
|
||||
Assert.Equal(c.SessionId, s.SessionId);
|
||||
Assert.Equal(8, c.SessionId.Length);
|
||||
Assert.Equal(32, c.Key.Length);
|
||||
|
||||
SessionCredentials otherNonce = client.Derive(server.PublicKeySpki, RandomNumberGenerator.GetBytes(32), client.PublicKeySpki, server.PublicKeySpki);
|
||||
Assert.NotEqual(c.Key, otherNonce.Key);
|
||||
using var third = new EphemeralKey();
|
||||
Assert.NotEqual(c.Key, third.Derive(server.PublicKeySpki, nonce, third.PublicKeySpki, server.PublicKeySpki).Key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DataPlaneMac_DetectsAnyTampering()
|
||||
{
|
||||
byte[] key = RandomNumberGenerator.GetBytes(32);
|
||||
byte[] data = [1, 2, 3, 4, 5];
|
||||
byte[] mac = new byte[DataPlaneMac.Bytes];
|
||||
DataPlaneMac.Compute(key, data, mac);
|
||||
Assert.True(DataPlaneMac.Verify(key, data, mac));
|
||||
data[2] ^= 1;
|
||||
Assert.False(DataPlaneMac.Verify(key, data, mac));
|
||||
data[2] ^= 1;
|
||||
mac[0] ^= 1;
|
||||
Assert.False(DataPlaneMac.Verify(key, data, mac));
|
||||
mac[0] ^= 1;
|
||||
Assert.False(DataPlaneMac.Verify(RandomNumberGenerator.GetBytes(32), data, mac));
|
||||
}
|
||||
|
||||
// ───────────────────────── 传输与会话 ─────────────────────────
|
||||
|
||||
private static async Task<(TlsFrameTransport Left, TlsFrameTransport Right)> PairAsync(int maxFrame = 1 << 20)
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var client = new TcpClient();
|
||||
Task<TcpClient> accept = listener.AcceptTcpClientAsync();
|
||||
await client.ConnectAsync(IPAddress.Loopback, ((IPEndPoint)listener.LocalEndpoint).Port);
|
||||
TcpClient server = await accept;
|
||||
listener.Stop();
|
||||
return (new TlsFrameTransport(client.GetStream(), maxFrame), new TlsFrameTransport(server.GetStream(), maxFrame));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TlsFrames_RoundTripTextAndBinary_AndCloseCleanly()
|
||||
{
|
||||
(TlsFrameTransport left, TlsFrameTransport right) = await PairAsync();
|
||||
await using (left)
|
||||
await using (right)
|
||||
{
|
||||
await left.SendAsync(new RpcFrame(FrameKind.Text, "hello"u8.ToArray()), default);
|
||||
await left.SendAsync(new RpcFrame(FrameKind.Binary, new byte[] { 1, 2, 3 }), default);
|
||||
await left.SendAsync(new RpcFrame(FrameKind.Binary, ReadOnlyMemory<byte>.Empty), default);
|
||||
|
||||
RpcFrame a = (await right.ReceiveAsync(default))!.Value;
|
||||
RpcFrame b = (await right.ReceiveAsync(default))!.Value;
|
||||
RpcFrame c = (await right.ReceiveAsync(default))!.Value;
|
||||
Assert.Equal((FrameKind.Text, "hello"), (a.Kind, System.Text.Encoding.UTF8.GetString(a.Data.Span)));
|
||||
Assert.Equal(new byte[] { 1, 2, 3 }, b.Data.ToArray());
|
||||
Assert.Equal(0, c.Data.Length);
|
||||
|
||||
await left.CloseAsync("done", default);
|
||||
Assert.Null(await right.ReceiveAsync(default));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TlsFrames_RefuseOversizedFrames_OnBothSides()
|
||||
{
|
||||
(TlsFrameTransport left, TlsFrameTransport right) = await PairAsync(maxFrame: 100);
|
||||
await using (left)
|
||||
await using (right)
|
||||
{
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(async () => await left.SendAsync(new RpcFrame(FrameKind.Binary, new byte[101]), default));
|
||||
|
||||
// 对端绕过长度检查发来超长帧:接收方直接断开
|
||||
(TlsFrameTransport raw, TlsFrameTransport receiver) = await PairAsync(maxFrame: 100);
|
||||
await using (raw)
|
||||
await using (receiver)
|
||||
{
|
||||
var permissive = new TlsFrameTransport(await TakeStreamAsync(raw), 1 << 20);
|
||||
await permissive.SendAsync(new RpcFrame(FrameKind.Binary, new byte[500]), default);
|
||||
Assert.Null(await receiver.ReceiveAsync(default));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Task<Stream> TakeStreamAsync(TlsFrameTransport transport) =>
|
||||
Task.FromResult((Stream)typeof(TlsFrameTransport).GetField("_stream", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!.GetValue(transport)!);
|
||||
|
||||
[Fact]
|
||||
public async Task Session_CorrelatesRequestsAndResponses_BothWays_WithoutDeadlockingInHandlers()
|
||||
{
|
||||
(TlsFrameTransport left, TlsFrameTransport right) = await PairAsync();
|
||||
RpcSession<string>? rightSession = null;
|
||||
var leftRouter = new RpcRouter<string>().On("left.double", LinkJson.Default.Number, LinkJson.Default.Number, (_, p, _) => ValueTask.FromResult(Result<Number>.Ok(new Number { Value = p.Value * 2 })));
|
||||
var rightRouter = new RpcRouter<string>().On(
|
||||
"right.relay", LinkJson.Default.Number, LinkJson.Default.Number,
|
||||
async (_, p, ct) =>
|
||||
{
|
||||
// 处理函数里等待向请求方发起的请求:接收循环和处理函数在不同的任务里,所以不会死锁
|
||||
Result<Number> inner = await rightSession!.RequestAsync("left.double", p, LinkJson.Default.Number, LinkJson.Default.Number, ct);
|
||||
return Result<Number>.Ok(new Number { Value = inner.Value.Value + 1 });
|
||||
});
|
||||
|
||||
await using var leftSession = new RpcSession<string>(left, leftRouter, "left", TimeProvider.System);
|
||||
rightSession = new RpcSession<string>(right, rightRouter, "right", TimeProvider.System);
|
||||
await using (rightSession)
|
||||
{
|
||||
Task leftRun = leftSession.RunAsync(default);
|
||||
Task rightRun = rightSession.RunAsync(default);
|
||||
|
||||
// left 请求 right.relay;right 的处理函数回头请求 left.double:21 → 42 → +1
|
||||
Result<Number> result = await leftSession.RequestAsync("right.relay", new Number { Value = 21 }, LinkJson.Default.Number, LinkJson.Default.Number, default);
|
||||
Assert.Equal(43, result.Value.Value);
|
||||
|
||||
Result<JsonElement> unknown = await leftSession.RequestAsync("nope", null, default);
|
||||
Assert.Equal("fleet_unsupported", unknown.Error.Code.Value);
|
||||
|
||||
await leftSession.CloseAsync("bye", default);
|
||||
await rightRun.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
await leftRun.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Session_RequestsFailWhenThePeerNeverAnswers_OrTheSessionCloses()
|
||||
{
|
||||
(TlsFrameTransport left, TlsFrameTransport right) = await PairAsync();
|
||||
await using var silent = right; // 对端不读也不回
|
||||
await using var session = new RpcSession<string>(left, new RpcRouter<string>(), "x", TimeProvider.System, new RpcSessionOptions { RequestTimeout = TimeSpan.FromMilliseconds(200) });
|
||||
Task run = session.RunAsync(default);
|
||||
|
||||
Result<JsonElement> timedOut = await session.RequestAsync("any", null, default);
|
||||
Assert.Equal(FleetErrors.Timeout, timedOut.Error.Code);
|
||||
|
||||
Task<Result<JsonElement>> pending = session.RequestAsync("any", null, default, TimeSpan.FromSeconds(30)).AsTask();
|
||||
await silent.CloseAsync("gone", default);
|
||||
Assert.Equal(FleetErrors.Timeout, (await pending.WaitAsync(TimeSpan.FromSeconds(5))).Error.Code);
|
||||
await run.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
// ───────────────────────── 点对点握手 ─────────────────────────
|
||||
|
||||
private sealed class PeerFixture : IAsyncDisposable
|
||||
{
|
||||
private readonly CancellationTokenSource _stop = new();
|
||||
|
||||
public PeerFixture(Func<string, byte[]?> lookup, Func<PeerSessionInfo, IRpcTransport, CancellationToken, Task>? onSession = null)
|
||||
{
|
||||
Certificate = NodeCertificate.LoadPfx(NodeCertificate.CreateSelfSignedPfx("agent", TimeSpan.FromDays(30), TimeProvider.System));
|
||||
Listener = new PeerListener(
|
||||
new PeerListenerOptions { Listen = new IPEndPoint(IPAddress.Loopback, 0), Certificate = Certificate, LookupPeerKey = lookup },
|
||||
onSession ?? ((_, transport, ct) => KeepOpenAsync(transport, ct)),
|
||||
NullNodeLog.Instance, TimeProvider.System);
|
||||
Task = Listener.RunAsync(_stop.Token);
|
||||
}
|
||||
|
||||
public X509Certificate2 Certificate { get; }
|
||||
|
||||
public PeerListener Listener { get; }
|
||||
|
||||
public Task Task { get; }
|
||||
|
||||
public byte[] Spki => NodeCertificate.SpkiFingerprint(Certificate);
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _stop.CancelAsync();
|
||||
await Task;
|
||||
Certificate.Dispose();
|
||||
_stop.Dispose();
|
||||
}
|
||||
|
||||
private static async Task KeepOpenAsync(IRpcTransport transport, CancellationToken ct)
|
||||
{
|
||||
while (await transport.ReceiveAsync(ct) is not null)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PeerHandshake_AuthenticatesTheProbe_AndBothSidesDeriveTheSameDataPlaneCredentials()
|
||||
{
|
||||
using NodeIdentity probe = NodeIdentity.Generate();
|
||||
var seen = new TaskCompletionSource<PeerSessionInfo>();
|
||||
await using var agent = new PeerFixture(id => id == "prb_1" ? probe.PublicKeySpki : null, (info, transport, ct) =>
|
||||
{
|
||||
seen.TrySetResult(info);
|
||||
return Task.Delay(Timeout.Infinite, ct).ContinueWith(_ => { }, CancellationToken.None);
|
||||
});
|
||||
await agent.Listener.Started.Task;
|
||||
|
||||
(PeerSessionInfo client, TlsFrameTransport transport) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
|
||||
await using (transport)
|
||||
{
|
||||
PeerSessionInfo server = await seen.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("prb_1", server.PeerId);
|
||||
Assert.Equal(client.Credentials.Key, server.Credentials.Key);
|
||||
Assert.Equal(client.Credentials.SessionId, server.Credentials.SessionId);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PeerHandshake_RejectsUnknownProbes_AndForgedSignatures_WithTheSameError()
|
||||
{
|
||||
using NodeIdentity probe = NodeIdentity.Generate();
|
||||
using NodeIdentity impostor = NodeIdentity.Generate();
|
||||
await using var agent = new PeerFixture(id => id == "prb_1" ? probe.PublicKeySpki : null);
|
||||
await agent.Listener.Started.Task;
|
||||
|
||||
InvalidOperationException unknown = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
||||
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_unknown", null, 1 << 20, TimeProvider.System, default));
|
||||
InvalidOperationException forged = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
||||
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, impostor, "prb_1", null, 1 << 20, TimeProvider.System, default));
|
||||
Assert.Equal(unknown.Message, forged.Message);
|
||||
Assert.Contains("fleet_unauthorized", forged.Message);
|
||||
|
||||
// 正主仍然可以连接
|
||||
(_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
|
||||
await ok.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PeerHandshake_PinsTheAgentCertificate_ByItsSpkiFingerprint()
|
||||
{
|
||||
using NodeIdentity probe = NodeIdentity.Generate();
|
||||
await using var agent = new PeerFixture(_ => probe.PublicKeySpki);
|
||||
await agent.Listener.Started.Task;
|
||||
|
||||
byte[] wrong = SHA256.HashData("another certificate"u8);
|
||||
await Assert.ThrowsAnyAsync<AuthenticationException>(async () =>
|
||||
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, wrong, probe, "prb_1", null, 1 << 20, TimeProvider.System, default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PeerSession_CarriesRpcBothWays_AfterTheHandshake()
|
||||
{
|
||||
using NodeIdentity probe = NodeIdentity.Generate();
|
||||
var router = new RpcRouter<string>().On("agent.echo", LinkJson.Default.Number, LinkJson.Default.Number, (_, p, _) => ValueTask.FromResult(Result<Number>.Ok(new Number { Value = p.Value + 1 })));
|
||||
await using var agent = new PeerFixture(
|
||||
_ => probe.PublicKeySpki,
|
||||
async (info, transport, ct) =>
|
||||
{
|
||||
await using var session = new RpcSession<string>(transport, router, info.PeerId, TimeProvider.System);
|
||||
await session.RunAsync(ct);
|
||||
});
|
||||
await agent.Listener.Started.Task;
|
||||
|
||||
(_, TlsFrameTransport transport) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
|
||||
await using var client = new RpcSession<string>(transport, new RpcRouter<string>(), "probe", TimeProvider.System);
|
||||
Task run = client.RunAsync(default);
|
||||
Result<Number> result = await client.RequestAsync("agent.echo", new Number { Value = 41 }, LinkJson.Default.Number, LinkJson.Default.Number, default);
|
||||
Assert.Equal(42, result.Value.Value);
|
||||
await client.CloseAsync("bye", default);
|
||||
await run.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PeerListener_LimitsConcurrentSessions_AndSurvivesGarbageConnections()
|
||||
{
|
||||
using NodeIdentity probe = NodeIdentity.Generate();
|
||||
await using var agent = new PeerFixture(_ => probe.PublicKeySpki);
|
||||
await agent.Listener.Started.Task;
|
||||
|
||||
// 不说 TLS 的连接:握手失败,监听继续
|
||||
using (var garbage = new TcpClient())
|
||||
{
|
||||
await garbage.ConnectAsync(IPAddress.Loopback, agent.Listener.Port);
|
||||
await garbage.GetStream().WriteAsync("GET / HTTP/1.1\r\n\r\n"u8.ToArray());
|
||||
}
|
||||
|
||||
(_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
|
||||
await ok.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelfSignedCertificate_IsEcdsaP256_AndHasAStableFingerprint()
|
||||
{
|
||||
byte[] pfx = NodeCertificate.CreateSelfSignedPfx("agent", TimeSpan.FromDays(30), TimeProvider.System);
|
||||
using X509Certificate2 certificate = NodeCertificate.LoadPfx(pfx);
|
||||
Assert.True(certificate.HasPrivateKey);
|
||||
Assert.Equal("1.2.840.10045.2.1", certificate.PublicKey.Oid.Value); // id-ecPublicKey
|
||||
Assert.Equal(NodeCertificate.SpkiFingerprint(certificate), NodeCertificate.SpkiFingerprint(NodeCertificate.LoadPfx(pfx)));
|
||||
Assert.Equal(32, NodeCertificate.SpkiFingerprint(certificate).Length);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class Number
|
||||
{
|
||||
[JsonPropertyName("value")]
|
||||
public int Value { get; set; }
|
||||
}
|
||||
|
||||
[JsonSerializable(typeof(Number))]
|
||||
internal sealed partial class LinkJson : JsonSerializerContext;
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Fleet.Core;
|
||||
using Fleet.Enrollment.Client;
|
||||
|
||||
// fleet-enroll --server https://panel --join jt_xxx --name jp-1 --identity-dir /etc/monitor [--meta '{"probe_tcp_port":7443,...}'] [--version 1.0]
|
||||
// 一次性运行:生成身份、注册、写入身份目录,然后退出。
|
||||
string? Arg(string name) => args.SkipWhile(a => a != name).Skip(1).FirstOrDefault();
|
||||
|
||||
string? server = Arg("--server");
|
||||
string? join = Arg("--join");
|
||||
string? name = Arg("--name");
|
||||
string? directory = Arg("--identity-dir");
|
||||
if (server is null || join is null || name is null || directory is null)
|
||||
{
|
||||
Console.Error.WriteLine("usage: fleet-enroll --server <url> --join <token> --name <node name> --identity-dir <dir> [--meta <json>] [--version <v>]");
|
||||
return 2;
|
||||
}
|
||||
|
||||
JsonNode? meta = null;
|
||||
if (Arg("--meta") is { } metaText)
|
||||
{
|
||||
try
|
||||
{
|
||||
meta = JsonNode.Parse(metaText);
|
||||
}
|
||||
catch (System.Text.Json.JsonException ex)
|
||||
{
|
||||
Console.Error.WriteLine("--meta is not valid JSON: " + ex.Message);
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
|
||||
Result<EnrollmentResult> result = await EnrollmentClient.EnrollAsync(
|
||||
http, new Uri(server), join, name, directory, Arg("--version") ?? "0.0.0", meta, TimeProvider.System, CancellationToken.None);
|
||||
if (!result.TryGet(out EnrollmentResult? enrolled, out FleetError? error))
|
||||
{
|
||||
Console.Error.WriteLine($"enrollment failed ({error.Code}): {error.Message}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
Console.WriteLine($"enrolled as {enrolled.NodeId} ({enrolled.Status}); identity written to {enrolled.IdentityDirectory}");
|
||||
return 0;
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<AssemblyName>fleet-enroll</AssemblyName>
|
||||
<RootNamespace>FleetEnroll</RootNamespace>
|
||||
<IsAotCompatible>true</IsAotCompatible>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Fleet.Enrollment.Client\Fleet.Enrollment.Client.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in new issue
Block a user