Как стать автором
Обновить
112.7
Ростелеком
Крупнейший провайдер цифровых услуг и решений

Не только для «обхода». Что такое Http Upgrade и как его использовать в .NET

Уровень сложностиСредний
Время на прочтение8 мин
Количество просмотров3.4K

Некоторое время назад на Хабре вышел обзор того-о-чём-не-стоит-говорить-в-этом-блоге. И, в числе прочих способов того-о-чём-не-стоит-говорить, был упомянут «транспорт HttpUpgrade».

Так как я использую заголовок Upgrade на практике, меня смутило, что в поиске в русскоязычном сегменте интернета найти корректное описание механизма непросто. Конечно, метод является достаточно редко используемым «вчистую» (то есть не в паре с протоколом WebSocket), но тем не менее.

Статья может пригодиться всем, кому требуется создать полнодуплексное tcp-соединение (то есть такое соединение, в котором и читать данные, и писать их могут и клиент, и сервер, причем в любой момент времени жизни соединения).

Я расскажу о том, что есть «HttpUpgrade», как наш ЦК использует его для создания соединения, по которому клиент и сервер могут обмениваться пакетами: клиент запрашивает видео, а сервер передаёт клиенту пакеты. Ну и разумеется, поделюсь кодом клиента и сервера, создающим между собой «апгрейднутое» соединение в .NET 8.

Меня зовут Андриевский Леонид, я тимлид команды .NET в ЦК Видеонаблюдения Ростелеком. Давайте приступим!

Что на самом деле представляет собой «HttpUpgrade»

Cтандарт HTTP/1.1 (опубликованный ещё в 1999 году) в секции 14.42 содержит описание заголовка (request header) Upgrade. Данный заголовок указывается клиентом в запросе, например, так:

GET /index.html HTTP/1.1
Host: camera.rt.ru
Connection: Upgrade
Upgrade: example/1, foo/2

Заголовок Upgrade предназначен для указания серверу, что с момента ответа сервера на запрос клиент хочет сменить протокол с HTTP на один из протоколов, перечисленных в значении поля Upgrade. В вышеописанном примере серверу предлагается выбор: переключиться либо на протокол example/1, либо на foo/2.

И на подобный запрос у сервера есть три варианта ответа:

  • В самом плохом случае – сервер может ответить кодом ошибки, скажем 400 Bad request (если в запросе, с точки зрения сервера, есть ошибка).

  • Также сервер может вежливо отказаться от предложенной смены протокола, отправив на запрос ответ 200 OK, после чего отдав тело ответа согласно обычным правилам протокола http.

  • Ну и самый хороший для нас вариант – сервер может согласиться на смену протокола; тогда он отвечает 101 Switching Protocols, с указанием в ответе какой из протоколов, предложенных клиентом, он выбрал (в примере ниже это протокол example/1):

HTTP/1.1 101 Switching Protocols
Upgrade: example/1
Connection: Upgrade

В результате, после чтения из соединения ответа от сервера, сервер и клиент будут иметь уже установленный друг между другом канал связи, правила «общения» по которому задаются уже не протоколом HTTP 1.1, а тем протоколом, который был выбран при обмене запросом и ответом между сторонами.

То есть по сути своей «HttpUpgrade» – это просто древний способ переключения уже созданного tcp-соединения с режима «клиент и сервер общаются по протоколу HTTP» на «голое» полнодуплексное (когда и клиент, и сервер могут и читать, и писать данные) соединение.

А есть ли другие способы создания двухстороннего соединения?

Разумеется, в зависимости от поставленных задач двухстороннее соединение может быть реализовано:

  • через «голую» работу с сокетами (без всякого Http Upgrade),

  • через WebSocket, который как раз и создаёт двухстороннее соединение на основе Upgrade-а http/https-соединения, но даёт дополнительный оверхед, так как оборачивает передаваемые данные в свои пакеты (подробнее о структуре пакетов WebSocket),

  • через WebRTC (преимущественно используется браузерами – и часто «под капотом» использует WebSocket),

  • через WebTransport (достаточно новая «тема» на основе протокола HTTP/3); кстати очень неплохо освещённая на хабре,

  • используя только возможности HTTP 1.1; Http Long Polling или есть же http-запрос с бесконечным таймаутом (тут ссылку вставить нет возможности, подскажу только гугл по словам Twitter Streaming API).

Для наших рабочих задач требовалось передавать видеопоток с камеры клиенту – и одновременно от клиента передавать команды по изменению параметров этого видеопотока.

Поэтому после анализа доступных возможностей, команда разработки и приняла решение использовать «чистый» Http Upgrade, так как:

  • Решение принималось лет 10 назад. В то время HTTP/2 только появлялся, да и стабильный релиз WebRTC ещё не выпустили.

  • Для реализации на основе Http Upgrade требуются только сервер и клиент, реализующие HTTP/1.1. Сервером (в нашем случае) выступает камера. А почти на всех камерах, даже самых слабых, какой-никакой http-сервер всегда присутствует (так как у камер есть web-интерфейс).

  • Не требуется «плясок с бубном», если в локальной сети заказчика имеются системы анализа траффика, прокси или иные хитрости. Например, некоторые клиенты осуществляют доступ в интернет в корпоративных сетях через специальным образом настроенные http-прокси. А .NET-овский TcpClient, да и другие tcp-клиенты, не умеют сами работать с http-проксями. И это вполне логично: TCP – протокол более низкого уровня, чем HTTP.

