Protocole QUIC
QUIC est un protocole de la couche de transport réseau standardisé dans RFC 9000. Il utilise UDP comme protocole sous-jacent et est intrinsèquement sécurisé, car il impose l’utilisation de TLS 1.3. Pour plus d’informations, consultez RFC 9001. Une autre différence intéressante par rapport aux protocoles de transport connus comme TCP et UDP est qu’il a un multiplexage de flux intégré à la couche de transport. Cela permet d’avoir plusieurs flux de données simultanés et indépendants qui ne s’affectent pas mutuellement.
QUIC lui-même ne définit aucune sémantique pour les données échangées, car il s’agit d’un protocole de transport. Il est plutôt utilisé dans les protocoles de la couche application, par exemple, dans HTTP/3 ou dans SMB sur QUIC. Il peut également être utilisé pour n’importe quel protocole défini sur mesure.
Le protocole offre de nombreux avantages par rapport à TCP avec TLS, en voici quelques-uns :
- Établissement de connexion plus rapide, car ne nécessite pas autant d’allers-retours que s’il y a TCP avec TLS en plus.
- Évite le problème de blocage de la tête de ligne dans lequel un paquet perdu ne bloque pas les données de tous les autres flux.
En revanche, il existe des inconvénients potentiels à prendre en compte pendant l’utilisation de QUIC. Parce que c’est un protocole plus récent, son adoption est encore en développement et limitée. Par ailleurs, le trafic QUIC peut même être bloqué par certains composants réseau.
QUIC dans .NET
L’implémentation QUIC a été introduite dans .NET 5 sous la forme de la bibliothèque System.Net.Quic
. Toutefois, jusqu’à .NET 7, la bibliothèque était strictement interne et servait uniquement d’implémentation de HTTP/3. Avec .NET 7, la bibliothèque a été rendue publique, exposant ainsi ses API.
Remarque
Dans .NET 7.0 et 8.0, les API ont été publiées en tant que fonctionnalités en préversion. À compter de .NET 9, ces API ne sont plus considérées comme des fonctionnalités en préversion, elles sont considérées comme stables.
Du point de vue de l’implémentation, System.Net.Quic
dépend de MsQuic, l’implémentation native du protocole QUIC. Par conséquent, la prise en charge et les dépendances de la plateforme System.Net.Quic
sont héritées de MsQuic et documentées dans la section Dépendances de la plateforme. Pour résumer, la bibliothèque MsQuic est fournie dans le cadre de .NET pour Windows. Toutefois, pour Linux, vous devez installer libmsquic
manuellement en utilisant un gestionnaire de package approprié. Pour les autres plateformes, vous pouvez toujours générer MsQuic manuellement, que ce soit sur SChannel ou OpenSSL, et l’utiliser avec System.Net.Quic
. Toutefois, ces scénarios ne font pas partie de notre matrice de test et des problèmes imprévus peuvent se produire.
Dépendances de plateforme
Les sections suivantes décrivent les dépendances de plateforme pour QUIC dans .NET.
Windows
- Windows 11 / Windows Server 2022 ou ultérieur. (Les versions antérieures de Windows ne disposent pas des API de chiffrement requises pour la prise en charge de QUIC.)
Sous Windows, msquic.dll est distribué dans le cadre du runtime .NET et aucune étape supplémentaire n’est requise pour l’installer.
Linux
Remarque
Les versions 7 et ultérieures de .NET sont compatibles uniquement avec les versions 2.2 et ultérieures de libmsquic.
Le package libmsquic
est obligatoire sur Linux. Ce package est publié dans le référentiel officiel de package Linux de Microsoft et https://packages.microsoft.com est également disponible dans certains référentiels officiels, tels que les packages Alpine - libmsquic.
Installation libmsquic
à partir du référentiel de package Linux officiel de Microsoft
Vous devez ajouter ce référentiel à votre gestionnaire de package avant d’installer le package. Pour plus d’informations, consultez Référentiel logiciel Linux pour les produits Microsoft.
Attention
L’ajout du référentiel de packages Microsoft peut entrer en conflit avec le référentiel de votre distribution lorsque le dépôt de votre distribution fournit .NET et d’autres packages Microsoft. Pour éviter ou résoudre les problèmes liés aux mélanges de packages, consultez Résoudre les erreurs .NET liées à des fichiers manquants sous Linux.
Exemples
Voici quelques exemples d’utilisation d’un gestionnaire de package pour installer libmsquic
:
APT
sudo apt-get install libmsquic
APK
sudo apk add libmsquic
DNF
sudo dnf install libmsquic
zypper
sudo zypper install libmsquic
YUM
sudo yum install libmsquic
Installation libmsquic
à partir du référentiel de package de distribution
L’installation libmsquic
à partir d’un référentiel de package de distribution est également possible, mais il est actuellement disponible uniquement pour Alpine
.
Exemples
Voici quelques exemples d’utilisation d’un gestionnaire de package pour installer libmsquic
:
- Alpine 3.21 et versions ultérieures
apk add libmsquic
- Alpine 3.20 et plus
# Get libmsquic from community repository edge branch.
apk add --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community/ libmsquic
Dépendances de libmsquic
Toutes les dépendances suivantes sont indiquées dans le manifeste de package libmsquic
et sont automatiquement installées par le gestionnaire de package :
OpenSSL 3+ ou 1.1 : dépend de la version OpenSSL par défaut pour la version de distribution, par exemple, OpenSSL 3 pour Ubuntu 22 et OpenSSL 1.1 pour Ubuntu 20.
libnuma1
macOS
QUIC est désormais partiellement pris en charge sur macOS via un gestionnaire de package Homebrew non standard avec certaines limitations. Vous pouvez installer libmsquic
sur macOS à l’aide de Homebrew avec la commande suivante :
brew install libmsquic
Pour exécuter une application .NET qui utilise libmsquic
, vous devez définir la variable d’environnement avant de l’exécuter. Cela garantit que l’application peut trouver la bibliothèque pendant le libmsquic
chargement dynamique de l’exécution. Pour ce faire, ajoutez la commande suivante avant votre commande principale :
DYLD_FALLBACK_LIBRARY_PATH=$DYLD_FALLBACK_LIBRARY_PATH:$(brew --prefix)/lib dotnet run
or
DYLD_FALLBACK_LIBRARY_PATH=$DYLD_FALLBACK_LIBRARY_PATH:$(brew --prefix)/lib ./binaryname
Vous pouvez également définir la variable d’environnement avec :
export DYLD_FALLBACK_LIBRARY_PATH=$DYLD_FALLBACK_LIBRARY_PATH:$(brew --prefix)/lib
puis exécutez votre commande principale :
./binaryname
Présentation de l’API
System.Net.Quic apporte trois classes principales qui permettent l’utilisation du protocole QUIC :
- QuicListener - classe côté serveur pour accepter les connexions entrantes.
- QuicConnection - Connexion QUIC, correspondant à RFC 9000 Section 5.
- QuicStream - Flux QUIC, correspondant à RFC 9000 Section 2.
Mais avant d’utiliser ces classes, votre code doit vérifier si QUIC est actuellement pris en charge, car libmsquic
peut être manquant, ou TLS 1.3 peut ne pas être pris en charge. Pour cela, QuicListener
et QuicConnection
exposent une propriété statique IsSupported
:
if (QuicListener.IsSupported)
{
// Use QuicListener
}
else
{
// Fallback/Error
}
if (QuicConnection.IsSupported)
{
// Use QuicConnection
}
else
{
// Fallback/Error
}
Ces propriétés indiquent la même valeur, mais cela peut changer à l’avenir. Nous vous recommandons de consulter IsSupported pour les scénarios serveur et IsSupported pour les scénarios clients.
QuicListener
QuicListener représente une classe côté serveur qui accepte les connexions entrantes des clients. L’écouteur est construit et démarré avec une méthode statique ListenAsync(QuicListenerOptions, CancellationToken). La méthode accepte une instance de la classe QuicListenerOptions avec tous les paramètres nécessaires pour démarrer l’écouteur et accepter les connexions entrantes. Après cela, l’écouteur est prêt à distribuer les connexions via AcceptConnectionAsync(CancellationToken). Les connexions renvoyées par cette méthode sont toujours entièrement connectées, ce qui signifie que la liaison TLS est terminée et que la connexion est prête à être utilisée. Enfin, pour arrêter l’écoute et libérer toutes les ressources, DisposeAsync() doit être appelé.
Prenons l’exemple de code QuicListener
suivant :
using System.Net.Quic;
// First, check if QUIC is supported.
if (!QuicListener.IsSupported)
{
Console.WriteLine("QUIC is not supported, check for presence of libmsquic and support of TLS 1.3.");
return;
}
// Share configuration for each incoming connection.
// This represents the minimal configuration necessary.
var serverConnectionOptions = new QuicServerConnectionOptions
{
// Used to abort stream if it's not properly closed by the user.
// See https://www.rfc-editor.org/rfc/rfc9000#section-20.2
DefaultStreamErrorCode = 0x0A, // Protocol-dependent error code.
// Used to close the connection if it's not done by the user.
// See https://www.rfc-editor.org/rfc/rfc9000#section-20.2
DefaultCloseErrorCode = 0x0B, // Protocol-dependent error code.
// Same options as for server side SslStream.
ServerAuthenticationOptions = new SslServerAuthenticationOptions
{
// Specify the application protocols that the server supports. This list must be a subset of the protocols specified in QuicListenerOptions.ApplicationProtocols.
ApplicationProtocols = [new SslApplicationProtocol("protocol-name")],
// Server certificate, it can also be provided via ServerCertificateContext or ServerCertificateSelectionCallback.
ServerCertificate = serverCertificate
}
};
// Initialize, configure the listener and start listening.
var listener = await QuicListener.ListenAsync(new QuicListenerOptions
{
// Define the endpoint on which the server will listen for incoming connections. The port number 0 can be replaced with any valid port number as needed.
ListenEndPoint = new IPEndPoint(IPAddress.Loopback, 0),
// List of all supported application protocols by this listener.
ApplicationProtocols = [new SslApplicationProtocol("protocol-name")],
// Callback to provide options for the incoming connections, it gets called once per each connection.
ConnectionOptionsCallback = (_, _, _) => ValueTask.FromResult(serverConnectionOptions)
});
// Accept and process the connections.
while (isRunning)
{
// Accept will propagate any exceptions that occurred during the connection establishment,
// including exceptions thrown from ConnectionOptionsCallback, caused by invalid QuicServerConnectionOptions or TLS handshake failures.
var connection = await listener.AcceptConnectionAsync();
// Process the connection...
}
// When finished, dispose the listener.
await listener.DisposeAsync();
Pour plus d’informations sur la conception de QuicListener
, consultez la proposition d’API.
QuicConnection
QuicConnection est une classe utilisée pour les connexions QUIC côté serveur et côté client. Les connexions côté serveur sont créées en interne par l’écouteur et distribuées via AcceptConnectionAsync(CancellationToken). Les connexions côté client doivent être ouvertes et connectées au serveur. Comme avec l’écouteur, il existe une méthode ConnectAsync(QuicClientConnectionOptions, CancellationToken) statique qui instancie et connecte la connexion. Elle accepte une instance de QuicClientConnectionOptions, une classe analogue à QuicServerConnectionOptions. Après cela, le travail avec la connexion ne diffère pas entre le client et le serveur. Il peut ouvrir des flux sortants et accepter des flux entrants. Il fournit également des propriétés avec des informations sur la connexion, comme LocalEndPoint, RemoteEndPoint ou RemoteCertificate.
Quand le travail avec la connexion est terminé, elle doit être fermée et supprimée. Le protocole QUIC impose l’utilisation d’un code de couche application pour la fermeture immédiate, consultez RFC 9000 Section 10.2. Pour cela, CloseAsync(Int64, CancellationToken) avec le code de couche application peut être appelé ou, si ce n’est pas le cas, DisposeAsync() utilise le code fourni dans DefaultCloseErrorCode. Dans les deux cas, DisposeAsync() doit être appelé à la fin du travail avec la connexion pour libérer entièrement toutes les ressources associées.
Prenons l’exemple de code QuicConnection
suivant :
using System.Net.Quic;
// First, check if QUIC is supported.
if (!QuicConnection.IsSupported)
{
Console.WriteLine("QUIC is not supported, check for presence of libmsquic and support of TLS 1.3.");
return;
}
// This represents the minimal configuration necessary to open a connection.
var clientConnectionOptions = new QuicClientConnectionOptions
{
// End point of the server to connect to.
RemoteEndPoint = listener.LocalEndPoint,
// Used to abort stream if it's not properly closed by the user.
// See https://www.rfc-editor.org/rfc/rfc9000#section-20.2
DefaultStreamErrorCode = 0x0A, // Protocol-dependent error code.
// Used to close the connection if it's not done by the user.
// See https://www.rfc-editor.org/rfc/rfc9000#section-20.2
DefaultCloseErrorCode = 0x0B, // Protocol-dependent error code.
// Optionally set limits for inbound streams.
MaxInboundUnidirectionalStreams = 10,
MaxInboundBidirectionalStreams = 100,
// Same options as for client side SslStream.
ClientAuthenticationOptions = new SslClientAuthenticationOptions
{
// List of supported application protocols.
ApplicationProtocols = [new SslApplicationProtocol("protocol-name")],
// The name of the server the client is trying to connect to. Used for server certificate validation.
TargetHost = ""
}
};
// Initialize, configure and connect to the server.
var connection = await QuicConnection.ConnectAsync(clientConnectionOptions);
Console.WriteLine($"Connected {connection.LocalEndPoint} --> {connection.RemoteEndPoint}");
// Open a bidirectional (can both read and write) outbound stream.
// Opening a stream reserves it but does not notify the peer or send any data. If you don't send data, the peer
// won't be informed about the stream, which can cause AcceptInboundStreamAsync() to hang. To avoid this, ensure
// you send data on the stream to properly initiate communication.
var outgoingStream = await connection.OpenOutboundStreamAsync(QuicStreamType.Bidirectional);
// Work with the outgoing stream ...
// To accept any stream on a client connection, at least one of MaxInboundBidirectionalStreams or MaxInboundUnidirectionalStreams of QuicConnectionOptions must be set.
while (isRunning)
{
// Accept an inbound stream.
var incomingStream = await connection.AcceptInboundStreamAsync();
// Work with the incoming stream ...
}
// Close the connection with the custom code.
await connection.CloseAsync(0x0C);
// Dispose the connection.
await connection.DisposeAsync();
pour plus d’informations sur la conception de QuicConnection
, consultez la proposition d’API.
QuicStream
QuicStream est le type réel utilisé pour envoyer et recevoir des données dans le protocole QUIC. Il dérive du Stream ordinaire et peut être utilisé en tant que tel, mais il offre également plusieurs fonctionnalités propres au protocole QUIC. Tout d’abord, un flux QUIC peut être unidirectionnel ou bidirectionnel, consultez RFC 9000 Section 2.1. Un flux bidirectionnel peut envoyer et recevoir des données des deux côtés, tandis que le flux unidirectionnel peut seulement écrire à partir du côté initiateur et lire sur le côté acceptant. Chaque pair peut limiter le nombre de flux simultanés de chaque type qu’il est prêt à accepter, consultez MaxInboundBidirectionalStreams et MaxInboundUnidirectionalStreams.
Une autre particularité du flux QUIC est la possibilité de fermer explicitement le côté écriture au milieu du travail avec le flux, consultez la surcharge CompleteWrites() ou WriteAsync(ReadOnlyMemory<Byte>, Boolean, CancellationToken) avec l’argument completeWrites
. La fermeture du côté écriture permet au pair de savoir qu’il n’y a plus de données, mais qu’il peut continuer à en envoyer (dans le cas d’un flux bidirectionnel). Cela est utile dans les scénarios comme l’échange de demandes/réponses HTTP quand le client envoie la demande et ferme le côté écriture pour informer le serveur qu’il s’agit de la fin du contenu de la demande. Le serveur peut toujours envoyer la réponse après cela, mais sait qu’il n’y a plus de données du client. Par ailleurs, pour les cas erronés, l’écriture ou la lecture du flux peut être abandonnée, consultez Abort(QuicAbortDirection, Int64).
Remarque
L'ouverture d'un flux ne fait que le réserver sans envoyer de données. Cette approche vise à optimiser l’utilisation du réseau en évitant la transmission de trames presque vides. L'homologue n'étant pas informé jusqu'à ce que des données soient effectivement envoyées, de son point de vue, le flux reste inactif. Si vous n'envoyez pas de données, l'homologue ne reconnaîtra pas le flux, ce qui peut bloquer AcceptInboundStreamAsync()
dans l'attente d'un flux pertinent. Pour garantir une communication appropriée, vous devez envoyer des données après l’ouverture du flux.
Le comportement des méthodes individuelles pour chaque type de flux est récapitulé dans le tableau suivant (notez que le client et le serveur peuvent ouvrir et accepter des flux) :
Méthode | Pair ouvrant le flux | Pair acceptant le flux |
---|---|---|
CanRead |
bidirectionnel : true unidirectionnel : false |
true |
CanWrite |
true |
bidirectionnel : true unidirectionnel : false |
ReadAsync |
bidirectionnel : lit les données unidirectionnel : InvalidOperationException |
lit les données |
WriteAsync |
envoie des données => la lecture du pair renvoie les données | bidirectionnel : envoie des données => la lecture du pair renvoie les données unidirectionnel : InvalidOperationException |
CompleteWrites |
ferme le côté écriture => la lecture du pair renvoie 0 | bidirectionnel : ferme le côté écriture => la lecture du pair renvoie 0 unidirectionnel : no-op |
Abort(QuicAbortDirection.Read) |
bidirectionnel : STOP_SENDING => l’écriture du pair déclenche QuicException(QuicError.OperationAborted) unidirectionnel : no-op |
STOP_SENDING => l’écriture du pair déclenche QuicException(QuicError.OperationAborted) |
Abort(QuicAbortDirection.Write) |
RESET_STREAM => la lecture du pair déclenche QuicException(QuicError.OperationAborted) |
bidirectionnel : RESET_STREAM => la lecture du pair déclenche QuicException(QuicError.OperationAborted) unidirectionnel : no-op |
En plus de ces méthodes, QuicStream
offre deux propriétés spécialisées pour recevoir une notification chaque fois que le côté lecture ou écriture du flux a été fermé : ReadsClosed et WritesClosed. Les deux renvoient un Task
qui se termine quand son côté correspondant est fermé, qu’il s’agisse d’une réussite ou d’un abandon, auquel cas le Task
contient l’exception appropriée. Ces propriétés sont utiles quand le code utilisateur a besoin de savoir quand le côté du flux est fermé sans envoyer d’appel à ReadAsync
ou WriteAsync
.
Enfin, quand le travail avec le flux est terminé, il doit être supprimé avec DisposeAsync(). La suppression vérifie que le côté lecture et/ou écriture, en fonction du type de flux, est fermé. Si le flux n’a pas été correctement lu jusqu’à la fin, la suppression envoie un équivalent de Abort(QuicAbortDirection.Read)
. Toutefois, si le côté écriture du flux n’a pas été fermé, il est fermé normalement, comme avec CompleteWrites
. Cette différence permet de vérifier que les scénarios fonctionnant avec un Stream
ordinaire se comportent comme prévu et aboutissent à un chemin réussi. Prenons l’exemple suivant :
// Work done with all different types of streams.
async Task WorkWithStreamAsync(Stream stream)
{
// This will dispose the stream at the end of the scope.
await using (stream)
{
// Simple echo, read data and send them back.
byte[] buffer = new byte[1024];
int count = 0;
// The loop stops when read returns 0 bytes as is common for all streams.
while ((count = await stream.ReadAsync(buffer)) > 0)
{
await stream.WriteAsync(buffer.AsMemory(0, count));
}
}
}
// Open a QuicStream and pass to the common method.
var quicStream = await connection.OpenOutboundStreamAsync(QuicStreamType.Bidirectional);
await WorkWithStreamAsync(quicStream);
L’exemple d’utilisation de QuicStream
dans le scénario client :
// Consider connection from the connection example, open a bidirectional stream.
await using var stream = await connection.OpenOutboundStreamAsync(QuicStreamType.Bidirectional, cancellationToken);
// Send some data.
await stream.WriteAsync(data, cancellationToken);
await stream.WriteAsync(data, cancellationToken);
// End the writing-side together with the last data.
await stream.WriteAsync(data, completeWrites: true, cancellationToken);
// Or separately.
stream.CompleteWrites();
// Read data until the end of stream.
while (await stream.ReadAsync(buffer, cancellationToken) > 0)
{
// Handle buffer data...
}
// DisposeAsync called by await using at the top.
Et l’exemple d’utilisation de QuicStream
dans le scénario serveur :
// Consider connection from the connection example, accept a stream.
await using var stream = await connection.AcceptInboundStreamAsync(cancellationToken);
if (stream.Type != QuicStreamType.Bidirectional)
{
Console.WriteLine($"Expected bidirectional stream, got {stream.Type}");
return;
}
// Read the data.
while (stream.ReadAsync(buffer, cancellationToken) > 0)
{
// Handle buffer data...
// Client completed the writes, the loop might be exited now without another ReadAsync.
if (stream.ReadsCompleted.IsCompleted)
{
break;
}
}
// Listen for Abort(QuicAbortDirection.Read) from the client.
var writesClosedTask = WritesClosedAsync(stream);
async ValueTask WritesClosedAsync(QuicStream stream)
{
try
{
await stream.WritesClosed;
}
catch (Exception ex)
{
// Handle peer aborting our writing side ...
}
}
// DisposeAsync called by await using at the top.
Pour plus d’informations sur la conception de QuicStream
, consultez la proposition d’API.