Condividi tramite


Protocollo QUIC

QUIC è un protocollo del livello trasporto di rete standardizzato in RFC 9000. Usa UDP come protocollo sottostante ed è intrinsecamente sicuro perché impone l'utilizzo di TLS 1.3. Per altre informazioni, vedere RFC 9001. Un'altra differenza interessante rispetto ai protocolli di trasporto noti, ad esempio TCP e UDP, è che include il multiplexing di flusso predefinito sul livello di trasporto. Ciò consente di avere più flussi di dati indipendenti e simultanei che non influiscono l'uno sull'altro.

QUIC non definisce alcuna semantica per i dati scambiati perché è un protocollo di trasporto. È piuttosto usato nei protocolli del livello applicazione, ad esempio in HTTP/3 o in SMB su QUIC. Può anche essere usato per qualsiasi protocollo personalizzato.

Il protocollo offre molti vantaggi rispetto a TCP con TLS. Eccone alcuni:

  • Stabilisce la connessione più velocemente perché non richiede il numero di round trip necessari per TCP con TLS all'inizio.
  • Evita il problema di blocco head-of-line in cui un pacchetto perso non blocca i dati di tutti gli altri flussi.

D'altra parte, esistono potenziali svantaggi da considerare quando si usa QUIC. Come protocollo più recente, la sua adozione è ancora in crescita e limitata. A parte questo, il traffico QUIC potrebbe anche essere bloccato da alcuni componenti di rete.

QUIC in .NET

L'implementazione di QUIC è stata introdotta in .NET 5 come libreria System.Net.Quic. Tuttavia, fino a .NET 7 la libreria era rigorosamente interna e serviva solo come implementazione di HTTP/3. Con .NET 7, la libreria è stata resa pubblica esponendo così le sue API.

Nota

In .NET 7.0 e 8.0 le API erano pubblicate come funzionalità di anteprima. A partire da .NET 9, queste API non sono più considerate funzionalità di anteprima; ora sono considerate stabili.

Dal punto di vista dell'implementazione, System.Net.Quic dipende da MsQuic, l'implementazione nativa del protocollo QUIC. Di conseguenza, il supporto della piattaforma System.Net.Quic e le dipendenze vengono ereditate da MsQuic e documentate nella sezione Dipendenze della piattaforma. In breve, la libreria MsQuic viene fornita come parte di .NET per Windows. Per Linux, tuttavia, è necessario eseguire manualmente l'installazione di libmsquic tramite una gestione pacchetti appropriata. Per le altre piattaforme, è comunque possibile compilare manualmente MsQuic, per SChannel o per OpenSSL e usarlo con System.Net.Quic. Tuttavia, questi scenari non fanno parte della matrice di test e possono verificarsi problemi imprevisti.

Dipendenze della piattaforma

Le sezioni seguenti descrivono le dipendenze della piattaforma per QUIC in .NET.

Finestre

  • Windows 11, Windows Server 2022 o versioni successive. Nelle versioni precedenti di Windows mancano le API di crittografia necessarie per supportare QUIC.

In Windows, msquic.dll viene distribuito come parte del runtime .NET e non sono necessari altri passaggi per installarlo.

Linux

Nota

.NET 7+ è compatibile solo con le versioni 2.2.x di libmsquic.

Il pacchetto libmsquic è obbligatorio in Linux. Questo pacchetto viene pubblicato nel repository https://packages.microsoft.com ufficiale dei pacchetti Linux di Microsoft ed è disponibile anche in alcuni repository ufficiali, ad esempio Alpine Packages - libmsquic.

Installazione libmsquic dal repository ufficiale del pacchetto Linux di Microsoft

È necessario aggiungere questo repository alla gestione pacchetti prima di installare il pacchetto. Per altre informazioni, vedere Repository del software Linux per prodotti Microsoft.

Attenzione

L'aggiunta del repository di pacchetti Microsoft può essere in conflitto con il repository della distribuzione quando il repository della distribuzione fornisce .NET e altri pacchetti Microsoft. Per evitare o risolvere i problemi relativi alle combinazioni di pacchetti, vedere Risolvere gli errori .NET correlati ai file mancanti in Linux.

Esempi

Ecco alcuni esempi dell'uso di una gestione pacchetti per installare 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
    

Installazione libmsquic dal repository dei pacchetti di distribuzione

L'installazione libmsquic dal repository dei pacchetti di distribuzione è anche possibile, ma attualmente è disponibile solo per Alpine.

Esempi

Ecco alcuni esempi dell'uso di una gestione pacchetti per installare libmsquic:

  • Alpine 3.21 e versioni successive
apk add libmsquic
  • Alpine 3.20 e versioni precedenti
