[Перевод] Почему нельзя отправлять UDP-пакеты через браузер?

habralogo.jpg

Введение


В 2017 году большинство популярных веб-игр типа agar.io использует для передачи данных WebSockets через TCP. Если бы в браузерах был встроенный UDP-аналог WebSockets, то это бы сильно улучшило работу с сетями в этих играх.

Вводная информация


Работа веб-браузеров основана на протоколе HTTP (протоколе запросов и ответов без сохранения состояния). Первоначально он был предназначен для обслуживания статичных веб-страниц. HTTP работает поверх TCP, низкоуровневого протокола, гарантирующего надёжную доставку и правильный порядок передаваемых по Интернету данных.

Всё это отлично работало многие годы, но недавно веб-сайты стали более интерактивными и перестали отвечать парадигме «запрос-ответ» протокола HTTP. Для решения этой проблемы изобретены современные веб-протоколы, такие как WebSockets, WebRTC, HTTP 2.0 и QUIC, имеющие потенциал значительного улучшения интерактивности сети.

К сожалению, новый комплект стандартов веб-разработки не отвечает потребностям многопользовательских игр или слишком сложен в реализации.

Это вызывает разочарование у разработчиков игр, ведь они просто хотят иметь возможность отправлять и принимать UDP-пакеты через браузер.

Проблема


Веб создан поверх TCP, который является протоколом с сохранением порядка пакетов. Для надёжной доставки данных в нужном порядке в условиях утери пакетов TCP должен хранить самые новые данные в очереди, ожидая повторной отправки утерянных пакетов. В противном случае данные будут доставлены в неправильном порядке.

Этот принцип называется блокировкой начала очереди. Он создаёт раздражающую разработчиков и почти трагикомичную ситуацию. Самые новые данные, которые им нужны, ждут повторной пересылки старых данных, но на момент получения пересланных данных они уже устаревают и становятся бесполезными.

К сожалению, этот процесс невозможно исправить в рамках TCP, в нём все данные должны получаться безотказно и в нужном порядке. Поэтому стандартным решением для игровой индустрии в течение последних 20 лет была передача данных по UDP.

На практике это означало, что для каждой игры разрабатывался собственный протокол поверх UDP, реализующий весь необходимый функционал, и отправляющий бóльшую часть данных ненадёжным способом без сохранения порядка. Это обеспечивало максимально быструю доставку временны́х данных без ожидания повторной передачи утерянных пакетов.

А что же нужно сделать в случае с веб-играми?

Главная проблема веб-игр сегодня заключается в том, что разработчики игр не имеют возможности воспользоваться в браузере наилучшим решением, выбранным игровой индустрией. Вместо этого веб-игры отправляют игровые данные по TCP, что приводит к низкой отзывчивости.

Использование TCP совершенно необязательно, эту проблему можно было решить «по щелчку пальцев», если бы у веб-игр появилась возможность отправлять и принимать UDP-пакеты.

А что такое WebSockets?


WebSockets — это расширение протокола HTTP, модифицирующее HTTP-соединение таким образом, что данные могут передаваться в обе стороны. При этом не используется стандартный паттерн «запрос-ответ».

Такая техника позволяет элегантно решить проблему веб-сайтов, которым нужно отображать динамически изменяющийся контент, потому что после установки websocket-соединения сервер может отправлять данные в браузер без соответствующего запроса.

К сожалению, поскольку WebSockets реализован поверх TCP, данные всё равно подвержены блокировке начала очереди.

Что такое QUIC?


QUIC — это экспериментальный протокол, созданный поверх UDP и разработанный в качестве заменяющего транспортного слоя для HTTP. В настоящее время он поддерживается только в Google Chrome.

Важнейшая черта QUIC — поддержка множественных потоков данных. Клиент или сервер может неявным образом создавать новые каналы, увеличивая идентификатор канала (channel id).

Концепция каналов обеспечивает два больших преимущества:

  1. Позволяет избежать отправки запросов подтверждения подключения при каждом создании нового запроса.
  2. Устраняет блокировку начала очереди между несвязанными потоками данных.

К сожалению, хоть мы и устраняем проблему блокировки начала очереди у отдельных потоков, но она по-прежнему существует внутри каждого потока.

Что такое WebRTC?


WebRTC — это набор протоколов, обеспечивающих соединение типа «точка-точка» (peer-to-peer) между браузерами для таких областей применения, как потоковое воспроизведение аудио и видео.

Замечу, что WebRTC поддерживает канал данных, который можно настроить на «ненадёжный» режим, что позволяет осуществлять через браузер ненадёжную передачу данных без сохранения порядка.

