Protocolo QUIC

QUIC es un protocolo de capa de transporte de red estandarizado en RFC 9000. Usa UDP como protocolo subyacente y es inherentemente seguro, ya que exige el uso de TLS 1.3. Para obtener más información, vea RFC 9001. Otra diferencia interesante de los protocolos de transporte conocidos, como TCP y UDP, es que tiene la multiplexación de flujos integrada en la capa de transporte. Esto permite tener varios flujos de datos independientes y simultáneos que no afectan entre sí.

QUIC no define ninguna semántica para los datos intercambiados, ya que es un protocolo de transporte. Se usa en protocolos de capa de aplicación, por ejemplo, en HTTP/3 o en SMB mediante QUIC. También se puede usar para cualquier protocolo definido personalizado.

El protocolo ofrece muchas ventajas sobre TCP con TLS, estas son algunas:

  • Establecimiento de conexión más rápido, ya que no requiere tantos recorridos de ida y vuelta como TCP con TLS encima.
  • Evitar problemas de bloqueo de encabezado de línea en los que un paquete perdido no bloquea los datos de todos los demás flujos.

Por otro lado, hay posibles desventajas que se deben tener en cuenta al usar QUIC. Como protocolo más reciente, su adopción sigue creciendo y limitándose. Aparte de eso, el tráfico QUIC puede incluso estar bloqueado por algunos componentes de red.

QUIC en .NET

La implementación de QUIC se introdujo en .NET 5 como biblioteca System.Net.Quic. Sin embargo, hasta .NET 7, la biblioteca era estrictamente interna y solo servía como una implementación de HTTP/3. Con .NET 7, la biblioteca se hizo pública exponiendo así sus API.

Nota:

En .NET 7.0 y 8.0, las API se publicaron como características en vista previa. A partir de .NET 9, estas API ya no se consideran características en vista previa y ahora se consideran estables.

Desde la perspectiva de la implementación, System.Net.Quic depende de MsQuic, la implementación nativa del protocolo QUIC. Como resultado, la compatibilidad con la plataforma System.Net.Quic y las dependencias se heredan de MsQuic y se documentan en la sección de las dependencias de la plataforma . En resumen, la biblioteca de MsQuic se envía como parte de .NET para Windows. Pero para Linux, debe instalar manualmente libmsquic mediante un administrador de paquetes adecuado. En el caso de las otras plataformas, todavía es posible compilar MsQuic manualmente, ya sea en SChannel o OpenSSL, y usarlo con System.Net.Quic. Sin embargo, estos escenarios no forman parte de nuestra matriz de pruebas y pueden producirse problemas imprevistos.

Dependencias de la plataforma

En las secciones siguientes se describen las dependencias de la plataforma para QUIC en .NET.

Windows

  • Windows 11/Windows Server 2022 o posterior. (Faltan las versiones anteriores de Windows las API criptográficas necesarias para admitir QUIC).

En Windows, msquic.dll se distribuye como parte del entorno de ejecución de .NET y no es necesario realizar ningún otro paso para instalarlo.

Linux

Nota:

.NET 7+ solo es compatible con versiones posteriores de 2.2+ de libmsquic.

El paquete libmsquic es necesario en Linux. Este paquete se publica en el repositorio https://packages.microsoft.com oficial de paquetes de Linux de Microsoft y también está disponible en algunos repositorios oficiales, como los paquetes alpine - libmsquic.

Instalación libmsquic desde el repositorio oficial de paquetes de Linux de Microsoft

Debe agregar este repositorio al administrador de paquetes antes de instalar el paquete. Para obtener más información, consulte Repositorio de software de Linux para productos de Microsoft.

Precaución

Agregar el repositorio de paquetes de Microsoft puede entrar en conflicto con el repositorio de la distribución cuando el repositorio de la distribución proporciona .NET y otros paquetes de Microsoft. Para evitar o solucionar problemas de combinaciones de paquetes, revise Solución de problemas de errores de .NET relacionados con los archivos que faltan en Linux.

Ejemplos

Estos son algunos ejemplos de uso de un administrador de paquetes para instalar 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
    

Instalación libmsquic desde el repositorio de paquetes de distribución

La instalación libmsquic desde el repositorio de paquetes de distribución también es posible, pero actualmente solo está disponible para Alpine.

Ejemplos

Estos son algunos ejemplos de uso de un administrador de paquetes para instalar libmsquic:

  • Alpine 3.21 y versiones posteriores
apk add libmsquic
  • Alpine 3.20 y versiones anteriores
