feat(quic): replace probe UDP data plane with QUIC datagrams
This commit is contained in:
1 parent
4609048865
commit
b964dc2bbc
53 files changed
+2413
-411
No files matched your search
@@ -26,7 +26,7 @@ public static class BlockCatalog
|
||||
{
|
||||
public static readonly IReadOnlyList<BlockPair> All =
|
||||
[
|
||||
new("udp_echo", (e, s) => new UdpEchoDialer(e, s), (e, s) => new UdpEchoListener(e, s)),
|
||||
new("udp_echo", (e, s) => new DatagramEchoDialer(e, s), (e, s) => new DatagramEchoListener(e, s)),
|
||||
new("tcp_echo", (e, s) => new TcpEchoDialer(e, s), (e, s) => new TcpEchoListener(e, s)),
|
||||
new("udp_train", (e, s) => new TrainDialer(e, s), (e, s) => new TrainListener(e, s), OnDemandOnly: true, Directions: ["rev"]),
|
||||
new("tcp_bulk", (e, s) => new TcpBulkDialer(e, s), (e, s) => new TcpBulkListener(e, s), OnDemandOnly: true, Directions: ["fwd", "rev"]),
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace MoShiro.Measurement;
|
||||
internal sealed class EchoRows
|
||||
{
|
||||
/// <summary>UDP:没有在这段时间内回来的包就是丢了。</summary>
|
||||
public const long UdpGraceUs = 2_000_000;
|
||||
public const long DatagramGraceUs = 2_000_000;
|
||||
|
||||
/// <summary>
|
||||
/// TCP 不会丢包,只会延迟(重传、队头阻塞):一次几秒的中断之后,积压的回显会陆续回来,RTT 就是那几秒。
|
||||
@@ -65,26 +65,27 @@ internal sealed class EchoRows
|
||||
/// <summary>
|
||||
/// UDP 回显,拨号方(Probe):按声明的速率发包,t1 在发出时记录,回复到达时回填 t4。回复丢失的行 t4 缺失(Agent 一侧再补 t2/t3)。
|
||||
/// </summary>
|
||||
public sealed class UdpEchoDialer : BlockBase
|
||||
public sealed class DatagramEchoDialer : BlockBase
|
||||
{
|
||||
private readonly EchoRows _rows;
|
||||
|
||||
public UdpEchoDialer(BlockEnv env, BlockSpec spec)
|
||||
public DatagramEchoDialer(BlockEnv env, BlockSpec spec)
|
||||
: base(env, spec)
|
||||
{
|
||||
_rows = new EchoRows(spec.Kind, 16384, EchoRows.UdpGraceUs, settleOnReply: false);
|
||||
_rows = new EchoRows(spec.Kind, 16384, EchoRows.DatagramGraceUs, settleOnReply: false);
|
||||
}
|
||||
|
||||
public override long Produced => _rows.Count;
|
||||
|
||||
public override async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
UdpMux udp = Env.Udp!;
|
||||
QuicDatagramTransport datagrams = Env.Datagrams!;
|
||||
DataSession session = Env.Session() ?? throw new BlockFaultException("no data session");
|
||||
await datagrams.WaitUntilConnectedAsync(ct);
|
||||
IPEndPoint target = PeerEndpoint(Spec.Port);
|
||||
int size = (int)Spec.Int("size");
|
||||
byte[] packet = new byte[size];
|
||||
udp.Register(Spec.StreamId, OnPacket);
|
||||
datagrams.Register(Spec.StreamId, OnPacket);
|
||||
try
|
||||
{
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1.0 / Spec.Int("rate_hz")), Env.Time);
|
||||
@@ -92,8 +93,8 @@ public sealed class UdpEchoDialer : BlockBase
|
||||
{
|
||||
long t1 = Env.Clock.NowUs();
|
||||
uint seq = _rows.Add(size, t1);
|
||||
UdpPacket.Write(packet, new UdpHeader(UdpPacketType.EchoRequest, session.SessionId, Spec.StreamId, seq, t1), session.Key);
|
||||
udp.Send(target, packet);
|
||||
MeasurementDatagram.Write(packet, new MeasurementDatagramHeader(MeasurementDatagramType.EchoRequest, session.SessionId, Spec.StreamId, seq, t1), session.Key);
|
||||
datagrams.Send(session, target, packet);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
@@ -102,7 +103,7 @@ public sealed class UdpEchoDialer : BlockBase
|
||||
}
|
||||
finally
|
||||
{
|
||||
udp.Unregister(Spec.StreamId);
|
||||
datagrams.Unregister(Spec.StreamId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,9 +119,9 @@ public sealed class UdpEchoDialer : BlockBase
|
||||
return 1;
|
||||
}
|
||||
|
||||
private void OnPacket(in UdpDelivery delivery)
|
||||
private void OnPacket(in QuicDatagramDelivery delivery)
|
||||
{
|
||||
if (delivery.Header.Type == UdpPacketType.EchoReply)
|
||||
if (delivery.Header.Type == MeasurementDatagramType.EchoReply)
|
||||
{
|
||||
_rows.Reply(delivery.Header.Seq, delivery.ReceiveUs);
|
||||
}
|
||||
@@ -128,11 +129,11 @@ public sealed class UdpEchoDialer : BlockBase
|
||||
}
|
||||
|
||||
/// <summary>UDP 回显,监听方(Agent):校验后立即回复一个等长的包,记录 t2(到达)和 t3(回复发出),按序号留给合并。</summary>
|
||||
public sealed class UdpEchoListener : BlockBase
|
||||
public sealed class DatagramEchoListener : BlockBase
|
||||
{
|
||||
private readonly RowStore _records;
|
||||
|
||||
public UdpEchoListener(BlockEnv env, BlockSpec spec)
|
||||
public DatagramEchoListener(BlockEnv env, BlockSpec spec)
|
||||
: base(env, spec)
|
||||
{
|
||||
_records = new RowStore(spec.Kind.FindStream("msg")!, Side.Agent, 32768);
|
||||
@@ -142,26 +143,26 @@ public sealed class UdpEchoListener : BlockBase
|
||||
|
||||
public override async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
Env.Udp!.Register(Spec.StreamId, OnPacket);
|
||||
Env.Datagrams!.Register(Spec.StreamId, OnPacket);
|
||||
try
|
||||
{
|
||||
await WaitUntilCancelledAsync(ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Env.Udp.Unregister(Spec.StreamId);
|
||||
Env.Datagrams.Unregister(Spec.StreamId);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPacket(in UdpDelivery delivery)
|
||||
private void OnPacket(in QuicDatagramDelivery delivery)
|
||||
{
|
||||
if (delivery.Header.Type != UdpPacketType.EchoRequest)
|
||||
if (delivery.Header.Type != MeasurementDatagramType.EchoRequest)
|
||||
{
|
||||
return; // Data:探活包,只用来保持 NAT 映射和让准入策略学到源地址
|
||||
}
|
||||
|
||||
long t2 = delivery.ReceiveUs;
|
||||
delivery.ReplyAs(UdpPacketType.EchoReply);
|
||||
delivery.ReplyAs(MeasurementDatagramType.EchoReply);
|
||||
Span<long> row = [t2, Env.Clock.NowUs()];
|
||||
_records.SetRow(delivery.Header.Seq, row);
|
||||
}
|
||||
|
||||
@@ -30,11 +30,11 @@ public sealed class TrainListener : BlockBase
|
||||
public override async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
// Probe 开始前发来的探活包(Data)要被接受,准入策略才能据此学到它当前的源地址(NAT 映射);它们本身没有内容
|
||||
Env.Udp!.Register(Spec.StreamId, static (in UdpDelivery _) => { });
|
||||
Env.Datagrams!.Register(Spec.StreamId, static (in QuicDatagramDelivery _) => { });
|
||||
try
|
||||
{
|
||||
DataSession session = await WaitForPeerAsync(ct);
|
||||
IPEndPoint target = session.UdpEndpoint!;
|
||||
IPEndPoint target = session.DatagramRemoteEndPoint!;
|
||||
TimeSpan duration = TimeSpan.FromSeconds(Spec.Int("duration_s"));
|
||||
var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var thread = new Thread(() =>
|
||||
@@ -58,7 +58,7 @@ public sealed class TrainListener : BlockBase
|
||||
}
|
||||
finally
|
||||
{
|
||||
Env.Udp.Unregister(Spec.StreamId);
|
||||
Env.Datagrams.Unregister(Spec.StreamId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ public sealed class TrainListener : BlockBase
|
||||
while (true)
|
||||
{
|
||||
DataSession? session = Env.Session();
|
||||
if (session?.UdpEndpoint is not null)
|
||||
if (session?.DatagramRemoteEndPoint is not null)
|
||||
{
|
||||
return session;
|
||||
}
|
||||
@@ -120,8 +120,8 @@ public sealed class TrainListener : BlockBase
|
||||
for (; seq < due; seq++)
|
||||
{
|
||||
long sendUs = Env.Clock.NowUs();
|
||||
UdpPacket.Write(packet, new UdpHeader(UdpPacketType.Data, session.SessionId, Spec.StreamId, seq, sendUs), session.Key);
|
||||
Env.Udp!.Send(target, packet);
|
||||
MeasurementDatagram.Write(packet, new MeasurementDatagramHeader(MeasurementDatagramType.Data, session.SessionId, Spec.StreamId, seq, sendUs), session.Key);
|
||||
Env.Datagrams!.Send(session, target, packet);
|
||||
Span<long> row = [size, sendUs];
|
||||
_records.SetRow(seq, row);
|
||||
Interlocked.Exchange(ref _sent, seq + 1L);
|
||||
@@ -159,18 +159,19 @@ public sealed class TrainDialer : BlockBase
|
||||
|
||||
public override async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
UdpMux udp = Env.Udp!;
|
||||
QuicDatagramTransport datagrams = Env.Datagrams!;
|
||||
DataSession session = Env.Session() ?? throw new BlockFaultException("no data session");
|
||||
await datagrams.WaitUntilConnectedAsync(ct);
|
||||
_startUs = Env.Clock.NowUs();
|
||||
udp.Register(Spec.StreamId, OnPacket);
|
||||
datagrams.Register(Spec.StreamId, OnPacket);
|
||||
try
|
||||
{
|
||||
byte[] hello = new byte[UdpPacket.MinBytes + 20];
|
||||
byte[] hello = new byte[MeasurementDatagram.MinBytes + 20];
|
||||
IPEndPoint target = PeerEndpoint(Spec.Port);
|
||||
for (uint seq = 0; seq < 3; seq++)
|
||||
{
|
||||
UdpPacket.Write(hello, new UdpHeader(UdpPacketType.Data, session.SessionId, Spec.StreamId, seq, Env.Clock.NowUs()), session.Key);
|
||||
udp.Send(target, hello);
|
||||
MeasurementDatagram.Write(hello, new MeasurementDatagramHeader(MeasurementDatagramType.Data, session.SessionId, Spec.StreamId, seq, Env.Clock.NowUs()), session.Key);
|
||||
datagrams.Send(session, target, hello);
|
||||
}
|
||||
|
||||
await WaitUntilCancelledAsync(ct);
|
||||
@@ -178,7 +179,7 @@ public sealed class TrainDialer : BlockBase
|
||||
finally
|
||||
{
|
||||
_endUs = Env.Clock.NowUs();
|
||||
udp.Unregister(Spec.StreamId);
|
||||
datagrams.Unregister(Spec.StreamId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,10 +229,10 @@ public sealed class TrainDialer : BlockBase
|
||||
private static System.Text.Json.JsonElement Number(long value) =>
|
||||
System.Text.Json.JsonDocument.Parse(value.ToString(System.Globalization.CultureInfo.InvariantCulture)).RootElement.Clone();
|
||||
|
||||
private void OnPacket(in UdpDelivery delivery)
|
||||
private void OnPacket(in QuicDatagramDelivery delivery)
|
||||
{
|
||||
long seq = delivery.Header.Seq;
|
||||
if (delivery.Header.Type == UdpPacketType.Data && seq < _maxSeq && !_received.IsSet(seq, _tRecv))
|
||||
if (delivery.Header.Type == MeasurementDatagramType.Data && seq < _maxSeq && !_received.IsSet(seq, _tRecv))
|
||||
{
|
||||
_received.Set(seq, _tRecv, delivery.ReceiveUs); // 只记第一次到达(重复的包不覆盖)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ namespace MoShiro.Measurement;
|
||||
/// </summary>
|
||||
public class DataSession
|
||||
{
|
||||
private IDatagramConnection? _datagramConnection;
|
||||
|
||||
public DataSession(long sessionId, byte[] key)
|
||||
{
|
||||
SessionId = sessionId;
|
||||
@@ -21,13 +23,26 @@ public class DataSession
|
||||
|
||||
public byte[] Key { get; }
|
||||
|
||||
public IDatagramConnection? DatagramConnection => Volatile.Read(ref _datagramConnection);
|
||||
|
||||
public void BindDatagramConnection(IDatagramConnection connection) => Volatile.Write(ref _datagramConnection, connection);
|
||||
|
||||
/// <summary>对端最近一次通过认证的 UDP 源地址(监听方把单向数据发往这里;拨号方不需要)。</summary>
|
||||
public virtual IPEndPoint? UdpEndpoint => null;
|
||||
public virtual IPEndPoint? DatagramRemoteEndPoint => null;
|
||||
|
||||
/// <summary>TCP 首帧的随机数只能用一次。拨号方不接受连接,所以默认放行。</summary>
|
||||
public virtual bool UseNonce(ReadOnlySpan<byte> nonce) => true;
|
||||
}
|
||||
|
||||
/// <summary>Unreliable QUIC datagram sender bound to one authenticated measurement session.</summary>
|
||||
public interface IDatagramConnection
|
||||
{
|
||||
bool Send(ReadOnlySpan<byte> datagram);
|
||||
}
|
||||
|
||||
/// <summary>Transport-layer QUIC counters. These are separate from application Datagram delivery/loss.</summary>
|
||||
public sealed record QuicTransportSnapshot(double? PacketLossRate, ulong PacketsSent, uint CongestionEvents, uint EcnCongestionEvents, ushort PathMtu);
|
||||
|
||||
public interface IDataSessions
|
||||
{
|
||||
DataSession? Find(long sessionId);
|
||||
@@ -60,7 +75,7 @@ public enum Admission
|
||||
public interface IAdmission
|
||||
{
|
||||
/// <summary>UDP 包:<paramref name="source"/> 是认证通过的源地址(实现可以据此学习 NAT 重绑定)。</summary>
|
||||
Admission AdmitPacket(DataSession session, in UdpHeader header, SocketAddress source, long nowUs);
|
||||
Admission AdmitDatagram(DataSession session, in MeasurementDatagramHeader header, SocketAddress source, long nowUs);
|
||||
|
||||
/// <summary>TCP 回显的一条消息(只限速)。</summary>
|
||||
bool AdmitMessage(DataSession session, uint streamId, long nowUs);
|
||||
@@ -201,7 +216,7 @@ public sealed class BlockEnv
|
||||
/// <summary>当前的数据面会话(拨号方固定;监听方是 Agent 当前的 Probe 会话,可能没有)。</summary>
|
||||
public required Func<DataSession?> Session { get; init; }
|
||||
|
||||
public UdpMux? Udp { get; init; }
|
||||
public QuicDatagramTransport? Datagrams { get; init; }
|
||||
|
||||
/// <summary>监听方的准入策略(限速等);拨号方为 null。</summary>
|
||||
public IAdmission? Admission { get; init; }
|
||||
@@ -238,7 +253,7 @@ public sealed class BlockEnv
|
||||
Clock = Clock,
|
||||
Log = Log,
|
||||
Session = () => session,
|
||||
Udp = Udp,
|
||||
Datagrams = Datagrams,
|
||||
Admission = Admission,
|
||||
Counters = Counters,
|
||||
TcpListen = TcpListen,
|
||||
|
||||
@@ -17,7 +17,7 @@ public sealed class NodeLimits
|
||||
public int MaxConcurrentTests { get; init; } = 1;
|
||||
|
||||
/// <summary>任务参数里的 <c>port</c> 只能取这些端口(本机配置的 listen 列表)。</summary>
|
||||
public IReadOnlyList<int> AllowedUdpPorts { get; init; } = [7443];
|
||||
public IReadOnlyList<int> AllowedQuicPorts { get; init; } = [7443];
|
||||
|
||||
public IReadOnlyList<int> AllowedTcpPorts { get; init; } = [7444];
|
||||
}
|
||||
@@ -143,7 +143,7 @@ public sealed class BudgetGuard
|
||||
}
|
||||
|
||||
int port = (int)values.GetInt("port");
|
||||
return kind.Blocks.Transport == "udp" ? _limits.AllowedUdpPorts.Contains(port) : _limits.AllowedTcpPorts.Contains(port);
|
||||
return kind.Blocks.Transport == "quic_datagram" ? _limits.AllowedQuicPorts.Contains(port) : _limits.AllowedTcpPorts.Contains(port);
|
||||
}
|
||||
|
||||
private static long EstimatePps(string kindId, ParamValues values) => kindId switch
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Measurement;
|
||||
|
||||
/// <summary>An authenticated, per-stream QUIC Datagram delivered to a measurement block.</summary>
|
||||
public readonly ref struct QuicDatagramDelivery
|
||||
{
|
||||
private readonly Span<byte> _payload;
|
||||
private readonly MsQuicConnection _connection;
|
||||
private readonly DataPlaneCounters _counters;
|
||||
|
||||
internal QuicDatagramDelivery(MeasurementDatagramHeader header, Span<byte> payload, long receiveUs, DataSession session, MsQuicConnection connection, DataPlaneCounters counters)
|
||||
{
|
||||
Header = header;
|
||||
_payload = payload;
|
||||
ReceiveUs = receiveUs;
|
||||
Session = session;
|
||||
_connection = connection;
|
||||
_counters = counters;
|
||||
}
|
||||
|
||||
public MeasurementDatagramHeader Header { get; }
|
||||
public ReadOnlySpan<byte> Payload => _payload;
|
||||
public long ReceiveUs { get; }
|
||||
public DataSession Session { get; }
|
||||
|
||||
public void ReplyAs(MeasurementDatagramType type)
|
||||
{
|
||||
_payload[3] = (byte)type;
|
||||
MeasurementDatagram.Seal(_payload, Session.Key);
|
||||
if (_connection.Send(_payload))
|
||||
{
|
||||
_counters.PacketOut();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public delegate void QuicDatagramHandler(in QuicDatagramDelivery delivery);
|
||||
|
||||
/// <summary>Authenticates datagrams, applies Agent admission policy, and dispatches by stream.</summary>
|
||||
internal sealed class MeasurementDatagramRouter
|
||||
{
|
||||
private readonly IDataSessions _sessions;
|
||||
private readonly IAdmission? _admission;
|
||||
private readonly MonoClock _clock;
|
||||
private readonly ConcurrentDictionary<uint, QuicDatagramHandler> _handlers = new();
|
||||
|
||||
public MeasurementDatagramRouter(IDataSessions sessions, IAdmission? admission, MonoClock clock, DataPlaneCounters counters)
|
||||
{
|
||||
_sessions = sessions;
|
||||
_admission = admission;
|
||||
_clock = clock;
|
||||
Counters = counters;
|
||||
}
|
||||
|
||||
public DataPlaneCounters Counters { get; }
|
||||
|
||||
public void Register(uint streamId, QuicDatagramHandler handler) => _handlers[streamId] = handler;
|
||||
|
||||
public void Unregister(uint streamId) => _handlers.TryRemove(streamId, out _);
|
||||
|
||||
public void Dispatch(MsQuicConnection connection, ReadOnlySpan<byte> datagram)
|
||||
{
|
||||
Counters.PacketIn();
|
||||
Span<byte> payload = stackalloc byte[MeasurementDatagram.MaxBytes];
|
||||
if (datagram.Length > payload.Length)
|
||||
{
|
||||
Counters.DropInvalid();
|
||||
return;
|
||||
}
|
||||
|
||||
datagram.CopyTo(payload);
|
||||
payload = payload[..datagram.Length];
|
||||
if (!MeasurementDatagram.TryParseHeader(payload, out MeasurementDatagramHeader header)
|
||||
|| !_handlers.TryGetValue(header.StreamId, out QuicDatagramHandler? handler)
|
||||
|| _sessions.Find(header.SessionId) is not { } session
|
||||
|| !MeasurementDatagram.VerifyMac(payload, session.Key))
|
||||
{
|
||||
Counters.DropInvalid();
|
||||
return;
|
||||
}
|
||||
|
||||
IPEndPoint? remote = connection.RefreshRemoteEndPoint();
|
||||
if (remote is null)
|
||||
{
|
||||
Counters.DropInvalid();
|
||||
return;
|
||||
}
|
||||
|
||||
long receiveUs = _clock.NowUs();
|
||||
if (_admission is not null)
|
||||
{
|
||||
switch (_admission.AdmitDatagram(session, header, remote.Serialize(), receiveUs))
|
||||
{
|
||||
case Admission.Invalid:
|
||||
Counters.DropInvalid();
|
||||
return;
|
||||
case Admission.RateLimited:
|
||||
Counters.DropRateLimited();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
session.BindDatagramConnection(connection);
|
||||
handler(new QuicDatagramDelivery(header, payload, receiveUs, session, connection, Counters));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace MoShiro.Measurement;
|
||||
|
||||
/// <summary>Small, versioned view of the MsQuic v2 C API table used by this transport.</summary>
|
||||
internal static unsafe class MsQuicApi
|
||||
{
|
||||
private const int SetContextIndex = 0;
|
||||
private const int SetCallbackHandlerIndex = 2;
|
||||
private const int SetParamIndex = 3;
|
||||
private const int GetParamIndex = 4;
|
||||
private const int RegistrationOpenIndex = 5;
|
||||
private const int RegistrationCloseIndex = 6;
|
||||
private const int ConfigurationOpenIndex = 8;
|
||||
private const int ConfigurationCloseIndex = 9;
|
||||
private const int ConfigurationLoadCredentialIndex = 10;
|
||||
private const int ListenerOpenIndex = 11;
|
||||
private const int ListenerCloseIndex = 12;
|
||||
private const int ListenerStartIndex = 13;
|
||||
private const int ListenerStopIndex = 14;
|
||||
private const int ConnectionOpenIndex = 15;
|
||||
private const int ConnectionCloseIndex = 16;
|
||||
private const int ConnectionShutdownIndex = 17;
|
||||
private const int ConnectionStartIndex = 18;
|
||||
private const int ConnectionSetConfigurationIndex = 19;
|
||||
private const int DatagramSendIndex = 28;
|
||||
private const uint ConnectionStatisticsV2Parameter = 0x05000016;
|
||||
|
||||
private static readonly SetContextDelegate s_setContext = MsQuicRuntime.Function<SetContextDelegate>(SetContextIndex);
|
||||
private static readonly SetCallbackHandlerDelegate s_setCallbackHandler = MsQuicRuntime.Function<SetCallbackHandlerDelegate>(SetCallbackHandlerIndex);
|
||||
private static readonly SetParamDelegate s_setParam = MsQuicRuntime.Function<SetParamDelegate>(SetParamIndex);
|
||||
private static readonly GetParamDelegate s_getParam = MsQuicRuntime.Function<GetParamDelegate>(GetParamIndex);
|
||||
private static readonly RegistrationOpenDelegate s_registrationOpen = MsQuicRuntime.Function<RegistrationOpenDelegate>(RegistrationOpenIndex);
|
||||
private static readonly HandleCloseDelegate s_registrationClose = MsQuicRuntime.Function<HandleCloseDelegate>(RegistrationCloseIndex);
|
||||
private static readonly ConfigurationOpenDelegate s_configurationOpen = MsQuicRuntime.Function<ConfigurationOpenDelegate>(ConfigurationOpenIndex);
|
||||
private static readonly HandleCloseDelegate s_configurationClose = MsQuicRuntime.Function<HandleCloseDelegate>(ConfigurationCloseIndex);
|
||||
private static readonly ConfigurationLoadCredentialDelegate s_loadCredential = MsQuicRuntime.Function<ConfigurationLoadCredentialDelegate>(ConfigurationLoadCredentialIndex);
|
||||
private static readonly ListenerOpenDelegate s_listenerOpen = MsQuicRuntime.Function<ListenerOpenDelegate>(ListenerOpenIndex);
|
||||
private static readonly HandleCloseDelegate s_listenerClose = MsQuicRuntime.Function<HandleCloseDelegate>(ListenerCloseIndex);
|
||||
private static readonly ListenerStartDelegate s_listenerStart = MsQuicRuntime.Function<ListenerStartDelegate>(ListenerStartIndex);
|
||||
private static readonly ListenerStopDelegate s_listenerStop = MsQuicRuntime.Function<ListenerStopDelegate>(ListenerStopIndex);
|
||||
private static readonly ConnectionOpenDelegate s_connectionOpen = MsQuicRuntime.Function<ConnectionOpenDelegate>(ConnectionOpenIndex);
|
||||
private static readonly HandleCloseDelegate s_connectionClose = MsQuicRuntime.Function<HandleCloseDelegate>(ConnectionCloseIndex);
|
||||
private static readonly ConnectionShutdownDelegate s_connectionShutdown = MsQuicRuntime.Function<ConnectionShutdownDelegate>(ConnectionShutdownIndex);
|
||||
private static readonly ConnectionStartDelegate s_connectionStart = MsQuicRuntime.Function<ConnectionStartDelegate>(ConnectionStartIndex);
|
||||
private static readonly ConnectionSetConfigurationDelegate s_connectionSetConfiguration = MsQuicRuntime.Function<ConnectionSetConfigurationDelegate>(ConnectionSetConfigurationIndex);
|
||||
private static readonly DatagramSendDelegate s_datagramSend = MsQuicRuntime.Function<DatagramSendDelegate>(DatagramSendIndex);
|
||||
|
||||
public static void SetContext(nint handle, nint context) => s_setContext(handle, context);
|
||||
|
||||
public static void SetCallbackHandler<T>(nint handle, T callback, nint context)
|
||||
where T : Delegate => s_setCallbackHandler(handle, Marshal.GetFunctionPointerForDelegate(callback), context);
|
||||
|
||||
public static void SetParam(nint handle, uint parameter, byte value)
|
||||
{
|
||||
unsafe
|
||||
{
|
||||
uint status;
|
||||
status = s_setParam(handle, parameter, sizeof(byte), &value);
|
||||
Check(status, "SetParam");
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[] GetAddress(nint handle, uint parameter)
|
||||
{
|
||||
byte[] address = new byte[128];
|
||||
unsafe
|
||||
{
|
||||
fixed (byte* buffer = address)
|
||||
{
|
||||
uint length = (uint)address.Length;
|
||||
uint status = s_getParam(handle, parameter, &length, buffer);
|
||||
Check(status, "GetParam(address)");
|
||||
Array.Resize(ref address, checked((int)length));
|
||||
}
|
||||
}
|
||||
|
||||
return address;
|
||||
}
|
||||
|
||||
public static QuicStatisticsV2 GetStatisticsV2(nint connection)
|
||||
{
|
||||
QuicStatisticsV2 statistics = default;
|
||||
unsafe
|
||||
{
|
||||
uint length = (uint)sizeof(QuicStatisticsV2);
|
||||
uint status = s_getParam(connection, ConnectionStatisticsV2Parameter, &length, &statistics);
|
||||
Check(status, "GetParam(connection statistics v2)");
|
||||
}
|
||||
|
||||
return statistics;
|
||||
}
|
||||
|
||||
public static nint RegistrationOpen(string appName)
|
||||
{
|
||||
nint utf8 = Marshal.StringToCoTaskMemUTF8(appName);
|
||||
try
|
||||
{
|
||||
var config = new RegistrationConfig { AppName = utf8, ExecutionProfile = 0 };
|
||||
uint status = s_registrationOpen(ref config, out nint registration);
|
||||
Check(status, "RegistrationOpen");
|
||||
return registration;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeCoTaskMem(utf8);
|
||||
}
|
||||
}
|
||||
|
||||
public static void RegistrationClose(nint handle) => s_registrationClose(handle);
|
||||
|
||||
public static nint ConfigurationOpen(nint registration, byte[] alpn)
|
||||
{
|
||||
unsafe
|
||||
{
|
||||
fixed (byte* alpnBytes = alpn)
|
||||
{
|
||||
var buffer = new QuicBuffer { Length = (uint)alpn.Length, Buffer = (nint)alpnBytes };
|
||||
uint status = s_configurationOpen(registration, ref buffer, 1, 0, 0, 0, out nint configuration);
|
||||
Check(status, "ConfigurationOpen");
|
||||
return configuration;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void ConfigurationClose(nint handle) => s_configurationClose(handle);
|
||||
|
||||
public static void ConfigurationLoadCredential(nint configuration, ref CredentialConfig credential)
|
||||
{
|
||||
uint status = s_loadCredential(configuration, ref credential);
|
||||
Check(status, "ConfigurationLoadCredential");
|
||||
}
|
||||
|
||||
public static nint ListenerOpen<T>(nint registration, T callback, nint context)
|
||||
where T : Delegate
|
||||
{
|
||||
uint status = s_listenerOpen(registration, Marshal.GetFunctionPointerForDelegate(callback), context, out nint listener);
|
||||
Check(status, "ListenerOpen");
|
||||
return listener;
|
||||
}
|
||||
|
||||
public static void ListenerClose(nint handle) => s_listenerClose(handle);
|
||||
|
||||
public static void ListenerStart(nint listener, byte[] alpn, byte[] address)
|
||||
{
|
||||
unsafe
|
||||
{
|
||||
fixed (byte* alpnBytes = alpn)
|
||||
fixed (byte* addressBytes = address)
|
||||
{
|
||||
var buffer = new QuicBuffer { Length = (uint)alpn.Length, Buffer = (nint)alpnBytes };
|
||||
uint status = s_listenerStart(listener, ref buffer, 1, (nint)addressBytes);
|
||||
Check(status, "ListenerStart");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void ListenerStop(nint handle) => s_listenerStop(handle);
|
||||
|
||||
public static nint ConnectionOpen<T>(nint registration, T callback, nint context)
|
||||
where T : Delegate
|
||||
{
|
||||
uint status = s_connectionOpen(registration, Marshal.GetFunctionPointerForDelegate(callback), context, out nint connection);
|
||||
Check(status, "ConnectionOpen");
|
||||
return connection;
|
||||
}
|
||||
|
||||
public static void ConnectionClose(nint handle) => s_connectionClose(handle);
|
||||
|
||||
public static void ConnectionShutdown(nint handle, ulong errorCode = 0) => s_connectionShutdown(handle, 0x00000001, errorCode);
|
||||
|
||||
public static void ConnectionStart(nint handle, nint configuration, ushort addressFamily, nint serverName, ushort port)
|
||||
{
|
||||
uint status = s_connectionStart(handle, configuration, addressFamily, serverName, port);
|
||||
Check(status, "ConnectionStart");
|
||||
}
|
||||
|
||||
public static void ConnectionBind(nint handle, byte[] address)
|
||||
{
|
||||
unsafe
|
||||
{
|
||||
fixed (byte* addressBytes = address)
|
||||
{
|
||||
uint status = s_setParam(handle, 0x05000001, (uint)address.Length, addressBytes);
|
||||
Check(status, "SetParam(local address)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void ConnectionSetConfiguration(nint handle, nint configuration)
|
||||
{
|
||||
uint status = s_connectionSetConfiguration(handle, configuration);
|
||||
Check(status, "ConnectionSetConfiguration");
|
||||
}
|
||||
|
||||
public static uint DatagramSend(nint connection, nint buffers, nint clientContext) => s_datagramSend(connection, buffers, 1, 0, clientContext);
|
||||
|
||||
|
||||
public static void Check(uint status, string operation)
|
||||
{
|
||||
if (!Succeeded(status))
|
||||
{
|
||||
throw new InvalidOperationException($"MsQuic {operation} failed with status 0x{status:X8}.");
|
||||
}
|
||||
}
|
||||
|
||||
// QUIC_STATUS_PENDING (0xFFFFFFFE) is a successful asynchronous result. Other
|
||||
// negative HRESULT/NTSTATUS values are failures on both Windows and POSIX.
|
||||
public static bool Succeeded(uint status) => status == 0 || status == 0xFFFFFFFE;
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct QuicBuffer
|
||||
{
|
||||
public uint Length;
|
||||
public nint Buffer;
|
||||
}
|
||||
|
||||
// MsQuic QUIC_STATISTICS_V2 through v2.5. All fields are fixed-width on
|
||||
// the supported 64-bit RIDs; keeping the field order aligned with msquic.h.
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct QuicStatisticsV2
|
||||
{
|
||||
public ulong CorrelationId;
|
||||
public uint Flags;
|
||||
public uint RttUs;
|
||||
public uint MinRttUs;
|
||||
public uint MaxRttUs;
|
||||
public ulong TimingStart;
|
||||
public ulong TimingInitialFlightEnd;
|
||||
public ulong TimingHandshakeFlightEnd;
|
||||
public uint HandshakeClientFlight1Bytes;
|
||||
public uint HandshakeServerFlight1Bytes;
|
||||
public uint HandshakeClientFlight2Bytes;
|
||||
public ushort SendPathMtu;
|
||||
public ulong SendTotalPackets;
|
||||
public ulong SendRetransmittablePackets;
|
||||
public ulong SendSuspectedLostPackets;
|
||||
public ulong SendSpuriousLostPackets;
|
||||
public ulong SendTotalBytes;
|
||||
public ulong SendTotalStreamBytes;
|
||||
public uint SendCongestionCount;
|
||||
public uint SendPersistentCongestionCount;
|
||||
public ulong RecvTotalPackets;
|
||||
public ulong RecvReorderedPackets;
|
||||
public ulong RecvDroppedPackets;
|
||||
public ulong RecvDuplicatePackets;
|
||||
public ulong RecvTotalBytes;
|
||||
public ulong RecvTotalStreamBytes;
|
||||
public ulong RecvDecryptionFailures;
|
||||
public ulong RecvValidAckFrames;
|
||||
public uint KeyUpdateCount;
|
||||
public uint SendCongestionWindow;
|
||||
public uint DestCidUpdateCount;
|
||||
public uint SendEcnCongestionCount;
|
||||
public byte HandshakeHopLimitTtl;
|
||||
public uint RttVarianceUs;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct RegistrationConfig
|
||||
{
|
||||
public nint AppName;
|
||||
public int ExecutionProfile;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct CredentialConfig
|
||||
{
|
||||
public uint Type;
|
||||
public uint Flags;
|
||||
public nint Certificate;
|
||||
public nint Principal;
|
||||
public nint Reserved;
|
||||
public nint AsyncHandler;
|
||||
public uint AllowedCipherSuites;
|
||||
public nint CaCertificateFile;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct Pkcs12Certificate
|
||||
{
|
||||
public nint Asn1Blob;
|
||||
public uint Asn1BlobLength;
|
||||
public nint PrivateKeyPassword;
|
||||
}
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
internal delegate uint ListenerCallback(nint listener, nint context, nint @event);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
internal delegate uint ConnectionCallback(nint connection, nint context, nint @event);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate void SetContextDelegate(nint handle, nint context);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate void SetCallbackHandlerDelegate(nint handle, nint handler, nint context);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate uint SetParamDelegate(nint handle, uint parameter, uint bufferLength, void* buffer);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate uint GetParamDelegate(nint handle, uint parameter, uint* bufferLength, void* buffer);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate uint RegistrationOpenDelegate(ref RegistrationConfig config, out nint registration);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate void HandleCloseDelegate(nint handle);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate uint ConfigurationOpenDelegate(nint registration, ref QuicBuffer alpn, uint alpnCount, nint settings, uint settingsLength, nint context, out nint configuration);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate uint ConfigurationLoadCredentialDelegate(nint configuration, ref CredentialConfig credential);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate uint ListenerOpenDelegate(nint registration, nint callback, nint context, out nint listener);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate uint ListenerStartDelegate(nint listener, ref QuicBuffer alpn, uint alpnCount, nint address);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate void ListenerStopDelegate(nint listener);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate uint ConnectionOpenDelegate(nint registration, nint callback, nint context, out nint connection);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate void ConnectionShutdownDelegate(nint connection, uint flags, ulong errorCode);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate uint ConnectionStartDelegate(nint connection, nint configuration, ushort family, nint serverName, ushort serverPort);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate uint ConnectionSetConfigurationDelegate(nint connection, nint configuration);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate uint DatagramSendDelegate(nint connection, nint buffers, uint bufferCount, uint flags, nint clientContext);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,610 @@
|
||||
using System.Buffers;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Fleet.Core;
|
||||
|
||||
namespace MoShiro.Measurement;
|
||||
|
||||
internal delegate void MsQuicDatagramCallback(MsQuicConnection connection, ReadOnlySpan<byte> datagram);
|
||||
|
||||
/// <summary>A single MsQuic connection with connection-bound datagram delivery.</summary>
|
||||
internal sealed class MsQuicConnection : IAsyncDisposable, IDatagramConnection
|
||||
{
|
||||
private const uint DatagramReceiveEnabled = 0x0500000D;
|
||||
private const uint ClientCredentialFlags = 0x00000001 | 0x00000004 | 0x00000010 | 0x00004000;
|
||||
private const uint CertificateCredentialNone = 0;
|
||||
private const uint CertificateCredentialContext = 3;
|
||||
private const uint CertificateCredentialPkcs12 = 6;
|
||||
private const uint ConnectionEventConnected = 0;
|
||||
private const uint ConnectionEventShutdownByTransport = 1;
|
||||
private const uint ConnectionEventShutdownByPeer = 2;
|
||||
private const uint ConnectionEventShutdownComplete = 3;
|
||||
private const uint ConnectionEventPeerAddressChanged = 5;
|
||||
private const uint ConnectionEventDatagramStateChanged = 10;
|
||||
private const uint ConnectionEventDatagramReceived = 11;
|
||||
private const uint ConnectionEventDatagramSendStateChanged = 12;
|
||||
private const uint ConnectionEventPeerCertificateReceived = 15;
|
||||
private const int DatagramStateFinal = 3;
|
||||
private const int QuicAddressBytes = 28;
|
||||
private static readonly MsQuicApi.ConnectionCallback s_callback = OnEvent;
|
||||
|
||||
private readonly object _sendGate = new();
|
||||
private readonly TaskCompletionSource _connected = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly TaskCompletionSource _shutdown = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly GCHandle _self;
|
||||
private readonly byte[]? _expectedPeerSpki;
|
||||
private readonly Action<MsQuicConnection>? _closed;
|
||||
private readonly Action<IPEndPoint>? _peerAddressChanged;
|
||||
private readonly INodeLog _log;
|
||||
private readonly Dictionary<nint, SendState> _sends = [];
|
||||
private nint _registration;
|
||||
private nint _configuration;
|
||||
private nint _handle;
|
||||
private int _maxDatagramBytes;
|
||||
private int _sendEnabled;
|
||||
private int _disposeStarted;
|
||||
private double? _handshakeDurationMs;
|
||||
|
||||
private MsQuicConnection(
|
||||
nint handle,
|
||||
nint registration,
|
||||
nint configuration,
|
||||
byte[]? expectedPeerSpki,
|
||||
MsQuicDatagramCallback? datagramHandler,
|
||||
INodeLog log,
|
||||
Action<MsQuicConnection>? closed,
|
||||
Action<IPEndPoint>? peerAddressChanged)
|
||||
{
|
||||
_handle = handle;
|
||||
_registration = registration;
|
||||
_configuration = configuration;
|
||||
_expectedPeerSpki = expectedPeerSpki;
|
||||
DatagramHandler = datagramHandler;
|
||||
_log = log;
|
||||
_closed = closed;
|
||||
_peerAddressChanged = peerAddressChanged;
|
||||
_self = GCHandle.Alloc(this);
|
||||
}
|
||||
|
||||
public MsQuicDatagramCallback? DatagramHandler { get; set; }
|
||||
|
||||
public IPEndPoint? RemoteEndPoint { get; private set; }
|
||||
|
||||
public IPEndPoint? LocalEndPoint => Volatile.Read(ref _handle) is nint handle && handle != 0
|
||||
? ReadEndpoint(MsQuicApi.GetAddress(handle, 0x05000001))
|
||||
: null;
|
||||
|
||||
public nint Handle => Volatile.Read(ref _handle);
|
||||
|
||||
public IPEndPoint? RefreshRemoteEndPoint()
|
||||
{
|
||||
if (RemoteEndPoint is not null || Handle == 0)
|
||||
{
|
||||
return RemoteEndPoint;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
RemoteEndPoint = ReadEndpoint(MsQuicApi.GetAddress(Handle, 0x05000002));
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// Address isn't available until the handshake finishes.
|
||||
}
|
||||
|
||||
return RemoteEndPoint;
|
||||
}
|
||||
|
||||
public bool IsReady => _connected.Task.IsCompletedSuccessfully && Volatile.Read(ref _sendEnabled) != 0;
|
||||
|
||||
public int MaxDatagramBytes => Volatile.Read(ref _maxDatagramBytes);
|
||||
|
||||
public double? HandshakeDurationMs => _handshakeDurationMs;
|
||||
|
||||
public bool TryGetStatistics(out MsQuicApi.QuicStatisticsV2 statistics)
|
||||
{
|
||||
nint handle = Volatile.Read(ref _handle);
|
||||
if (handle == 0)
|
||||
{
|
||||
statistics = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
statistics = MsQuicApi.GetStatisticsV2(handle);
|
||||
return true;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
statistics = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<MsQuicConnection> ConnectAsync(
|
||||
IPAddress address,
|
||||
int port,
|
||||
int localPort,
|
||||
byte[] expectedPeerSpki,
|
||||
MsQuicDatagramCallback datagramHandler,
|
||||
INodeLog log,
|
||||
CancellationToken ct)
|
||||
{
|
||||
nint registration = 0;
|
||||
nint configuration = 0;
|
||||
MsQuicConnection? connection = null;
|
||||
long handshakeStarted = 0;
|
||||
try
|
||||
{
|
||||
log.Info("opening MsQuic client registration");
|
||||
registration = MsQuicApi.RegistrationOpen("moshiro-probe");
|
||||
log.Info("opening MsQuic client configuration");
|
||||
configuration = MsQuicApi.ConfigurationOpen(registration, QuicAlpn.Value);
|
||||
log.Info("loading MsQuic client credentials");
|
||||
LoadClientCredentials(configuration);
|
||||
var endpoint = new IPEndPoint(address.AddressFamily == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any, localPort);
|
||||
SocketAddress serialized = endpoint.Serialize();
|
||||
byte[] localAddress = new byte[QuicAddressBytes];
|
||||
for (int i = 0; i < serialized.Size; i++)
|
||||
{
|
||||
localAddress[i] = serialized[i];
|
||||
}
|
||||
ushort nativeFamily = ReadAddressFamily(localAddress);
|
||||
nint name = Marshal.StringToCoTaskMemUTF8(address.ToString());
|
||||
try
|
||||
{
|
||||
nint handle = MsQuicApi.ConnectionOpen(registration, s_callback, 0);
|
||||
log.Info("opened MsQuic client connection");
|
||||
connection = new MsQuicConnection(handle, registration, configuration, expectedPeerSpki, datagramHandler, log, null, null);
|
||||
registration = 0;
|
||||
configuration = 0;
|
||||
MsQuicApi.SetContext(handle, GCHandle.ToIntPtr(connection._self));
|
||||
log.Info("enabling MsQuic datagram receive");
|
||||
MsQuicApi.SetParam(handle, DatagramReceiveEnabled, 1);
|
||||
log.Info("binding MsQuic local address", ("local_port", localPort));
|
||||
MsQuicApi.ConnectionBind(handle, localAddress);
|
||||
log.Info("starting MsQuic QUIC handshake", ("remote", address), ("port", port), ("local_port", localPort));
|
||||
handshakeStarted = Stopwatch.GetTimestamp();
|
||||
MsQuicApi.ConnectionStart(handle, connection._configuration, nativeFamily, name, checked((ushort)port));
|
||||
log.Info("MsQuic connection start returned");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeCoTaskMem(name);
|
||||
}
|
||||
|
||||
await connection._connected.Task.WaitAsync(TimeSpan.FromSeconds(10), ct);
|
||||
connection._handshakeDurationMs = Stopwatch.GetElapsedTime(handshakeStarted).TotalMilliseconds;
|
||||
return connection;
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (connection is not null)
|
||||
{
|
||||
await connection.DisposeAsync();
|
||||
}
|
||||
|
||||
if (configuration != 0)
|
||||
{
|
||||
MsQuicApi.ConfigurationClose(configuration);
|
||||
}
|
||||
|
||||
if (registration != 0)
|
||||
{
|
||||
MsQuicApi.RegistrationClose(registration);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public static MsQuicConnection Accept(
|
||||
nint handle,
|
||||
nint configuration,
|
||||
MsQuicDatagramCallback datagramHandler,
|
||||
INodeLog log,
|
||||
Action<MsQuicConnection> closed,
|
||||
Action<IPEndPoint>? peerAddressChanged)
|
||||
{
|
||||
var connection = new MsQuicConnection(handle, 0, 0, null, datagramHandler, log, closed, peerAddressChanged);
|
||||
MsQuicApi.SetContext(handle, GCHandle.ToIntPtr(connection._self));
|
||||
MsQuicApi.SetCallbackHandler(handle, s_callback, GCHandle.ToIntPtr(connection._self));
|
||||
MsQuicApi.ConnectionSetConfiguration(handle, configuration);
|
||||
MsQuicApi.SetParam(handle, DatagramReceiveEnabled, 1);
|
||||
return connection;
|
||||
}
|
||||
|
||||
public bool Send(ReadOnlySpan<byte> datagram)
|
||||
{
|
||||
nint handle = Volatile.Read(ref _handle);
|
||||
if (handle == 0 || !IsReady || datagram.Length > MaxDatagramBytes)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
byte[] buffer = ArrayPool<byte>.Shared.Rent(datagram.Length);
|
||||
datagram.CopyTo(buffer);
|
||||
GCHandle pinned = GCHandle.Alloc(buffer, GCHandleType.Pinned);
|
||||
nint nativeBuffer = Marshal.AllocHGlobal(Marshal.SizeOf<MsQuicApi.QuicBuffer>());
|
||||
var state = new SendState(buffer, pinned, nativeBuffer);
|
||||
GCHandle stateHandle = GCHandle.Alloc(state);
|
||||
nint context = GCHandle.ToIntPtr(stateHandle);
|
||||
lock (_sendGate)
|
||||
{
|
||||
if (_handle == 0)
|
||||
{
|
||||
stateHandle.Free();
|
||||
state.Release();
|
||||
return false;
|
||||
}
|
||||
|
||||
_sends.Add(context, state);
|
||||
}
|
||||
|
||||
var nativeBufferValue = new MsQuicApi.QuicBuffer
|
||||
{
|
||||
Length = (uint)datagram.Length,
|
||||
Buffer = pinned.AddrOfPinnedObject(),
|
||||
};
|
||||
Marshal.StructureToPtr(nativeBufferValue, nativeBuffer, false);
|
||||
uint status = MsQuicApi.DatagramSend(handle, nativeBuffer, context);
|
||||
if (MsQuicApi.Succeeded(status))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
ReleaseSend(context);
|
||||
return false;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposeStarted, 1) == 0 && Volatile.Read(ref _handle) is nint handle && handle != 0)
|
||||
{
|
||||
MsQuicApi.ConnectionShutdown(handle);
|
||||
}
|
||||
|
||||
if (Volatile.Read(ref _handle) != 0)
|
||||
{
|
||||
await _shutdown.Task.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public Task WaitForShutdownAsync() => _shutdown.Task;
|
||||
|
||||
private static void LoadClientCredentials(nint configuration)
|
||||
{
|
||||
var credential = new MsQuicApi.CredentialConfig { Type = CertificateCredentialNone, Flags = ClientCredentialFlags };
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
credential.Flags &= ~0x00004000u;
|
||||
}
|
||||
|
||||
MsQuicApi.ConfigurationLoadCredential(configuration, ref credential);
|
||||
}
|
||||
|
||||
internal static void LoadServerCredentials(nint configuration, X509Certificate2 certificate)
|
||||
{
|
||||
var credential = new MsQuicApi.CredentialConfig();
|
||||
GCHandle pfxPin = default;
|
||||
nint pkcs12 = 0;
|
||||
try
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
credential.Type = CertificateCredentialContext;
|
||||
credential.Certificate = certificate.Handle;
|
||||
}
|
||||
else
|
||||
{
|
||||
byte[] pfxBytes = certificate.Export(X509ContentType.Pkcs12);
|
||||
pfxPin = GCHandle.Alloc(pfxBytes, GCHandleType.Pinned);
|
||||
var pfxCertificate = new MsQuicApi.Pkcs12Certificate
|
||||
{
|
||||
Asn1Blob = pfxPin.AddrOfPinnedObject(),
|
||||
Asn1BlobLength = (uint)pfxBytes.Length,
|
||||
};
|
||||
pkcs12 = Marshal.AllocHGlobal(Marshal.SizeOf<MsQuicApi.Pkcs12Certificate>());
|
||||
Marshal.StructureToPtr(pfxCertificate, pkcs12, false);
|
||||
credential.Type = CertificateCredentialPkcs12;
|
||||
credential.Flags = 0x00004000;
|
||||
credential.Certificate = pkcs12;
|
||||
}
|
||||
|
||||
MsQuicApi.ConfigurationLoadCredential(configuration, ref credential);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (pkcs12 != 0)
|
||||
{
|
||||
Marshal.FreeHGlobal(pkcs12);
|
||||
}
|
||||
|
||||
if (pfxPin.IsAllocated)
|
||||
{
|
||||
pfxPin.Free();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static uint OnEvent(nint handle, nint context, nint eventPointer)
|
||||
{
|
||||
try
|
||||
{
|
||||
var connection = (MsQuicConnection)GCHandle.FromIntPtr(context).Target!;
|
||||
uint eventType = unchecked((uint)Marshal.ReadInt32(eventPointer));
|
||||
switch (eventType)
|
||||
{
|
||||
case ConnectionEventConnected:
|
||||
connection._log.Info("MsQuic QUIC handshake completed");
|
||||
connection.RefreshRemoteEndPoint();
|
||||
connection._connected.TrySetResult();
|
||||
break;
|
||||
case ConnectionEventShutdownByTransport:
|
||||
case ConnectionEventShutdownByPeer:
|
||||
connection._connected.TrySetException(new IOException("QUIC connection closed during handshake."));
|
||||
break;
|
||||
case ConnectionEventShutdownComplete:
|
||||
connection.OnShutdownComplete(handle);
|
||||
break;
|
||||
case ConnectionEventPeerAddressChanged:
|
||||
{
|
||||
nint address = Marshal.ReadIntPtr(eventPointer, IntPtr.Size);
|
||||
IPEndPoint? endpoint = ReadEndpoint(address);
|
||||
if (endpoint is not null)
|
||||
{
|
||||
connection.RemoteEndPoint = endpoint;
|
||||
connection._peerAddressChanged?.Invoke(endpoint);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case ConnectionEventDatagramStateChanged:
|
||||
connection.OnDatagramStateChanged(eventPointer);
|
||||
break;
|
||||
case ConnectionEventDatagramReceived:
|
||||
connection.OnDatagramReceived(eventPointer);
|
||||
break;
|
||||
case ConnectionEventDatagramSendStateChanged:
|
||||
connection.OnDatagramSendStateChanged(eventPointer);
|
||||
break;
|
||||
case ConnectionEventPeerCertificateReceived:
|
||||
return connection.ValidatePeerCertificate(eventPointer);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (context != 0 && GCHandle.FromIntPtr(context).Target is MsQuicConnection connection)
|
||||
{
|
||||
connection._log.Error("MsQuic connection callback failed", ("err", ex.Message));
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep exceptions inside the native callback boundary.
|
||||
}
|
||||
|
||||
return OperatingSystem.IsWindows() ? 0x80004005u : 1u;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDatagramStateChanged(nint eventPointer)
|
||||
{
|
||||
byte sendEnabled = Marshal.ReadByte(eventPointer, IntPtr.Size);
|
||||
int maxSendLength = (ushort)Marshal.ReadInt16(eventPointer, IntPtr.Size + 1);
|
||||
Volatile.Write(ref _maxDatagramBytes, maxSendLength);
|
||||
Volatile.Write(ref _sendEnabled, sendEnabled == 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
private void OnDatagramReceived(nint eventPointer)
|
||||
{
|
||||
nint bufferPointer = Marshal.ReadIntPtr(eventPointer, IntPtr.Size);
|
||||
if (bufferPointer == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var buffer = Marshal.PtrToStructure<MsQuicApi.QuicBuffer>(bufferPointer);
|
||||
if (buffer.Buffer != 0 && buffer.Length > 0 && buffer.Length <= 65535)
|
||||
{
|
||||
unsafe
|
||||
{
|
||||
DatagramHandler?.Invoke(this, new ReadOnlySpan<byte>((void*)buffer.Buffer, checked((int)buffer.Length)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDatagramSendStateChanged(nint eventPointer)
|
||||
{
|
||||
nint context = Marshal.ReadIntPtr(eventPointer, IntPtr.Size);
|
||||
int state = Marshal.ReadInt32(eventPointer, IntPtr.Size * 2);
|
||||
if (state >= DatagramStateFinal)
|
||||
{
|
||||
ReleaseSend(context);
|
||||
}
|
||||
}
|
||||
|
||||
private uint ValidatePeerCertificate(nint eventPointer)
|
||||
{
|
||||
nint certificate = Marshal.ReadIntPtr(eventPointer, IntPtr.Size);
|
||||
if (certificate == 0 || _expectedPeerSpki is null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
byte[] der;
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
nint encoded = Marshal.ReadIntPtr(certificate, IntPtr.Size);
|
||||
int length = Marshal.ReadInt32(certificate, IntPtr.Size * 2);
|
||||
if (encoded == 0 || length <= 0 || length > 64 * 1024)
|
||||
{
|
||||
return 0x80004005u;
|
||||
}
|
||||
|
||||
der = new byte[length];
|
||||
Marshal.Copy(encoded, der, 0, length);
|
||||
}
|
||||
else
|
||||
{
|
||||
var nativeBuffer = Marshal.PtrToStructure<MsQuicApi.QuicBuffer>(certificate);
|
||||
if (nativeBuffer.Buffer == 0 || nativeBuffer.Length == 0 || nativeBuffer.Length > 64 * 1024)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
der = new byte[checked((int)nativeBuffer.Length)];
|
||||
Marshal.Copy(nativeBuffer.Buffer, der, 0, der.Length);
|
||||
}
|
||||
|
||||
using X509Certificate2 peerCertificate = X509CertificateLoader.LoadCertificate(der);
|
||||
bool valid = ExportSubjectPublicKeyInfo(peerCertificate).AsSpan().SequenceEqual(_expectedPeerSpki);
|
||||
return valid ? 0 : OperatingSystem.IsWindows() ? 0x80004005u : 1u;
|
||||
}
|
||||
|
||||
private static byte[] ExportSubjectPublicKeyInfo(X509Certificate2 certificate)
|
||||
{
|
||||
using ECDsa? ecdsa = certificate.GetECDsaPublicKey();
|
||||
if (ecdsa is not null)
|
||||
{
|
||||
return ecdsa.ExportSubjectPublicKeyInfo();
|
||||
}
|
||||
|
||||
using RSA? rsa = certificate.GetRSAPublicKey();
|
||||
if (rsa is not null)
|
||||
{
|
||||
return rsa.ExportSubjectPublicKeyInfo();
|
||||
}
|
||||
|
||||
using DSA? dsa = certificate.GetDSAPublicKey();
|
||||
if (dsa is not null)
|
||||
{
|
||||
return dsa.ExportSubjectPublicKeyInfo();
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private void ReleaseSend(nint context)
|
||||
{
|
||||
SendState? state;
|
||||
lock (_sendGate)
|
||||
{
|
||||
if (!_sends.Remove(context, out state))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (GCHandle.FromIntPtr(context).IsAllocated)
|
||||
{
|
||||
GCHandle.FromIntPtr(context).Free();
|
||||
}
|
||||
|
||||
state.Release();
|
||||
}
|
||||
|
||||
private void OnShutdownComplete(nint handle)
|
||||
{
|
||||
if (Interlocked.Exchange(ref _handle, 0) == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
MsQuicApi.ConnectionClose(handle);
|
||||
lock (_sendGate)
|
||||
{
|
||||
foreach ((nint context, SendState state) in _sends)
|
||||
{
|
||||
GCHandle.FromIntPtr(context).Free();
|
||||
state.Release();
|
||||
}
|
||||
|
||||
_sends.Clear();
|
||||
}
|
||||
|
||||
if (_configuration != 0)
|
||||
{
|
||||
MsQuicApi.ConfigurationClose(_configuration);
|
||||
}
|
||||
|
||||
if (_registration != 0)
|
||||
{
|
||||
MsQuicApi.RegistrationClose(_registration);
|
||||
}
|
||||
|
||||
_self.Free();
|
||||
_shutdown.TrySetResult();
|
||||
_closed?.Invoke(this);
|
||||
}
|
||||
|
||||
private static IPEndPoint? ReadEndpoint(nint address)
|
||||
{
|
||||
if (address == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
ushort family = unchecked((ushort)Marshal.ReadInt16(address));
|
||||
if (family == 2)
|
||||
{
|
||||
byte[] bytes = new byte[4];
|
||||
Marshal.Copy(address + 4, bytes, 0, bytes.Length);
|
||||
int port = (Marshal.ReadByte(address, 2) << 8) | Marshal.ReadByte(address, 3);
|
||||
return new IPEndPoint(new IPAddress(bytes), port);
|
||||
}
|
||||
|
||||
ushort ipv6Family = OperatingSystem.IsWindows() ? (ushort)23 : OperatingSystem.IsMacOS() ? (ushort)30 : (ushort)10;
|
||||
if (family == ipv6Family)
|
||||
{
|
||||
byte[] bytes = new byte[16];
|
||||
Marshal.Copy(address + 8, bytes, 0, bytes.Length);
|
||||
int port = (Marshal.ReadByte(address, 2) << 8) | Marshal.ReadByte(address, 3);
|
||||
long scope = unchecked((uint)Marshal.ReadInt32(address, 24));
|
||||
return new IPEndPoint(new IPAddress(bytes, scope), port);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IPEndPoint? ReadEndpoint(byte[] address)
|
||||
{
|
||||
unsafe
|
||||
{
|
||||
fixed (byte* addressBytes = address)
|
||||
{
|
||||
return ReadEndpoint((nint)addressBytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static ushort ReadAddressFamily(byte[] address) => (ushort)(address[0] | (address[1] << 8));
|
||||
|
||||
private sealed class SendState(byte[] buffer, GCHandle pin, nint nativeBuffer)
|
||||
{
|
||||
public void Release()
|
||||
{
|
||||
if (pin.IsAllocated)
|
||||
{
|
||||
pin.Free();
|
||||
}
|
||||
|
||||
if (nativeBuffer != 0)
|
||||
{
|
||||
Marshal.FreeHGlobal(nativeBuffer);
|
||||
}
|
||||
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Fleet.Core;
|
||||
|
||||
namespace MoShiro.Measurement;
|
||||
|
||||
/// <summary>Agent-side MsQuic listener. Measurement frames arrive only as QUIC DATAGRAM frames.</summary>
|
||||
internal sealed class MsQuicListener : IAsyncDisposable
|
||||
{
|
||||
private const int QuicAddressBytes = 28;
|
||||
private const uint ListenerEventNewConnection = 0;
|
||||
private const uint ListenerLocalAddress = 0x04000000;
|
||||
private static readonly MsQuicApi.ListenerCallback s_callback = OnListenerEvent;
|
||||
|
||||
private readonly IPAddress _bindAddress;
|
||||
private readonly IReadOnlyList<int> _ports;
|
||||
private readonly MsQuicDatagramCallback _datagramHandler;
|
||||
private readonly INodeLog _log;
|
||||
private readonly Action<IPEndPoint>? _peerAddressChanged;
|
||||
private readonly X509Certificate2 _certificate;
|
||||
private readonly GCHandle _self;
|
||||
private readonly ConcurrentDictionary<nint, MsQuicConnection> _connections = new();
|
||||
private readonly List<nint> _listeners = [];
|
||||
private nint _registration;
|
||||
private nint _configuration;
|
||||
private int _disposed;
|
||||
|
||||
public MsQuicListener(
|
||||
IPAddress bindAddress,
|
||||
IReadOnlyList<int> ports,
|
||||
X509Certificate2 certificate,
|
||||
MsQuicDatagramCallback datagramHandler,
|
||||
INodeLog log,
|
||||
Action<IPEndPoint>? peerAddressChanged)
|
||||
{
|
||||
_bindAddress = bindAddress;
|
||||
_ports = ports.Count == 0 ? [7443] : ports;
|
||||
_certificate = certificate;
|
||||
_datagramHandler = datagramHandler;
|
||||
_log = log;
|
||||
_peerAddressChanged = peerAddressChanged;
|
||||
_self = GCHandle.Alloc(this);
|
||||
}
|
||||
|
||||
public IReadOnlyList<int> BoundPorts { get; private set; } = [];
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (_registration != 0)
|
||||
{
|
||||
throw new InvalidOperationException("MsQuic listener is already started.");
|
||||
}
|
||||
|
||||
_registration = MsQuicApi.RegistrationOpen("moshiro-agent");
|
||||
try
|
||||
{
|
||||
_configuration = MsQuicApi.ConfigurationOpen(_registration, QuicAlpn.Value);
|
||||
MsQuicConnection.LoadServerCredentials(_configuration, _certificate);
|
||||
var bound = new List<int>(_ports.Count);
|
||||
foreach (int port in _ports)
|
||||
{
|
||||
nint listener = MsQuicApi.ListenerOpen(_registration, s_callback, GCHandle.ToIntPtr(_self));
|
||||
_listeners.Add(listener);
|
||||
byte[] address = Serialize(new IPEndPoint(_bindAddress, port));
|
||||
MsQuicApi.ListenerStart(listener, QuicAlpn.Value, address);
|
||||
bound.Add(ReadPort(MsQuicApi.GetAddress(listener, ListenerLocalAddress)));
|
||||
}
|
||||
|
||||
BoundPorts = bound;
|
||||
}
|
||||
catch
|
||||
{
|
||||
foreach (nint listener in _listeners)
|
||||
{
|
||||
MsQuicApi.ListenerStop(listener);
|
||||
MsQuicApi.ListenerClose(listener);
|
||||
}
|
||||
|
||||
_listeners.Clear();
|
||||
DisposeHandles();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (nint listener in _listeners)
|
||||
{
|
||||
MsQuicApi.ListenerStop(listener);
|
||||
}
|
||||
|
||||
foreach (nint listener in _listeners)
|
||||
{
|
||||
MsQuicApi.ListenerClose(listener);
|
||||
}
|
||||
|
||||
_listeners.Clear();
|
||||
MsQuicConnection[] connections = [.. _connections.Values];
|
||||
await Task.WhenAll(connections.Select(c => c.DisposeAsync().AsTask())).ConfigureAwait(false);
|
||||
DisposeHandles();
|
||||
_self.Free();
|
||||
}
|
||||
|
||||
private static uint OnListenerEvent(nint listener, nint context, nint @event)
|
||||
{
|
||||
try
|
||||
{
|
||||
var owner = (MsQuicListener)GCHandle.FromIntPtr(context).Target!;
|
||||
uint eventType = unchecked((uint)Marshal.ReadInt32(@event));
|
||||
if (eventType != ListenerEventNewConnection || Volatile.Read(ref owner._disposed) != 0)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
nint connectionHandle = Marshal.ReadIntPtr(@event, IntPtr.Size * 2);
|
||||
owner._log.Info("accepted MsQuic QUIC connection");
|
||||
MsQuicConnection? connection = null;
|
||||
connection = MsQuicConnection.Accept(
|
||||
connectionHandle,
|
||||
owner._configuration,
|
||||
owner._datagramHandler,
|
||||
owner._log,
|
||||
closed => owner._connections.TryRemove(connectionHandle, out _),
|
||||
owner._peerAddressChanged);
|
||||
if (!owner._connections.TryAdd(connectionHandle, connection))
|
||||
{
|
||||
_ = connection.DisposeAsync().AsTask();
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (context != 0 && GCHandle.FromIntPtr(context).Target is MsQuicListener owner)
|
||||
{
|
||||
owner._log.Error("MsQuic listener callback failed", ("err", ex.Message));
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep exceptions inside the native callback boundary.
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private void DisposeHandles()
|
||||
{
|
||||
if (_configuration != 0)
|
||||
{
|
||||
MsQuicApi.ConfigurationClose(_configuration);
|
||||
_configuration = 0;
|
||||
}
|
||||
|
||||
if (_registration != 0)
|
||||
{
|
||||
MsQuicApi.RegistrationClose(_registration);
|
||||
_registration = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] Serialize(IPEndPoint endpoint)
|
||||
{
|
||||
SocketAddress source = endpoint.Serialize();
|
||||
byte[] address = new byte[QuicAddressBytes];
|
||||
for (int i = 0; i < source.Size; i++)
|
||||
{
|
||||
address[i] = source[i];
|
||||
}
|
||||
|
||||
return address;
|
||||
}
|
||||
|
||||
private static int ReadPort(byte[] address)
|
||||
{
|
||||
if (address.Length < 4)
|
||||
{
|
||||
throw new InvalidOperationException("MsQuic returned a truncated bound address.");
|
||||
}
|
||||
|
||||
return (address[2] << 8) | address[3];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace MoShiro.Measurement;
|
||||
|
||||
/// <summary>Loads the MsQuic v2 ABI used by the QUIC datagram transport.</summary>
|
||||
internal static class MsQuicRuntime
|
||||
{
|
||||
private const uint ApiVersion = 2;
|
||||
private static readonly object s_gate = new();
|
||||
private static nint s_library;
|
||||
private static nint s_api;
|
||||
private static OpenVersionDelegate? s_open;
|
||||
private static CloseDelegate? s_close;
|
||||
private static readonly List<nint> s_supportLibraries = [];
|
||||
|
||||
public static nint Api
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (s_gate)
|
||||
{
|
||||
if (s_api != 0)
|
||||
{
|
||||
return s_api;
|
||||
}
|
||||
|
||||
string[] candidates = OperatingSystem.IsWindows()
|
||||
? ["msquic.dll"]
|
||||
: OperatingSystem.IsMacOS()
|
||||
? ["libmsquic.dylib"]
|
||||
: ["libmsquic.so.2", "libmsquic.so"];
|
||||
LoadBundledSupportLibraries();
|
||||
string appDirectory = AppContext.BaseDirectory;
|
||||
foreach (string candidate in candidates)
|
||||
{
|
||||
if (NativeLibrary.TryLoad(Path.Combine(appDirectory, candidate), out s_library)
|
||||
|| NativeLibrary.TryLoad(candidate, out s_library))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (s_library == 0)
|
||||
{
|
||||
throw new DllNotFoundException($"MsQuic runtime was not found. Expected one of: {string.Join(", ", candidates)}.");
|
||||
}
|
||||
|
||||
s_open = Marshal.GetDelegateForFunctionPointer<OpenVersionDelegate>(NativeLibrary.GetExport(s_library, "MsQuicOpenVersion"));
|
||||
s_close = Marshal.GetDelegateForFunctionPointer<CloseDelegate>(NativeLibrary.GetExport(s_library, "MsQuicClose"));
|
||||
int status = s_open(ApiVersion, out s_api);
|
||||
if (status != 0 || s_api == 0)
|
||||
{
|
||||
NativeLibrary.Free(s_library);
|
||||
s_library = 0;
|
||||
s_api = 0;
|
||||
throw new InvalidOperationException($"MsQuic API v{ApiVersion} could not be opened (status {status}).");
|
||||
}
|
||||
|
||||
return s_api;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void Close()
|
||||
{
|
||||
lock (s_gate)
|
||||
{
|
||||
if (s_api != 0)
|
||||
{
|
||||
s_close!(s_api);
|
||||
s_api = 0;
|
||||
}
|
||||
|
||||
if (s_library != 0)
|
||||
{
|
||||
NativeLibrary.Free(s_library);
|
||||
s_library = 0;
|
||||
}
|
||||
|
||||
for (int i = s_supportLibraries.Count - 1; i >= 0; i--)
|
||||
{
|
||||
NativeLibrary.Free(s_supportLibraries[i]);
|
||||
}
|
||||
|
||||
s_supportLibraries.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private static void LoadBundledSupportLibraries()
|
||||
{
|
||||
if (OperatingSystem.IsLinux())
|
||||
{
|
||||
TryLoadSupport("libnuma.so.1");
|
||||
}
|
||||
else if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
TryLoadSupport("libcrypto.3.dylib");
|
||||
TryLoadSupport("libssl.3.dylib");
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryLoadSupport(string library)
|
||||
{
|
||||
if (NativeLibrary.TryLoad(Path.Combine(AppContext.BaseDirectory, library), out nint handle))
|
||||
{
|
||||
s_supportLibraries.Add(handle);
|
||||
}
|
||||
}
|
||||
|
||||
public static T Function<T>(int index)
|
||||
where T : Delegate => Marshal.GetDelegateForFunctionPointer<T>(Marshal.ReadIntPtr(Api, checked(index * IntPtr.Size)));
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate int OpenVersionDelegate(uint version, out nint api);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate void CloseDelegate(nint api);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace MoShiro.Measurement;
|
||||
|
||||
/// <summary>The application protocol negotiated by the Agent and Probe QUIC endpoints.</summary>
|
||||
internal static class QuicAlpn
|
||||
{
|
||||
internal static readonly byte[] Value = "h3"u8.ToArray();
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Fleet.Core;
|
||||
|
||||
namespace MoShiro.Measurement;
|
||||
|
||||
/// <summary>
|
||||
/// Measurement data transport. Agent instances listen on configured QUIC UDP ports; Probe instances
|
||||
/// open one QUIC connection from a newly selected ephemeral source port. Measurement frames are sent
|
||||
/// only with MsQuic DATAGRAM calls; the existing TLS peer connection continues to carry session control.
|
||||
/// </summary>
|
||||
public sealed class QuicDatagramTransport : IDisposable
|
||||
{
|
||||
private readonly MeasurementDatagramRouter _router;
|
||||
private readonly INodeLog _log;
|
||||
private readonly MsQuicDatagramCallback _onDatagram;
|
||||
private readonly IPAddress _bind;
|
||||
private readonly IReadOnlyList<int> _ports;
|
||||
private readonly X509Certificate2? _serverCertificate;
|
||||
private readonly DataSession? _clientSession;
|
||||
private readonly IPEndPoint? _remote;
|
||||
private readonly byte[]? _peerSpki;
|
||||
private readonly int _previousSourcePort;
|
||||
private readonly TaskCompletionSource _clientReady = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private MsQuicListener? _listener;
|
||||
private MsQuicConnection? _client;
|
||||
private int _disposed;
|
||||
|
||||
/// <summary>Constructs an Agent-side QUIC listener.</summary>
|
||||
public QuicDatagramTransport(IDataSessions sessions, IAdmission? admission, MonoClock clock, DataPlaneCounters counters, INodeLog log, IPAddress bind, IReadOnlyList<int> ports, X509Certificate2 serverCertificate)
|
||||
{
|
||||
_router = new MeasurementDatagramRouter(sessions, admission, clock, counters);
|
||||
_log = log;
|
||||
_onDatagram = _router.Dispatch;
|
||||
_bind = bind;
|
||||
_ports = ports;
|
||||
_serverCertificate = serverCertificate;
|
||||
}
|
||||
|
||||
/// <summary>Constructs a Probe-side QUIC dialer for one Agent session.</summary>
|
||||
public QuicDatagramTransport(DataSession session, MonoClock clock, DataPlaneCounters counters, INodeLog log, IPEndPoint remote, byte[] peerSpki, int previousSourcePort)
|
||||
{
|
||||
_router = new MeasurementDatagramRouter(new SingleSession(session), null, clock, counters);
|
||||
_log = log;
|
||||
_onDatagram = _router.Dispatch;
|
||||
_bind = remote.AddressFamily == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any;
|
||||
_ports = [];
|
||||
_clientSession = session;
|
||||
_remote = remote;
|
||||
_peerSpki = peerSpki;
|
||||
_previousSourcePort = previousSourcePort;
|
||||
}
|
||||
|
||||
public IReadOnlyList<int> BoundPorts { get; private set; } = [];
|
||||
|
||||
public DataPlaneCounters Counters => _router.Counters;
|
||||
|
||||
public bool IsConnected => _client?.IsReady == true;
|
||||
|
||||
public double? HandshakeDurationMs => _client?.HandshakeDurationMs;
|
||||
|
||||
public QuicTransportSnapshot? GetTransportSnapshot()
|
||||
{
|
||||
if (_client is null || !_client.TryGetStatistics(out MsQuicApi.QuicStatisticsV2 stats))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
ulong lost = stats.SendSuspectedLostPackets >= stats.SendSpuriousLostPackets
|
||||
? stats.SendSuspectedLostPackets - stats.SendSpuriousLostPackets
|
||||
: 0;
|
||||
double? packetLossRate = stats.SendTotalPackets > 0 ? lost * 100.0 / stats.SendTotalPackets : null;
|
||||
return new QuicTransportSnapshot(packetLossRate, stats.SendTotalPackets, stats.SendCongestionCount, stats.SendEcnCongestionCount, stats.SendPathMtu);
|
||||
}
|
||||
|
||||
public void Bind()
|
||||
{
|
||||
if (_remote is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_listener is not null)
|
||||
{
|
||||
throw new InvalidOperationException("QUIC listener is already bound.");
|
||||
}
|
||||
|
||||
_listener = new MsQuicListener(_bind, _ports, _serverCertificate!, _onDatagram, _log, null);
|
||||
_listener.Start();
|
||||
BoundPorts = _listener.BoundPorts;
|
||||
}
|
||||
|
||||
public async Task ConnectAsync(CancellationToken ct)
|
||||
{
|
||||
if (_remote is null || _clientSession is null || _peerSpki is null)
|
||||
{
|
||||
throw new InvalidOperationException("Only Probe-side QUIC transports can connect.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
int sourcePort = SelectSourcePort(_remote.AddressFamily, _previousSourcePort);
|
||||
_client = await MsQuicConnection.ConnectAsync(
|
||||
_remote.Address,
|
||||
_remote.Port,
|
||||
sourcePort,
|
||||
_peerSpki,
|
||||
_onDatagram,
|
||||
_log,
|
||||
ct);
|
||||
_clientSession.BindDatagramConnection(_client);
|
||||
BoundPorts = _client.LocalEndPoint is { } local ? [local.Port] : [sourcePort];
|
||||
_clientReady.TrySetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Error("QUIC client connection failed", ("err", ex.Message));
|
||||
_clientReady.TrySetException(ex);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public Task WaitUntilConnectedAsync(CancellationToken ct) => _remote is null ? Task.CompletedTask : _clientReady.Task.WaitAsync(ct);
|
||||
|
||||
public void Register(uint streamId, QuicDatagramHandler handler) => _router.Register(streamId, handler);
|
||||
|
||||
public void Unregister(uint streamId) => _router.Unregister(streamId);
|
||||
|
||||
/// <summary>Sends one measurement datagram on the connection for the supplied session.</summary>
|
||||
public void Send(DataSession session, EndPoint target, ReadOnlySpan<byte> packet)
|
||||
{
|
||||
_ = target; // A QUIC connection owns its path; the Agent learns it from authenticated datagrams.
|
||||
if (session.DatagramConnection?.Send(packet) == true)
|
||||
{
|
||||
_router.Counters.PacketOut();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
if (_remote is not null)
|
||||
{
|
||||
await _clientReady.Task.WaitAsync(ct);
|
||||
await _client!.WaitForShutdownAsync().WaitAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, ct);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
// Listener lifetime is controlled by AgentApp.
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_ = DisposeNativeResourcesAsync();
|
||||
}
|
||||
|
||||
private async Task DisposeNativeResourcesAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_client is not null)
|
||||
{
|
||||
await _client.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (_listener is not null)
|
||||
{
|
||||
await _listener.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidOperationException or ObjectDisposedException)
|
||||
{
|
||||
_log.Error("QUIC datagram transport shutdown failed", ("err", ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
private static int SelectSourcePort(AddressFamily family, int previousPort)
|
||||
{
|
||||
IPAddress local = family == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any;
|
||||
for (int attempt = 0; attempt < 8; attempt++)
|
||||
{
|
||||
using var socket = new Socket(family, SocketType.Dgram, ProtocolType.Udp);
|
||||
socket.Bind(new IPEndPoint(local, 0));
|
||||
int port = ((IPEndPoint)socket.LocalEndPoint!).Port;
|
||||
if (port != previousPort)
|
||||
{
|
||||
return port;
|
||||
}
|
||||
}
|
||||
|
||||
throw new SocketException((int)SocketError.AddressAlreadyInUse);
|
||||
}
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Fleet.Core;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Measurement;
|
||||
|
||||
/// <summary>一个通过校验的 UDP 包,交给按 <c>stream_id</c> 登记的处理函数。处理函数在接收循环里同步执行,不能阻塞。</summary>
|
||||
public readonly ref struct UdpDelivery
|
||||
{
|
||||
private readonly Span<byte> _packet;
|
||||
private readonly Socket _socket;
|
||||
private readonly SocketAddress _source;
|
||||
private readonly DataPlaneCounters _counters;
|
||||
|
||||
internal UdpDelivery(UdpHeader header, Span<byte> packet, long receiveUs, DataSession session, Socket socket, SocketAddress source, DataPlaneCounters counters)
|
||||
{
|
||||
Header = header;
|
||||
_packet = packet;
|
||||
ReceiveUs = receiveUs;
|
||||
Session = session;
|
||||
_socket = socket;
|
||||
_source = source;
|
||||
_counters = counters;
|
||||
}
|
||||
|
||||
public UdpHeader Header { get; }
|
||||
|
||||
public ReadOnlySpan<byte> Packet => _packet;
|
||||
|
||||
/// <summary>包到达的时刻(本节点的单调时钟,微秒):从接收返回的那一刻取,不含校验的耗时。</summary>
|
||||
public long ReceiveUs { get; }
|
||||
|
||||
public DataSession Session { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 原地回复:同样的长度,只把 type 改成 <paramref name="type"/> 并重新计算 MAC,发回请求的实际源地址。
|
||||
/// 回复与请求等长,所以监听方不能被当成放大器。发送失败(源地址已经不可达)不抛出。
|
||||
/// </summary>
|
||||
public void ReplyAs(UdpPacketType type)
|
||||
{
|
||||
_packet[3] = (byte)type;
|
||||
UdpPacket.Seal(_packet, Session.Key);
|
||||
try
|
||||
{
|
||||
_socket.SendTo(_packet, SocketFlags.None, _source);
|
||||
_counters.PacketOut();
|
||||
}
|
||||
catch (SocketException)
|
||||
{
|
||||
// 回复丢失由对端的缺失体现
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public delegate void UdpHandler(in UdpDelivery delivery);
|
||||
|
||||
/// <summary>
|
||||
/// UDP 数据面:一个或多个套接字,按 <c>stream_id</c> 把包分发给积木。校验顺序:长度、magic、version、type →
|
||||
/// 流和会话都存在 → MAC → (监听方)<see cref="IAdmission"/>(重放窗口、限速)。任何一步失败都静默丢弃并计数,不回复。
|
||||
/// 接收循环只做校验和分发,不分配对象。两端共用这一份代码:Agent 监听多个端口并带准入策略,Probe 每个 Agent 一个临时端口。
|
||||
/// </summary>
|
||||
public sealed class UdpMux : IDisposable
|
||||
{
|
||||
private const int WindowsUdpConnReset = -1744830452; // SIO_UDP_CONNRESET
|
||||
|
||||
private readonly IDataSessions _sessions;
|
||||
private readonly IAdmission? _admission;
|
||||
private readonly MonoClock _clock;
|
||||
private readonly DataPlaneCounters _counters;
|
||||
private readonly INodeLog _log;
|
||||
private readonly IPAddress _bind;
|
||||
private readonly IReadOnlyList<int> _ports;
|
||||
private readonly List<Socket> _sockets = [];
|
||||
private readonly ConcurrentDictionary<uint, UdpHandler> _handlers = new();
|
||||
|
||||
/// <param name="ports">要监听的端口(0 表示由系统分配);空表示一个临时端口。</param>
|
||||
public UdpMux(IDataSessions sessions, IAdmission? admission, MonoClock clock, DataPlaneCounters counters, INodeLog log, IPAddress bind, IReadOnlyList<int> ports)
|
||||
{
|
||||
_sessions = sessions;
|
||||
_admission = admission;
|
||||
_clock = clock;
|
||||
_counters = counters;
|
||||
_log = log;
|
||||
_bind = bind;
|
||||
_ports = ports.Count == 0 ? [0] : ports;
|
||||
}
|
||||
|
||||
public IReadOnlyList<int> BoundPorts { get; private set; } = [];
|
||||
|
||||
public DataPlaneCounters Counters => _counters;
|
||||
|
||||
/// <summary>绑定套接字(端口被占用等错误在这里抛出)。</summary>
|
||||
public void Bind()
|
||||
{
|
||||
var bound = new List<int>();
|
||||
foreach (int port in _ports)
|
||||
{
|
||||
var socket = new Socket(_bind.AddressFamily, SocketType.Dgram, ProtocolType.Udp) { ReceiveBufferSize = 4 << 20, SendBufferSize = 4 << 20 };
|
||||
if (_bind.AddressFamily == AddressFamily.InterNetworkV6)
|
||||
{
|
||||
socket.DualMode = true;
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
// Windows 上对一个已关闭的端口发送之后,下一次接收会抛 ConnectionReset:关掉这个行为
|
||||
socket.IOControl(WindowsUdpConnReset, [0], null);
|
||||
}
|
||||
|
||||
socket.Bind(new IPEndPoint(_bind, port));
|
||||
bound.Add(((IPEndPoint)socket.LocalEndPoint!).Port);
|
||||
_sockets.Add(socket);
|
||||
}
|
||||
|
||||
BoundPorts = bound;
|
||||
}
|
||||
|
||||
public void Register(uint streamId, UdpHandler handler) => _handlers[streamId] = handler;
|
||||
|
||||
public void Unregister(uint streamId) => _handlers.TryRemove(streamId, out _);
|
||||
|
||||
/// <summary>从第一个套接字发出一个已经封好的包。发送失败(没有路由、缓冲区满)不抛出:丢包本身就是测量结果。</summary>
|
||||
public void Send(EndPoint to, ReadOnlySpan<byte> packet)
|
||||
{
|
||||
try
|
||||
{
|
||||
_sockets[0].SendTo(packet, SocketFlags.None, to);
|
||||
_counters.PacketOut();
|
||||
}
|
||||
catch (SocketException)
|
||||
{
|
||||
// 记为丢失
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(_sockets.Select(s => ReceiveLoopAsync(s, ct)));
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// 套接字已释放:停止
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (Socket socket in _sockets)
|
||||
{
|
||||
socket.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReceiveLoopAsync(Socket socket, CancellationToken ct)
|
||||
{
|
||||
byte[] buffer = new byte[UdpPacket.MaxBytes + 64];
|
||||
var source = new SocketAddress(socket.AddressFamily);
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
int length;
|
||||
try
|
||||
{
|
||||
length = await socket.ReceiveFromAsync(buffer.AsMemory(), SocketFlags.None, source, ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (SocketException ex)
|
||||
{
|
||||
// 个别错误(例如对端的 ICMP 不可达)不能让接收循环退出
|
||||
_log.Warn("udp receive failed", ("err", ex.SocketErrorCode));
|
||||
continue;
|
||||
}
|
||||
|
||||
Dispatch(socket, buffer, length, source, _clock.NowUs());
|
||||
}
|
||||
}
|
||||
|
||||
private void Dispatch(Socket socket, byte[] buffer, int length, SocketAddress source, long receiveUs)
|
||||
{
|
||||
_counters.PacketIn();
|
||||
Span<byte> packet = buffer.AsSpan(0, length);
|
||||
if (!UdpPacket.TryParseHeader(packet, out UdpHeader header)
|
||||
|| !_handlers.TryGetValue(header.StreamId, out UdpHandler? handler)
|
||||
|| _sessions.Find(header.SessionId) is not { } session
|
||||
|| !UdpPacket.VerifyMac(packet, session.Key))
|
||||
{
|
||||
_counters.DropInvalid();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_admission is not null)
|
||||
{
|
||||
switch (_admission.AdmitPacket(session, header, source, receiveUs))
|
||||
{
|
||||
case Admission.Invalid:
|
||||
_counters.DropInvalid();
|
||||
return;
|
||||
case Admission.RateLimited:
|
||||
_counters.DropRateLimited();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
handler(new UdpDelivery(header, packet, receiveUs, session, socket, source, _counters));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user