Альтернативы Http Upgrade на основе HTTP 1.1 не очень подошли:

  • Http-запрос с бесконечным таймаутом это по сути обычное http-соединение, оно не обеспечивает дуплексной связи (чтобы и сервер, и клиент в любой момент времени могли передать данные),

  • Http Long Polling может обеспечить полудуплексную связь (данные по очереди будут передаваться и сервером, и клиентом), но для передачи от клиента к серверу придётся пересоздавать соединение, что может происходить не очень быстро при наличии прокси-сервера.

Время практики

Итак, давайте напишем код сервера и клиента на C# (.NET 8), между которыми создаётся полнодуплексное http-соединение.

  • Для пробного «общения» будем передавать текстовые сообщения в кодировке UTF8, разделённые переводом строки (\r\n). Назовём этот придуманный протокол rawstring.

  • Клиент будет отправлять сообщения – а сервер принимать и дублировать их же клиенту обратно.

  • Завершение «общения» между клиентом и сервером – сообщение с текстом BYE. На него сервер реагирует закрытием сетевого соединения с клиентом.

Код сервера

Тут не буду вдаваться в подробности сильно, так как код сервера вполне можно реализовать на основе «голых» TCP-сокетов и далее «скрыть» реализацию за, скажем, nginx-ом.

По крайней мере аналогичный пример тестового самописного WebSocket-сервера приводят товарищи из Mozilla – а специалистам, делающим свой браузер, думаю, можно верить.

Наш тестовый сервер будет уметь:

  1. слушать порт 8080 через TcpListener и принимать подключение очередного клиента;

  2. парсить запрос от клиента по существующему соединению:

    • если это HTTP GET запрос, то ожидать в нём заголовок Upgrade с указанием в качестве протокола rawstring,

    • если такой заголовок в запросе имеется получен – дальнейшие сообщения обрабатывать уже в соответствии с протоколом (пересылать обратно клиенту всё, что он отправил серверу);

  3. если очередной командой оказывается BYE – завершить соединение и переходить снова к пункту 1 данного списка.

Уверен, что с использованием ASP Net Core вполне можно написать более красивый код – но для демонстрации работы и такой код должен подойти.

Код сервера
// На основе https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_server
static async Task RunServerAsync(string ip, int port)
{
	// простой сервер, переключающий единственного клиента
    // между протоколами HTTP 1.1 и rawstring с использованием заголовка Http Upgrade
    // и при работе по протоколу rawstring отправляющий все сообщения от клиента обратно клиенту
	using var server = new TcpListener(IPAddress.Parse(ip), port);

	server.Start();

	Console.WriteLine("Server has started on {0}:{1}, Waiting for a connection…", ip, port);

	while (true)
	{
		using var client = server.AcceptTcpClient();

		Console.WriteLine(new string('-', 25));
		Console.WriteLine("New client has connected!");

		using var stream = client.GetStream();

		while (stream.Socket.Connected)
		{
			var request = await WaitForHttpRequestAsync(client, stream);

			if (Regex.IsMatch(request, "^GET", RegexOptions.IgnoreCase))
			{
				Console.WriteLine("HTTP: Handshaking from client!");

				var result = await SwitchProtocolsAsync(stream, request);
				if (!result)
				{
					Console.WriteLine("Handshake failed!");
					break;
				}
				else
					Console.WriteLine($"Handshake success! Switched to {ProtocolName}");
			}
			else
			{
				Console.WriteLine($"{ProtocolName}: got new request {request.Length} bytes");
				await WriteHttpResponseAsync(stream, request);

				if (request.Split("\r\n", StringSplitOptions.RemoveEmptyEntries).Any(line => line == "BYE"))
				{
					Console.WriteLine("Got BYE command. Terminating");
					break;
				}
			}
		}
	}
}

private static async Task<bool> SwitchProtocolsAsync(NetworkStream stream, string request)
{
	var protocolToSwitch = Regex.Match(request, "Upgrade: (.*)").Groups[1].Value.Trim();

	// HTTP/1.1 определяет перенос строки как CR LF
	if (protocolToSwitch == ProtocolName)
	{
		await WriteHttpResponseAsync(stream,
			"HTTP/1.1 101 Switching Protocols\r\n" +
			"Connection: Upgrade\r\n" +
			$"Upgrade: {ProtocolName}\r\n\r\n");
		return true;
	}
	else
	{
		await WriteHttpResponseAsync(stream, "HTTP/1.1 400 Bad request\r\n\r\n");
		return false;
	}
}