# Get libmsquic from community repository edge branch.
apk add --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community/ libmsquic
Dipendenze di libmsquic

Tutte le dipendenze seguenti sono indicate nel manifesto del pacchetto libmsquic e vengono installate automaticamente dalla gestione pacchetti:

  • OpenSSL 3+ o 1.1: dipende dalla versione predefinita di OpenSSL per la versione di distribuzione, ad esempio OpenSSL 3 per Ubuntu 22 e OpenSSL 1.1 per Ubuntu 20.

  • libnuma1

macOS

QUIC è ora parzialmente supportato in macOS tramite una gestione pacchetti Homebrew non standard con alcune limitazioni. È possibile eseguire l'installazione libmsquic in macOS usando Homebrew con il comando seguente:

brew install libmsquic

Per eseguire un'applicazione .NET che usa libmsquic, è necessario impostare la variabile di ambiente prima di eseguirla. In questo modo l'applicazione può trovare la libreria durante il libmsquic caricamento dinamico del runtime. A tale scopo, aggiungere il comando seguente prima del comando 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

In alternativa, è possibile impostare la variabile di ambiente con:

export DYLD_FALLBACK_LIBRARY_PATH=$DYLD_FALLBACK_LIBRARY_PATH:$(brew --prefix)/lib

e quindi eseguire il comando principale:

./binaryname

Panoramica delle API

System.Net.Quic offre tre classi principali che consentono l'utilizzo del protocollo QUIC:

Ma prima di usare queste classi, il codice deve verificare se QUIC è attualmente supportato, perché libmsquic potrebbe non essere presente o TLS 1.3 potrebbe non essere supportato. A tale scopo, sia QuicListener che QuicConnection espongono una proprietà statica IsSupported:

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

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

Queste proprietà segnalano lo stesso valore, ma potrebbero cambiare in futuro. È consigliabile verificare IsSupported per scenari server e IsSupported per scenari client.

QuicListener

QuicListener rappresenta una classe lato server che accetta connessioni in ingresso dai client. Il listener viene costruito e avviato con un metodo statico ListenAsync(QuicListenerOptions, CancellationToken). Il metodo accetta un'istanza della classe QuicListenerOptions con tutte le impostazioni necessarie per avviare il listener e accettare le connessioni in ingresso. Successivamente, il listener è pronto per distribuire le connessioni tramite AcceptConnectionAsync(CancellationToken). Le connessioni restituite da questo metodo sono sempre completamente connesse, ovvero l'handshake TLS è terminato e la connessione è pronta per essere usata. Infine, per interrompere l'ascolto e rilasciare tutte le risorse, è necessario chiamare DisposeAsync().

Si consideri il codice di esempio QuicListener seguente:

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

Per altre informazioni sul modo in cui QuicListener è stato progettato, vedere la proposta API.

QuicConnection

QuicConnection è una classe usata per le connessioni QUIC lato server e lato client. Le connessioni lato server vengono create internamente dal listener e distribuite tramite AcceptConnectionAsync(CancellationToken). Le connessioni lato client devono essere aperte e connesse al server. Come per il listener, esiste un metodo statico ConnectAsync(QuicClientConnectionOptions, CancellationToken) che crea un'istanza e connette la connessione. Accetta un'istanza di QuicClientConnectionOptions, una classe analoga a QuicServerConnectionOptions. Successivamente, il lavoro con la connessione non è diverso tra client e server. Può aprire i flussi in uscita e accettare uno quelli in ingresso. Fornisce inoltre proprietà con informazioni sulla connessione, ad esempio LocalEndPoint, RemoteEndPoint o RemoteCertificate.

Al termine dell'operazione con la connessione, è necessario chiuderla ed eliminarla. Il protocollo QUIC impone l'uso di un codice a livello di applicazione per la chiusura immediata, vedere la sezione 10.2 di RFC 9000. A tale scopo, CloseAsync(Int64, CancellationToken) con il codice del livello applicazione può essere chiamato o meno, DisposeAsync() userà il codice fornito in DefaultCloseErrorCode. In entrambi i casi, è necessario chiamare DisposeAsync() alla fine del lavoro con la connessione per rilasciare completamente tutte le risorse associate.

Si consideri il codice di esempio QuicConnection seguente:

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

Per altre informazioni sul modo in cui QuicConnection è stato progettato, vedere la proposta API.

QuicStream

QuicStream è il tipo effettivo usato per inviare e ricevere dati nel protocollo QUIC. Deriva dal normale Stream e può essere usato come tale, ma offre anche diverse funzionalità specifiche del protocollo QUIC. In primo luogo, un flusso QUIC può essere unidirezionale o bidirezionale. Vedere la Sezione 2.1 di RFC 9000. Un flusso bidirezionale è in grado di inviare e ricevere dati su entrambi i lati, mentre un flusso unidirezionale può scrivere solo dal lato di avvio e leggere sul lato di accettazione. Ogni peer può limitare il numero di flussi simultanei di ogni tipo che possono essere accettati. Vedere MaxInboundBidirectionalStreams e MaxInboundUnidirectionalStreams.

Un'altra particolarità del flusso QUIC è la possibilità di chiudere in modo esplicito il lato di scrittura durante il lavoro con il flusso. Vedere l'overload di CompleteWrites() o WriteAsync(ReadOnlyMemory<Byte>, Boolean, CancellationToken) con l'argomento completeWrites. La chiusura del lato di scrittura consente al peer di sapere che non arriveranno altri dati, ma il peer può comunque continuare a inviare (nel caso di un flusso bidirezionale). Ciò è utile in scenari come lo scambio di richieste/risposte HTTP quando il client invia la richiesta e chiude il lato di scrittura per informare il server che si è giunti alla fine del contenuto della richiesta. Il server è ancora in grado di inviare la risposta in seguito, ma sa che non arriveranno altri dati dal client. Per i casi di errore, il lato di scrittura o lettura del flusso possono essere interrotti. Vedere Abort(QuicAbortDirection, Int64).

Nota

L'apertura di un flusso lo riserva solo, senza inviare alcun dato. Questo approccio è progettato per ottimizzare l'uso della rete evitando la trasmissione di frame quasi vuoti. Dato che il peer non riceve una notifica finché non vengono inviati dati effettivi, dal punto di vista del peer il flusso rimane inattivo. Se non si inviano dati, il peer non riconoscerà il flusso, causando potenzialmente il blocco di AcceptInboundStreamAsync() in attesa di un flusso significativo. Per garantire una comunicazione adeguata, è necessario inviare i dati dopo l'apertura del flusso.

Il comportamento dei singoli metodi per ogni tipo di flusso è riepilogato nella tabella seguente (si noti che sia il client che il server possono aprire e accettare flussi):

metodo Flusso di apertura del peer Flusso di accettazione del peer
CanRead bidirezionale: true
unidirezionale: false
true
CanWrite true bidirezionale: true
unidirezionale: false
ReadAsync bidirezionale: legge i dati
unidirezionale: InvalidOperationException
legge i dati
WriteAsync invia dati => la lettura del peer restituisce i dati bidirezionale: invia dati => la lettura del peer restituisce i dati
unidirezionale: InvalidOperationException
CompleteWrites chiude il lato di scrittura => la lettura del peer restituisce 0 bidirezionale: chiude il lato di scrittura => la lettura del peer restituisce 0
unidirezionale: no-op
Abort(QuicAbortDirection.Read) bidirezionale: STOP_SENDING => la scrittura del peer genera QuicException(QuicError.OperationAborted)
unidirezionale: no-op
STOP_SENDING => la scrittura del peer genera QuicException(QuicError.OperationAborted)
Abort(QuicAbortDirection.Write) RESET_STREAM => la scrittura del peer genera QuicException(QuicError.OperationAborted) bidirezionale: RESET_STREAM => la lettura del peer genera QuicException(QuicError.OperationAborted)
unidirezionale: no-op

Oltre a questi metodi, QuicStream offre due proprietà specializzate per ricevere una notifica ogni volta che il lato di lettura o scrittura del flusso viene chiuso: ReadsClosed e WritesClosed. Entrambi restituiscono Task che viene completato con la chiusura del lato corrispondente, indipendentemente dal fatto che abbia esito positivo o che sia stato interrotto, nel qual caso Task conterrà l'eccezione appropriata. Queste proprietà sono utili quando il codice utente deve conoscere il lato flusso che viene chiuso senza eseguire una chiamata a ReadAsync o WriteAsync.

Infine, quando viene eseguita l'operazione con il flusso, è necessario eliminarla con DisposeAsync(). L'eliminazione garantisce che il lato di lettura e/o il lato di scrittura, a seconda del tipo di flusso, sia chiuso. Se il flusso non è stato letto correttamente fino alla fine, l'eliminazione emetterà un equivalente di Abort(QuicAbortDirection.Read). Tuttavia, se il lato di scrittura del flusso non è stato chiuso, verrà chiuso normalmente come avverrebbe con CompleteWrites. Il motivo di questa differenza è assicurarsi che gli scenari che utilizzano normale Stream si comportino come previsto e portino a un percorso corretto. Si consideri l'esempio seguente:

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

Utilizzo di esempio di QuicStream in uno scenario 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.

E l'utilizzo di esempio di QuicStream in uno scenario server:

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

Per altre informazioni sul modo in cui QuicStream è stato progettato, vedere la proposta API.

Vedi anche