# Get libmsquic from community repository edge branch.
apk add --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community/ libmsquic
Dependencias de libmsquic

Todas las dependencias siguientes se indican en el manifiesto del paquete libmsquic y el administrador de paquetes los instala automáticamente:

  • OpenSSL 3+ o 1.1: depende de la versión predeterminada de OpenSSL para la versión de distribución, por ejemplo, OpenSSL 3 para Ubuntu 22 y OpenSSL 1.1 para Ubuntu 20.

  • libnuma 1

macOS

Actualmente, QUIC no se admite en macOS, pero puede que esté disponible en una versión futura.

Introducción a la API

System.Net.Quic incluye tres clases principales que permiten el uso del protocolo QUIC:

Pero antes de usar estas clases, el código debe comprobar si QUIC es compatible actualmente, ya que libmsquic podría faltar, o es posible que no se admita TLS 1.3. Para ello, QuicListener y QuicConnection exponen una propiedad estática IsSupported:

if (QuicListener.IsSupported)
{
    // Use QuicListener
}
else
{
    // Fallback/Error
}

if (QuicConnection.IsSupported)
{
    // Use QuicConnection
}
else
{
    // Fallback/Error
}

Estas propiedades notificarán el mismo valor, pero eso podría cambiar en el futuro. Se recomienda comprobar si hay escenarios de servidor en IsSupported y de clientes en IsSupported.

QuicListener

QuicListener representa una clase del lado servidor que acepta conexiones entrantes de los clientes. El cliente de escucha se construye e inicia con un método estático ListenAsync(QuicListenerOptions, CancellationToken). El método acepta una instancia de la clase QuicListenerOptions con todos los valores necesarios para iniciar el cliente de escucha y aceptar conexiones entrantes. Después, el cliente de escucha está listo para entregar conexiones mediante AcceptConnectionAsync(CancellationToken). Las conexiones devueltas por este método siempre están totalmente conectadas, lo que significa que el protocolo de enlace TLS ha finalizado y la conexión está lista para usarse. Por último, para dejar de escuchar y liberar todos los recursos, se debe llamar a DisposeAsync().

Considere el código de ejemplo QuicListener siguiente:

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();

Para obtener más información sobre cómo se diseñó QuicListener, consulte la Propuesta de API.

QuicConnection

QuicConnection es una clase que se usa para las conexiones QUIC del lado servidor y del lado cliente. El cliente de escucha crea internamente las conexiones del lado servidor y se entregan mediante AcceptConnectionAsync(CancellationToken). Las conexiones del lado cliente deben abrirse y conectarse al servidor. Al igual que con el cliente de escucha, hay un método estático ConnectAsync(QuicClientConnectionOptions, CancellationToken) que crea instancias y conecta la conexión. Acepta una instancia de QuicClientConnectionOptions, una clase análoga de QuicServerConnectionOptions. Después, el trabajo con la conexión no difiere entre el cliente y el servidor. Puede abrir secuencias salientes y aceptar las entrantes. También proporciona propiedades con información sobre la conexión, como LocalEndPoint, RemoteEndPoint o RemoteCertificate.

Cuando se realiza el trabajo con la conexión, debe cerrarse y eliminarse. El protocolo QUIC exige el uso de un código de capa de aplicación para el cierre inmediato, consulte la sección 10.2 de RFC 9000. Para ello, se puede llamar al código de capa de aplicación con CloseAsync(Int64, CancellationToken) o, si no es así, DisposeAsync() usará el código proporcionado en DefaultCloseErrorCode. En cualquier caso, DisposeAsync() se debe llamar al final del trabajo con la conexión para liberar completamente todos los recursos asociados.

Considere el código de ejemplo QuicConnection siguiente:

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();

para obtener más información sobre cómo se diseñó QuicConnection, consulte la Propuesta de API.

QuicStream

QuicStream es el tipo real que se usa para enviar y recibir datos en el protocolo QUIC. Se deriva de Stream normal y se puede usar como tal, pero también ofrece varias características específicas del protocolo QUIC. En primer lugar, una secuencia QUIC puede ser unidireccional o bidireccional; consulte la sección 2.1 de RFC 9000. Una secuencia bidireccional puede enviar y recibir datos en ambos lados, mientras que la secuencia unidireccional solo puede escribir desde el lado iniciador y leer en la aceptación. Cada nodo del mismo nivel puede limitar el número de flujos simultáneos de cada tipo que está dispuesto a aceptar, vea MaxInboundBidirectionalStreams y MaxInboundUnidirectionalStreams.