private static async Task WriteHttpResponseAsync(NetworkStream stream, string response)
{
	var responseBytes = Encoding.UTF8.GetBytes(response);
	await stream.WriteAsync(responseBytes, 0, response.Length);
}

private static async Task<string> WaitForHttpRequestAsync(TcpClient client, NetworkStream stream)
{
	while (!stream.DataAvailable)
		await Task.Delay(1000);

	// Ждём, пока в буфере накопится минимум 3 байта (там ожидается "get")
	while (client.Available < 3)
		await Task.Delay(1000);

	var bytes = new byte[client.Available];
	stream.Read(bytes, 0, bytes.Length);
	var request = Encoding.UTF8.GetString(bytes);

	return request;
}

Ссылка на репозиторий с полным кодом сервера.

Код клиента: используем HttpClient (.NET 5+)

Если ваш проект использует .NET 5+ и HttpClient – вам очень повезло. Вы сможете использовать Http Upgrade добавив буквально несколько строк при формировании HttpRequestMessage:

// Прямое создание HttpClient тут только для примера! В production используйте HttpClientFactory!
using var client = new HttpClient();

// url: url сервера, к которому требуется подключиться
var message = new HttpRequestMessage(HttpMethod.Get, url); 

message.Headers.TryAddWithoutValidation("Connection", "Upgrade");
message.Headers.TryAddWithoutValidation("Upgrade", "rawstring");

var response = await client.SendAsync(message, HttpCompletionOption.ResponseHeadersRead);

После получения ответа вам надо проверить код ответа сервера (response.StatusCode) чтобы понять:

  • согласен ли сервер на upgrade протокола (код 101),

  • не согласен (код 200),

  • или вспоминает известный мем, начинающийся с «вы кто такие, я вас не звал» (ответ 400).

Полученный из объекта response поток при положительном исходе (101) будет иметь признак возможности записи – и далее можно работать с ним уже по вашему кастомному протоколу.

Полный код тестового приложения-клиента, подключающегося через HttpClient к написанному серверу по «rawstring» выложен на нашем gitlab.

Код клиента: используем WebRequest (.NET 5+)

К сожалению, далеко не всем нам везёт работать с актуальным кодом. Если вдруг ваш код по любым причинам работает с объектами WebRequest в .NET 5+, у вас ещё есть шанс создать upgrade-нутое соединение – правда, придётся обойти одну тонкость.

Дело в том, что в коде класса HttpWebRequest.cs в строке 1192 прописано, что следует кидать WebException при ответах меньше 200. То есть корректно сформировав заголовок и отправив запрос, на ответ сервера 101 .NET Framework тупо кинет WebException. Поэтому мы вынуждены обернуть вызов GetResponse в try-catch:

var request = (HttpWebRequest)WebRequest.Create(url);

request.Connection = "Upgrade";
request.Headers["Upgrade"] = Program.ProtocolName;
request.AllowAutoRedirect = true;
request.Method = "GET";
request.Accept = "*/*";
request.Proxy = WebRequest.DefaultWebProxy;

try
{
	response = await request.GetResponseAsync();
}
catch (WebException e) when (e.Response is HttpWebResponse exResponse &&
							 exResponse.StatusCode == HttpStatusCode.SwitchingProtocols)
{
	response = e.Response;
}

Далее анализируем полученный HttpWebResponse аналогично предыдущему пункту.

Ссылка на полный код приложения-клиента, подключающегося к серверу через WebRequest.

Код клиента: используем WebRequest (.NET Framework)

А вот тут, товарищи, без рефлексии никуда. Дело в том, что в .NET Framework почему-то не подумали про заголовок Upgrade отдельно от WebSocket. То есть передать вы его можете – но поток вам всегда будет возвращаться такой, в который нельзя ничего записать.

Возможно так случилось потому, что в .NET Framework сам WebSocket полностью не реализован. В частности, до выхода .NET 5, в Windows 7 у класса WebSocket не было реализации (sic!).

К счастью, есть грязный хак. У объекта HttpWebRequest есть internal-конструктор с параметром isWebSocketRequest. И, если вы создадите экземпляр HttpWebRequest с данным флагом, то возвращаемый при ответе объект Stream можно будет писать:

// переменная uri должна быть типа System.Uri
var request = CreateInstance<HttpWebRequest>(uri, null, true, "RawStringGroupName" + Guid.NewGuid());

request.Connection = "Upgrade";
request.Headers["Upgrade"] = "rawstring";

// далее выполняем запрос с созданным объектом request

В остальном код не отличается от кода из предыдущего пункта.

Полезные ссылки по теме

На этом у меня всё, спасибо за внимание!

Напишите в комментариях, какие необычные способы взаимодействия между сервером и клиентом вы используете в production-среде?

Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
Всего голосов 12: ↑12 и ↓0+18
Комментарии0

Публикации

Информация

Сайт
www.company.rt.ru
Дата регистрации
Дата основания
Численность
свыше 10 000 человек
Местоположение
Россия
Представитель
Vatuhaa