Так почему же в современных браузерных играх 2017 года до сих пор используется WebSockets?

Причина заключается в том, что в многопользовательских играх существует тенденция перехода от передачи peer-to-peer к клиент-серверной модели. И хотя WebRTC позволяет удобно отправлять ненадёжные «беспорядочные» данные из браузера в браузер, он терпит крах, когда требуется передача данных между браузером и выделенным сервером.

Проблема возникает из-за чрезвычайной сложности WebRTC. Причины этой сложности понятны: WebRTC в первую очередь был разработан для обмена данными peer-to-peer между браузерами, поэтому для обхода NAT и передачи пакетов он в худшем случае требует поддержки STUN, ICE и TURN.

Но с точки зрения разработчиков игр вся эта сложность ложится на них мёртвым грузом, ведь STUN, ICE и TURN совершенно не нужны для обмена данными с выделенными серверами, имеющими публичные IP-адреса.

«Я чувствовал, что нам нужна UDP-версия WebSockets. Это единственное, о чём мы мечтали».
Матеус Валадарес (Matheus Valadares), создатель agar.io
Если вкратце, то разработчики игр любят простоту, и решение типа «WebSockets for UDP» привлекает их гораздо больше, чем сложность WebRTC.

Почему бы просто не разрешить отправлять UDP?


Последний вариант решения проблемы — просто позволить пользователям отправлять и получать UDP-пакеты непосредственно через браузер. Разумеется, это абсолютно ужасная идея и есть веские причины тому, почему этого никогда нельзя допустить.
  1. Веб-сайты смогли бы запускать DDoS-атаки, координируя массовую рассылку UDP-пакетов из браузеров.
  2. Появились бы новые дыры в безопасности, потому что JavaScript, выполняемый на веб-страницах, мог бы создавать вредоносные UDP-пакеты для «прощупывания» внутренней системы корпоративных сетей и передавать отчёты по HTTPS.
  3. UDP-пакеты не шифруются, поэтому атакующему очень просто организовать сниффинг и чтение всех данных, передаваемых в этих пакетах, или даже изменять их при передаче. Обеспечение возможности передачи браузерами незашифрованных пакетов стало бы огромным шагом назад в сетевой безопасности.
  4. В UDP отсутствует аутентификация, поэтому выделенный сервер, считывающий отправленные браузером пакеты, должен был бы применять собственный метод валидности подключающихся к нему пользователей. Такие трудозатраты гораздо выше тех усилий, которые разработчики игр готовы вложить в решение этой проблемы.

Итак, совершенно ясно, что JavaScript ни в коем случае не должен создавать UDP-пакеты в браузере.

Каким может быть решение?


Но что, если подойти с другого конца? Вместо попыток навести мосты от мира веба к играм мы можем начать с нужных играм техник и доработать их до решения, хорошо работающего в вебе.

Меня зовут Гленн Фидлер (Glenn Fiedler), я занимаюсь разработкой игр в течение последних 15 лет. Бóльшую часть этого времени я специализировался в сетевом программировании. Я получил огромный опыт, работая над динамичными экшн-играми. Последней игрой, над которой я работал, была Titanfall 2.

Около месяца я прочитал эту статью на Hacker News: WebRTC: будущее веб-игр.

В ней создатель agar.io Матеус Валадарес рассказывал, что WebRTC слишком сложен для него, и он продолжает использовать в своих играх WebSockets.

Я задумался: ведь наверняка должно быть более простое решение, чем WebRTC?

Мне стало интересно, как бы выглядело такое решение?

По моему мнению решение должно обладать следующими свойствами:

  1. Оно должно устанавливать соединение, чтобы его нельзя было использовать в DDoS-атаках и для поиска брешей в безопасности.
  2. Шифрование, потому что в 2017 году ни одна игра или приложение не должны отправлять незашифрованные пакеты.
  3. Аутентификация, потому что выделенные серверы должны принимать соединения только от клиентов, авторизованных в бекэнде.

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

Надеюсь, в результате мы получим в ближайшем будущем гораздо лучшую работу многопользовательских браузерных игр.

netcode.io


Решение к которому я пришёл — это netcode.io

netcode.io — простой сетевой протокол, позволяющий клиентам безопасно подключаться к выделенным серверам и обмениваться данными по UDP. Он ориентирован на подключения, шифрует и подписывает пакеты, а также обеспечивает поддержку аутентификации, чтобы к выделенным серверам могли подключаться только авторизованные клиенты.

