chore: initial project import
This commit is contained in:
commit
3fc4e5ca53
347 files changed
+56172
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user