chore: initial project import

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

No files matched your search

@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>
<ItemGroup>
<AdditionalFiles Include="$(MSBuildThisFileDirectory)../../../BannedSymbols.Node.txt" />
<ProjectReference Include="..\Fleet.Core\Fleet.Core.csproj" />
<ProjectReference Include="..\Fleet.Identity\Fleet.Identity.csproj" />
<ProjectReference Include="..\Fleet.Rpc\Fleet.Rpc.csproj" />
</ItemGroup>
</Project>
+317
View File
@@ -0,0 +1,317 @@
using System.Net.WebSockets;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using Fleet.Core;
using Fleet.Identity;
using Fleet.Rpc;
namespace Fleet.Link;
public sealed record LinkContext(FleetLink Link);
/// <summary>期望状态的处理者:应用完整快照,返回已应用的版本和被拒绝的条目(带结构化原因)。</summary>
public interface IStateHandler
{
string Type { get; }
ValueTask<StateSyncResult> ApplyAsync(long version, JsonElement document, CancellationToken ct);
}
public sealed class StateHandler<TDoc> : IStateHandler
{
private readonly JsonTypeInfo<TDoc> _info;
private readonly Func<long, TDoc, CancellationToken, ValueTask<StateSyncResult>> _apply;
public StateHandler(string type, JsonTypeInfo<TDoc> info, Func<long, TDoc, CancellationToken, ValueTask<StateSyncResult>> apply)
{
Type = type;
_info = info;
_apply = apply;
}
public string Type { get; }
public async ValueTask<StateSyncResult> ApplyAsync(long version, JsonElement document, CancellationToken ct)
{
TDoc? doc = document.Deserialize(_info);
return doc is null ? new StateSyncResult { AppliedVersion = 0 } : await _apply(version, doc, ct);
}
}
public sealed class FleetLinkOptions
{
/// <summary>面板的地址(http 或 https),连接路径固定为 <c>/api/agent/v1/rpc</c>。</summary>
public required Uri Server { get; init; }
public required NodeIdentity Identity { get; init; }
public required NodeId NodeId { get; init; }
public string NodeVersion { get; init; } = "0.0.0";
/// <summary>框架之外的能力,例如 <c>monitor.v1</c> 与 <c>monitor.kinds:udp_echo,tcp_echo</c>。</summary>
public IReadOnlyList<string> Capabilities { get; init; } = [];
/// <summary>附加到 <c>fleet.hello</c> 的产品元数据。</summary>
public Func<JsonElement?>? HelloMeta { get; init; }
/// <summary>附加到 <c>fleet.heartbeat</c> 的进程内计数(不能包含主机信息)。</summary>
public Func<JsonElement?>? HeartbeatExtra { get; init; }
public TimeSpan HeartbeatInterval { get; init; } = TimeSpan.FromSeconds(10);
public int MaxFrameBytes { get; init; } = 16 << 20;
/// <summary>发件箱在途字节上限(服务端慢时不会把内存撑爆)。</summary>
public long MaxInflightBytes { get; init; } = 4 << 20;
public Backoff Backoff { get; init; } = new();
}
/// <summary>
/// 节点到服务端的连接:挑战 → 签名认证 → hello → 状态调和 / 可靠发件箱 / 心跳,断线之后指数退避重连。
/// 被服务端撤销(<c>fleet.revoked</c>)或禁用时停止重试。
/// </summary>
public sealed class FleetLink
{
private const string RpcPath = "/api/agent/v1/rpc";
private readonly FleetLinkOptions _options;
private readonly RpcRouter<LinkContext> _router;
private readonly Dictionary<string, IStateHandler> _handlers;
private readonly TimeProvider _time;
private readonly INodeLog _log;
private volatile RpcSession<LinkContext>? _session;
private volatile bool _stopped;
public FleetLink(FleetLinkOptions options, RpcRouter<LinkContext> router, IEnumerable<IStateHandler> handlers, Outbox outbox, TimeProvider time, INodeLog log)
{
_options = options;
_router = router;
_handlers = handlers.ToDictionary(h => h.Type, StringComparer.Ordinal);
Outbox = outbox;
_time = time;
_log = log;
_router
.On(FleetMethods.StateSync, FleetRpcContext.Default.StateSyncParams, FleetRpcContext.Default.StateSyncResult, HandleStateSyncAsync)
.OnNotify(FleetMethods.OutboxAck, FleetRpcContext.Default.OutboxAckParams, HandleAckAsync)
.OnNotify(FleetMethods.Revoked, FleetRpcContext.Default.RevokedParams, HandleRevokedAsync);
}
public Outbox Outbox { get; }
public bool IsConnected => _session is not null;
public RpcSession<LinkContext>? CurrentSession => _session;
/// <summary>被服务端撤销或禁用:不会再重连。</summary>
public bool Revoked => _stopped;
/// <summary>每次连接并完成 hello 之后调用。</summary>
public Func<RpcSession<LinkContext>, CancellationToken, ValueTask>? OnConnected { get; set; }
public Action<string>? OnDisconnected { get; set; }
public async Task RunAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested && !_stopped)
{
string reason;
try
{
reason = await RunOnceAsync(ct);
_options.Backoff.Reset();
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
return;
}
catch (Exception ex) when (ex is WebSocketException or IOException or HttpRequestException or InvalidOperationException or TimeoutException)
{
reason = ex.Message;
}
OnDisconnected?.Invoke(reason);
if (ct.IsCancellationRequested)
{
return;
}
if (_stopped)
{
_log.Warn("link stopped", ("err", reason));
return;
}
TimeSpan delay = _options.Backoff.Next();
_log.Warn("link down, retrying", ("err", reason), ("retry_ms", (long)delay.TotalMilliseconds));
await Task.Delay(delay, _time, ct);
}
}
private async Task<string> RunOnceAsync(CancellationToken ct)
{
using var socket = new ClientWebSocket();
socket.Options.KeepAliveInterval = TimeSpan.FromSeconds(15);
socket.Options.KeepAliveTimeout = TimeSpan.FromSeconds(20); // 对端 20 秒没有回 Pong 就判定连接已死
var builder = new UriBuilder(_options.Server) { Scheme = _options.Server.Scheme == "https" ? "wss" : "ws", Path = RpcPath };
await socket.ConnectAsync(builder.Uri, ct);
await using var transport = new WsTransport(socket, _options.MaxFrameBytes);
string? failure = await AuthenticateAsync(transport, ct);
if (failure is not null)
{
return failure;
}
var session = new RpcSession<LinkContext>(transport, _router, new LinkContext(this), _time);
using CancellationTokenSource sessionCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
Task run = session.RunAsync(sessionCts.Token);
try
{
var hello = new HelloParams
{
NodeVersion = _options.NodeVersion,
Capabilities = [.. _options.Capabilities],
NowUtcMs = _time.GetUtcNow().ToUnixTimeMilliseconds(),
Meta = _options.HelloMeta?.Invoke(),
};
Result<HelloResult> helloResult = await session.RequestAsync(FleetMethods.Hello, hello, FleetRpcContext.Default.HelloParams, FleetRpcContext.Default.HelloResult, sessionCts.Token);
if (!helloResult.IsSuccess)
{
return "hello failed: " + helloResult.Error.Code;
}
Outbox.ResetSent();
_session = session;
_log.Info("link up", ("result", "connected"));
Task pump = PumpAsync(session, sessionCts.Token);
Task heartbeat = HeartbeatAsync(session, sessionCts.Token);
if (OnConnected is not null)
{
await OnConnected(session, sessionCts.Token);
}
Task finished = await Task.WhenAny(run, pump, heartbeat);
await sessionCts.CancelAsync();
await Task.WhenAll(Quiet(run), Quiet(pump), Quiet(heartbeat));
return finished == run ? "closed by server" : "link task failed";
}
finally
{
_session = null;
await sessionCts.CancelAsync();
await Quiet(run);
await session.DisposeAsync();
}
}
/// <summary>挑战 → 签名 → 认证结果。失败返回原因;服务端禁用该节点时不再重试。</summary>
private async Task<string?> AuthenticateAsync(WsTransport transport, CancellationToken ct)
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15), _time);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
RpcMessage? challenge = await ReceiveMessageAsync(transport, linked.Token);
if (challenge?.Method != FleetMethods.Challenge || challenge.Params is null)
{
return "no challenge from the server";
}
ChallengeParams nonce = challenge.Params.Value.Deserialize(FleetRpcContext.Default.ChallengeParams)!;
string signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.Auth, _options.NodeId.Value, nonce.Nonce));
RpcMessage request = RpcMessage.Request(1, FleetMethods.Auth, new AuthParams { NodeId = _options.NodeId.Value, Signature = signature }, FleetRpcContext.Default.AuthParams);
await transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(request, FleetRpcContext.Default.RpcMessage)), linked.Token);
while (true)
{
RpcMessage? reply = await ReceiveMessageAsync(transport, linked.Token);
if (reply is null)
{
return "connection closed during authentication";
}
if (reply.Method == FleetMethods.Revoked)
{
_stopped = true;
return "revoked";
}
if (!reply.IsResponse)
{
continue;
}
if (reply.Error is { } error)
{
if (error.Data?.Code is "fleet_node_disabled")
{
_stopped = true;
}
return "authentication failed: " + error.Data?.Code;
}
return null;
}
}
private static async Task<RpcMessage?> ReceiveMessageAsync(WsTransport transport, CancellationToken ct)
{
RpcFrame? frame = await transport.ReceiveAsync(ct);
return frame is { Kind: FrameKind.Text } f ? JsonSerializer.Deserialize(f.Data.Span, FleetRpcContext.Default.RpcMessage) : null;
}
private async Task PumpAsync(RpcSession<LinkContext> session, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
byte[] frame = await Outbox.TakeNextAsync(_options.MaxInflightBytes, ct);
await session.SendBinaryAsync(frame, ct);
}
}
private async Task HeartbeatAsync(RpcSession<LinkContext> session, CancellationToken ct)
{
using var timer = new PeriodicTimer(_options.HeartbeatInterval, _time);
while (await timer.WaitForNextTickAsync(ct))
{
await session.NotifyAsync(FleetMethods.Heartbeat, new HeartbeatParams { Extra = _options.HeartbeatExtra?.Invoke() }, FleetRpcContext.Default.HeartbeatParams, ct);
}
}
private async ValueTask<Result<StateSyncResult>> HandleStateSyncAsync(LinkContext context, StateSyncParams parameters, CancellationToken ct)
{
if (!_handlers.TryGetValue(parameters.Type, out IStateHandler? handler))
{
return Result<StateSyncResult>.Fail(FleetErrors.Unsupported, $"unknown state type {parameters.Type}");
}
return Result<StateSyncResult>.Ok(await handler.ApplyAsync(parameters.Version, parameters.Doc, ct));
}
private ValueTask HandleAckAsync(LinkContext context, OutboxAckParams parameters, CancellationToken ct)
{
Outbox.Acknowledge(parameters.Ids.Concat(parameters.Duplicates).Select(id => Guid.TryParse(id, out Guid g) ? g : Guid.Empty));
return ValueTask.CompletedTask;
}
private ValueTask HandleRevokedAsync(LinkContext context, RevokedParams parameters, CancellationToken ct)
{
_stopped = true;
_log.Warn("revoked by the server", ("err", parameters.Reason));
return ValueTask.CompletedTask;
}
private static async Task Quiet(Task task)
{
try
{
await task;
}
catch (Exception ex) when (ex is OperationCanceledException or WebSocketException or IOException or ObjectDisposedException)
{
// 会话结束时的正常异常
}
}
}
+182
View File
@@ -0,0 +1,182 @@
using Fleet.Rpc;
namespace Fleet.Link;
/// <summary>
/// 可靠发件箱:至少一次投递。消息带 msg_id(UUIDv7),服务端按它去重,持久化之后才回复 ACK。
/// 有界(按字节和条数):满了丢弃最旧的,并累计缺口数通知产品。默认只在内存里,节点重启会丢失未上传的数据。
/// 重新连接之后,所有还没确认的消息按原顺序重发。
/// </summary>
public sealed class Outbox
{
private readonly object _gate = new();
private readonly LinkedList<Entry> _entries = [];
private readonly Dictionary<Guid, LinkedListNode<Entry>> _byId = [];
private readonly long _maxBytes;
private readonly int _maxCount;
private TaskCompletionSource _signal = NewSignal();
private long _bytes;
private long _inflightBytes;
private long _dropped;
public Outbox(long maxBytes, int maxCount = int.MaxValue)
{
_maxBytes = maxBytes;
_maxCount = maxCount;
}
/// <summary>因队列已满而丢弃的消息数(缺口)。</summary>
public long Dropped => Interlocked.Read(ref _dropped);
public long PendingBytes
{
get
{
lock (_gate)
{
return _bytes;
}
}
}
public int PendingCount
{
get
{
lock (_gate)
{
return _entries.Count;
}
}
}
/// <summary>每丢弃一批消息调用一次(参数是这一批的条数)。</summary>
public Action<int>? OnGap { get; set; }
public void Enqueue(byte channel, Guid msgId, ReadOnlySpan<byte> payload)
{
byte[] frame = OutboxFrame.Build(channel, msgId, payload);
int dropped = 0;
lock (_gate)
{
if (_byId.ContainsKey(msgId))
{
return;
}
while (_entries.Count > 0 && (_bytes + frame.Length > _maxBytes || _entries.Count >= _maxCount))
{
Remove(_entries.First!);
dropped++;
}
_byId[msgId] = _entries.AddLast(new Entry(msgId, frame));
_bytes += frame.Length;
Wake();
}
if (dropped > 0)
{
Interlocked.Add(ref _dropped, dropped);
OnGap?.Invoke(dropped);
}
}
/// <summary>连接断开之后:所有消息都视为没有发出,重连时重发。</summary>
public void ResetSent()
{
lock (_gate)
{
foreach (Entry entry in _entries)
{
entry.Sent = false;
}
_inflightBytes = 0;
Wake();
}
}
public void Acknowledge(IEnumerable<Guid> ids)
{
lock (_gate)
{
foreach (Guid id in ids)
{
if (_byId.TryGetValue(id, out LinkedListNode<Entry>? node))
{
Remove(node);
}
}
Wake();
}
}
/// <summary>
/// 取下一条还没发出的消息;在途字节达到 <paramref name="maxInflightBytes"/> 时等待 ACK(服务端慢时不会把内存撑爆)。
/// </summary>
public async ValueTask<byte[]> TakeNextAsync(long maxInflightBytes, CancellationToken ct)
{
while (true)
{
Task wait;
lock (_gate)
{
foreach (Entry entry in _entries)
{
if (!entry.Sent)
{
// 一条消息比在途上限还大时也要放行,否则会永远卡住
if (_inflightBytes == 0 || _inflightBytes + entry.Frame.Length <= maxInflightBytes)
{
entry.Sent = true;
_inflightBytes += entry.Frame.Length;
return entry.Frame;
}
break;
}
}
wait = _signal.Task;
}
await wait.WaitAsync(ct);
}
}
private void Remove(LinkedListNode<Entry> node)
{
_entries.Remove(node);
_byId.Remove(node.Value.Id);
_bytes -= node.Value.Frame.Length;
if (node.Value.Sent)
{
_inflightBytes = Math.Max(0, _inflightBytes - node.Value.Frame.Length);
}
}
private void Wake()
{
_signal.TrySetResult();
_signal = NewSignal();
}
private static TaskCompletionSource NewSignal() => new(TaskCreationOptions.RunContinuationsAsynchronously);
private sealed class Entry
{
public Entry(Guid id, byte[] frame)
{
Id = id;
Frame = frame;
}
public Guid Id { get; }
public byte[] Frame { get; }
public bool Sent { get; set; }
}
}
+278
View File
@@ -0,0 +1,278 @@
using System.Net;
using System.Net.Security;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text.Json;
using Fleet.Core;
using Fleet.Identity;
using Fleet.Rpc;
namespace Fleet.Link;
/// <summary>一个已经认证的点对点会话:对端是谁、数据面使用的会话凭据、对端地址。</summary>
public sealed record PeerSessionInfo(string PeerId, SessionCredentials Credentials, EndPoint? Remote, string? Version = null);
public sealed class PeerListenerOptions
{
public required IPEndPoint Listen { get; init; }
public required X509Certificate2 Certificate { get; init; }
/// <summary>按对端 ID 查找允许连接的公钥(SPKI)。不在列表里的对端一律拒绝(期望状态里的 peers)。</summary>
public required Func<string, byte[]?> LookupPeerKey { get; init; }
public int MaxFrameBytes { get; init; } = 8 << 20;
public int MaxSessions { get; init; } = 8;
public TimeSpan HandshakeTimeout { get; init; } = TimeSpan.FromSeconds(10);
}
/// <summary>
/// 监听点对点会话(Agent 一侧):TLS(自签名证书,对端按 SPKI 指纹固定)→ 挑战 → 对端用身份私钥签名(purpose = peer)
/// → 临时 ECDH 派生数据面凭据。认证通过之后把会话交给产品代码,由它挂上自己的路由。
/// </summary>
public sealed class PeerListener
{
private readonly PeerListenerOptions _options;
private readonly Func<PeerSessionInfo, IRpcTransport, CancellationToken, Task> _onSession;
private readonly INodeLog _log;
private readonly TimeProvider _time;
private int _sessions;
public PeerListener(PeerListenerOptions options, Func<PeerSessionInfo, IRpcTransport, CancellationToken, Task> onSession, INodeLog log, TimeProvider time)
{
_options = options;
_onSession = onSession;
_log = log;
_time = time;
}
/// <summary>实际监听的端口(配置为 0 时由系统分配);<see cref="RunAsync"/> 开始监听之后有效。</summary>
public int Port { get; private set; }
public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public async Task RunAsync(CancellationToken ct)
{
var listener = new TcpListener(_options.Listen);
listener.Start();
Port = ((IPEndPoint)listener.LocalEndpoint).Port;
Started.TrySetResult();
try
{
while (!ct.IsCancellationRequested)
{
TcpClient client = await listener.AcceptTcpClientAsync(ct);
if (Interlocked.Increment(ref _sessions) > _options.MaxSessions)
{
Interlocked.Decrement(ref _sessions);
client.Dispose();
continue;
}
_ = Task.Run(() => HandleAsync(client, ct), CancellationToken.None);
}
}
catch (OperationCanceledException)
{
// 停止监听
}
finally
{
listener.Stop();
}
}
private async Task HandleAsync(TcpClient client, CancellationToken ct)
{
try
{
client.NoDelay = true;
PeerSockets.EnableKeepAlive(client.Client);
using var timeout = new CancellationTokenSource(_options.HandshakeTimeout, _time);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
var tls = new SslStream(client.GetStream(), leaveInnerStreamOpen: false);
await tls.AuthenticateAsServerAsync(
new SslServerAuthenticationOptions { ServerCertificate = _options.Certificate, ClientCertificateRequired = false, EnabledSslProtocols = System.Security.Authentication.SslProtocols.Tls12 | System.Security.Authentication.SslProtocols.Tls13 },
linked.Token);
var transport = new TlsFrameTransport(tls, _options.MaxFrameBytes);
PeerSessionInfo? info = await HandshakeAsync(transport, client.Client.RemoteEndPoint, linked.Token);
if (info is null)
{
await transport.DisposeAsync();
return;
}
_log.Info("peer session", ("probe", info.PeerId), ("result", "established"));
await using (transport)
{
await _onSession(info, transport, ct);
}
}
catch (Exception ex) when (ex is IOException or System.Security.Authentication.AuthenticationException or OperationCanceledException or SocketException or ObjectDisposedException)
{
// 握手失败或连接断开:这个会话结束,监听继续
}
finally
{
client.Dispose();
Interlocked.Decrement(ref _sessions);
}
}
private async Task<PeerSessionInfo?> HandshakeAsync(TlsFrameTransport transport, EndPoint? remote, CancellationToken ct)
{
using var ephemeral = new EphemeralKey();
byte[] nonceBytes = RandomNumberGenerator.GetBytes(32);
string nonce = Convert.ToBase64String(nonceBytes);
string serverPub = Convert.ToBase64String(ephemeral.PublicKeySpki);
await SendAsync(transport, RpcMessage.Notification(PeerMethods.Challenge, new PeerChallengeParams { Nonce = nonce, EcdhPublicKey = serverPub }, PeerRpcContext.Default.PeerChallengeParams), ct);
RpcFrame? frame = await transport.ReceiveAsync(ct);
RpcMessage? message = frame is { Kind: FrameKind.Text } f ? JsonSerializer.Deserialize(f.Data.Span, FleetRpcContext.Default.RpcMessage) : null;
if (message?.Method != PeerMethods.Auth || message.Id is null || message.Params is null)
{
return null;
}
PeerAuthParams? auth = null;
try
{
auth = message.Params.Value.Deserialize(PeerRpcContext.Default.PeerAuthParams);
}
catch (JsonException)
{
// 按缺失处理
}
byte[]? key = auth is null ? null : _options.LookupPeerKey(auth.PeerId);
byte[] clientPublic = [];
bool ok = key is not null
&& TryDecode(auth!.Signature, out byte[] signature)
&& TryDecode(auth.EcdhPublicKey, out clientPublic)
&& NodeIdentity.Verify(key, signature, SignaturePurpose.Peer, auth.PeerId, nonce, auth.EcdhPublicKey, serverPub);
if (!ok)
{
// 对端不在允许列表里和签名错误返回同样的错误
await SendAsync(transport, RpcMessage.Failure(message.Id, new FleetError(FleetErrors.Unauthorized, "peer authentication failed")), ct);
_log.Warn("peer rejected", ("result", "unauthorized"));
return null;
}
SessionCredentials credentials;
try
{
credentials = ephemeral.Derive(clientPublic, nonceBytes, clientPublic, ephemeral.PublicKeySpki);
}
catch (CryptographicException)
{
await SendAsync(transport, RpcMessage.Failure(message.Id, new FleetError(FleetErrors.InvalidParams, "invalid ecdh key")), ct);
return null;
}
await SendAsync(transport, RpcMessage.Success(message.Id.Value, new PeerAuthResult { SessionId = Convert.ToHexStringLower(credentials.SessionId) }, PeerRpcContext.Default.PeerAuthResult), ct);
return new PeerSessionInfo(auth!.PeerId, credentials, remote, auth.Version is { Length: <= 64 } version ? version : null);
}
private static ValueTask SendAsync(TlsFrameTransport transport, RpcMessage message, CancellationToken ct) =>
transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(message, FleetRpcContext.Default.RpcMessage)), ct);
private static bool TryDecode(string text, out byte[] data)
{
try
{
data = Convert.FromBase64String(text);
return data.Length > 0;
}
catch (FormatException)
{
data = [];
return false;
}
}
}
/// <summary>发起点对点会话(Probe 一侧):按 SPKI 指纹固定对端证书,用身份私钥签名,派生同样的数据面凭据。</summary>
/// <summary>点对点会话的套接字:开启 TCP keepalive(空闲 15 秒后每 5 秒探测,连续 3 次无响应就断开,约 30 秒),主机丢失或路径被切断时连接不会一直挂着。</summary>
internal static class PeerSockets
{
public static void EnableKeepAlive(Socket socket)
{
try
{
socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true);
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, 15);
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, 5);
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, 3);
}
catch (Exception ex) when (ex is SocketException or ArgumentException or PlatformNotSupportedException)
{
// 个别平台不支持其中某个选项:退回系统默认
}
}
}
public static class PeerConnector
{
public static async Task<(PeerSessionInfo Info, TlsFrameTransport Transport)> ConnectAsync(
string host, int port, byte[] expectedSpkiSha256, NodeIdentity identity, string peerId, string? version, int maxFrameBytes, TimeProvider time, CancellationToken ct)
{
var client = new TcpClient { NoDelay = true };
PeerSockets.EnableKeepAlive(client.Client);
try
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10), time);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
await client.ConnectAsync(host, port, linked.Token);
var tls = new SslStream(
client.GetStream(), leaveInnerStreamOpen: false,
(_, certificate, _, _) => certificate is not null && SpkiMatches(certificate, expectedSpkiSha256));
await tls.AuthenticateAsClientAsync(new SslClientAuthenticationOptions { TargetHost = host }, linked.Token);
var transport = new TlsFrameTransport(tls, maxFrameBytes);
RpcFrame? first = await transport.ReceiveAsync(linked.Token);
RpcMessage? challengeMessage = first is { Kind: FrameKind.Text } f ? JsonSerializer.Deserialize(f.Data.Span, FleetRpcContext.Default.RpcMessage) : null;
if (challengeMessage?.Method != PeerMethods.Challenge || challengeMessage.Params is null)
{
throw new InvalidOperationException("the agent did not send a challenge");
}
PeerChallengeParams challenge = challengeMessage.Params.Value.Deserialize(PeerRpcContext.Default.PeerChallengeParams)!;
using var ephemeral = new EphemeralKey();
string clientPub = Convert.ToBase64String(ephemeral.PublicKeySpki);
string signature = Convert.ToBase64String(identity.Sign(SignaturePurpose.Peer, peerId, challenge.Nonce, clientPub, challenge.EcdhPublicKey));
RpcMessage request = RpcMessage.Request(1, PeerMethods.Auth, new PeerAuthParams { PeerId = peerId, EcdhPublicKey = clientPub, Signature = signature, Version = version }, PeerRpcContext.Default.PeerAuthParams);
await transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(request, FleetRpcContext.Default.RpcMessage)), linked.Token);
RpcFrame? replyFrame = await transport.ReceiveAsync(linked.Token);
RpcMessage? reply = replyFrame is { Kind: FrameKind.Text } r ? JsonSerializer.Deserialize(r.Data.Span, FleetRpcContext.Default.RpcMessage) : null;
if (reply is null || reply.Error is not null)
{
throw new InvalidOperationException("peer authentication failed: " + reply?.Error?.Data?.Code);
}
SessionCredentials credentials = ephemeral.Derive(Convert.FromBase64String(challenge.EcdhPublicKey), Convert.FromBase64String(challenge.Nonce), ephemeral.PublicKeySpki, Convert.FromBase64String(challenge.EcdhPublicKey));
string announced = reply.Result!.Value.Deserialize(PeerRpcContext.Default.PeerAuthResult)!.SessionId;
if (announced != Convert.ToHexStringLower(credentials.SessionId))
{
throw new InvalidOperationException("session id mismatch: the key agreement did not produce the same credentials");
}
return (new PeerSessionInfo(peerId, credentials, client.Client.RemoteEndPoint), transport);
}
catch
{
client.Dispose();
throw;
}
}
/// <summary>固定证书:只看 SPKI 的 SHA-256 指纹,不看证书链和主机名(自签名证书)。</summary>
private static bool SpkiMatches(X509Certificate certificate, byte[] expected)
{
X509Certificate2 full = certificate as X509Certificate2 ?? X509CertificateLoader.LoadCertificate(certificate.GetRawCertData());
return CryptographicOperations.FixedTimeEquals(NodeCertificate.SpkiFingerprint(full), expected);
}
}
+207
View File
@@ -0,0 +1,207 @@
using System.Buffers.Binary;
using System.Net.WebSockets;
using Fleet.Rpc;
namespace Fleet.Link;
/// <summary>WebSocket 传输(节点 → 服务端):文本帧是 JSON-RPC,二进制帧是发件箱。</summary>
public sealed class WsTransport : IRpcTransport
{
private readonly WebSocket _socket;
private readonly int _maxFrameBytes;
private readonly SemaphoreSlim _sendLock = new(1, 1);
public WsTransport(WebSocket socket, int maxFrameBytes)
{
_socket = socket;
_maxFrameBytes = maxFrameBytes;
}
public async ValueTask SendAsync(RpcFrame frame, CancellationToken ct)
{
await _sendLock.WaitAsync(ct);
try
{
await _socket.SendAsync(frame.Data, frame.Kind == FrameKind.Text ? WebSocketMessageType.Text : WebSocketMessageType.Binary, endOfMessage: true, ct);
}
finally
{
_sendLock.Release();
}
}
public async ValueTask<RpcFrame?> ReceiveAsync(CancellationToken ct)
{
var message = new MemoryStream();
byte[] buffer = new byte[16 * 1024];
while (true)
{
ValueWebSocketReceiveResult result = await _socket.ReceiveAsync(buffer.AsMemory(), ct);
if (result.MessageType == WebSocketMessageType.Close)
{
if (_socket.State == WebSocketState.CloseReceived)
{
// 按协议确认对端发起的关闭
try
{
await _socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "ack", CancellationToken.None);
}
catch (WebSocketException)
{
// 对端已经断开
}
}
return null;
}
message.Write(buffer, 0, result.Count);
if (message.Length > _maxFrameBytes)
{
await CloseAsync("frame too large", CancellationToken.None);
return null;
}
if (result.EndOfMessage)
{
return new RpcFrame(result.MessageType == WebSocketMessageType.Text ? FrameKind.Text : FrameKind.Binary, message.ToArray());
}
}
}
public async ValueTask CloseAsync(string reason, CancellationToken ct)
{
if (_socket.State is WebSocketState.Open or WebSocketState.CloseReceived)
{
try
{
await _socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, reason, ct);
}
catch (WebSocketException)
{
// 对端已经断开
}
}
}
public ValueTask DisposeAsync()
{
_socket.Dispose();
_sendLock.Dispose();
return ValueTask.CompletedTask;
}
}
/// <summary>
/// TLS 帧传输(Probe → Agent):<c>u32 LE 正文长度 | u8 类型(0 文本, 1 二进制) | 正文</c>。
/// 比 WebSocket 轻得多,Agent 不需要内置 HTTP 服务端。
/// </summary>
public sealed class TlsFrameTransport : IRpcTransport
{
private const int HeaderBytes = 5;
private readonly Stream _stream;
private readonly int _maxFrameBytes;
private readonly SemaphoreSlim _sendLock = new(1, 1);
public TlsFrameTransport(Stream stream, int maxFrameBytes)
{
_stream = stream;
_maxFrameBytes = maxFrameBytes;
}
public async ValueTask SendAsync(RpcFrame frame, CancellationToken ct)
{
if (frame.Data.Length > _maxFrameBytes)
{
throw new InvalidOperationException("frame exceeds the maximum size");
}
byte[] header = new byte[HeaderBytes];
BinaryPrimitives.WriteUInt32LittleEndian(header, (uint)frame.Data.Length);
header[4] = (byte)frame.Kind;
await _sendLock.WaitAsync(ct);
try
{
await _stream.WriteAsync(header, ct);
await _stream.WriteAsync(frame.Data, ct);
await _stream.FlushAsync(ct);
}
finally
{
_sendLock.Release();
}
}
public async ValueTask<RpcFrame?> ReceiveAsync(CancellationToken ct)
{
byte[] header = new byte[HeaderBytes];
try
{
if (!await ReadExactlyOrEofAsync(header, ct))
{
return null;
}
uint length = BinaryPrimitives.ReadUInt32LittleEndian(header);
if (length > _maxFrameBytes || header[4] > (byte)FrameKind.Binary)
{
await _stream.DisposeAsync(); // 非法帧:直接断开
return null;
}
byte[] body = new byte[length];
if (!await ReadExactlyOrEofAsync(body, ct))
{
return null;
}
return new RpcFrame((FrameKind)header[4], body);
}
catch (IOException)
{
return null;
}
catch (ObjectDisposedException)
{
return null;
}
}
public async ValueTask CloseAsync(string reason, CancellationToken ct)
{
try
{
await _stream.FlushAsync(ct);
}
catch (Exception ex) when (ex is IOException or ObjectDisposedException)
{
// 对端已经断开
}
await _stream.DisposeAsync();
}
public async ValueTask DisposeAsync()
{
await _stream.DisposeAsync();
_sendLock.Dispose();
}
private async ValueTask<bool> ReadExactlyOrEofAsync(byte[] buffer, CancellationToken ct)
{
int read = 0;
while (read < buffer.Length)
{
int n = await _stream.ReadAsync(buffer.AsMemory(read), ct);
if (n == 0)
{
return false;
}
read += n;
}
return true;
}
}