Он предназначен для таких игр, как agar.io, которым требуется разнести игроков с основного веб-сайта на экземпляры выделенных серверов. Каждый из серверов имеет ограничение на максимальное количество игроков (в базовой реализации — до 256 игроков на экземпляр сервера).

Основная идея заключается в том, что веб-бэкенд выполняет авторизацию. Когда игрок захочет поиграть, бекэнд осуществляет вызов REST для получения токена подключения, который передаётся выделенному серверу как часть запроса подтверждения подключения по UDP.

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

netcode.io выигрывает у WebRTC в простоте. В нём применяется схема только с выделенными серверами, поэтому использовать ICE, STUN и TURN не требуется. Благодаря реализации шифрования, подписей и аутентификации с помощью libsodium он позволяет избежать сложностей полной реализации DTLS, при этом обеспечивая тот же уровень безопасности.

За прошлый месяц я создал базовую реализацию netcode.io на C. Она выпущена под лицензией BSD из трёх пунктов. За несколько месяцев я надеюсь усовершенствовать эту реализацию, написать спецификацию и поработать с другими разработчиками над портированием netcode.io на различные языки.

Как это работает


Клиент авторизируется в веб-бекэнде с помощью стандартных техник аутентификации (например, через OAuth). После авторизации клиента он отправляет запрос на начало игры, выполняя вызов REST. Вызов REST возвращает клиенту по HTTPS токен подключения, закодированный в base64.

Токен подключения состоит из двух частей:

  1. Приватная часть, зашифрованная и подписанная общим приватным ключом с помощью примитива AEAD из libsodium. Его невозможно считать, модифицировать или подделать в клиенте.
  2. Публичная часть, предоставляющая информацию, необходимую клиенту для подключения. Например, ключи шифрования для UDP-пакетов и список адресов серверов, к которым можно подключиться, а также другую информацию, относящуюся к части «связанных данных» AEAD.

Клиент считывает токен подключения и имеет список N IP-адресов, к которым можно подключиться. Поскольку N может быть равным 1, лучше всего передавать клиенту адреса нескольких серверов на случай, если ко времени попытки подключения клиента первый сервер будет уже заполнен.

При подключении к выделенному серверу клиент периодически отправляет по UDP пакет запроса на подключение. Этот пакет содержит приватные данные токена подключения, а также дополнительные данные для AEAD, например, информацию о версии netcode.io, идентификатор протокола (64-битное число, уникальное для каждой конкретной игры), временну́ю метку срока действия токена подключения и порядковый номер примитива AEAD.

Когда выделенный сервер получает по UDP запрос на подключение, он сначала с помощью примитива AEAD проверяет валидность содержимого пакета. Если какие-то публичные данные в пакете запроса на подключение были изменены, то проверка сигнатуры выдаст ошибку. Это не позволит клиентам изменять временну́ю метку срока действия токена подключения, а также позволит быстро отклонять токены с истёкшим сроком.

Если токен подключения валиден, то он расшифровывается. Внутри он содержит список адресов выделенных серверов, для которых он валиден. Это не позволяет вредоносным клиентам использовать один токен для подключения ко всем доступным серверам.

Сервер также проверяет, не был ли токен подключения уже использован, выполняя поиск краткой истории HMAC токена. Если совпадение найдено, то запрос на подключение игнорируется. Благодаря этому один токен нельзя использовать для подключения нескольких клиентов.

Кроме того, сервер допускает подключение только одного клиента с одним IP-адресом и портом в любой момент времени. Также одновременно к серверу может быть подключен только один клиент по уникальному client id. Идентификатор client id — это 64-битное целое число, уникальным образом идентифицирующее клиента, авторизованного веб-бекэндом.

Если срок действия токена подключения не истёк, он дешифруется. Если публичный IP-адрес выделенного сервера находится в списке адресов серверов и все остальные проверки выполнены успешно, то выделенный сервер устанавливает соответствие между IP-адресом клиента и ключами шифрования, содержащимися в приватных данных токена подключения.

С этого момента все пакеты, передаваемые между клиентом и сервером, шифруются этими ключами. Если в течение короткого промежутка времени (например, пяти секунд) UDP-пакеты от адреса не поступают, то связка адреса и ключей шифрования становится недействительной.

Затем сервер проверяет, есть ли на сервере место для клиента. Каждый сервер поддерживает определённый максимум клиентов. Например, в игре на 64 игроков будет 64 места для подключения клиентов. Если сервер заполнен, он отвечает пакетом отказа на запрос подключения. Это позволяет клиентам быстро узнавать о том, что сервер заполнен и нужно переместиться на следующий сервер в списке.