Otra particularidad de la secuencia QUIC es la capacidad de cerrar explícitamente el lado de escritura en medio del trabajo con la secuencia, consulte la sobrecarga CompleteWrites() o WriteAsync(ReadOnlyMemory<Byte>, Boolean, CancellationToken) con el argumento completeWrites. El cierre del lado de escritura permite al nodo del mismo nivel saber que no llegarán más datos, pero el nodo del mismo nivel todavía puede seguir enviando (en caso de una secuencia bidireccional). Esto resulta útil en escenarios como el intercambio de solicitud/respuesta HTTP cuando el cliente envía la solicitud y cierra el lado de escritura para informar al servidor de que este es el final del contenido de la solicitud. El servidor todavía puede enviar la respuesta después de eso, pero sabe que no llegarán más datos del cliente. Y para los casos erróneos, se puede anular la escritura o lectura del lado de la secuencia, consulte Abort(QuicAbortDirection, Int64).

Nota:

Al abrir una secuencia solo se reserva sin enviar datos. Este enfoque está diseñado para optimizar el uso de la red evitando la transmisión de fotogramas casi vacíos. Dado que el elemento del mismo nivel no se notifica hasta que se envían los datos reales, la secuencia permanece inactiva desde la perspectiva del mismo nivel. Si no envía datos, el elemento del mismo nivel no reconocerá la secuencia, lo que puede provocar que AcceptInboundStreamAsync() se bloquee mientras espera una secuencia significativa. Para garantizar una comunicación adecuada, debe enviar datos después de abrir la secuencia.

El comportamiento de los métodos individuales para cada tipo de secuencia se resume en la tabla siguiente (tenga en cuenta que tanto el cliente como el servidor pueden abrir y aceptar secuencias):

Método Secuencia de apertura del nodo del mismo nivel Secuencia de aceptación del nodo del mismo nivel
CanRead bidireccional: true
unidireccional: false
true
CanWrite true bidireccional: true
unidireccional: false
ReadAsync bidireccional: lee los datos
unidireccional: InvalidOperationException
lee los datos
WriteAsync envía los datos = la lectura del nodo del mismo nivel > devuelve los datos bidireccional: envía los datos = la lectura del nodo del mismo nivel > devuelve los datos
unidireccional: InvalidOperationException
CompleteWrites cierra el lado de escritura = la lectura del nodo del mismo nivel > devuelve 0 bidireccional: cierra el lado de escritura = la lectura del nodo del mismo nivel > devuelve 0
unidireccional: no-op
Abort(QuicAbortDirection.Read) bidireccional: STOP_SENDING = la escritura del nodo del mismo nivel de > devuelve QuicException(QuicError.OperationAborted)
unidireccional: no-op
STOP_SENDING = la escritura del nodo del mismo nivel > devuelve QuicException(QuicError.OperationAborted)
Abort(QuicAbortDirection.Write) RESET_STREAM = la lectura del nodo del mismo nivel de > devuelve QuicException(QuicError.OperationAborted) bidireccional: RESET_STREAM = la lectura del nodo del mismo nivel de > devuelve QuicException(QuicError.OperationAborted)
unidireccional: no-op

Además de estos métodos, QuicStream ofrece dos propiedades especializadas para recibir notificaciones cada vez que se ha cerrado el lado de lectura o escritura de la secuencia: ReadsClosed y WritesClosed. Ambos devuelven un Task que se completa y cierra su lado correspondiente, ya sea correcto o anulado, en cuyo caso Task contendrá la excepción adecuada. Estas propiedades son útiles cuando el código de usuario necesita saber cuándo se cierra el lado de la secuencia sin emitir llamadas a ReadAsync o WriteAsync.

Por último, cuando se finaliza el trabajo con la secuencia, debe eliminarse con DisposeAsync(). La eliminación se asegurará de que tanto la lectura como el lado de escritura (según el tipo de secuencia) estén cerradas. Si la secuencia no se ha leído correctamente hasta el final, al desechar se emitirá un equivalente de Abort(QuicAbortDirection.Read). Sin embargo, si no se ha cerrado el lado de escritura de la secuencia, se cerrará correctamente igual que con CompleteWrites. El motivo de esta diferencia es asegurarse de que los escenarios que funcionan con un Stream normal se comportan según lo previsto y conducen a una ruta de acceso correcta. Considere el ejemplo siguiente:

// 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);

El uso del ejemplo de QuicStream en el escenario de cliente:

// 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.

Y el uso del ejemplo de QuicStream en el escenario del servidor:

// 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.

Para obtener más información sobre cómo se diseñó QuicStream, consulte la Propuesta de API.

Vea también