Как направить udp пакеты в интернет
Перейти к содержимому

Как направить udp пакеты в интернет

Полное руководство по сетевому программированию для разработчиков игр. Часть 3. UDP

Да! Раньше наш сокет был сухой и безжизненный, теперь мы заставим его шевелиться! В этой части мы (НАКОНЕЦ-ТО. ) узнаем, как отсылать и принимать данные (пока только по протоколу UDP).

Одноранговый обмен данными

Логически правильно начинать с простого и переходить к более сложному, поэтому мы начнем с UDP. Это, однако, вовсе не означает, что UDP менее важен, чем TCP, вовсе нет. Все зависит от того, что за игру ты хочешь сделать. Для некоторых игр/жанров больше подходит UDP, для других — TCP. Главное сделать правильный выбор ("И почему я не послал его подальше и не взял синюю таблетку?! Расскажи он мне тогда все полностью — я бы засунул красную пилюлю ему в задницу!!" (с) Warner Bros — Матрица).

UDP. Что такое "одноранговая передача данных"? Это когда взаимодействуют компьютеры, которые имеют одинаковые права по управлению обменом данными и одинаковые приоритеты (peer-to-peer — "равный равному", не путать с point-to-point protocol). Взаимодействие по протоколу UDP можно сравнить с отправкой и приемом почты. Отправив пакет, остановить его движение нельзя. Как уже было сказано, этот протокол не гарантирует доставку данных, то есть, отправляя данные, надо иметь ввиду, что адресат может их и не получить. Что самое привлекательное в UDP, так это то, что на обоих концах маршрута низкоуровневый код обмена данными одинаков (здесь мы пока еще не рассматриваем UDP по схеме "клиент-сервер").

Общий алгоритм сетевого приложения, работающего через UDP выглядит так:

1. Инициализируем (если надо) необходимые библиотеки и создаем сокет. В качестве второго параметра сокета надо передать константу SOCK_DGRAM — мы используем дэйтаграммы.

2. Если необходимо, привязываем сокет к определенному адресу и порту.

3. Отправляем/принимаем пакеты

4. Закрываем сокет и производим очистку

Допустим, нам надо, чтобы один peer передал другому две строки текста (две последовательности байт) – "AA" и "BBBB". А другой peer передал первому другие две строки – "CCC" и "DD". Вот, как это выглядит на схеме:

Обратите внимание, на то, что происходит с пакетами в Интернет. Как видим, каждая строка передается в своем собственном "конверте"-пакете. То есть они вполне самостоятельны и идут по сети отдельно друг от друга. Все логично.

Первый, второй и четвертый пункты нам уже ясны, и мы знаем как произвести соответствующие действия.

Для того чтобы отправить письмо, надо его написать и заполнить необходимые поля на конверте. Причем надо указать адрес места назначения и имя получателя. Применительно к сокетам это выглядит так: письмо — это наши данные, адрес — это IP адрес получателя, имя — это порт который прослушивает получатель на наличие входящих данных. Мы уже знаем, что адреса и порты у нас хранятся в адресных структурах. Однако наша обертка для адресных структур еще не завершена, мы должны добавить в нее возможность смены порта и адреса после вызова конструктора.

Вот как выглядят соответствующие методы:

Для чего нам эти методы? Щас узнаем! 🙂 Дело в том, что они позволяют нам использовать одну и ту же адресную структуру для отправки дэйтаграмм разным адресатам, мы просто задаем нужные порт и адрес — и отправляем. Это позволяет нам избежать ненужных операций выделения/освобождения памяти.

Чтобы отправить кому-то дэйтаграмму надо знать адрес получателя. Для этого с получателем либо "договориться заранее" или отправителю должен быть известен адрес постоянного проживания. Если на компьютере получателя несколько сетевых интерфейсов – то он должен при помощи bind() выбрать один из них, иначе при автоматической привязке ему будет назначен какой-то из адресов, по которому пришла первая дэйтаграмма.

Вот так выглядит код для отправки сообщений по протоколу UDP на языке С:

Теперь посмотрим, что к чему. Первый параметр — это дескриптор сокета, через который надо отправлять данные. Следом идет указатель на последовательность байтов (второй параметр), которые составляют тело сообщения (наше письмо другому компьютеру). Дальше нам надо указать длину нашего сообщения (третий параметр). Затем идет число, которое определяет, каким образом система будет обслуживать данную дэйтаграмму (в большинстве случаев нас устроит способ обслуживания по умолчанию — значение 0). Как видно, четвертый параметр формируется из набора флагов, которые мы рассмотрим далее. Пятый параметр — это адресная структура, содержащая адрес и порт получателя дэйтаграммы. И, наконец, шестой параметр (tolen) — это длина указанной адресной структуры (мы уже знаем, что адресные структуры бывают разные, и, соответственно размер у них тоже варьируется). В качестве шестого параметра мы должны указать sizeof(struct sockaddr_in) (равно 16-ти на архитектуре Intel x86). Для чего указывать размер структуры? Очень просто — эта функция должна работать со всеми уровнями модели OSI, поэтому она не знает, с каким семейством адресов имеет дело — мы должны ей сказать об этом.