Если на сервере есть место для клиента, то сервер не предоставляет это место сразу. Вместо этого он хранит адрес + HMAC токена подключения клиента как потенциального клиента. Затем сервер отвечает пакетом вызова подключения, содержащим токен вызова. Токен вызова — это блок данных, зашифрованных случайным ключом. Ключ выпускается в момент запуска сервера.

Рандомизация ключа гарантирует отсутствие проблем с безопасностью, возникающих при шифровании токенов вызова нескольких серверов одним порядковым числом (серверы не координируются друг с другом). Кроме того, пакет вызова подключения значительно меньше пакета запроса на подключение, что позволяет избежать использования протокола для DDoS-атак типа «усиление».

Клиент получает пакет вызова подключения через UDP и переключается в состояние, в котором он отправляет серверу пакеты ответов на подключение. Пакеты ответов на подключение просто пересылают токен вызова обратно на выделенный сервер, подтверждая таким образом, что клиент действительно может получать пакеты на исходный IP-адрес, с которого, как он сообщал, передавались пакеты. Это позволяет избежать спуфинга исходных адресов пакетов.

Когда сервер получает пакет ответа на подключение, он ищет соответствующую запись об ожидающем клиенте, и если она существует, он снова выполняет поиск места для подключения клиента. Если свободных мест нет, он отвечает пакетом отказа на запрос подключения, потому что место, бывшее свободным на момент первого получения запроса на подключение, уже занято.

В противном случае сервер назначает клиенту свободное место на сервере и отвечает пакетом поддержки подключения, который сообщает клиенту, что ему выделено место на сервере. Такое место называется индексом клиента. В многопользовательских играх он обычно используется для идентификации клиентов, подключённых к серверу. Например, клиенты 0, 1, 2, 3 в игре на четырёх игроков соответствуют игрокам 1, 2, 3 и 4.

Теперь сервер считает, что клиент подключен и что ему можно отправлять пакеты полезной нагрузки. В этих пакетах содержатся данные, относящиеся к игре. Пакеты доставляются без сохранения порядка. Единственный недостаток такого способа в том, что поскольку клиент перед тем, как получить индекс клиента и убедиться в полном подключении, он должен сначала получить пакет поддержки подключения, а сервер отслеживает, подтверждён ли клиент, проверяя место для каждого клиента.

Флаг подтверждения для каждого клиента изначально имеет значение false и становится true, когда сервер получает от клиента пакет поддержки подключения или пакет полезной нагрузки. Пока клиент не подтверждён, при каждой отправке пакета полезной нагрузки этому клиенту предварительно отправляется и пакет поддержки соединения. Это гарантирует статистическую вероятность того, что клиент знает свой индекс и будет полностью подключён до получения первого пакета полезной нагрузки, что минимизирует количество циклов установки подключения.

После того, как подключение клиента и сервера полностью выполнено, они могут обмениваться UDP-пакетами в обоих направлениях. Обычно игровые протоколы отправляют введённую игроком информацию от клиента к серверу с большой скоростью, например, 60 раз в секунду, а состояние мира от сервера к клиенту немного реже, например, 20 раз в секунду. Однако в самых современных AAA-играх скорость обновления данных сервера увеличена.

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

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

Заключение


В популярных веб-играх типа agar.io передача данных осуществляется через WebSockets поверх TCP, поскольку в контексте клиент-серверной структуры с выделенными серверами WebRTC использовать сложно.

Один из вариантов решений для Google — сделать интеграцию поддержки каналов данных WebRTC для выделенных серверов гораздо более простой для разработчиков игр.

Или же можно использовать netcode.io, применяющий намного более простое решение типа «WebSockets для UDP». Если стандартизировать его и встроить в браузеры, это тоже может решить проблему.


Гленн Фидлер (Glenn Fiedler) — основатель и президент The Network Protocol Company. Он предоставляет услуги по настройке сетевой части игр. До основания компании Гленн был ведущим программистом Respawn Entertainment, где работал над Titanfall 1 и 2.

Гленн также является автором нескольких популярных циклов статей на gafferongames.com о сетевой передаче данных и физике в играх. Фидлер создал сетевые библиотеки libyojimbo и netcode.io с открытым исходным кодом.

Комментарии (1)

  • 28 февраля 2017 в 13:41

    0

    UDP-пакеты не шифруются

    В UDP отсутствует аутентификация

    Может, я чего-то не понимаю, но разве шифрование и аутентификация есть в TCP?

© Habrahabr.ru