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;
/// 一个已经认证的点对点会话:对端是谁、数据面使用的会话凭据、对端地址。
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; }
/// 按对端 ID 查找允许连接的公钥(SPKI)。不在列表里的对端一律拒绝(期望状态里的 peers)。
public required Func LookupPeerKey { get; init; }
public int MaxFrameBytes { get; init; } = 8 << 20;
public int MaxSessions { get; init; } = 8;
public TimeSpan HandshakeTimeout { get; init; } = TimeSpan.FromSeconds(10);
}
///
/// 监听点对点会话(Agent 一侧):TLS(自签名证书,对端按 SPKI 指纹固定)→ 挑战 → 对端用身份私钥签名(purpose = peer)
/// → 临时 ECDH 派生数据面凭据。认证通过之后把会话交给产品代码,由它挂上自己的路由。
///
public sealed class PeerListener
{
private readonly PeerListenerOptions _options;
private readonly Func _onSession;
private readonly INodeLog _log;
private readonly TimeProvider _time;
private int _sessions;
public PeerListener(PeerListenerOptions options, Func onSession, INodeLog log, TimeProvider time)
{
_options = options;
_onSession = onSession;
_log = log;
_time = time;
}
/// 实际监听的端口(配置为 0 时由系统分配); 开始监听之后有效。
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 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;
}
}
}
/// 发起点对点会话(Probe 一侧):按 SPKI 指纹固定对端证书,用身份私钥签名,派生同样的数据面凭据。
/// 点对点会话的套接字:开启 TCP keepalive(空闲 15 秒后每 5 秒探测,连续 3 次无响应就断开,约 30 秒),主机丢失或路径被切断时连接不会一直挂着。
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;
}
}
/// 固定证书:只看 SPKI 的 SHA-256 指纹,不看证书链和主机名(自签名证书)。
private static bool SpkiMatches(X509Certificate certificate, byte[] expected)
{
X509Certificate2 full = certificate as X509Certificate2 ?? X509CertificateLoader.LoadCertificate(certificate.GetRawCertData());
return CryptographicOperations.FixedTimeEquals(NodeCertificate.SpkiFingerprint(full), expected);
}
}