sendto возвращает количество отосланных байт. "Как?! Разве она не отсылает данные целиком и полностью?!" — спросишь ты. Ответ: да, этот вызов по отношению к дэйтаграммам всегда возвращает число, равное указанной в третьем параметре длине сообщения. В самом простом случае (который мы сейчас рассматриваем) функция sendto() возвратит либо длину отосланного сообщения, либо -1 (*nix) или SOCKET_ERROR (Windows) в случае ошибки при отсылке (напомню, что -1 и SOCKET_ERROR — одно и то же, но константу SOCKET_ERROR определяют разработчики winsock, и мы не будем этому противиться). Также возможно указать 0 в качестве длины сообщения, тогда и возврат будет равен нулю, что абсолютно легально для операционной системы. Но какой смысл в этом? Смысл, оказывается, все-таки есть, причем он вовсе не тривиален, поэтому мы отложим это на потом.

Для того, чтобы понять, что же все-таки делает sendto, мы вспомним, что когда мы создали сокет, система выделила место для буфера исходящих сообщений (пакетов). Функция sendto на самом деле ничего не отсылает, она лишь только помещает сообщение в вышеуказанный буфер. Дальнейшая судьба сообщения полностью зависит от системы. Размер буфера тоже определяется системой, исходя из пропускной способности интерфейса (сетевой карты), размера свободной памяти и других факторов. Мы имеем шанс получить на выходе из sendto -1, когда в буфере исходящих сообщений недостаточно места для помещения нового сообщения в очередь отправки. В этом случае надо проверить код ошибки, и в зависимости от него повторить попытку позднее.

Ранее мы говорили о том, что сокету вовсе не обязательно назначать "имя", система сама осуществит привязку при первом обращении к сокету. Если мы до сих пор не вызвали bind(), то во время вызова sendto() система выберет подходящий интерфейс (подходящий адрес с точки зрения оптимального маршрута сообщения, исходя из адреса получателя, указанного в sendto) и любой доступный порт из списка эфемерных (временных). Узнать порт и адрес, к которым привязан сокет можно при помощи функции getsockname().

Выглядит она так:

s — это дескриптор сокета, чье "имя" нам надо узнать. name — указатель на адресную структуру, куда будут записаны все данные об "имени" сокета. namelen — это длина структуры, которая была записана в name. После этого мы можем использовать поля этой структуры, чтоб узнать порт и адрес нашего сокета.

Все очень просто:

Итак, теперь мы знаем, что успешный вызов sendto вовсе не означает успешную доставку сообщения адресату. Мы просто помещаем его в исходящую очередь. С этого момента мы уже не можем повлиять на его судьбу (вернее, можем, но пока еще не умеем :)) После этого система берет на себя обязательство по дальнейшему распоряжению отправкой дэйтаграммы. После отправки (после того, как сообщение покинет компьютер отправителя) сообщение будет зависеть от промежуточных компьютеров, до тех пор, пока не достигнет адресата.

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

В 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-адреса.

Если вкратце, то разработчики игр любят простоту, и решение типа «WebSockets for UDP» привлекает их гораздо больше, чем сложность WebRTC.

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

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

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

При подключении к выделенному серверу клиент периодически отправляет по 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.

Подключение микроконтроллера к локальной сети: UDP-клиент

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

Краткое содержание:

  • Введение в роутинг
  • ARP-ресолвер
  • Отправка пакетов
  • Пример работы со стеком
  • Заключение
Маршрутизация в Internet

Немножко теории. Рассмотрим основы роутинга в Internet. Думаю, большинство читателей могут эту часть спокойно пропустить)

Итак, возьмём, для примера, мою домашнюю сеть.

Домашняя сеть

Допустим, комп знает IP-адрес девайса и хочет отправить ему пакет. При этом происходит следующее:

  • Комп убеждается, что девайс находится в той же локальной сети, что и он сам.
  • С помощью ARP, комп определяет MAC-адрес девайса.
  • Комп заворачивает IP-пакет в Ethernet-фрейм и отрпавляет на MAC-адрес девайса.
  • Девайс получает фрейм, видит в нём IP-пакет, в котором в качестве адреса получателя указан его собственный IP-адрес.
  • Полученный IP-пакет передаётся протоколу транспортного уровня (UDP, etc.) и, затем, приложению.

