feat(quic): replace probe UDP data plane with QUIC datagrams

This commit is contained in:
justme committed 2026-10-10 02:59:56 +08:00
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));
}
}