Мы выпустили .NET 7 — и в этом материале мы расскажем вам об интересных изменениях и дополнениях в области сетевого программирования. Речь пойдёт о нововведениях в области HTTP, новых QUIC API, сетевой безопасности и WebSocket.
HTTP
Улучшена обработка ошибок соединения
В ранних версиях .NET если в пуле не было доступного соединения: HTTP-запрос формировал новое соединение и ждал, пока оно станет доступно (если позволяли настройки обработчика: MaxConnectionsPerServer
в HTTP/1.1 или EnableMultipleHttp2Connections
в HTTP/2). В этом был недостаток: даже если другое соединение освобождалось, запрос продолжал ожидать, и задержка увеличивалась. В .NET 6.0 мы изменили это поведение: запрос обрабатывается первым доступным соединением, не важно, новым или только освободившимся. Новое соединение всё ещё создаётся (с учётом ограничений), но если оно не используется новым запросом, то отправляется в пул.
К сожалению, у некоторых пользователей были проблемы с .NET 6.0: неудачная попытка соединения приводила к отмене запроса в начале очереди, хотя это мог быть не тот запрос, который создал соединение. Кроме того, в пуле могло оказаться соединение, непригодное для использования, например, из-за ошибок сервера или сети. Cвязанные с таким соединением запросы останавливались, а время ожидания истекало.
Вот что мы изменили в .NET 7.0, чтобы решить эти проблемы:
- Теперь неудачная попытка соединения может вывести из строя только инициировавший её запрос. Если исходный запрос был обработан к моменту ошибки соединения, то она игнорируется (dotnet/runtime#62935).
- Если запрос формирует новое соединение, но обрабатывается другим соединением из пула, то новое соединение быстро и незаметно закроется независимо от времени
ConnectTimeout
. Благодаря этому изменению ошибки соединения не останавливают запросы, не связанные с ними. (dotnet/runtime#71785). Обратите внимание, что ненужные соединения закрываются в фоновом режиме, пользователь этого не видит: единственный способ увидеть его — включить телеметрию.
Потокобезопасное чтение HttpHeaders
Коллекция заголовков HttpHeaders
никогда не была потокобезопасной. Получение доступа к заголовку могло вызвать ленивый парсинг его значения, что приводило к изменению нижележащих структур данных.
Хотя до .NET 6 в большинстве сценариев потокобезопасное чтение коллекции всё-таки было.
Но в .NET 6 создание соединения отделено от инициирующего запроса. Из-за этого изменения у пользователей появлялись ошибки при вызове заголовков: gRPC (dotnet/runtime#55898), NewRelic (newrelic/newrelic-dotnet-agent#803) или даже самого HttpClient (dotnet/runtime#65379). В .NET 6, когда потокобезопасность нарушалась, значения заголовка могли быть продублированы или испорчены или же появлялись ошибки во время доступа к перечислению/заголовку.
В .NET 7 поведение заголовка стало понятнее. Теперь коллекция HttpHeaders
соответствует стандартам потокобезопасности Dictionary
:
Коллекция, пока она не модифицирована, поддерживает несколько одновременных операций чтения. В редких случаях, когда в перечислении есть запись, коллекция блокируется. Чтобы разблокировать коллекцию для чтения и записи, используйте синхронизацию.
Вот как мы этого достигли:
- теперь проверка чтения недопустимого значения не приводит к его удалению (dotnet/runtime#67833);
- теперь параллельное чтение потокобезопасно (dotnet/runtime#68115).
Обнаружение ошибок протоколов HTTP/2 и HTTP/3
Ошибки протоколов HTTP/2 и HTTP/3 описываются в RFC 7540, раздел 7 и RFC 9114 раздел 8.1, например REFUSED_STREAM (0x7)
в HTTP/2 или H3_EXCESSIVE_LOAD (0x0107)
в HTTP/3. Хотя коды состояния HTTP — это низкоуровневые ошибки, которые не важны для большинства пользователей HttpClient
, они помогают в сложных сценариях HTTP/2 или HTTP/3, например в grpc-dotnet, где очень важно знать ошибки клиентских попыток соединения.
Мы определили новое исключение HttpProtocolException
для хранения кода ошибки в ErrorCode
.
Если вызывать HttpClient
напрямую, то HttpProtocolException
будет внутренним исключением HttpRequestException
:
try
{
using var response = await httpClient.GetStringAsync(url);
}
catch (HttpRequestException ex) when (ex.InnerException is HttpProtocolException pex)
{
Console.WriteLine("HTTP error code: " + pex.ErrorCode)
}
HttpProtocolException
показывается напрямую при работе с потоком ответов HttpContent
:
using var response = await httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
using var responseStream = await response.Content.ReadAsStreamAsync();
try
{
await responseStream.ReadAsync(buffer);
}
catch (HttpProtocolException pex)
{
Console.WriteLine("HTTP error code: " + pex.ErrorCode)
}
HTTP/3
В .NET 6 мы уже ввели поддержку HTTP/3 в HttpClient
, а потому в этом обновлении занимались System.Net.Quic
. В .NET 7 мы сделали лишь несколько изменений и исправили ошибки.
Теперь протокол HTTP/3 доступен по умолчанию (dotnet/runtime#73153). Чтобы HTTP-запросы шли по протоколу HTTP/3, задайте RequestVersionOrHigher
в HttpRequestMessage.VersionPolicy
. Затем, если сервер объявит авторизацию через HTTP/3 в заголовке Alt-Svc
, для следующих запросов будет использоваться HttpClient
RFC 9114 Section 3.1.1.
Несколько других изменений:
- теперь HTTP-телеметрия представлена в HTTP/3 dotnet/runtime#40896;
- обогащены сведения об ошибке соединения QUIC dotnet/runtime#70949;
- Server Name Identification (SNI) правильно использует заголовок Host dotnet/runtime#57169.
QUIC
QUIC — новый протокол транспортного уровня. Недавно он стандартизирован в RFC 9000. Работает QUIC поверх протокола UDP и действительно безопасен, поскольку требует TLS 1.3, RFC 9001. Ещё одно его отличие от известных транспортных протоколов, таких как TCP и UDP, заключается в том, что он имеет встроенное мультиплексирование потоков на транспортном уровне, которое позволяет иметь несколько независимых потоков данных, которые не влияют друг на друга.
QUIC сам по себе не меняет семантику данных, потому что это транспортный протокол. Он используется в протоколах прикладного уровня, например в HTTP/3 или в SMB. Его можно использовать с любым пользовательским протоколом.
QUIC лучше TCP и TLS. Он требует меньше циклов «приём — передача», поэтому соединение устанавливается быстрее. Также устранена проблема блокировки начала очереди, и теперь один пакет не блокирует данные остальных потоков. Но QUIC ещё не доработан, и некоторые сетевые компоненты могут блокировать трафик QUIC, потому что его внедрение ещё не закончено.
QUIC в .NET
Мы реализовали QUIC в .NET 5 в библиотеке System.Net.Quic
. Эта библиотека была внутренней и служила только для HTTP/3. В .NET 7 мы сделали библиотеку общедоступной и раскрыли её API. поскольку потребители API в этой версии только HttpClient
и Kestrel, мы показываем изменения на них. Теперь мы сможем доработать API и в следующем обновлении привести его к окончательному виду.
С точки зрения реализации System.Net.Quic
зависит от внутренней реализации протокола QUIC, MsQuic. Поэтому поддержка и описание пространства имён System.Net.Quic
находятся в документации HTTP/3. На Windows библиотека MsQuic поставляется в составе .NET, а на Linux требуется установить libmsquic
через менеджер пакетов. На других платформах можно вручную установить MsQuic через SChannel или OpenSSL и использовать её с System.Net.Quic
.
Обзор API
System.Net.Quic
объединяет три класса, которые позволяют использовать QUIC:
-
QuicListener
— класс на стороне сервера для приёма запросов; -
QuicConnection
— соединение QUIC, описанное в RFC 9000 Section 5; -
QuicStream
— поток QUIC, описанный в RFC 9000 Section 2.
Но, перед тем как использовать эти классы, нужно проверить, поддерживается ли QUIC, потому что может отсутствовать libmsquic
или не поддерживаться TLS 1.3. Поэтому для QuicListener
и QuicConnection
нужно задать статический параметр IsSupported
.
if (QuicListener.IsSupported)
{
// Use QuicListener
}
else
{
// Fallback/Error
}
if (QuicConnection.IsSupported)
{
// Use QuicConnection
}
else
{
// Fallback/Error
}
Заметьте, что на данный момент оба этих свойства синхронизированы и возвращают одно значение, но это может измениться. Поэтому мы рекомендуем проверять QuicListener.IsSupported
для серверных сценариев и QuicConnection.IsSupported
— для клиентских.
QuicListener
QuicListener
— серверный класс, который принимает запросы от клиентов. QuicListener
создаётся и запускается с помощью статического метода QuicListener.ListenAsync
. Этот метод принимает объект класса QuicListenerOptions
со всеми необходимыми для начала работы настройками и принимает входящие запросы. После этого он готов возвращать соединения через AcceptConnectionAsync
. Возвращённые этим методом соединения всегда подключены: TLS-рукопожатие завершено и соединение готово к использованию. Чтобы остановить QuicListener
и освободить ресурсы, нужно вызвать DisposeAsync
.
Пример использования QuicListener
:
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;
}
// We want the same configuration for each incoming connection, so we prepare the connection options upfront and reuse them.
// 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();
Больше информации об этом классе: dotnet/runtime#67560.
QuicConnection
QuicConnection
— класс, используемый и на серверной, и на клиентской сторонах QUIC-соединений. Серверные соединения создаются QuicListener
и передаются через QuicListener.AcceptConnectionAsync
. Клиентские соединения нужно открыть и подключить к серверу при помощи статического метода QuicConnection.ConnectAsync
, который создаст объект и соединение. Он принимает объект класса QuicClientConnectionOptions
(аналогичного классу QuicServerConnectionOptions
). После этого клиентские и серверные соединения работают одинаково. QuicConnection
может открывать и принимать потоки. Кроме того, он поддерживает свойства с информацией о подключении: LocalEndPoint
, RemoteEndPoint
или RemoteCertificate
.
Когда работа с подключением закончена, нужно закрыть и удалить QuicConnection
. QUIC предписывает использовать код прикладного уровня для мгновенного закрытия (RFC 9000 Section 10.2). Для этого нужно вызвать CloseAsync
с кодом прикладного уровня, а если этого не сделать, то DisposeAsync
использует код, предоставленный в QuicConnectionOptions.DefaultCloseErrorCode
. После работы с соединением в любом случае нужно вызвать DisposeAsync
, чтобы освободить ресурсы.
Пример использования QuicConnection
:
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();
Больше информации об этом классе: dotnet/runtime#68902.
QuicStream
QuicStream
— actual-тип, который нужен, чтобы отправлять и получать информацию по протоколу QUIC. Он принадлежит к обычному классу Stream
, но есть несколько фич, уникальных для QUIC. Поток данных QUIC может быть как одно-, так и двунаправленным (RFC 9000 Section 2.1). Данные в двунаправленном потоке могут передаваться в обе стороны, а в однонаправленном каждая сторона либо получает данные, либо передаёт. Узлы могут ограничивать количество одновременных потоков каждого типа. Подробнее: QuicConnectionOptions.MaxInboundBidirectionalStreams
и QuicConnectionOptions.MaxInboundBidirectionalStreams
.
Ещё одна особенность: QUIC может закрыть записывающую сторону во время работы с потоком. Подробнее: CompleteWrites
или перегрузка WriteAsync
-system-boolean-system-threading-cancellationtoken)) с аргументом completeWrites
. Когда записывающая сторона закроется, данные перестанут приходить, но узел продолжит отправлять их (если это двунаправленный поток). Это полезно в сценариях HTTP «запрос — ответ», когда клиент отправляет запрос и закрывает поток, чтобы сервер понял, что больше данных не будет; но после этого сервер может отправлять ответы. В случае ошибки запись или чтение потока может прерваться (подробнее: метод Abort
). Помните, что и клиент, и сервер могут принимать и отправлять потоки. Описание методов для каждого типа потока дано в таблице:
Отправка | Приём | |
---|---|---|
CanRead |
двунправленный: true однонаправленный: false
|
true |
CanWrite |
true |
двунаправленный: true однонаправленный: false
|
ReadAsync |
двунаправленный: считывает данные однонаправленный: InvalidOperationException
|
считывает данные |
WriteAsync |
отправляет данные => считыватель узла возвращает данные |
двунаправленный: отправляет данные => считыватель узла возвращает данные unidirectional: InvalidOperationException
|
CompleteWrites |
закрывает записывающую сторону => считыватель узла возвращает 0 | двунаправленный: закрывает записывающую сторону => считыватель узла возвращает ноль однонаправленный: операция не происходит |
Abort(QuicAbortDirection.Read) |
bidirectional: STOP_SENDING => узел выводит: QuicException(QuicError.OperationAborted) однонаправленный: операция не происходит |
STOP_SENDING => узел выводит: QuicException(QuicError.OperationAborted)
|
Abort(QuicAbortDirection.Write) |
RESET_STREAM => узел считывает QuicException(QuicError.OperationAborted)
|
двунаправленный: RESET_STREAM => узел считывает QuicException(QuicError.OperationAborted) однонаправленный: операция не происходит |
Кроме этих методов, в QuicStream
есть свойства, которые уведомляют, когда записывающая (WritesClosed
) или считывающая (ReadsClosed
) сторона закрывается. Оба возвращают Task
, когда соответствующая сторона закрывается. Если она закрывается с ошибкой, то Task
будет содержать соответствующий код ошибки. Эти свойства полезны, когда в коде должна быть информация о закрытии стороны потока, но ReadAsync
или WriteAsync
не вызваны.
Когда работа с потоком завершена, его нужно закрыть методом DisposeAsync
. После этого обе стороны потока закроются независимо от его типа. Если поток не прочитан до конца, после его закрытия будет ошибка, как при Abort(QuicAbortDirection.Read)
. А если записывающая сторона потока не закрылась, то она закроется, как при CompleteWrites
. Это нужно, чтобы убедиться, что сценарии работы с Stream
работают правильно. Посмотрите пример:
// Work done with all different types of streams.
async Task WorkWithStream(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 WorkWithStream(quicStream);
Пример использования QuicStream
в клиентском сценарии:
// Consider connection from the connection example, open a bidirectional stream.
await using var stream = await connection.OpenStreamAsync(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.
Пример использования QuicStream
в серверном сценарии:
// Consider connection from the connection example, accept a stream.
await using var stream = await connection.AcceptStreamAsync(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.
Больше информации об этом классе: dotnet/runtime#69675.
Будущие обновления
Библиотека System.Net.Quic
только недавно стала общедоступной, поэтому мы будем рады любым вашим отзывам об API и сообщениям об ошибках. Благодаря вашим отзывам мы улучшим API в .NET 8. Вы можете отправлять ошибки сюда.
Безопасность
Negotiate API
Аутентификация Windows — термин, охватывающий несколько технологий аутентификации пользователей и приложений в операционной системе, обычно через контроллер домена. Например, он делает возможными технологию единого входа в электронную почту и аутентификацию в частную корпоративную сеть. Базовые протоколы аутентификации — Kerberos, NTLM и Negotiate, который позволяет пользователю выбрать между двумя предыдущими сценариями аутентификации.
До .NET 7 аутентификация Windows была в высокоуровневых API, таких как HttpClient
(схемы аутентификации: Negotiate
и NTLM
), SmtpClient
(схемы: GSSAPI
и NTLM
), NegotiateStream
, ASP.NET Core, и в клиентских библиотеках SQL Server. Пока они покрывают большинство сценариев для конечных пользователей, они ограничивают авторов других библиотек. Библиотеки Npgsql PostgreSQL client, [MailKit](https://github.com/jstedfast/MailKit, Apache Kudu client и другие должны прибегать к разным уловкам, чтобы реализовать такие же схемы аутентификации для низкоуровневых протоколов, которые не используют HTTP или другой доступный высокоуровневый элемент структуры.
Для этих протоколов в .NET 7 вводится новый API, предоставляющий низкоуровневые элементы (подробнее: dotnet/runtime#69920). Он кросс-платформенный, как и другие API в .NET. На Linux, macOS, iOS и других похожих платформах он использует системную библиотеку GSS-API. На Windows — библиотеку SSPI. Для платформ, где системная реализация недоступна, например Android и tvOS, есть ограниченная, только клиентская реализация.
Использование API
Чтобы понять, как происходит аутентификация с помощью API, рассмотрим её на примере высокоуровневого протокола SMTP. Сам пример возьмём из документации протоколов от Microsoft:
S: 220 server.contoso.com Authenticated Receive Connector
C: EHLO client.contoso.com
S: 250-server-contoso.com Hello [203.0.113.1]
S: 250-AUTH GSSAPI NTLM
S: 250 OK
C: AUTH GSSAPI <token1>
S: 334 <token2>
C: <token3>
S: 235 2.7.0 Authentication successful
Аутентификация начинается, когда клиент отправляет токен вызова. Затем сервер отвечает. Клиент обрабатывает ответ и отправляет новый вызов на сервер. Такой обмен может происходить несколько раз. Он продолжается, пока сервер или клиент не отклонит аутентификацию или пока они оба не примут её. Формат токенов устанавливается протоколами аутентификации Windows, причём инкапсуляция является частью спецификации высокоуровневого протокола. В этом примере SMTP отправляет код 334
, чтобы сообщить клиенту, что сервер ответил на запрос об аутентификации; а код 235
показывает, что аутентификация прошла успешно.
Большая часть новых API сосредотачивается вокруг нового класса NegotiateAuthentication
. Он нужен, чтобы создать экземпляр контекста для аутентификации на стороне клиента или на стороне пользователя. Есть несколько способов указать требования для создания сессии аутентификации, например с помощью шифрования или конкретного протокола (Negotiate, Kerberos, или NTLM). Когда параметры заданы, аутентификация происходит с помощью обмена вызовами и ответами между клиентом и сервером. Для этого используется метод GetOutgoingBlob
. Он работает как для типа Span, так и для строк в кодировке base64.
Этот код показывает и клиентскую, и серверную части аутентификации для текущего пользователя на одной машине:
using System.Net;
using System.Net.Security;
var serverAuthentication = new NegotiateAuthentication(new NegotiateAuthenticationServerOptions { });
var clientAuthentication = new NegotiateAuthentication(
new NegotiateAuthenticationClientOptions
{
Package = "Negotiate",
Credential = CredentialCache.DefaultNetworkCredentials,
TargetName = "HTTP/localhost",
RequiredProtectionLevel = ProtectionLevel.Sign
});
string? serverBlob = null;
while (!clientAuthentication.IsAuthenticated)
{
// Client produces the authentication challenge, or response to server's challenge
string? clientBlob = clientAuthentication.GetOutgoingBlob(serverBlob, out var clientStatusCode);
if (clientStatusCode == NegotiateAuthenticationStatusCode.ContinueNeeded)
{
// Send the client blob to the server; this would normally happen over a network
Console.WriteLine($"C: {clientBlob}");
serverBlob = serverAuthentication.GetOutgoingBlob(clientBlob, out var serverStatusCode);
if (serverStatusCode != NegotiateAuthenticationStatusCode.Completed &&
serverStatusCode != NegotiateAuthenticationStatusCode.ContinueNeeded)
{
Console.WriteLine($"Server authentication failed with status code {serverStatusCode}");
break;
}
Console.WriteLine($"S: {serverBlob}");
}
else
{
Console.WriteLine(
clientStatusCode == NegotiateAuthenticationStatusCode.Completed ?
"Successfully authenticated" :
$"Authentication failed with status code {clientStatusCode}");
break;
}
}
Когда аутентификация произошла, можно использовать NegotiateAuthentication
, чтобы подписать или зашифровать исходящие сообщения, а также чтобы верифицировать или расшифровать входящие сообщения. Это можно сделать с помощью методов: Wrap
и Unwrap
.
Мы благодарим @filipnavara, который внёс эти изменения и написал о них в этом материале.
Проверка сертификата
Клиентский и серверный сертификаты проверяются с помощью класса X509Chain
. Проверка происходит всегда, даже если есть RemoteCertificateValidationCallback
, и дополнительные сертификаты могут быть загружены во время проверки. Если бы не было способа управлять этим процессом, могли бы возникнуть проблемы. Например: запросы полностью запретить загрузку сертификата, установить для неё тайм-аут или предоставить пользовательское хранилище сертификатов. Чтобы уменьшить количество подобных ошибок, мы ввели новое свойство CertificateChainPolicy
как для клиента (SslClientAuthenticationOptions
), так и для сервера (SslServerAuthenticationOptions
). Это свойство нужно, чтобы переопределить стандартное поведение класса SslStream
при построении цепочки во время операций AuthenticateAsClientAsync
или AuthenticateAsServerAsync
. По умолчанию X509ChainPolicy
создаётся автоматически в фоновом режиме. Но, если указано это новое свойство, оно будет иметь приоритет, предоставляя пользователю право полноценного управления проверкой сертификата.
Пример использования цепочки политики:
// Client side:
var policy = new X509ChainPolicy();
policy.TrustMode = X509ChainTrustMode.CustomRootTrust;
policy.ExtraStore.Add(s_ourPrivateRoot);
policy.UrlRetrievalTimeout = TimeSpan.FromSeconds(3);
var options = new SslClientAuthenticationOptions();
options.TargetHost = "myServer";
options.CertificateChainPolicy = policy;
var sslStream = new SslStream(transportStream);
sslStream.AuthenticateAsClientAsync(options, cancellationToken);
// Server side:
var policy = new X509ChainPolicy();
policy.DisableCertificateDownloads = true;
var options = new SslServerAuthenticationOptions();
options.CertificateChainPolicy = policy;
var sslStream = new SslStream(transportStream);
sslStream.AuthenticateAsServerAsync(options, cancellationToken);
Больше информации здесь: dotnet/runtime#71191.
Производительность
Стивен Тоуб рассказал обо всех улучшениях сетевой производительности в своём материале (Performance Improvements in .NET 7 – Networking), но мы хотим упомянуть ещё раз о некоторых улучшениях, связанных с безопасностью.
Резюме по TLS
Установление нового TLS-соединения — длительный и трудоёмкий процесс, потому что требует несколько шагов и рукопожатий. В сценариях, когда соединение с сервером создаётся очень часто, затрачиваемое на рукопожатия время складывается. Чтобы сократить его, существует механизм возобновления TLS-соединения (TLS Session Resumption). Подробнее здесь: RFC 5246 Section 7.3 и RFC 8446 Section 2.2. Если коротко: во время рукопожатия клиент может отправить идентификацию ранее установленного сеанса TLS, и, если сервер согласен, контекст безопасности восстанавливается на основе кешированных данных из предыдущего соединения. В разных версиях TLS методика может различаться, но конечная цель — уменьшить время приёма-передачи и время, затраченное процессором на восстановление соединения с ранее подключённым сервером. Это фича есть в пакете безопасности SChannel на Windows. Но, чтобы она работала на Linux в OpenSSL, нужно внести несколько изменений:
- на стороне сервера: stateless (подробнее: dotnet/runtime#57079 и dotnet/runtime#63030);
- на стороне клиента (dotnet/runtime#64369);
- установить размера кеша (dotnet/runtime#69065).
Если кеширование контекста TLS нежелательно, его можно отключить во всём процессе с помощью DOTNET\_SYSTEM\_NET\_SECURITY\_DISABLETLSRESUME» или через [
AppContext.SetSwitch](https://learn.microsoft.com/dotnet/api/system.appcontext.setswitch?view=net-7.0 ) «System.Net.Security.TlsCacheSize
.
OCSP Stapling
OCSP-stapling — расширение протокола TLS, позволяющее прикреплять подписанный ответ со статусом сертификата (OCSP-ответ), указанного в запросе: срок не истёк, отозван или статус неизвестен (RFC 6961). Если он не может обработать запрос, то вернёт код ошибки. В результате клиенту не нужно связываться с сервером OCSP. Это уменьшает количество запросов, необходимых для установления соединения, а также нагрузку на сервер OCSP. Удостоверяющий центр (УЦ) подписывает OCSP-ответ, поэтому его не может подделать сервер, который предоставляет сертификат. В этом обновлении мы использовали преимущества этой фичи (больше информации: dotnet/runtime#33377).
Кросс-платформенность
Мы обеспокоены тем, что есть функционал .NET, недоступный для всех платформ. Эту разницу мы стремимся сократить в каждом обновлении. В .NET 7 сделано несколько изменений в области сетевой безопасности:
- поддерживается аутентификации после завершения рукопожатия на Linux для TLS 1.3 (dotnet/runtime#64268);
- удалённый сертификат установлен на Windows в
SslClientAuthenticationOptions.LocalCertificateSelectionCallback
(dotnet/runtime#65134); - поддерживается отправка сертификатов, подписанных УЦ, в TLS-рукопожатии на OSX и Linux (dotnet/runtime#65195).
WebSocket
Ответ сервера на рукопожатие по WebSocket
До .NET 7 ответ сервера на установку соединения (HTTP-ответ на запрос) был скрыт внутри реализации ClientWebSocket
, а все ошибки рукопожатия отображались как WebSocketException
без подробностей. Но HTTP-заголовки ответа и код состояния важны как в случае успешного соединения, так и в случае ошибок.
В случае неудачного соединения код состояния помогает понять, временный ли это сбой или ошибка, которая не исправляется повтором соединения. Например: сервер не поддерживает WebSocket или это просто временная сетевая ошибка. Кроме того, HTTP-заголовки могут содержать информацию о том, как исправить проблему. А в случае успешного рукопожатия они могут содержать привязанный к сеансу токен, информацию, относящуюся к версии подпротокола, или информацию о том, что скоро сервер может упасть.
В .NET 7 появляется значение CollectHttpResponseDetails
, относящееся к классу ClientWebSocketOptions
, которое позволяет собирать обновлённую информацию об ответе в ClientWebSocket
во время вызова ConnectAsync
. Вы можете посмотреть эти сведения с помощью HttpStatusCode
и HttpResponseHeaders
(настроек ClientWebSocket
), даже если ConnectAsync
выдаёт ошибку. Но информация может быть недоступна, если сервер не ответил на запрос.
После успешного соединения и использования данных HttpResponseHeaders вы можете уменьшить потребление памяти, если поставите значение null
в ClientWebSocket.HttpResponseHeaders
.
var ws = new ClientWebSocket();
ws.Options.CollectHttpResponseDetails = true;
try
{
await ws.ConnectAsync(uri, cancellationToken);
// success scenario
ProcessSuccess(ws.HttpResponseHeaders);
ws.HttpResponseHeaders = null; // clean up (if needed)
}
catch (WebSocketException)
{
// failure scenario
if (ws.HttpStatusCode != 0)
{
ProcessFailure(ws.HttpStatusCode, ws.HttpResponseHeaders);
}
}
Внешний клиент HTTP
По умолчанию ClientWebSocket
использует кешированный статический объект HttpMessageInvoker
, чтобы выполнить запрос HTTP Upgrade. Но есть параметры ClientWebSocketOptions
, которые предотвращают кеширование, например инициатор вызова Proxy
, ClientCertificates
или Cookies
. Небезопасно использовать эти функции с параметром HttpMessageInvoker
— он должен создаваться заново при вызове ConnectAsync
. Это приводит к бесполезным выделениям памяти и делает невозможным повторное использование пула подключений HttpMessageInvoker
.
В .NET 7 вы можете передавать существующий объект HttpMessageInvoker
(например HttpClient
) при вызове ConnectAsync
, используя перегрузку: ConnectAsync(Uri, HttpMessageInvoker, CancellationToken)
. В этом случае HTTP-запрос на обновление выполнится через этот предоставленный объект.
var httpClient = new HttpClient();
var ws = new ClientWebSocket();
await ws.ConnectAsync(uri, httpClient, cancellationToken);
Обратите внимание, что в случае передачи пользовательского HTTP-вызова ClientWebSocketOptions
должны быть не установлены, а настроены на HTTP-вызов:
ClientCertificates
Cookies
Credentials
Proxy
RemoteCertificateValidationCallback
UseDefaultCredentials
Вы можете настроить все эти параметры в HttpMessageInvoker
вот так:
var handler = new HttpClientHandler();
handler.CookieContainer = cookies;
handler.UseCookies = cookies != null;
handler.ServerCertificateCustomValidationCallback = remoteCertificateValidationCallback;
handler.Credentials = useDefaultCredentials ?
CredentialCache.DefaultCredentials :
credentials;
if (proxy == null)
{
handler.UseProxy = false;
}
else
{
handler.Proxy = proxy;
}
if (clientCertificates?.Count > 0)
{
handler.ClientCertificates.AddRange(clientCertificates);
}
var invoker = new HttpMessageInvoker(handler);
var ws = new ClientWebSocket();
await ws.ConnectAsync(uri, invoker, cancellationToken);
WebSocket по HTTP/2
В .NET 7 появилась возможность использовать протокол WebSocket по HTTP/2 (RFC 8441). Теперь WebSocket-соединение устанавливается через один поток HTTP/2 соединения. Поэтому одно TCP-соединение может быть использовано одновременно для нескольких WebSocket-соединений и HTTP-запросов, а сеть используется эффективно.
Чтобы использовать WebSocket по HTTP/2, поставьте опцию ClientWebSocketOptions.HttpVersion
для HttpVersion.Version20
. Вы можете изменить версию HTTP с помощью свойства ClientWebSocketOptions.HttpVersionPolicy
. Параметры HttpRequestMessage.Version
и HttpRequestMessage.VersionPolicy
работают похожим образом.
Например, этот код сначала попробует установить соединение WebSocket по HTTP/2, а если не получается, то по HTTP/1.1:
var ws = new ClientWebSocket();
ws.Options.HttpVersion = HttpVersion.Version20;
ws.Options.HttpVersionPolicy = HttpVersionPolicy.RequestVersionOrLower;
await ws.ConnectAsync(uri, httpClient, cancellationToken);
Сочетание HttpVersion.Version11
и HttpVersionPolicy.RequestVersionOrHigher
будет работать, как в примере выше, пока HttpVersionPolicy.RequestVersionExact
запрещает изменение версии HTTP.
По умолчанию заданы HttpVersion = HttpVersion.Version11
и HttpVersionPolicy = HttpVersionPolicy.RequestVersionOrLower
— значит, будет использоваться только HTTP/1.1.
Мультиплексирование WebSocket-соединений и HTTP-запросов по одному HTTP/2-соединению — важнейшая часть обновления. Чтобы мультиплексирование работало правильно, вы должны передать и повторно использовать тот же объект класса HttpMessageInvoker
(например: HttpClient
) в коде, когда вызываете ConnectAsync
. Например, используйте перегрузку: ConnectAsync(Uri, HttpMessageInvoker, CancellationToken)
. Так вы уменьшите очередь в пуле соединений внутри объекта класса HttpMessageInvoker
.
Резюме
В этом материале мы рассказали о самых интересных и важных обновлениях. Полный список изменений вы можете посмотреть в архиве. Как обычно, мы опустили большинство изменений для повышения производительности в .NET 7, потому что о них рассказывает Стивен Тоуб. Мы будем рады, если вы сообщите нам об ошибках или дадите обратную связь на нашем GitHub. Вы можете найти информацию о предыдущих обновлениях по ссылкам: обновление .NET 6, обновление .NET 5.
Data Science и Machine Learning
- Профессия Data Scientist
- Профессия Data Analyst
- Курс «Математика для Data Science»
- Курс «Математика и Machine Learning для Data Science»
- Курс по Data Engineering
- Курс «Machine Learning и Deep Learning»
- Курс по Machine Learning
Python, веб-разработка
- Профессия Fullstack-разработчик на Python
- Курс «Python для веб-разработки»
- Профессия Frontend-разработчик
- Профессия Веб-разработчик
Мобильная разработка
Java и C#
- Профессия Java-разработчик
- Профессия QA-инженер на JAVA
- Профессия C#-разработчик
- Профессия Разработчик игр на Unity
От основ — в глубину
А также
Комментарии (7)
atd
25.01.2023 20:44+2⚠ Public Service Announcement ⚠
Перевод содержит фактические ошибки, читайте лучше оригинал
Aquahawk
Я относительно недавно в дотнете, но погиужаюсь в него с разных сторон, и первое что меня реально удивило(очень литературная форма) что когда я собрал сервер пример на Net.Http.Server, первое что я захотел сделать это залогировать сырые данные которые пришли из tcp и что расшифровалось из tls. Потому что меня интересовало что там реально ходит и как обрабатывается. Невозможно вообще никак легальными средствами прочитать текс http запроса который пришёл. Как это дебажить если мать его текст запроса нельзя залогировать. А у нас сотни разных реализаций http приходят, в том числе нарушающие стандарты и просто странные. И я не понимаю почиму ms старательно прячут низкоуровневые процессы от программиста. Выпячивать не надо, но почему бы не дать доступ тем, кому надо?
navferty
Практически в любой продакшн системе логировать целиком HTTP запрос/ответ - очень плохая идея, так как там могут быть конфиденциальные данные: пароли, токены доступа и так далее.
К тому же, если вы работаете на уровне TCP-пакетов, странно ожидать увидеть HTTP - это разные уровни, один запрос HTTP может быть разбит на несколько пакетов.
Aquahawk
я тот человек к торому приходят с вопросом, а почему у нас не работает и мы не понимаем что делать. И мне действительно нужно знать что на самом деле произошло и на tcp уровне и на http. Потому что видя верхушку айсберга не сказать почему он не работает
DistortNeo
У нас по этой причине приходилось изобретать велосипеды — писать свой HTTP-клиент и отдельнл прикручивать SSL. Просто сервер (который на самом деле железка, в которую не залезть никак), который отвечал на запрос, выдавал ответ с опечаткой в заголовках, из-за которой HttpClient поднимал лапки.
lair
Потому что (по опыту разработки платформ для стороннего использования) очень сложно одновременно дать доступ к низкоуровнему апи и при этом не нарушить инкапсуляцию.
web3_Venture
обычно первый вопрос у новичков в .net. Это когда пришел (мы получили) ответ, допустим с status = 200 , и ты хочешь взять из него хэдеры , ты должен это сделать с помощью грубо говоря "чтение из стрима", и тут начинаются вопросы "мы же получили ответ, откуда еще читается?" "зачем еще раз читать откуда-то" , "всё же есть - должно быть", "почему мы еще должны делать одну IO операцию?" и .тд..
И правда в большинстве языков в ответе есть всё. Но в .net всё направленно на производительность , и такие вопросы возникают из за того что люди мало понимают как работает более низкий стэк , в том числе тот же http.sys драйвер и его кэши.