IP-адрес и маска подсети

Для выделения адреса подсети из IP-адреса служит маска подсети. Накладываем на IP-адрес маску и получаем адрес подсети.

В моей домашней сетке маска равна 255.255.255.0. Соответственно, адрес подсети равен 192.168.0.0, а допустимые IP-адреса — от 192.168.0.1 до 192.168.0.254 (первый и последний адреса зарезервированы). Видя, что адрес девайса относится к этому промежутку, комп и понимает — девайс находится с ним в одной локальной сети.

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

Пример сети

Запись вроде 192.168.3.0/24 означает подсеть с адресом 192.168.3.0 и маской 255.255.255.0 (24 бита установлено).

Допустим, 192.168.0.33 хочет отправить пакет узлу 192.168.3.3, находящемуся в сети 192.168.3.0. Происходит следующее:

  • 192.168.0.33 записывает в IP пакет адрес отправителя 192.168.0.33 (свой), а адрес получателя — 192.168.3.3. Ничего необычного.
  • Заворачивает пакет в Ethernet-фрейм и отправляет его на MAC-адрес узла 192.168.0.22.
  • 192.168.0.22 получает фрейм, видит в нём пакет, предназначеный 192.168.3.3. Поскольку он подключен к обоим локальным сетям, он просто пересылает пакет узлу 192.168.3.3.

Таблица роутов

Когда узел хочет отправить пакет в другую сеть, он просматривает свою таблицу роутов. В ней он находит адрес узла (гейт), на который нужно переслать пакет, чтобы он попал в нужную сеть. Последняя запись — роут по-умолчанию (default route). Она определяет основной гейт (default gateway) — узел, на который пересылаются все пакеты, роут для которых не прописан в явном виде.

Вернёмся к нашей сети. Все пакеты, выходящие за пределы сети проходят через роутер (192.168.0.1), он-то и является основным гейтом. Дополнительные роуты нам прописывать ни к чему, для отправки пакетов наружу достаточно знать адрес основного гейта.

Собирая вместе всё вышесказанное, можно отметить: для полноценной работы наш девайс должен знать три вещи — свой IP-адрес, маску подсети и IP-адрес основного гейта. Маска подсети используется, чтобы определять, относится ли определённый IP-адрес к нашей локальной сети. Все пакеты, выходящие за пределы локальной сети мы пересылаем основному гейту.

ARP-ресолвер

В предыдущей части мы научились отвечать на ARP-запросы. Чтобы пересылать пакеты другим узлам, нам понадобится также написать ARP-ресолвер.

Алгоритм работы нашего ARP-ресолвера будет следующий:

  • Когда нам нужно определить MAC-адрес узла, ищем его в ARP-кэше по IP-адресу.
  • Если узел найден в кэше, просто возвращаем его MAC-адрес.
  • Если узел в кэше не найден, посылаем широковещательный ARP-запрос, чтобы найти нужный узел.
  • Получив ответ на наш запрос, добавляем узел в ARP-кэш.

Проверять валидность записей кэша мы не будем — врядли MAC-адрес какого-либо узла внезапно изменится.

Отправка пакетов

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

Алгоритм отправки IP-пакета будет простой:

  • Определяем IP-адрес узла, на который будем отправлять Etheret-фрейм, содержащий пакет. Если пакет пересылается в пределах локальной сети, сразу посылаем его нужному узлу. Иначе будем отправлять фрейм основному гейту.
  • Ресолвим MAC-адрес узла.
  • Заворачиваем IP-пакет в Ethernet-фрейм и посылаем.

Ну и отправка UDP-пакета.

Пишем приложение

Чисто чтобы потестить наш улучшенный стек, попробуем получить какую-нибудь информацию из интернета. Например, точное время по NTP.

NTP реализуем самым тупым способом — отрпавляем серверу запрос, получаем ответ с точным временем. Для правильной работы NTP, локальный UDP-порт не должен равняться UDP-порту сервера (123). Также, нужно учитывать, что NTP возвращает неправильный timestamp — количество секунд, прошедших с 1 января 1900 года. Чтобы получить нормальный timestamp, считающийся с 1 января 1970, года, нужно отнять от NTP-timestamp’а ровно 2208988800 секунд.

С помощью NTP, будем запрашивать время каждые 12 часов.

Потестируем то, что получилось.

Заключение

Скачать проект можно тут.

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

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *