chore: initial project import

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

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
namespace Fleet.Core;
/// <summary>
/// 错误码:snake_case 字符串。框架的错误码以 <c>fleet_</c> 开头,产品可以扩展自己的错误码。
/// </summary>
public readonly record struct ErrorCode(string Value)
{
public override string ToString() => Value;
}
public static class FleetErrors
{
public static readonly ErrorCode Unauthorized = new("fleet_unauthorized");
public static readonly ErrorCode Forbidden = new("fleet_forbidden");
public static readonly ErrorCode Unsupported = new("fleet_unsupported");
public static readonly ErrorCode InvalidParams = new("fleet_invalid_params");
public static readonly ErrorCode NotFound = new("fleet_not_found");
public static readonly ErrorCode Internal = new("fleet_internal");
public static readonly ErrorCode Timeout = new("fleet_timeout");
public static readonly ErrorCode NodeOffline = new("fleet_node_offline");
public static readonly ErrorCode NodePending = new("fleet_node_pending");
public static readonly ErrorCode NodeDisabled = new("fleet_node_disabled");
public static readonly ErrorCode NameConflict = new("fleet_name_conflict");
public static readonly ErrorCode JoinTokenInvalid = new("fleet_join_token_invalid");
public static readonly ErrorCode JoinTokenExpired = new("fleet_join_token_expired");
public static readonly ErrorCode ProofInvalid = new("fleet_proof_invalid");
public static readonly ErrorCode FrameTooLarge = new("fleet_frame_too_large");
}
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsAotCompatible>true</IsAotCompatible>
<RootNamespace>Fleet.Core</RootNamespace>
</PropertyGroup>
<ItemGroup>
<AdditionalFiles Include="$(MSBuildThisFileDirectory)../../../BannedSymbols.Node.txt" />
</ItemGroup>
</Project>
+119
View File
@@ -0,0 +1,119 @@
using System.Security.Cryptography;
namespace Fleet.Core;
public enum NodeLogLevel
{
Info,
Warn,
Error,
}
/// <summary>节点日志:字段名统一为 logfmt(<c>ts level msg agent probe session task result err</c>)。逐包事件不记录,只累加计数。</summary>
public interface INodeLog
{
void Write(NodeLogLevel level, string message, params (string Key, object? Value)[] fields);
}
public sealed class NullNodeLog : INodeLog
{
public static NullNodeLog Instance { get; } = new();
public void Write(NodeLogLevel level, string message, params (string Key, object? Value)[] fields)
{
}
}
public static class NodeLogExtensions
{
public static void Info(this INodeLog log, string message, params (string Key, object? Value)[] fields) => log.Write(NodeLogLevel.Info, message, fields);
public static void Warn(this INodeLog log, string message, params (string Key, object? Value)[] fields) => log.Write(NodeLogLevel.Warn, message, fields);
public static void Error(this INodeLog log, string message, params (string Key, object? Value)[] fields) => log.Write(NodeLogLevel.Error, message, fields);
}
/// <summary>指数退避(带随机抖动):1 s → 20 s(对端恢复之后最多等 20 秒;节点数量不大,重连风暴不是问题)。连接成功之后调用 <see cref="Reset"/>。</summary>
public sealed class Backoff
{
private readonly TimeSpan _initial;
private readonly TimeSpan _max;
private int _attempt;
public Backoff(TimeSpan? initial = null, TimeSpan? max = null)
{
_initial = initial ?? TimeSpan.FromSeconds(1);
_max = max ?? TimeSpan.FromSeconds(20);
}
public TimeSpan Next()
{
double baseMs = Math.Min(_max.TotalMilliseconds, _initial.TotalMilliseconds * Math.Pow(2, Math.Min(_attempt, 20)));
_attempt++;
// 抖动:0.5 ~ 1.0 倍,避免所有节点同时重连
double jitter = 0.5 + (RandomNumberGenerator.GetInt32(0, 1000) / 2000.0);
return TimeSpan.FromMilliseconds(baseMs * jitter);
}
public void Reset() => _attempt = 0;
}
/// <summary>
/// 序号重放窗口(滑动 1024 位):同一个序号只接受一次;比窗口还旧的序号视为重放。
/// 用于数据面的 UDP 包,每个 (session, stream) 一个。
/// </summary>
public sealed class ReplayWindow
{
private const int Size = 1024;
private readonly ulong[] _bits = new ulong[Size / 64];
private long _highest = -1;
public bool TryAccept(uint seq)
{
if (_highest < 0)
{
_highest = seq;
Set(seq);
return true;
}
if (seq > _highest)
{
long shift = seq - _highest;
if (shift >= Size)
{
Array.Clear(_bits);
}
else
{
for (long s = _highest + 1; s <= seq; s++)
{
Clear((uint)s);
}
}
_highest = seq;
Set(seq);
return true;
}
if (_highest - seq >= Size)
{
return false; // 太旧
}
if (IsSet(seq))
{
return false; // 重复
}
Set(seq);
return true;
}
private void Set(uint seq) => _bits[(int)(seq % Size) / 64] |= 1UL << (int)(seq % 64);
private void Clear(uint seq) => _bits[(int)(seq % Size) / 64] &= ~(1UL << (int)(seq % 64));
private bool IsSet(uint seq) => (_bits[(int)(seq % Size) / 64] & (1UL << (int)(seq % 64))) != 0;
}
+50
View File
@@ -0,0 +1,50 @@
namespace Fleet.Core;
public record FleetError(ErrorCode Code, string Message)
{
public override string ToString() => $"{Code}: {Message}";
}
/// <summary>预期内的失败用结果表示,不抛异常。</summary>
public readonly struct Result<T>
{
private readonly T? _value;
private readonly FleetError? _error;
private Result(T? value, FleetError? error)
{
_value = value;
_error = error;
}
public bool IsSuccess => _error is null;
public T Value => _error is null ? _value! : throw new InvalidOperationException(_error.ToString());
public FleetError Error => _error ?? throw new InvalidOperationException("Result is successful.");
public static Result<T> Ok(T value) => new(value, null);
public static Result<T> Fail(FleetError error) => new(default, error);
public static Result<T> Fail(ErrorCode code, string message) => new(default, new FleetError(code, message));
public bool TryGet(out T value, out FleetError error)
{
value = _value!;
error = _error!;
return _error is null;
}
public Result<TOut> Map<TOut>(Func<T, TOut> map) =>
_error is null ? Result<TOut>.Ok(map(_value!)) : Result<TOut>.Fail(_error);
public static implicit operator Result<T>(FleetError error) => Fail(error);
}
public static class Result
{
public static Result<T> Ok<T>(T value) => Result<T>.Ok(value);
public static Result<T> Fail<T>(ErrorCode code, string message) => Result<T>.Fail(code, message);
}
+38
View File
@@ -0,0 +1,38 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Fleet.Core;
/// <summary>节点 ID(带类型前缀的 ULID)。Monitor 中的 AgentId 就是 NodeId。</summary>
[JsonConverter(typeof(NodeIdJsonConverter))]
public readonly record struct NodeId(string Value)
{
public override string ToString() => Value;
}
/// <summary>强类型字符串 ID 的 JSON 转换器基类(源生成友好,不使用反射)。</summary>
public abstract class StringIdJsonConverter<TId> : JsonConverter<TId>
{
protected abstract TId Create(string value);
protected abstract string GetValue(TId id);
public sealed override TId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
Create(reader.GetString() ?? throw new JsonException("id must be a string"));
public sealed override void Write(Utf8JsonWriter writer, TId value, JsonSerializerOptions options) =>
writer.WriteStringValue(GetValue(value));
public sealed override TId ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
Create(reader.GetString() ?? throw new JsonException("id must be a string"));
public sealed override void WriteAsPropertyName(Utf8JsonWriter writer, TId value, JsonSerializerOptions options) =>
writer.WritePropertyName(GetValue(value));
}
public sealed class NodeIdJsonConverter : StringIdJsonConverter<NodeId>
{
protected override NodeId Create(string value) => new(value);
protected override string GetValue(NodeId id) => id.Value;
}
+43
View File
@@ -0,0 +1,43 @@
using System.Security.Cryptography;
using System.Text;
namespace Fleet.Core;
/// <summary>令牌的生成与哈希:服务端只保存 SHA-256,明文只在创建时返回一次。</summary>
public static class TokenHash
{
public static byte[] Sha256(string token) => SHA256.HashData(Encoding.UTF8.GetBytes(token));
/// <summary>生成高熵令牌:<c>{prefix}_{43 个 base64url 字符}</c>。</summary>
public static string NewToken(string prefix)
{
Span<byte> random = stackalloc byte[32];
RandomNumberGenerator.Fill(random);
return prefix + "_" + Base64Url.Encode(random);
}
public static bool FixedTimeEquals(ReadOnlySpan<byte> a, ReadOnlySpan<byte> b) =>
CryptographicOperations.FixedTimeEquals(a, b);
}
public static class Base64Url
{
public static string Encode(ReadOnlySpan<byte> data) =>
Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_');
public static bool TryDecode(string text, out byte[] data)
{
string s = text.Replace('-', '+').Replace('_', '/');
s = s.PadRight(s.Length + ((4 - (s.Length % 4)) % 4), '=');
try
{
data = Convert.FromBase64String(s);
return true;
}
catch (FormatException)
{
data = [];
return false;
}
}
}
+41
View File
@@ -0,0 +1,41 @@
using System.Security.Cryptography;
namespace Fleet.Core;
/// <summary>26 个字符的 ULID(Crockford base32,前 48 位为毫秒时间戳),按时间可排序。</summary>
public static class Ulid
{
private const string Alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
public static string New(TimeProvider time)
{
long ms = time.GetUtcNow().ToUnixTimeMilliseconds();
Span<byte> random = stackalloc byte[10];
RandomNumberGenerator.Fill(random);
Span<char> chars = stackalloc char[26];
for (int i = 9; i >= 0; i--)
{
chars[i] = Alphabet[(int)(ms & 31)];
ms >>= 5;
}
// 80 位随机数 → 16 个 base32 字符
UInt128 r = 0;
foreach (byte b in random)
{
r = (r << 8) | b;
}
for (int i = 25; i >= 10; i--)
{
chars[i] = Alphabet[(int)(r & 31)];
r >>= 5;
}
return new string(chars);
}
/// <summary>带类型前缀的 ID,例如 <c>agt_01J…</c>。</summary>
public static string NewPrefixed(string prefix, TimeProvider time) => prefix + "_" + New(time);
}
+66
View File
@@ -0,0 +1,66 @@
using System.Buffers.Binary;
namespace Fleet.Core;
/// <summary>LEB128 变长整数(无符号),以及 zigzag 有符号映射。</summary>
public static class VarInt
{
public const int MaxBytes = 10;
public static ulong ZigZag(long value) => (ulong)((value << 1) ^ (value >> 63));
public static long UnZigZag(ulong value) => (long)(value >> 1) ^ -(long)(value & 1);
public static int Write(Span<byte> destination, ulong value)
{
int i = 0;
while (value >= 0x80)
{
destination[i++] = (byte)(value | 0x80);
value >>= 7;
}
destination[i++] = (byte)value;
return i;
}
public static void Write(Stream destination, ulong value)
{
Span<byte> buffer = stackalloc byte[MaxBytes];
int n = Write(buffer, value);
destination.Write(buffer[..n]);
}
/// <summary>读取失败(数据被截断或超过 10 字节)时返回 false。</summary>
public static bool TryRead(ReadOnlySpan<byte> source, out ulong value, out int consumed)
{
value = 0;
consumed = 0;
int shift = 0;
while (consumed < source.Length && consumed < MaxBytes)
{
byte b = source[consumed++];
value |= (ulong)(b & 0x7F) << shift;
if ((b & 0x80) == 0)
{
return true;
}
shift += 7;
}
return false;
}
public static bool TryReadUInt32LittleEndian(ReadOnlySpan<byte> source, out uint value)
{
if (source.Length < 4)
{
value = 0;
return false;
}
value = BinaryPrimitives.ReadUInt32LittleEndian(source);
return true;
}
}