Share via


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.0, 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.

Notes

Dans .NET 7.0, les API sont publiées comme des fonctionnalités en préversion.

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é sur https://packages.microsoft.com, le référentiel officiel de packages Linux 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 si ce dernier 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 libmsquic 
    
  • APK

    sudo apk add libmsquic
    
  • DNF

    sudo dnf install libmsquic
    
  • zypper

    sudo zypper install libmsquic
    
  • YUM

    sudo yum install 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.

  • libnuma 1

macOS

QUIC n’est actuellement pas pris en charge sur macOS, mais sera peut-être disponible dans une version future.

Présentation de l’API

System.Net.Quic apporte trois classes principales qui permettent l’utilisation du protocole QUIC :

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
    {
        // List of supported application protocols, must be the same or subset of QuicListenerOptions.ApplicationProtocols.
        ApplicationProtocols = new List<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
{
    // Listening endpoint, port 0 means any port.
    ListenEndPoint = new IPEndPoint(IPAddress.Loopback, 0),
    // List of all supported application protocols by this listener.
    ApplicationProtocols = new List<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 List<SslApplicationProtocol>() { "protocol-name" }
    }
};

// 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.
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). 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, endStream: 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.

Voir aussi