Бытует мнение, что неткод Godot Engine 4 подходит только для создания простеньких локальных мультиплееров, как дополнительная фишка в игре. В общем, если посмотреть на соответствующую документацию к Godot Engine, станет очевиден источник мнения.
Более того, большинство обучающих материалов, которые встречал я, и которые вероятно встретите вы, будут почти дословно повторять документацию (пример по первой ссылке в гугле). Это что-то на уровне открытия сокета. А теперь напиши бекэнд высоконагруженного сервиса.
На почве этого родилось большое количество различных SaaS, которые предлагают взять на себя всякие базовые вещи, вроде лидербордов, БД, лобби и авторизации (раз, два). Не за бесплатно, разумеется.
Отсюда и родилась сия статья: я хочу показать, как не просто подключить два процесса друг к другу и научиться кидать RPC-шки, а как в рамках исключительно Godot Engine построить самодостаточную серверную инфраструктуру.
Дисклеймер
Прошу во время чтения держать в голове следующее: приведенный код не проходил полноценный цикл тестирования, аудит безопасности, не изучался на предмет производительности и прочее. Использовать его в реальных продуктах НЕЛЬЗЯ. Сетевые приложения — очень опасная вещь.
Также этот проект не является полноценным фреймворком и многие вещи в нем по-прежнему требуют ручной реализации. Воспринимайте этот код как идею, которую каждая команда может интерпретировать по своему и развивать для своего продукта. У меня, банально, не стояла задача полноценной продакшн реализации.
Прошу заметить, что я не описываю здесь базовые понятия из мира сетевого программирования и ожидаю, что читать хотя бы немного ориентируется в этой теме и знаком с такими понятиями, как сокет, IP адрес, порт, TCP/IP, TCP, UDP, сертификат, шифрование, токен. Без этого может быть сложно.
Чтобы не повторяться, в рамках этой статьи я не буду описывать фишки высокоуровневого мультиплеера в Godot Engine 4. Если вы с ним не знакомы, прочите документацию и попробуйте сами наваять чего-нибудь простенькое. Более того, будет очень полезно познакомиться с некоторыми концепциями низкоуровневого мультиплеера. Для работы я использовал Godot Engine 4.6.
Зачем?
Да, Godot Engine отлично реализует общение процессов между собой. Более того, он позволяет делать это большим количеством способов: хоть практически напрямую используйте сокеты, хоть переопределяйте стандартные интерфейсы и внедряйте свой код в базовый неткод движка. Однако на этом, в общем то, все. Более того, многие возможности просто не освещены ни в документации, ни в обучающих материалах. А жаль.
В онлайн приложениях, все таки, должен быть сопровождающий бизнес-логику код (для безопасности, удобства пользователя и масштабируемости). Именно такой код я и называю инфраструктурой, среди которого наиболее вероятно окажется: реализация аутентификации и авторизации, хранения данных, управления серверами.
Немного спойлеров: в Godot Engine в высокоуровневом мультиплеере есть авторизация, однако это не совсем та авторизация, о которой вы, скорее всего, подумали, но она сильно упростит нам жизнь. А еще можно получать статистику о пинге и потере пакетов прямо из коробки. Обо всем по порядку, и начнем с самых-самых дальних уголков.
В коробке чего-то не хватает
Как по мне, главной проблемой высокоуровневого мультиплеера Godot Engine является сложность реализации банального обмена данными в формате «Вопрос-ответ». Даже до реализации непосредственно сетевого взаимодействия начинаются сложности в виде создания функций с меткой @rpc для каждого такого взаимодействия. Более того, таких функций нужно две: одна для вопроса (которую клиент будет вызывать на сервере), вторая — для ответа (которую сервер будет вызывать на клиенте).
Написание этих функций лишь малая часть проблемы. Затем нужно создать и правильно сконфигурировать объекты пиров и следить за подключением и путями к нодам в дереве! Это я уже молчу про то, что высокоуровневое подключение не особо то и дешевое. Становится очевидно, что использовать для таких целей высокоуровневый мультиплеер нехорошо. Благо Godot Engine 4 предоставляет низкоуровневые сетевые абстракции, которые, по сути, представляют из себя работу с сокетами в неблокирующем стиле (polling), и из них можно сделать что угодно.
Лично мне первое, что приходит в голову, когда говорят о вопрос-ответ протоколе, — это REST и HTTP, так как эти понятия вокруг каждого из нас и на них держится несчетное количество онлайн сервисов. Несмотря на то, что Godot Engine 4 из коробки поддерживает HTTP запросы, HTTP сервера в нем нет, а значит для взаимодействия по этому протоколу необходимо либо самому реализовать сервер, либо использовать сторонние решения. Первый вариант ужасен по той причине, что HTTP — это стандартизированный протокол, и для поддержки даже версии 1.1 нужно сильно постараться, не говоря уже про современные версии. Второе буквально противоречит задаче, так как я работаю только в рамках Godot Engine.
В общем-то из самой задачи и выходит решение. Мы работаем только в Godot Engine 4, поэтому реализовывать HTTP сервер нет никакого смысла, так как и клиентом, и сервером является приложение на Godot Engine. Соответственно, можно реализовать нечто более подходящее движку, но со схожими принципами. Также мы отойдем от стандартов, что развяжет нам руки и упростит задачу.
Давайте наконец перейдем к конкретике и реализуем некоторый прикладной протокол на базе TCP. Задача-минимум достаточно простая: клиент подключается к серверу, шлет ему запрос, сервер шлет ответ, сеанс прекращается. Опустим фишки HTTP вроде постоянных соединений и сфокусируемся на основной идее. Начнем с небезопасного взаимодействия по «голому» TCP, но потом расширим идею до работы с TLS.
Работа с TCP в Godot Engine 4 осуществляется при помощи классов TCPServer (пассивное подключение) и StreamPeerTCP (активное подключение). Важно обратить внимание на понятие пира. Пир — это некоторая конечная точка, с которой происходит взаимодействие. Например, для сервера каждый клиент является отдельным пиром. Весь неткод движка опирается на это понятие, что отражается в названиях классов.
Начнем с рассмотрения общих моментов в порядке сервер — клиент (этот порядок будет в дальнейшем по умолчанию). Напоминаю, что работает все через опросы, поэтому про многопоточный кошмар можно забыть. Схема работы с TCPServer следующая: создаем объект, принимаем новые подключения и обрабатываем старые. Код весьма прост:
extends Nodeconst PORT : int = 9999var _tcp_server : TCPServervar _connected_peers : Array[StreamPeerTCP] = []func _ready() -> void: _tcp_server = TCPServer.new() if _tcp_server.listen(PORT) != OK: # returnfunc _process(delta:float) -> void: # TCPServer сам создаст для нас объекты пиров, # которые самодостаточны и полностью описывают взаимодействие с клиентами if _tcp_server.is_connection_available(): var new_peer : StreamPeerTCP = _tcp_server.take_connection() _connected_peers.append(new_peer) # Предполагается, что внутри вызова _process_peer прикладной протокол # сам решает, когда оборвать подключение и, # соотвественно, удалить пир из _connected_peers for peer:StreamPeerTCP in _connected_peers: _process_peer(peer, delta)# какая-то логика прикладного протоколаfunc _process_peer(peer:StreamPeerTCP, delta:float) -> void: peer.poll() # обновление статуса соединения и получение данных ...
Работа с подключениями происходит через объект класса StreamPeerTCP, а класс сервера нужен исключительно для получения этих самых объектов.
Схема работы клиента еще проще: создание объекта пира и подключение. В коде это выглядит так:
extends Nodevar _peer : StreamPeerTCPfunc _init(host:String, port:int) -> void: _peer = StreamPeerTCP.new() if _peer.connect_to_host(host, port) != OK: # returnfunc _process(delta:float) -> void: if _peer.get_status() == StreamPeerTCP.STATUS_CONNECTED: _peer.poll() # обновление статуса соединения и получение данных # какая-то логкика прикладного протокола ...
Тут мы уже сами создаем объект пира и указываем его данные. По ту сторону в нашем случае будет TCPServer, который обработает запрос на подключение и уже у себя создаст такой же объект пира, после чего мы сможем достучаться до сервера, а он до нас.
GDTP
Теперь давайте поговорим о том, что же все таки за волшебство происходит в функциях обработки пиров. Как я писал ранее, клиент посылает запрос, сервер высылает ответ и после этого сеанс связи завершается. Это все общие понятия, а вот в каком виде происходит обмен данными — это уже конкретика.
Огромное количество сервисов для обмена данными использует JSON. Движок поддерживает этот формат, однако в нашем случае он не оправдан, так как парсинг текста медленнее простой бинарной десериализации, а поддержка общения с другими сервисам не нужна по постановке задачи. Единственное, что мы теряем, — это человекочитаемость запросов и ответов.
JSON — это, по сути, один большой словарь. Раз все используют словари, значит их вполне достаточно и мы можем смело следовать общей практике. В Godot Engine 4 словарь представлен встроенным типом Dictionary, который мы и будем гонять по сети. Бинарная сериализация/десериализация доступна через функции var_to_bytes/bytes_to_var, которые являются встроенными в GDSсript и доступны в любом месте программы.
Помимо деталей реализации необходимо определиться с логикой, то есть с тем, какого вида словари участвуют в протоколе. В общем-то почти все определяется бизнес-логикой конкретного сервиса, однако с точки зрения протокола видится логичным реализовать простое мультиплексирование запросов, чтобы не заставлять программиста плодить сервисы на каждый чих или писать это мультиплексирование самому.
Для этого «стандартизируем», что в своем запросе клиент обязан указывать идентификатор обработчика типа String в поле словаря "endpoint".
Сервер же в свою очередь, как порядочный парень, предоставляет полный отчет о работе с запросом и «стандартизирует» следующие поля в своем словаре-ответе:
Поле
Вид
Описание
"datetime"
Дата и время в формате ISO 8601 (Строка вида YYYY-MM-DDTHH:MM:SS)
Дата и время формирования ответа
"status"
Целое число
Аналогично статусу в протоколе HTTP
"comment"
Строка
Описание ошибки или сообщение об успехе операции (для человека)
"endpoint"
Строка
(Опционально) Если запрос попал в какой либо обработчик, это поле указывает на него. Если запрос был отвергнут до попадания в обработчик, поле отсутствует
Какой статус что означает каждый сервис диктует сам в своей спецификации (она ведь будет, да?), и при этом один сервис может реализовывать любое количество обработчиков.
Однако важна следующая деталь: время обработки запроса не определено и, в общем то, может быть почти любым. Особенно это актуально в том случае, когда серверу приходится обращаться к другим API, например для авторизации пользователя в стороннем сервисе.
Клиент во время обработки запроса просто ждет, и я не вижу практического смысла в «стандартизации» какого-то сетевого взаимодействия на уровне прикладного протокола в это время. Давайте подытожим описание протокола схемой его работы:

В ней явно прослеживаются состояния взаимодействия, которые пригодятся нам немного позже. По сути мы получили протокол обмена Godot-словарями, или GDTP — Godot Dictionary Transfer Protocol.
Теперь приступим к реализации, в которой не забудем про базовую защиту: таймаут подключения и ограничение на размер запроса. В коде достаточно много деталей, поэтому рассмотрим его по порядку, а полностью я приведу его в конце главы.
Начнем с сервера, в котором вся реализация прикладного протокола должна быть в функции _process_peer. Это, как минимум, гарантирует, что запросы будут обрабатываться одинаково и независимо. Перед этим давайте расширим код принятия подключения, добавив некоторую метаинформация об объекте пира:
const PEER_LAST_STATUS_METANAME : String = "GDTPServicePeerLastStatus"const PEER_TIMESTAMP_METANAME : String = "GDTPServicePeerTimestamp"@warning_ignore("unused_parameter")func _process(delta: float) -> void:for peer:StreamPeerTCP in _connected_peers.keys():_process_peer(peer)if _tcp_server.is_connection_available():var new_peer : StreamPeerTCP = _tcp_server.take_connection() # Последний статус необходим, чтобы понимать, когда он поменялся_set_peer_last_status(new_peer, new_peer.get_status()) # Время принятия подключения - для отслеживания таймаута подключения_peer_set_timestamp(new_peer, Time.get_unix_time_from_system()) # второй агрумент показывает, первый ли раз обрабатывается объект пира_process_peer(new_peer, true) _connected_peers.append(peer) func _get_peer_last_status(peer:StreamPeerTCP) -> StreamPeerTCP.Status:return peer.get_meta(PEER_LAST_STATUS_METANAME)func _set_peer_last_status(peer:StreamPeerTCP, status:StreamPeerTCP.Status) -> void:peer.set_meta(PEER_LAST_STATUS_METANAME, status)func _peer_get_timestamp(peer:StreamPeerTCP) -> float:return peer.get_meta(PEER_TIMESTAMP_METANAME)func _peer_set_timestamp(peer:StreamPeerTCP, timestamp:float) -> void:peer.set_meta(PEER_TIMESTAMP_METANAME, timestamp)
Наверняка вы уже поняли, что нам необходимо отслеживать состояние подключения, и для этого объект класса StreamPeerTCP предоставляет 4 возможных статуса:
Статус
Описание (из документации движка)
StreamPeerTCP.STATUS_NONE
Начальное состояние объекта. Также является статусом после завершения сеанса.
StreamPeerTCP.STATUS_CONNECTING
Статус, означающий, что объект в процессе подключения к хосту.
StreamPeerTCP.STATUS_CONNECTED
Статус, означающий, что объект подключен к хосту.
StreamPeerTCP.STATUS_ERROR
Статус, означающий, что произошла какая-то ошибка.
Соответственно, обрабатывать объект пира нужно с учетом этого статуса. Получаем что-то вроде:
@exportvar peer_timeout : float = 5.0...func _process_peer(peer:StreamPeerTCP, init_call:bool=false) -> void: # благодаря метаинформации о веремни принятия подключения # мы можем проверить его на таймаутif Time.get_unix_time_from_system() - _peer_get_timestamp(peer) > peer_timeout:peer.disconnect_from_host()peer.poll()var last_status : StreamPeerTCP.Status = _get_peer_last_status(peer)var new_status : StreamPeerTCP.Status = peer.get_status()if last_status != new_status or init_call:_set_peer_last_status(peer, new_status)match new_status:StreamPeerTCP.STATUS_NONE:_on_peer_new_status_none(peer)StreamPeerTCP.STATUS_CONNECTING:_on_peer_new_status_connecting(peer)StreamPeerTCP.STATUS_CONNECTED:_on_peer_new_status_connected(peer)StreamPeerTCP.STATUS_ERROR:_on_peer_new_status_error(peer)if new_status == StreamPeerTCP.STATUS_CONNECTED:_process_connected_peer(peer) # рассмотрим позже
В функциях _on_peer_new_status_* будем инициализировать какие-то данные, связанные со статусом, и убирать мертвые пиры. В случаях STATUS_NONE и STATUS_ERROR никакого подключения уже нет, пир нам больше не нужен, и мы удаляем его из массива, оставляя на съедение сборщику мусора:
func _on_peer_new_status_none(peer:StreamPeerTCP) -> void:_connected_peers.erase(peer)func _on_peer_new_status_error(peer:StreamPeerTCP) -> void:_connected_peers.erase(peer)
STATUS_CONNECTING ничего интересного не представляет, поэтому внутри ничего не делаем (или логгируем, при надобности):
func _on_peer_new_status_connecting(peer:StreamPeerTCP) -> void:pass
После перехода пира в статус STATUS_CONNECTED наш протокол вступает в силу, и, согласно ему, у нас есть три состояния: прием запроса, обработка запроса и отправка ответа. Опишем это в коде:
const PEER_STATE_METANAME : String = "GDTPServicePeerState"enum PeerState{RecievingRequest, CreatingResponse, SendingResponse}...func _peer_set_state(peer:StreamPeerTCP, state:PeerState) -> void:peer.set_meta(PEER_STATE_METANAME, state)func _peer_get_state(peer:StreamPeerTCP) -> PeerState:return peer.get_meta(PEER_STATE_METANAME)
Более того, нам нужно начать принимать данные. Для этого класс пира предоставляет множество функций и, к сожалению, почти все из них грубо нарушают логику работы с объектом пира, так как являются потенциально блокирующими поток исполнения (прошу к ознакомлению сурсы).
Происходит это из-за наследования StreamPeerTCP от общего интерфейса StreamPeer, у которого можно удобно попросить сразу готовую программную сущность, что требует наличия сразу всех байт этой сущности. И, соответственно, если их не хватает, движок ждет недостающие байты по TCP соединению.
На нашу радость есть и функции, не требующие сразу всех данных, и их легко найти по слову partial. Они гарантированно не приведут к блокировке потока даже в случае StreamPeerTCP, так как эти функции буквально возвращают то, что уже есть. Конкретно сейчас нас интересует Array get_partial_data(bytes: int), которая возвращает массив с двумя элементами: кодом ошибки и извлеченным массивом байт.
Перед тем как бросаться получать данные нужно решить, а куда их собственно получать. Так как из объекта пира мы достаем данные пачками, эти пачки будем аккумулировать в некотором буфере, за размером которого нужно тщательно следить. Напишем функцию _on_peer_new_status_connected:
const PEER_REQUEST_BUFFER_METANAME : String = "GDTPServicePeerRequestBuffer"...func _on_peer_new_status_connected(peer:StreamPeerTCP) -> void:_peer_set_request_buffer(peer, PackedByteArray())_peer_set_state(peer, PeerState.RecievingRequest)func _peer_set_request_buffer(peer:StreamPeerTCP, buffer:PackedByteArray) -> void:peer.set_meta(PEER_REQUEST_BUFFER_METANAME, buffer)func _peer_get_request_buffer(peer:StreamPeerTCP) -> PackedByteArray:return peer.get_meta(PEER_REQUEST_BUFFER_METANAME)
Теперь данные есть куда получать, осталось просто научиться это делать:
func _read_peer_to_buffer(peer:StreamPeerTCP) -> Error: # PackedByteArray передается по ссылкеvar buffer : PackedByteArray = _peer_get_request_buffer(peer) var bytes_to_read : int = peer_buffer_max_bytesize - buffer.size()if peer.get_available_bytes() > bytes_to_read:return ERR_OUT_OF_MEMORYvar data : Array = peer.get_partial_data(peer_buffer_max_bytesize - buffer.size())if data[0] != OK:return data[0]buffer.append_array(data[1])return OK
И наконец получать запрос в функции _process_connected_peer:
func _process_connected_peer(peer:StreamPeerTCP) -> void:match _peer_get_state(peer):PeerState.SendingResponse:... # рассмотрим позжеPeerState.RecievingRequest:if peer.get_available_bytes() != 0 and _read_peer_to_buffer(peer) != OK:peer.disconnect_from_host()returnvar peer_buffer : PackedByteArray = _peer_get_request_buffer(peer)if len(peer_buffer) >= 4 and peer_buffer.has_encoded_var(0):var request_data : Dictionary = type_convert(peer_buffer.decode_var(0), TYPE_DICTIONARY)peer_buffer.clear()_create_and_send_response(peer, request_data)PeerState.CreatingResponse: # напоминаю, что просто ничего не делаемpass
Важный момент: если нам пришел какой-то мусор, условие peer_buffer.has_encoded_var(0) будет ложно и объект пира так и будет висеть в состоянии приема запроса, пока его не выкинут по таймауту. Также код peer_buffer.has_encoded_var(0) будет выводить ошибку в движок при возврате false и сделать с этим что-то на уровне GDScript невозможно.
Перед тем, как рассмотреть функцию формирования ответа, подумаем над тем, как мы будем этот ответ передавать. Для передачи данных применима совершенна та же логика, что и для приема, поэтому заведем некоторый буфер, от которого будем отщипывать пачки данных:
const PEER_RESPONSE_BUFFER_METANAME : String = "GDTPServicePeerResponseBuffer"...func _peer_get_request_buffer(peer:StreamPeerTCP) -> PackedByteArray:return peer.get_meta(PEER_REQUEST_BUFFER_METANAME)func _peer_set_response_buffer(peer:StreamPeerTCP, buffer:PackedByteArray) -> void:peer.set_meta(PEER_RESPONSE_BUFFER_METANAME, buffer)
Единственное отличие в том, что за размером этого буфера следить не нужно, так как сам сервер его и предоставляет. Теперь непосредственно формирование ответа:
func _create_and_send_response(peer:StreamPeerTCP, request:Dictionary) -> void:_peer_set_state(peer, PeerState.CreatingResponse)var response : Dictionary = {}var endpoint : String = type_convert(request.get("endpoint", ""), TYPE_STRING)if endpoint.is_empty():response["status"] = 400response["comment"] = "no endpoint provided"else: # handle_request - абстрактная функция # благодаря await наследник класса может обрабатывать запрос длительное времяvar endpoint_response : Dictionary = await _handle_request(request) response["status"] = 200response["comment"] = "ok" # функция обработчика вправе задавать поля статуса и комментарияresponse.merge(endpoint_response, true) response["endpoint"] = endpointresponse["datetime"] = Time.get_datetime_string_from_system(true)if peer.get_status() != StreamPeerTCP.STATUS_CONNECTED:return var response_buffer : PackedByteArray = var_to_bytes(response)_peer_set_response_buffer(peer, response_buffer)_peer_set_state(peer, PeerState.SendingResponse)
Давайте немного поясню, как это все работает. В функции _cteate_and_send_response есть await, и, когда функция-обработчик действительно является корутиной, вызов _cteate_and_send_response впадает в спячку, во время которой объект пира обрабатывается как обычно функцией _process_peer. То есть вызывается poll, проверки на состояния и так далее.
Именно для этого нужно условие if peer.get_status() != STATUS_CONNECTED. Во время спячки функции соединение могло разорваться или улететь по таймауту, и в таком случае дальнейший код, отвечающий за инициирование пересылки ответа, попросту лишний.
В коде отправки ответа нет никаких сюрпризов. Кладем в объект пира столько данных, сколько можем, а остальное откладываем до момента следующей отправки:
func _process_connected_peer(peer:StreamPeerTCP) -> void:match _peer_get_state(peer):PeerState.SendingResponse:var response_buffer : PackedByteArray = _peer_get_response_buffer(peer) # Функция put_partial_data - это антагонист # функции get_partial_data. Она кладет данные в объект пира # и возвращает массив из кода ошибки и кол-ва положенных байтvar result : Array = peer.put_partial_data(response_buffer)var sending_error : Error = result[0]if sending_error != OK:peer.disconnect_from_host()returnvar sent_bytes : int = result[1] # в этом месте происходит копирование данных и, по хорошему, # избавляться от первых sent_bytes элементов нужно "на месте"response_buffer = response_buffer.slice(sent_bytes) if response_buffer.is_empty():peer.disconnect_from_host()return_peer_set_response_buffer(peer, response_buffer)PeerState.RecievingRequest: # рассматривали ранее...PeerState.CreatingResponse:pass
Как только мы отправили весь буфер, согласно протоколу должны просто закрыть соединение.
Настало время поговорить о клиенте. Благо все нюансы мы уже рассмотрели на примере работы сервера, поэтому просто используем эти знания для реализации клиента. Существенное отличие в том, с точки зрения клиента после стадии отправки запроса сразу наступает стадия приема ответа. Он как бы говорит «Дай» (отправляет запрос) и стоит с протянутой рукой (ждет ответ), пока сервер шарится по карманам в поисках монеты (обработка запроса).
В итоге получается такой полнофункциональный класс запроса:
Ничего особого
extends Nodeclass_name GDTPRequestsignal responsed(response:Variant)enum State{SendingRequest, RecievingResponse}var _peer : StreamPeerTCPvar _request_buffer : PackedByteArrayvar _response_buffer : PackedByteArrayvar _last_status : StreamPeerTCP.Statusvar _started_timestamp : floatvar _done : bool = falsevar _first_process : bool = truevar _response_timeout : float var _state : Statestatic func request(request_body:Dictionary, host:String, port:int, timeout_sec:float=5.0) -> GDTPRequest:var root : Node = (Engine.get_main_loop() as SceneTree).rootvar request_node : GDTPRequest = GDTPRequest.new(request_body, host, port, timeout_sec)root.add_child(request_node)return request_nodefunc _init(request_body:Dictionary, gdtp_service_address:String, gdtp_service_port:int, timeout_sec:float=5.0) -> void:_peer = StreamPeerTCP.new()if _peer.connect_to_host(gdtp_service_address, gdtp_service_port) != OK:_done = truereturnprocess_mode = Node.PROCESS_MODE_ALWAYS_started_timestamp = Time.get_unix_time_from_system()_request_buffer = var_to_bytes(request_body)_last_status = _peer.get_status()_response_timeout = timeout_sec@warning_ignore("unused_parameter")func _process(delta: float) -> void:if _done:var response : Dictionary = type_convert(bytes_to_var(_response_buffer), TYPE_DICTIONARY)responsed.emit(response)set_process(false)queue_free()returnif Time.get_unix_time_from_system() - _started_timestamp > _response_timeout:_done = true_peer.disconnect_from_host()returnif not _first_process:_peer.poll()var new_status : StreamPeerTCP.Status = _peer.get_status()if new_status != _last_status or _first_process:_last_status = new_statusmatch new_status:StreamPeerTCP.STATUS_NONE:_on_peer_new_status_none()StreamPeerTCP.STATUS_CONNECTING:_on_peer_new_status_connecting()StreamPeerTCP.STATUS_CONNECTED:_on_peer_new_status_connected()StreamPeerTCP.STATUS_ERROR:_on_peer_new_status_error()if new_status == StreamPeerTCP.STATUS_CONNECTED:_process_connected()_first_process = falsefunc _process_connected() -> void:match _state:State.RecievingResponse:if _peer.get_available_bytes() > 0:var result : Array = _peer.get_partial_data(_peer.get_available_bytes())var get_code : Error = result[0]if get_code != OK:_response_buffer = PackedByteArray()_done = true_peer.disconnect_from_host()returnvar data : PackedByteArray = result[1]_response_buffer += dataif len(_request_buffer) >= 4 and _request_buffer.has_encoded_var(0):_done = true_peer.disconnect_from_host()State.SendingRequest:var result : Array = _peer.put_partial_data(_request_buffer)var put_code : Error = result[0]if put_code != OK:_response_buffer = PackedByteArray()_done = true_peer.disconnect_from_host()returnvar put_bytes : int = result[1]_request_buffer = _request_buffer.slice(put_bytes)if _request_buffer.is_empty():_state = State.RecievingResponsefunc _on_peer_new_status_connected() -> void:_state = State.SendingRequestfunc _on_peer_new_status_none() -> void:_done = truefunc _on_peer_new_status_connecting() -> void:passfunc _on_peer_new_status_error() -> void:_done = true
Который очень легко использовать:
var response : Dictionary = await GDTPRequest.request({ ... }, "127.0.0.1", 9999).responsed
При этом за счет таймаутов гарантируется, что сигнал responsed будет испущен и await всегда будет завершен.
Напоследок приведу код сервера:
Код сервера, собранный воедино
extends Nodeclass_name GDTPServiceconst PEER_ID_METANAME : String = "GDTPServicePeerID"const PEER_LAST_STATUS_METANAME : String = "GDTPServicePeerLastStatus"const PEER_TIMESTAMP_METANAME : String = "GDTPServicePeerTimestamp"const PEER_STATE_METANAME : String = "GDTPServicePeerState"const PEER_REQUEST_BUFFER_METANAME : String = "GDTPServicePeerRequestBuffer"const PEER_RESPONSE_BUFFER_METANAME : String = "GDTPServicePeerResponseBuffer"enum PeerState{RecievingRequest, CreatingResponse, SendingResponse}@exportvar port_to_listen : int = 9090@exportvar peer_buffer_max_bytesize : int = 512@exportvar peer_timeout : float = 5.0var _tcp_server : TCPServervar _connected_peers : Array[StreamPeerTCP] = []func _ready() -> void:_tcp_server = TCPServer.new()if _tcp_server.listen(port_to_listen) != OK:return queue_free()@warning_ignore("unused_parameter")func _process(delta: float) -> void:print("SERVER PEERS: %d" % len(_connected_peers))for peer:StreamPeerTCP in _connected_peers:_process_peer(peer)if _tcp_server.is_connection_available():var new_peer : StreamPeerTCP = _tcp_server.take_connection()_set_peer_last_status(new_peer, new_peer.get_status())_peer_set_timestamp(new_peer, Time.get_unix_time_from_system())_process_peer(new_peer, true)_connected_peers.append(new_peer)func _process_peer(peer:StreamPeerTCP, init_call:bool=false) -> void:if Time.get_unix_time_from_system() - _peer_get_timestamp(peer) > peer_timeout:peer.disconnect_from_host()_connected_peers.erase(peer)returnpeer.poll()var last_status : StreamPeerTCP.Status = _get_peer_last_status(peer)var new_status : StreamPeerTCP.Status = peer.get_status()if last_status != new_status or init_call:_set_peer_last_status(peer, new_status)match new_status:StreamPeerTCP.STATUS_NONE:_on_peer_new_status_none(peer)StreamPeerTCP.STATUS_CONNECTING:_on_peer_new_status_connecting(peer)StreamPeerTCP.STATUS_CONNECTED:_on_peer_new_status_connected(peer)StreamPeerTCP.STATUS_ERROR:_on_peer_new_status_error(peer)if new_status == StreamPeerTCP.STATUS_CONNECTED:_process_connected_peer(peer)func _process_connected_peer(peer:StreamPeerTCP) -> void:match _peer_get_state(peer):PeerState.SendingResponse:var response_buffer : PackedByteArray = _peer_get_response_buffer(peer)var result : Array = peer.put_partial_data(response_buffer)var sending_error : Error = result[0]if sending_error != OK:peer.disconnect_from_host()_connected_peers.erase(peer)returnvar sent_bytes : int = result[1]response_buffer = response_buffer.slice(sent_bytes)if response_buffer.is_empty():peer.disconnect_from_host()_connected_peers.erase(peer)return_peer_set_response_buffer(peer, response_buffer)PeerState.RecievingRequest:if peer.get_available_bytes() != 0:if _read_peer_to_buffer(peer) != OK:peer.disconnect_from_host()_connected_peers.erase(peer)returnvar peer_buffer : PackedByteArray = _peer_get_request_buffer(peer)if len(peer_buffer) >= 4 and peer_buffer.has_encoded_var(0):var request_data : Dictionary = type_convert(peer_buffer.decode_var(0), TYPE_DICTIONARY)peer_buffer.clear()_create_and_send_response(peer, request_data)PeerState.CreatingResponse:passfunc _create_and_send_response(peer:StreamPeerTCP, request:Dictionary) -> void:_peer_set_state(peer, PeerState.CreatingResponse)var response : Dictionary = {}var endpoint : String = type_convert(request.get("endpoint", ""), TYPE_STRING)if endpoint.is_empty():response["status"] = 400response["comment"] = "no endpoint provided"else:var endpoint_response : Dictionary = await _handle_request(request)response["status"] = 200response["comment"] = "ok"response.merge(endpoint_response, true)response["endpoint"] = endpointif peer.get_status() != StreamPeerTCP.STATUS_CONNECTED:returnresponse["datetime"] = Time.get_datetime_string_from_system(true)var response_buffer : PackedByteArray = var_to_bytes(response)_peer_set_response_buffer(peer, response_buffer)_peer_set_state(peer, PeerState.SendingResponse)func _read_peer_to_buffer(peer:StreamPeerTCP) -> Error:var buffer : PackedByteArray = _peer_get_request_buffer(peer)var bytes_to_read : int = peer_buffer_max_bytesize - buffer.size()if peer.get_available_bytes() > bytes_to_read:return ERR_OUT_OF_MEMORYvar data : Array = peer.get_partial_data(peer_buffer_max_bytesize - buffer.size())if data[0] != OK:return data[0]if len(buffer) + len(data[1]) > peer_buffer_max_bytesize:buffer.clear()return ERR_OUT_OF_MEMORYbuffer.append_array(data[1])return OKfunc _on_peer_new_status_none(peer:StreamPeerTCP) -> void:_connected_peers.erase(peer)func _on_peer_new_status_connecting(peer:StreamPeerTCP) -> void:passfunc _on_peer_new_status_connected(peer:StreamPeerTCP) -> void:_peer_set_request_buffer(peer, PackedByteArray())_peer_set_state(peer, PeerState.RecievingRequest)func _on_peer_new_status_error(peer:StreamPeerTCP) -> void:_connected_peers.erase(peer)func _peer_set_request_buffer(peer:StreamPeerTCP, buffer:PackedByteArray) -> void:peer.set_meta(PEER_REQUEST_BUFFER_METANAME, buffer)func _peer_get_request_buffer(peer:StreamPeerTCP) -> PackedByteArray:return peer.get_meta(PEER_REQUEST_BUFFER_METANAME)func _peer_set_response_buffer(peer:StreamPeerTCP, buffer:PackedByteArray) -> void:peer.set_meta(PEER_RESPONSE_BUFFER_METANAME, buffer)func _peer_get_response_buffer(peer:StreamPeerTCP) -> PackedByteArray:return peer.get_meta(PEER_RESPONSE_BUFFER_METANAME)func _peer_get_timestamp(peer:StreamPeerTCP) -> float:return peer.get_meta(PEER_TIMESTAMP_METANAME)func _peer_set_timestamp(peer:StreamPeerTCP, timestamp:float) -> void:peer.set_meta(PEER_TIMESTAMP_METANAME, timestamp)func _get_peer_last_status(peer:StreamPeerTCP) -> StreamPeerTCP.Status:return peer.get_meta(PEER_LAST_STATUS_METANAME)func _set_peer_last_status(peer:StreamPeerTCP, status:StreamPeerTCP.Status) -> void:peer.set_meta(PEER_LAST_STATUS_METANAME, status)func _peer_set_state(peer:StreamPeerTCP, state:PeerState) -> void:peer.set_meta(PEER_STATE_METANAME, state)func _peer_get_state(peer:StreamPeerTCP) -> PeerState:return peer.get_meta(PEER_STATE_METANAME)# VIRTUAL@warning_ignore("unused_parameter")func _handle_request(request:Dictionary) -> Dictionary:return {}
Но! Несмотря на то, что код я привел, использовать его не нужно, так как в нем данные гоняются открытым текстом и любой, кто видит трафик, может легко его использовать в своих интересах (угон аккаунта, сбор личных данных). А видят его все, кто доставляет IP пакеты от вас до сервера и обратно.
Поэтому необходимо сделать обмен данными безопасным, то есть таким, что видеть его будет только получатель (вы и сервер). Тут ничего изобретать не нужно, так как существует протокол TLS, который отлично поддерживается движком. Рассмотрим GDTPS — Godot Dictionary Transfer Protocol Secured.
GDTPS
Тут очень хотелось бы сказать, что нужно просто заменить StreamPeerTCP на StreamPeerTLS и TCPServer на TLSServer, но если StreamPeerTLS и существует, то вот никакого TLSServer нет. Как же тогда быть? Для этого достаточно вспомнить про то, как TLS работает.
Сначала происходит самое обычное соединение по TCP, а уже потом пиры договариваются об использовании TLS (стадия TLS handshake). В Godot Engine подключение по TLS реализуется точно также: сначала нам нужно инициировать соединение по TCP и потом «улучшить» это соединение до TLS.
Перед тем, как я расскажу о создании TLS соединения, нужно немного подготовиться, а именно создать секретный ключ и самоподписной сертификат (они требуются протоколом TLS). Сделать это можно не выходя из движка благодаря классу Crypto:
@toolextends EditorScriptfunc _run() -> void:var crypto : Crypto = Crypto.new()var key : CryptoKey = crypto.generate_rsa(2048) # SHA256var cert : X509Certificate = crypto.generate_self_signed_certificate(key, "CN=gdtps_test,O=Goerging,C=SP")key.save("res://auth_tls.key")cert.save("res://auth_tls.crt")
Код, в общем-то, говорит сам за себя. Создаем секретный ключ, на его основе генерируем сертификат и сохраняем это все в папку проекта. Скрипты, наследующие от EditorScript, можно запускать прям в редакторе при помощи опции File > Run, поэтому для генерации ключа и сертификата не нужно даже запускать проект.
Теперь можно начинать безопасный обмен данными при помощи упомянутого ранее StreamPeerTLS. Если вы заглянете в документацию к этому классу, то увидите, что в нем нет никаких функций подключения. Вместо этого он работает с уже созданными StreamPeer, «улучшая» существующее соединение до TLS.
То есть в случае сервера мы должны по старой схеме получить подключенный StreamPeerTCP и передать его в функцию accept_stream новому объекту StreamPeerTLS. Затем про StreamPeerTCP забываем и вызываем poll, чтение данных и т.д. только для объекта класса StreamPeerTLS. Для клиента схема аналогична: получаем подключенный StreamPeerTCP и передаем его функции connect_to_stream.
Давайте немного поговорим об этих «функциях улучшения». Обе они принимают объект TLSOptions, который является конфигурацией подключения и может быть создан только тремя способами. Для клиента предусмотрены функции созданияTLSOptions.client и TLSOptions.client_unsafe, которые принимают доверенную цепочку сертификатов. Разница между ними в том, что если конфигурация была создана при помощи client_unsafe, то StreamPeerTLS не проверяет сертификат от сервера и в любом случае допускает соединение. Использовать это стоит только для целей тестирования. Для сервера остается одна функция TLSOptions.server, которая принимает секретный ключ и публичный сертификат.
Так как общая логика работы с объектами пиров остается такой же, что и в незащищенном случае, рассмотрим только эту возню с «улучшением» подключений и начнем с сервера. В коде нам нужно поддерживать работу как со StreamPeerTCP, так и со StreamPeerTLS, поэтому массив _connected_peers будет хранить объекты класса StreamPeer (ближайший общий предок), а при «улучшении» подключения старый объект пира будем заменять на новый:
@exportvar private_key : CryptoKey@exportvar public_certificate : X509Certificatevar _tcp_server : TCPServervar _connected_peers : Array[StreamPeer] = []var _tls_options : TLSOptions...func _tcp_peer_upgrade_to_tls(peer:StreamPeerTCP) -> void:var tls_peer : StreamPeerTLS = StreamPeerTLS.new()var err : Error = tls_peer.accept_stream(peer, _tls_options)if err != OK:_shutdown_peer(peer)_connected_peers.erase(peer)return_connected_peers.erase(peer)_connected_peers.append(tls_peer)_set_peer_last_status(tls_peer, tls_peer.get_status())_peer_set_timestamp(tls_peer, _peer_get_timestamp(peer))_process_tls_peer(tls_peer, true)
«Улучшать» подключение будем при изменении статуса объекта StreamPeerTCP на STATUS_CONNECTED:
...func _on_tcp_peer_new_status_connected(peer:StreamPeerTCP) -> void:_tcp_peer_upgrade_to_tls(peer)
И обрабатывать пиры каждый по-своему в зависимости от типа:
func _process_peer(peer:StreamPeer, init_call:bool=false) -> void:if Time.get_unix_time_from_system() - _peer_get_timestamp(peer) > peer_timeout:_shutdown_peer(peer)if peer is StreamPeerTCP:_process_tcp_peer(peer as StreamPeerTCP, init_call)else:_process_tls_peer(peer as StreamPeerTLS)func _process_tcp_peer(peer:StreamPeerTCP, init_call:bool=false) -> void:peer.poll()var last_status : StreamPeerTCP.Status = _get_peer_last_status(peer)var new_status : StreamPeerTCP.Status = peer.get_status()if last_status != new_status or init_call:_set_peer_last_status(peer, new_status)match new_status:StreamPeerTCP.STATUS_NONE:_on_tcp_peer_new_status_none(peer)StreamPeerTCP.STATUS_CONNECTING:_on_tcp_peer_new_status_connecting(peer)StreamPeerTCP.STATUS_CONNECTED:_on_tcp_peer_new_status_connected(peer)StreamPeerTCP.STATUS_ERROR:_on_tcp_peer_new_status_error(peer)func _process_tls_peer(peer:StreamPeerTLS, init_call:bool=false) -> void:peer.poll()var last_status : StreamPeerTLS.Status = _get_peer_last_status(peer)var new_status : StreamPeerTLS.Status = peer.get_status()if last_status != new_status or init_call:_set_peer_last_status(peer, new_status)match new_status:StreamPeerTLS.STATUS_CONNECTED:_on_tls_peer_new_status_connected(peer)StreamPeerTLS.STATUS_DISCONNECTED:_on_tls_peer_new_status_disconnected(peer)StreamPeerTLS.STATUS_ERROR:_on_tls_peer_new_status_error(peer) # статус, аналогичный StreamPeerTCP.STATUS_CONNECTINGStreamPeerTLS.STATUS_HANDSHAKING:_on_tls_peer_new_status_handshaking(peer)if new_status == StreamPeerTLS.STATUS_CONNECTED:_process_connected_peer(peer)func _process_connected_peer(peer:StreamPeerTLS) -> void: ... # логика точно такая же как и раньше
Обратите внимание, что _process_connected_peer существует только для StreamPeerTLS, так как TCP подключение используется только для получения TLS подключения и никакой логики в нем мы не реализуем.
Еще одна важная деталь заключается в закрытии подключения, так как у TLS свой механизм и просто обрубать TCP соединение неправильно. У StreamPeerTLS на это есть своя функция, поэтому функция завершения сеанса не более чем:
func _shutdown_peer(peer:StreamPeer) -> void:if peer is StreamPeerTCP:peer.disconnect_from_host()elif peer is StreamPeerTLS:peer.disconnect_from_stream()
Улучшение подключения на стороне клиента осуществляется без всяких сюрпризов:
var _tcp_peer : StreamPeerTCPvar _tls_peer : StreamPeerTLSvar _upgraded_to_tls : bool = falsevar _common_name : Stringvar _trusted_certificate : X509Certificate...func _upgrade_to_tls() -> void:_tls_peer = StreamPeerTLS.new()if _tls_peer.connect_to_stream(_tcp_peer, _common_name, TLSOptions.client(_trusted_certificate)) != OK:_done = truereturn_last_tls_status = _tls_peer.get_status()_upgraded_to_tls = true
Но вот среди состояний сюрпризы есть — это STATUS_ERROR_HOSTNAME_MISMATCH. По документации он означает, что имя хоста в сертификате не совпало с ожидаемым, то есть тем, что мы передали в функцию StreamPeerTLS.connect_to_stream. Этот статус будет скорее возникать после неправильной конфигурации, чем после каких-то атак или ошибок сети, поэтому как при обычном STATUS_ERROR просто закрываем соединение и уходим ни с чем.
Касательно GDTP(S) пока все, давайте я приведу код и пойдем дальше:
Сервер и клиент GDTPS
Сервер:
extends Nodeclass_name GDTPSServiceconst PEER_LAST_STATUS_METANAME : String = "GDTPSServicePeerLastStatus"const PEER_TIMESTAMP_METANAME : String = "GDTPSServicePeerTimestamp"const PEER_STATE_METANAME : String = "GDTPSServicePeerState"const PEER_REQUEST_BUFFER_METANAME : String = "GDTPSServicePeerRequestBuffer"const PEER_RESPONSE_BUFFER_METANAME : String = "GDTPSServicePeerResponseBuffer"enum PeerState{RecievingRequest, CreatingResponse, SendingResponse}@exportvar port_to_listen : int = 9090@exportvar peer_buffer_max_bytesize : int = 512@exportvar peer_timeout : float = 5.0@exportvar private_key : CryptoKey@exportvar public_certificate : X509Certificatevar _tcp_server : TCPServervar _connected_peers : Array[StreamPeer] = []var _tls_options : TLSOptionsfunc _ready() -> void:_tcp_server = TCPServer.new()_tls_options = TLSOptions.server(private_key, public_certificate)if _tcp_server.listen(port_to_listen) != OK:return queue_free()@warning_ignore("unused_parameter")func _process(delta: float) -> void:for peer:StreamPeer in _connected_peers:_process_peer(peer)if _tcp_server.is_connection_available():var new_peer : StreamPeerTCP = _tcp_server.take_connection()_set_peer_last_status(new_peer, new_peer.get_status())_peer_set_timestamp(new_peer, Time.get_unix_time_from_system())_process_peer(new_peer, true)_connected_peers.append(new_peer)func _process_peer(peer:StreamPeer, init_call:bool=false) -> void:if Time.get_unix_time_from_system() - _peer_get_timestamp(peer) > peer_timeout:_shutdown_peer(peer)_connected_peers.erase(peer)returnif peer is StreamPeerTCP:_process_tcp_peer(peer as StreamPeerTCP, init_call)else:_process_tls_peer(peer as StreamPeerTLS)func _process_tcp_peer(peer:StreamPeerTCP, init_call:bool=false) -> void:peer.poll()var last_status : StreamPeerTCP.Status = _get_peer_last_status(peer)var new_status : StreamPeerTCP.Status = peer.get_status()if last_status != new_status or init_call:_set_peer_last_status(peer, new_status)match new_status:StreamPeerTCP.STATUS_NONE:_on_tcp_peer_new_status_none(peer)StreamPeerTCP.STATUS_CONNECTING:_on_tcp_peer_new_status_connecting(peer)StreamPeerTCP.STATUS_CONNECTED:_on_tcp_peer_new_status_connected(peer)StreamPeerTCP.STATUS_ERROR:_on_tcp_peer_new_status_error(peer)func _process_tls_peer(peer:StreamPeerTLS, init_call:bool=false) -> void:peer.poll()var last_status : StreamPeerTLS.Status = _get_peer_last_status(peer)var new_status : StreamPeerTLS.Status = peer.get_status()if last_status != new_status or init_call:_set_peer_last_status(peer, new_status)match new_status:StreamPeerTLS.STATUS_CONNECTED:_on_tls_peer_new_status_connected(peer)StreamPeerTLS.STATUS_DISCONNECTED:_on_tls_peer_new_status_disconnected(peer)StreamPeerTLS.STATUS_ERROR:_on_tls_peer_new_status_error(peer)StreamPeerTLS.STATUS_HANDSHAKING:_on_tls_peer_new_status_handshaking(peer)if new_status == StreamPeerTLS.STATUS_CONNECTED:_process_connected_peer(peer)func _process_connected_peer(peer:StreamPeerTLS) -> void:match _peer_get_state(peer):PeerState.SendingResponse:var response_buffer : PackedByteArray = _peer_get_response_buffer(peer)var result : Array = peer.put_partial_data(response_buffer)var sending_error : Error = result[0]if sending_error != OK:_shutdown_peer(peer)_connected_peers.erase(peer)returnvar sent_bytes : int = result[1]response_buffer = response_buffer.slice(sent_bytes)if response_buffer.is_empty():_shutdown_peer(peer)_connected_peers.erase(peer)return_peer_set_response_buffer(peer, response_buffer)PeerState.RecievingRequest:if peer.get_available_bytes() != 0:if _read_peer_to_buffer(peer) != OK:_shutdown_peer(peer)_connected_peers.erase(peer)returnvar peer_buffer : PackedByteArray = _peer_get_request_buffer(peer)if len(peer_buffer) >= 4 and peer_buffer.has_encoded_var(0):var request_data : Variant = peer_buffer.decode_var(0)peer_buffer.clear()_create_and_send_response(peer, request_data)PeerState.CreatingResponse:passfunc _create_and_send_response(peer:StreamPeerTLS, request:Variant) -> void:_peer_set_state(peer, PeerState.CreatingResponse)var response : Dictionary = {}var endpoint : String = type_convert(request.get("endpoint", ""), TYPE_STRING)if endpoint.is_empty():response["status"] = 400response["comment"] = "no endpoint provided"else:var endpoint_response : Dictionary = await _handle_request(request)response["status"] = 200response["comment"] = "ok"response.merge(endpoint_response, true)response["endpoint"] = endpointif peer.get_status() != StreamPeerTLS.STATUS_CONNECTED:returnresponse["datetime"] = Time.get_datetime_string_from_system(true)var response_buffer : PackedByteArray = var_to_bytes(response)_peer_set_response_buffer(peer, response_buffer)_peer_set_state(peer, PeerState.SendingResponse)func _read_peer_to_buffer(peer:StreamPeerTLS) -> Error:var buffer : PackedByteArray = _peer_get_request_buffer(peer)var bytes_to_read : int = peer_buffer_max_bytesize - buffer.size()if peer.get_available_bytes() > bytes_to_read:return ERR_OUT_OF_MEMORYvar data : Array = peer.get_partial_data(bytes_to_read)if data[0] != OK:return data[0]if len(buffer) + len(data[1]) > peer_buffer_max_bytesize:buffer.clear()return ERR_OUT_OF_MEMORYbuffer.append_array(data[1])return OKfunc _on_tcp_peer_new_status_none(peer:StreamPeerTCP) -> void:_connected_peers.erase(peer)func _on_tcp_peer_new_status_connecting(peer:StreamPeerTCP) -> void:passfunc _on_tcp_peer_new_status_connected(peer:StreamPeerTCP) -> void:_tcp_peer_upgrade_to_tls(peer)func _on_tcp_peer_new_status_error(peer:StreamPeerTCP) -> void:_connected_peers.erase(peer)func _on_tls_peer_new_status_disconnected(peer:StreamPeerTLS) -> void:_connected_peers.erase(peer)func _on_tls_peer_new_status_handshaking(peer:StreamPeerTLS) -> void:passfunc _on_tls_peer_new_status_connected(peer:StreamPeerTLS) -> void:_peer_set_request_buffer(peer, PackedByteArray())_peer_set_state(peer, PeerState.RecievingRequest)func _on_tls_peer_new_status_error(peer:StreamPeerTLS) -> void:_connected_peers.erase(peer)func _tcp_peer_upgrade_to_tls(peer:StreamPeerTCP) -> void:var tls_peer : StreamPeerTLS = StreamPeerTLS.new()var err : Error = tls_peer.accept_stream(peer, _tls_options)if err != OK:_shutdown_peer(peer)_connected_peers.erase(peer)return_connected_peers.erase(peer)_connected_peers.append(tls_peer)_set_peer_last_status(tls_peer, tls_peer.get_status())_peer_set_timestamp(tls_peer, _peer_get_timestamp(peer))_process_tls_peer(tls_peer, true)func _shutdown_peer(peer:StreamPeer) -> void:if peer is StreamPeerTCP:peer.disconnect_from_host()elif peer is StreamPeerTLS:peer.disconnect_from_stream()func _get_peer_last_status(peer:StreamPeer) -> Variant:return peer.get_meta(PEER_LAST_STATUS_METANAME)func _set_peer_last_status(peer:StreamPeer, status:Variant) -> void:peer.set_meta(PEER_LAST_STATUS_METANAME, status)func _peer_get_timestamp(peer:StreamPeer) -> float:return peer.get_meta(PEER_TIMESTAMP_METANAME)func _peer_set_timestamp(peer:StreamPeer, timestamp:float) -> void:peer.set_meta(PEER_TIMESTAMP_METANAME, timestamp)func _peer_set_request_buffer(peer:StreamPeerTLS, buffer:PackedByteArray) -> void:peer.set_meta(PEER_REQUEST_BUFFER_METANAME, buffer)func _peer_get_request_buffer(peer:StreamPeerTLS) -> PackedByteArray:return peer.get_meta(PEER_REQUEST_BUFFER_METANAME)func _peer_set_response_buffer(peer:StreamPeerTLS, buffer:PackedByteArray) -> void:peer.set_meta(PEER_RESPONSE_BUFFER_METANAME, buffer)func _peer_get_response_buffer(peer:StreamPeerTLS) -> PackedByteArray:return peer.get_meta(PEER_RESPONSE_BUFFER_METANAME)func _peer_set_state(peer:StreamPeerTLS, state:PeerState) -> void:peer.set_meta(PEER_STATE_METANAME, state)func _peer_get_state(peer:StreamPeerTLS) -> PeerState:return peer.get_meta(PEER_STATE_METANAME)# VIRTUAL@warning_ignore("unused_parameter")func _handle_request(request:Dictionary) -> Dictionary:return {}
Клиент:
extends Nodeclass_name GDTPSClient@exportvar requests_limit : int = 1@exportvar service_address : String@exportvar service_port : int@exportvar response_timeout : float = 7.0@exportvar trusted_certificate : X509Certificate@exportvar common_name : Stringfunc request(request_data:Dictionary) -> Dictionary:if get_child_count() >= requests_limit:return {}var request_node : GDTPSRequest = GDTPSRequest.new(request_data, common_name, service_address, service_port, response_timeout, trusted_certificate)add_child(request_node)return await request_node.responsed
Местный мультиплеер
Сейчас мы пока что дружно забудем о предыдущих главах и зайдем с противоположной стороны — со стороны высокоуровневого мультиплеера. Если вы посмотрите любой туториал по мультиплееру в Godot Engine, с огромной вероятностью там будет простое создание лобби с одним объектом мультиплеера на все дерево нод. Я с этим кардинально не согласен, так как при таком подходе невозможно создать несколько разных серверов или и сервер и клиент одновременно в одном процессе. А теперь давайте рассмотрим, почему это нужно.
Наверняка я не совру, если скажу, что все онлайн игры имеют понятие лобби/комнаты, то есть сервера, к которому подключаются клиенты для игры вместе. Прямое предназначение комнаты заключается в группировке игроков. В шахматы играет только двое, в CS 2 или LoL — десятеро, и никто из одного матча не может повлиять на другой.
Выходит некоторая задача группировки клиентов и изоляции полученных групп, которую можно решать как минимум двумя способами. Первый — назовем его программным — заключается в разделении клиентов на уровне кода. Условно имеем, что N групп в коде превращается в N массивов с идентификаторами клиентов, и при обращении к нашему серверу он обязан идентифицировать группу этого клиента и правильно обработать запрос. Такой способ мало того что ненадежный (очень легко создать зависимость между группами через глобальное состояние процесса), так еще и неэффективный, потому что все работает в одном потоке.
Второй — назовем его операционным — заключается в разделении клиентов на уровне ОС. То есть для каждой группы создается свой сервер в новом процессе. Клиенты работают уже с ним и мы получаем что-то вроде контейнеризации. У этого способа огромное преимущество перед первым, так как он и гарантирует изолированность групп и автоматически распараллеливает вычисления.
Однако у этого есть своя цена: во-первых из-за наличия большого количества серверов нужно решать задачу оркестрации, а во-вторых вводить понятие центрального сервера, который будет являться входной точкой в инфраструктуру и сообщать клиентам, по какому адресу их комната. Но несмотря на это я выбираю именно его, так как преимущества слишком вкусные. Можно описать структуру простенькой схемой:

Еще одно преимущество заключается в том, что этот способ допускает как масштабирование в ширину за счет простого добавления новых машин и запуска на них серверов, так и масштабирование в глубину за счет создания любого количества типов серверов (это пригодится и нам).
Так вот, мы хотим сделать это уже непосредственно в Godot Engine. Центральный сервер, например, должен принимать подключения от клиентов, а также поддерживать связь с комнатами, чтобы понимать, в каком они состоянии, где сколько места и т.д. Можно, конечно, сделать один сервер для всех, но в таком случае все будут подключены к одному мультиплееру и, по требованиям движка, видеть весь RPC интерфейс. Даже если вы гениальный программист и сделаете на сервере все проверки, разделив комнаты и клиентов, — падение или взлом это лишь вопрос времени. Да и никто не сможет поддерживать подобное месиво.
Поэтому мы хотим создавать несколько мультиплееров с независимыми интерфейсами и подключениями, и Godot Engine предоставляет нам такую возможность в классе SceneTree через функции set_multiplayer и get_multiplayer. Задается объект мультиплеера для некоторого поддерева, причем не важно, существует ли оно в данный момент. Даже если корня поддерева нет, он получит объект мультиплеера, как только будет создан.
Изначально у нас один мультиплеер на все дерево, и любые ноды, в том числе и новые, получают именно его. Задав же новый объект мультиплеера некоторому поддереву, мы сохраняем логику только для этого поддерева. То есть если в качестве корня мультиплеера мы указали путь /SomeNode/MyMultiplayerRoot, то этот объект мультиплеера будет доступен только потомкам ноды MyMultiplayerRoot.
Благодаря такому механизму можно создавать сколько угодно независимых мультиплееров в одном дереве, единственное ограничение — корневые ноды должны быть такими, чтобы любая нода, которая является потомком одной из них, была потомком строго одной из них. То есть поддеревья не должны пересекаться даже в теории.
Я назвал такой механизм «Местным мультиплеером», потому что он работает только для некоторого поддерева. Назвал бы «Локальный мультиплеер», но такое определение уже занято 🙂
Еще мне не очень нравится организация сигналов в объекте мультиплеера и то, что некоторые из них вызываются только на клиенте, а некоторые и на клиенте, и на сервере. Получается так, что все время нужно помнить, что некоторые сигналы лишние, потому что сейчас у этой ноды объект пира в режиме сервера. Это и не читабельно, и сложно поддерживать. Давайте разделим понятие клиента и сервера и создадим ноду-сервер, которая будет устанавливать новый объект мультиплеера на свое поддерево и делать это автоматически:
extends Nodeclass_name LocalNetworkServersignal client_connencted(peer_id:int)signal client_disconnected(peer_id:int)var online : bool :get:return _is_onlinevar _is_online : bool = falsefunc _enter_tree() -> void:if (multiplayer as SceneMultiplayer).root_path != get_path(): # тут можно создавать и свой объект мультиплеера, но в рамках этой статьи # мультиплеера по умолчанию более чем достаточноvar mp_api : SceneMultiplayer = SceneMultiplayer.create_default_interface()get_tree().set_multiplayer(mp_api, get_path())multiplayer.peer_connected.connect(_peer_connected)multiplayer.peer_disconnected.connect(_peer_disconnected)func _peer_connected(peer_id:int) -> void:client_connencted.emit(peer_id)func _peer_disconnected(peer_id:int) -> void:client_disconnected.emit(peer_id)
А также автоматически открывать и закрывать сервер:
@exportvar server_settings : LocalNetworkServerSettings...func _ready() -> void: (multiplayer as SceneMultiplayer).server_relay = server_settings.server_relay var peer : ENetMultiplayerPeer = ENetMultiplayerPeer.new() var err : Error = peer.create_server(server_settings.port, server_settings.max_clients, server_settings.max_channels, server_settings.in_bandwidth, server_settings.out_bandwidth) if err != OK: return multiplayer.multiplayer_peer = peer _is_online = truefunc _exit_tree() -> void:multiplayer.peer_connected.disconnect(_peer_connected)multiplayer.peer_disconnected.disconnect(_peer_disconnected) multiplayer.multiplayer_peer = null (multiplayer as SceneMultiplayer).clear()
На основании настроек:
extends Resourceclass_name LocalNetworkServerSettings@exportvar port : int@export_group("Additional settings")@export# Если server_relay true (так по умолчанию), то клиенты знают # peer_id друг друга и могут обращаться друг к другуvar server_relay : bool = false@exportvar max_clients : int = 32@exportvar max_channels : int = 0@exportvar in_bandwidth : int = 0@exportvar out_bandwidth : int = 0
Так мы получили нечто подобное RAII-обертке: вместо ручного создания подключения непонятно в каком месте программы и закрытия этого подключения достаточно просто создать ноду и думать о подключении только в рамках конфигурации.
Не забудем и про клиент:
extends Nodeclass_name LocalNetworkClientsignal peer_connencted(peer_id:int)signal peer_disconnected(peer_id:int)signal connected_to_serversignal disconnected_from_serversignal connection_to_server_failedenum ConnectionStatus{Connecting, Connected, Offline}@exportvar connection_settings : LocalNetworkClientSettingsvar _status : ConnectionStatus = ConnectionStatus.Offlinefunc _enter_tree() -> void:if (multiplayer as SceneMultiplayer).root_path != get_path():var mp_api : SceneMultiplayer = SceneMultiplayer.create_default_interface()get_tree().set_multiplayer(mp_api, get_path())multiplayer.peer_connected.connect(_peer_connected)multiplayer.peer_disconnected.connect(_peer_disconnected)multiplayer.connected_to_server.connect(_connected_to_server)multiplayer.server_disconnected.connect(_disconnected_from_server)multiplayer.connection_failed.connect(_connecting_to_server_failed)func _ready() -> void: var peer : ENetMultiplayerPeer = ENetMultiplayerPeer.new() var err : Error = peer.create_client(connection_settings.address, connection_settings.port, connection_settings.channel_count, connection_settings.in_bandwidth, connection_settings.out_bandwidth, connection_settings.local_port) if err != OK: return multiplayer.multiplayer_peer = peer _status = ConnectionStatus.Connectingfunc _exit_tree() -> void:multiplayer.peer_connected.disconnect(_peer_connected)multiplayer.peer_disconnected.disconnect(_peer_disconnected)multiplayer.connected_to_server.disconnect(_connected_to_server)multiplayer.server_disconnected.disconnect(_disconnected_from_server)multiplayer.connection_failed.disconnect(_connecting_to_server_failed) multiplayer.multiplayer_peer = null (multiplayer as SceneMultiplayer).clear()func _peer_connected(peer_id:int) -> void:peer_connencted.emit(peer_id)func _peer_disconnected(peer_id:int) -> void:peer_disconnected.emit(peer_id)func _connected_to_server() -> void:_status = ConnectionStatus.Connectedconnected_to_server.emit()func _disconnected_from_server() -> void:_status = ConnectionStatus.Offlinedisconnected_from_server.emit()func _connecting_to_server_failed() -> void:_status = ConnectionStatus.Offlineconnection_to_server_failed.emit()
Вместе с его настройками:
extends Resourceclass_name LocalNetworkClientSettings@exportvar address : String@exportvar port : int@export_group("Additional Settings")@exportvar channel_count : int = 0@exportvar in_bandwidth : int = 0@exportvar out_bandwidth : int = 0@exportvar local_port : int = 0
Вот вроде бы все работает, но давайте подумаем, а что будет, если нам нужно менять сцены во время онлайн игры? Тогда и на старой сцене, и на новой будет по ноде сервера, но подключение будет сбрасываться и создаваться заново.
Ранее я говорил, что объект мультиплеера дается в том числе и новым нодам и, получается, что однажды появившись он будет один и тот же для всех нод, то есть он перекочует от старой сцены к новой, если местный мультиплеер и там и там задан для одного и того же поддерева.
Заметим также, что объект пира задается объекту мультиплеера, а стало быть он кочует вместе с ним. Так зачем же нам пересоздавать подключение, если мы можем унаследовать его от старой сцены? Однако если мы не будем закрывать соединение, то получается, что мы опять отходим от идеи автоматизировать мультиплеерные проблемы, так как открывать и закрывать подключение в таком случае придется вручную.
На самом деле не придется, так как вопрос о том, кто открывает и закрывает соединение, можно все так же поместить в конфигурацию и настраивать для каждой сцены. То есть, если сцена А первая начинает работу в онлайн режиме, затем она переходит в онлайн сцену Б, а та в свою очередь переходит в последнюю онлайн сцену В, то на сцене А местный мультиплеер создает объект пира (подключение), на сцене Б объект пира используется как есть, а на сцене В он уничтожается и соединение закрывается.
Существенный недостаток в том, что разработчикам придется точно отслеживать переходы между сценами, так как малейшая ошибка приведет к рассинхронизации ожиданий и действительности. Более того, так как объект мультиплеера висит в памяти независимо от нод, вполне может случиться такое, что к активному объекту пира просто не будет доступа и его работа завершится ошибкой, причем отлавливать такие ошибки невероятно тяжело. Но способа лучше я не придумал.
Итак, получаем, что есть три типа нод местного мультиплеера:
-
Входные, открывающие подключение
-
Промежуточные, наследующие подключение
-
Выходные, наследующие подключение и закрывающие его
Очень частым сценарием является нода, которая совмещает входной и выходной тип, поэтому добавим соответствующий тип с названием «Одиночный». В коде эти типы влияют только на то, создается ли объект пира и уничтожается ли он:
enum Mode{Single, # Одиночный Listener, # Входной Gate, # Промежуточный Finisher # Выходной}@exportvar working_mode : Mode = Mode.Single...func _ready() -> void:if working_mode == Mode.Single or working_mode == Mode.Listener:...func _exit_tree() -> void:multiplayer.peer_connected.disconnect(_peer_connected)multiplayer.peer_disconnected.disconnect(_peer_disconnected)if working_mode == Mode.Single or working_mode == Mode.Finisher:multiplayer.multiplayer_peer = null(multiplayer as SceneMultiplayer).clear()
Таким образом мы можем легко настраивать переходы между сценами, управляя соединениями. При этом вся грязная работа автоматизирована и нам остается конфигурировать ноды, а не писать код. Для полной красоты не хватает аутентификации, но благо она уже предусмотрена.
Предусмотрена, правда, весьма убого, на мой взгляд (см. документацию). Работает это дело следующим образом: перед тем, как пир будет считаться подключенным и сможет использовать RPC, его нужно допустить. Решение о допуске сторона принимает на основании массива байт, переданных второй стороной в рамках процесса авторизации. Причем как клиент авторизуется на севере, так и сервер на клиенте.
Некоторым подобием шлюза авторизации выступает поле SceneMultiplayer.auth_callback, которое содержит указатель на функцию, принимающую peer_id:int и auth_data:PackedByteArray, то есть id подключения и то, что было передано в качестве данных авторизации. В этой функции нужно проверить данные и, если подключение допускается, вызвать SceneMultiplayer.complete_auth(peed_id), после чего пир будет считаться подключенным и сможет использовать RPC.
Передать данные авторизации можно, вызвав SceneMultiplayer.send_auth(peer_id:int, auth_data:PackedByteArray). Соответственно первый это делает клиент, так как он инициирует соединение. Для этого предусмотрен сигнал SceneMultiplayer.peer_authenticating(peer_id:int), который излучается при подключении к новому пиру, но только если задан валидный auth_callback. После этого сигнала нужно выслать серверу данные авторизации и затем в auth_callback ожидать ответа.
Схема достаточно запутанная, но в коде все гораздо понятнее. Рассмотрим сервер, который мы унаследуем от обычной ноды местного мультиплеера. Логика авторизации является бизнес-логикой, поэтому опишем ее абстрактной функцией:
@abstractextends LocalNetworkServerclass_name LocalNetworkAuthServer@export_range(1.0, 60.0)var authenticate_timeout_sec : float = 3.0func _enter_tree() -> void:super()(multiplayer as SceneMultiplayer).auth_timeout = authenticate_timeout_sec(multiplayer as SceneMultiplayer).auth_callback = _auth_callbackfunc _auth_callback(peer_id:int, auth_data:PackedByteArray) -> void:if _authenticate_connection(peer_id, auth_data):(multiplayer as SceneMultiplayer).send_auth(peer_id, var_to_bytes(true))(multiplayer as SceneMultiplayer).complete_auth(peer_id)else:(multiplayer as SceneMultiplayer).send_auth(peer_id, var_to_bytes(false))@abstractfunc _authenticate_connection(peer_id:int, auth_data:PackedByteArray) -> bool
В качестве ответа сервер просто высылает сериализованную булеву переменную, которая говорит клиенту, успешно ли прошла авторизация. У клиента код весьма похожий:
@abstractextends LocalNetworkClientclass_name LocalNetworkAuthClient@exportvar auth_timeout_sec : float = 3.0func _enter_tree() -> void:super()(multiplayer as SceneMultiplayer).auth_timeout = auth_timeout_sec(multiplayer as SceneMultiplayer).auth_callback = _auth_callback(multiplayer as SceneMultiplayer).peer_authenticating.connect(_peer_authenticating)(multiplayer as SceneMultiplayer).peer_authentication_failed.connect(_on_peer_authentication_failed)func _exit_tree() -> void:super()(multiplayer as SceneMultiplayer).peer_authenticating.disconnect(_peer_authenticating)(multiplayer as SceneMultiplayer).peer_authentication_failed.disconnect(_on_peer_authentication_failed)func _auth_callback(peer_id:int, recieved_data:PackedByteArray) -> void:assert(peer_id == 1)var is_authed : bool = type_convert(bytes_to_var(recieved_data), TYPE_BOOL)if is_authed:(multiplayer as SceneMultiplayer).complete_auth(1)func _peer_authenticating(peer_id:int) -> void:assert(peer_id == 1)(multiplayer as SceneMultiplayer).send_auth(1, auth_data())func _on_peer_authentication_failed(peer_id:int) -> void:assert(peer_id == 1)_status = ConnectionStatus.Offline@abstractfunc auth_data() -> PackedByteArray
В своем auth_callback клиент получает булеву переменную и на ее основании авторизирует сервер (хотя эта проверка необязательна). Причем, что интересно, в документации сказано, что сигнал peer_authenticating излучается при подключении к новому пиру, но даже если на стороне сервера server_relay = true и клиенты видят друг друга, то авторизации вызывается только при подключении к серверу и assert(peer_id == 1) не нарушается.
Теперь в качестве завершения главы рассмотрим использование защищенного соединения в высокоуровневом мультиплеере. Использовать это для обычных игр вряд ли понадобиться, так как никаких чувствительных данных в рамках обычного игрового взаимодействия они не передают, но для более интересных задач оно может пригодится. Например, можно попробовать сделать свое приложение для видеозвонков. Насколько это рентабельно я не знаю, но попробовать можно 🙂
Благо в случае высокоуровневого мультиплеера защищенный канал делается очень легко одним вызовом ENetConnection.dtls_*_setup, где на месте * либо server, либо client, который принимает объект уже знакомого нам класса TLSOptions. Объект класса ENetConnection доступен в ENetMultiplayerPeer в поле host.
Для сервера:
func _ready() -> void:if working_mode == Mode.Single or working_mode == Mode.Listener:...if server_settings.use_dtls:peer.host.dtls_server_setup(TLSOptions.server(server_settings.private_key,server_settings.cert)) ...
Для клиента:
func _ready() -> void:if working_mode == Mode.Single or working_mode == Mode.Connector: ...if connection_settings.use_dtls:peer.host.dtls_client_setup(connection_settings.address, TLSOptions.client(connection_settings.trusted_chain, connection_settings.common_name_override)) ...
И в конфигурации добавляются соответствующие поля. Приведу получившиеся коды:
Клиент
extends Nodeclass_name LocalNetworkClientsignal peer_connencted(peer_id:int)signal peer_disconnected(peer_id:int)signal connected_to_serversignal disconnected_from_serversignal connection_to_server_failedenum Mode{Single, Connector, Gate, Finisher}enum ConnectionStatus{Connecting, Connected, Offline}@exportvar working_mode : Mode = Mode.Single@exportvar connection_settings : LocalNetworkClientSettingsvar _status : ConnectionStatus = ConnectionStatus.Offlinefunc _enter_tree() -> void:if (multiplayer as SceneMultiplayer).root_path != get_path():var mp_api : SceneMultiplayer = SceneMultiplayer.create_default_interface()get_tree().set_multiplayer(mp_api, get_path())multiplayer.peer_connected.connect(_peer_connected)multiplayer.peer_disconnected.connect(_peer_disconnected)multiplayer.connected_to_server.connect(_connected_to_server)multiplayer.server_disconnected.connect(_disconnected_from_server)multiplayer.connection_failed.connect(_connecting_to_server_failed)func _ready() -> void:if working_mode == Mode.Single or working_mode == Mode.Connector:var peer : ENetMultiplayerPeer = ENetMultiplayerPeer.new()var err : Error = peer.create_client(connection_settings.address, connection_settings.port,connection_settings.channel_count, connection_settings.in_bandwidth, connection_settings.out_bandwidth, connection_settings.local_port)if err != OK:returnif connection_settings.use_dtls:peer.host.dtls_client_setup(connection_settings.address, TLSOptions.client(connection_settings.trusted_chain, connection_settings.common_name_override))multiplayer.multiplayer_peer = peer_status = ConnectionStatus.Connectingfunc _exit_tree() -> void:multiplayer.peer_connected.disconnect(_peer_connected)multiplayer.peer_disconnected.disconnect(_peer_disconnected)multiplayer.connected_to_server.disconnect(_connected_to_server)multiplayer.server_disconnected.disconnect(_disconnected_from_server)multiplayer.connection_failed.disconnect(_connecting_to_server_failed)if working_mode == Mode.Single or working_mode == Mode.Finisher:multiplayer.multiplayer_peer = null(multiplayer as SceneMultiplayer).clear()func _peer_connected(peer_id:int) -> void:peer_connencted.emit(peer_id)func _peer_disconnected(peer_id:int) -> void:peer_disconnected.emit(peer_id)func _connected_to_server() -> void:_status = ConnectionStatus.Connectedconnected_to_server.emit()func _disconnected_from_server() -> void:_status = ConnectionStatus.Offlinedisconnected_from_server.emit()func _connecting_to_server_failed() -> void:_status = ConnectionStatus.Offlineconnection_to_server_failed.emit()
Настройки:
extends Resourceclass_name LocalNetworkClientSettings@exportvar address : String@exportvar port : int@export_group("Security")@exportvar use_dtls : bool = false@exportvar trusted_chain : X509Certificate@exportvar common_name_override : String@export_group("Additional Settings")@exportvar channel_count : int = 0@exportvar in_bandwidth : int = 0@exportvar out_bandwidth : int = 0@exportvar local_port : int = 0
Дополнение с авторизацией:
@abstractextends LocalNetworkClientclass_name LocalNetworkAuthClient@exportvar auth_timeout_sec : float = 3.0func _enter_tree() -> void:super()(multiplayer as SceneMultiplayer).auth_timeout = auth_timeout_sec(multiplayer as SceneMultiplayer).auth_callback = _auth_callback(multiplayer as SceneMultiplayer).peer_authenticating.connect(_peer_authenticating)(multiplayer as SceneMultiplayer).peer_authentication_failed.connect(_on_peer_authentication_failed)func _exit_tree() -> void:super()(multiplayer as SceneMultiplayer).peer_authenticating.disconnect(_peer_authenticating)(multiplayer as SceneMultiplayer).peer_authentication_failed.disconnect(_on_peer_authentication_failed)func _auth_callback(peer_id:int, recieved_data:PackedByteArray) -> void:assert(peer_id == 1)var is_authed : bool = type_convert(bytes_to_var(recieved_data), TYPE_BOOL)if is_authed:(multiplayer as SceneMultiplayer).complete_auth(1)func _peer_authenticating(peer_id:int) -> void:assert(peer_id == 1)(multiplayer as SceneMultiplayer).send_auth(1, auth_data())func _on_peer_authentication_failed(peer_id:int) -> void:assert(peer_id == 1)_status = ConnectionStatus.Offline@abstractfunc auth_data() -> PackedByteArray
Сервер
extends Nodeclass_name LocalNetworkServersignal client_connencted(peer_id:int)signal client_disconnected(peer_id:int)enum Mode{Single, Listener, Gate, Finisher}@exportvar working_mode : Mode = Mode.Single@exportvar server_settings : LocalNetworkServerSettingsvar online : bool :get:return _is_onlinevar _is_online : bool = falsefunc _enter_tree() -> void:if (multiplayer as SceneMultiplayer).root_path != get_path():var mp_api : SceneMultiplayer = SceneMultiplayer.create_default_interface()get_tree().set_multiplayer(mp_api, get_path())multiplayer.peer_connected.connect(_peer_connected)multiplayer.peer_disconnected.connect(_peer_disconnected)func _ready() -> void:if working_mode == Mode.Single or working_mode == Mode.Listener:(multiplayer as SceneMultiplayer).server_relay = server_settings.server_relayvar peer : ENetMultiplayerPeer = ENetMultiplayerPeer.new()var err : Error = peer.create_server(server_settings.port, server_settings.max_clients,server_settings.max_channels, server_settings.in_bandwidth, server_settings.out_bandwidth)if err != OK:returnif server_settings.use_dtls:peer.host.dtls_server_setup(TLSOptions.server(server_settings.private_key,server_settings.cert))multiplayer.multiplayer_peer = peer_is_online = truefunc _exit_tree() -> void:multiplayer.peer_connected.disconnect(_peer_connected)multiplayer.peer_disconnected.disconnect(_peer_disconnected)if working_mode == Mode.Single or working_mode == Mode.Finisher:multiplayer.multiplayer_peer = null(multiplayer as SceneMultiplayer).clear()func _peer_connected(peer_id:int) -> void:client_connencted.emit(peer_id)func _peer_disconnected(peer_id:int) -> void:client_disconnected.emit(peer_id)
Настройки:
extends Resourceclass_name LocalNetworkServerSettings@exportvar port : int@export_group("Security")@exportvar use_dtls : bool = false@exportvar cert : X509Certificate@exportvar private_key : CryptoKey@export_group("Additional settings")@exportvar server_relay : bool = false@exportvar max_clients : int = 32@exportvar max_channels : int = 0@exportvar in_bandwidth : int = 0@exportvar out_bandwidth : int = 0
Дополнение с авторизацией:
@abstractextends LocalNetworkServerclass_name LocalNetworkAuthServer@export_range(1.0, 60.0)var authenticate_timeout_sec : float = 3.0func _enter_tree() -> void:super()(multiplayer as SceneMultiplayer).auth_timeout = authenticate_timeout_sec(multiplayer as SceneMultiplayer).auth_callback = _auth_callbackfunc _auth_callback(peer_id:int, auth_data:PackedByteArray) -> void:if _authenticate_connection(peer_id, auth_data):(multiplayer as SceneMultiplayer).send_auth(peer_id, var_to_bytes(true))(multiplayer as SceneMultiplayer).complete_auth(peer_id)else:(multiplayer as SceneMultiplayer).send_auth(peer_id, var_to_bytes(false))@abstractfunc _authenticate_connection(peer_id:int, auth_data:PackedByteArray) -> bool
И дальше подробнее поговорим об авторизации, так как это вещь, которая встречается буквально в любом проекте и для ее реализации хотелось бы иметь готовые инструменты.
GST
В этом разделе рассмотрим именно механизм авторизации. Ему почти всегда предшествует процесс аутентификация, но он обычно заключается то в проверке юзернейма и пароля, то в работе со сторонним API (например, через Steam), поэтому заранее его разработать на все случаи жизни не выйдет.
Механизм авторизации предлагаю списать с других решений. В принципе возможно только два варианта, — хранение данных авторизации (права, юзернейм и прочее) на сервере и на клиенте. Как правило первому соответствует подход session_id, когда клиенту передается некоторый уникальный идентификатор сессии, а второму — JWT, когда клиенту передаются все данные авторизации, подписанные сервером.
Чтобы не гадать, что выбрать, посмотрим прямо в нашу предметную область: Epic Games, Unity, Nakama. Перечисленные сервисы вовсю используют токены, то есть хранят данные авторизации на клиенте, и я не вижу причин идти против общей практики и хранить данные на сервере. У каждого из подходов есть свои преимущества, но ключевое преимущества токенов — децентрализация авторизации. Серверам не нужно обращаться к некоторой «базе авторизаций» за получением информации и, соответственно, пропадает узкое место в инфраструктуре. Любой сервер просто проверяет токен, полученный от клиента при авторизации на игровом сервере через уже известный нам механизм в Godot Engine.
То есть мы хотим использовать JWT? Не совсем так. Во-первых, с ним ситуация такая же, как и с HTTP — это понятие стандартизировано и его реализация весьма трудоемка. Во-вторых, данные в JWT передаются открытым текстом, а игровые сервера для скорости обычно работают без шифрования. Есть, конечно, защищенный вариант, но это тоже стандарт…
Логика изобретения GDTPS повторяется и я предлагаю спроектировать некоторый свой вариант токена. Пораскинув мозгами, понимаем, что к нему предъявляются два требования: пригодность для передачи по открытому каналу и скорость извлечения и проверки данных. Первое означает, по сути, что данные должны быть зашифрованы. Как зашифрованы? На это отвечает второе требование. Ассиметричное шифрование достаточно трудоемко, и даже тот же TLS использует его только на этапе рукопожатия, а дальше основной поток данных шифруется симметрично.
Поэтому мы возьмем данные авторизации, симметрично их зашифруем и это и будет наш токен. Теперь рассмотрим детали реализации. Хороший, широкоиспользуемый и проверенный алгоритм симметричного шифрования — это AES. Godot Engine поддерживает его из коробки, поэтому грех им не воспользоваться.
Вообще говоря, существует два алгоритма, а именно AES-ECB и AES-CBC. Чтобы не утопать в деталях криптографии (как будто я разбираюсь) я просто скажу, что ECB является менее безопасным, а CBC требует начальное состояние, называемое вектором инициализации (iv, initialization vector). Плата небольшая, поэтому возьмем AES-CBC.
В Godot Engine алгоритмы AES доступны через класс AESContext. У него всего четыре очень понятных метода, поэтому давайте остановимся на структуре токена. Как уже сказано выше, AES-CBC требует начальное состояние, причем оно должно быть криптографически стойко случайным и длиной ровно 16 байт. Вектор инициализации требуется как для шифрования, так и для дешифрования, поэтому он должен быть известен принимающей стороне.
В нашем случае принимающей стороной является сервер, так как он дешифрует и проверяет токен. На наше благо, вектор инициализации не является секретом и его можно спокойно передавать открытым текстом при условии соблюдения его криптостойкой случайности. Так мы и поступим, то есть вместе с зашифрованными данными поместим в токен 16 байт инициализирующего вектора.
На этом можно было бы и остановится, но мне еще для верности хочется добавить подпись. Все таки мы получаем данные непонятно откуда и если их молча дешифровать и попытаться прочитать содержимое, то можно нарваться на неприятности. В качестве подписи используем все такой же распространенный механизм HMAC-SHA256, который поддерживается движком буквально одним вызовом Crypto.hmac_digest. Эту подпись мы будем считать от инициализирующего вектора плюс шифротекста и поместим в самое начало токена.
Итак, получаем следующую структуру токена длиной N байт (токен это просто массив байт):
Название группы
Положение
Описание
[HMAC]
байты с 0 по 31
Код аутентификации (подпись)
[IV]
байты с 32 по 47
Инициализирующий вектор для алгоритма AES-CBC
[CIPHERTEXT]
байты с 48 по (N — 1)
Зашифрованная полезная нагрузка
Такой токен назовем GST — Godot Secure Token. Осталось обсудить, что будет находится в полезной нагрузке. Туда можно поместить любую сериализованную переменную, так мы и сделаем. Но практически необходимой функциональностью является понятие времени жизни токена, поэтому поместим в нагрузку дополнительно информацию о времени истечения жизни токена. «Стандартизируем», что в качестве нагрузки токен несет в себе сериализованный словарь со следующими полями:
Имя
Тип
Описание
data
Variant
Данные авторизации, определяемые бизнес логикой
live_until_datetime
String
Строка в формате ISO 8601 (YYYY-MM-DDTHH:MM:SS), в которой указывается время смерти токена. После этого времени использовать токен нельзя.
Также для нашего токена нам, очевидно, требуется ключ, которым мы будем шифровать нагрузку. Но и тут не все очевидно, так как одного ключа шифрования для токена недостаточно. Еще требуется ключ для получения подписи, и по соображениям безопасности, он не должен совпадать с ключом шифрования. Выходит, что нам нужно два ключа, которые удобно обозвать одним ключом токена GST и поместить в ресурс:
extends Resourceclass_name GSTKey@exportvar aes_key : PackedByteArray@exportvar hmac_key : PackedByteArray
Длина ключа aes_key может быть 16 или 32 байта, но для однозначности остановимся на 32 байтах. Длина ключа hmac_key — строго 32 байта, так как для подписи используется HMAC-SHA256 (32 байта это 256 бит). Для генерации ключей напишем скрипт:
@toolextends EditorScriptfunc _run() -> void:var crypto : Crypto = Crypto.new()var aes_key : PackedByteArray = crypto.generate_random_bytes(32)var hmac_key : PackedByteArray = crypto.generate_random_bytes(32)var key : GSTKey = GSTKey.new()key.aes_key = aes_keykey.hmac_key = hmac_keyResourceSaver.save(key, "res://Env/gst_key.res")
Все что нужно мы уже обсудили, осталось реализовать кодирование и декодирование. Алгоритм кодирования предельно прост, но важно не забыть, что шифрование AES требует количество байт, кратное 16 байтам:
-
Формирование и сериализация полезной нагрузки на основании данных авторизации
-
Генерация вектора инициализации
-
Выравнивание полезной нагрузки до размера, кратного 16
-
Шифрование полезной нагрузки
-
Вычисление подписи
Дополнение полезной нагрузки до размера, кратного 16 байтам, делается очень просто. В конец массива байт K с полезной нагрузкой добавляется n байт со значением n, где n = 16 - len(K) % 16. Чтобы из выровненного массива получить исходный нужно прочитать значение последнего байта и убрать столько байт с конца. Получается так, что даже если данные уже выровнены до 16 байт, то мы все равно добавим в конец 16 байт, но сочтем это простой издержкой алгоритма.
Алгоритм декодирования просто выполняет те же действия в обратном порядке:
-
Проверка подписи
-
Дешифровка полезной нагрузки
-
Избавление от выравнивания до 16 байт
-
Проверка времени жизни токена
Настало время программирования. Так как сервер, скорее всего, будет постоянно получать токены и проверять их, ключ должен быть как можно доступнее. Поместим его в поле класса, который назовем GSTContext и в котором будет как кодирование токена, так и его декодирование. Начнем с кодирования:
extends Nodeclass_name GSTContext@exportvar key : GSTKey@exportvar time_to_live_min : int = 15 # время жизни новых токенов в минутахfunc encrypt(what:Variant) -> PackedByteArray: # о том, что такое Payload, рассказано нижеvar payload : Payload = Payload.build(what, time_to_live_min)var serialized_payload : PackedByteArray = payload.serialized()_add_padding16(serialized_payload)var aes_ctx : AESContext = AESContext.new()var crypto : Crypto = Crypto.new() # специальная функция, криптостойко генерирующая случайные байтыvar iv : PackedByteArray = crypto.generate_random_bytes(16)aes_ctx.start(AESContext.MODE_CBC_ENCRYPT, key.aes_key, iv)var encrypted : PackedByteArray = aes_ctx.update(serialized_payload)aes_ctx.finish()iv.append_array(encrypted) # [iv] concat [encrypted]var result : PackedByteArray = crypto.hmac_digest(HashingContext.HASH_SHA256, key.hmac_key, iv)result.append_array(iv) # [hmac][iv][encrypted]return resultstatic func _add_padding16(data:PackedByteArray) -> void:const BLOCK_SIZE : int = 16var padding_size : int = BLOCK_SIZE - data.size() % BLOCK_SIZEdata.resize(data.size() + padding_size)for i:int in range(data.size() - padding_size, data.size()):data[i] = padding_size
Код полностью соответствует алгоритму. Теперь декодирование:
# TTL - time to livefunc decrypt(encrypted:PackedByteArray, check_ttl:bool=true) -> DecryptResult:var result : DecryptResult = DecryptResult.new()if encrypted.size() payload.live_until:result.error = DecryptResult.Err.TIMED_OUTreturn resultresult.data = payload.dataresult.error = DecryptResult.Err.OKreturn resultstatic func _remove_padding16(data:PackedByteArray) -> void:var padding_size : int = data[-1]if data.size() <= padding_size:returndata.resize(data.size() - padding_size) class DecryptResult:extends RefCountedenum Err{OK, INVALID_DATA, INVALID_PAYLOAD, INVALID_DIGEST, TIMED_OUT}var data : Variant = nullvar error : Err = Err.OK
И в классе Payload содержится вся логика работы с полезной нагрузкой, то есть соблюдение формата словаря, преобразование времени, сериализация:
class Payload:extends RefCountedvar data : Variant :get:return _datavar live_until : int :get:return _live_untilstatic func build(p_data:Variant, ttl_min:int) -> Payload:var curtime : int = int(Time.get_unix_time_from_system())var ttl_sec : int = ttl_min * 60var live_until_ : int = curtime + ttl_secvar result : Payload = Payload.new()result._data = p_dataresult._live_until = live_until_return resultstatic func extract(serialized_payload:PackedByteArray) -> Payload:var deserialized : Dictionary = type_convert(bytes_to_var(serialized_payload), TYPE_DICTIONARY)if deserialized.is_empty():return nullvar data_ : Variant = deserialized.get("data")if data_ == null:return nullvar live_until_datetime : String = type_convert(deserialized.get( "live_until_datetime"), TYPE_STRING)if live_until_datetime.is_empty():return nullvar live_until_ : int = Time.get_unix_time_from_datetime_string(live_until_datetime)if live_until_ == 0:return nullvar result : Payload = Payload.new()result._data = data_result._live_until = live_until_return resultfunc serialized() -> PackedByteArray:var live_until_datetime : String = Time.get_datetime_string_from_unix_time(_live_until)var data_to_serialize : Dictionary = {"data" : _data,"live_until_datetime" : live_until_datetime}return var_to_bytes(data_to_serialize)var _data : Variantvar _live_until : int
Итого мы получили способ передачи данных авторизации. Но как же это использовать? Об этом ниже, в заключительной (ура?) главе.
Собираем все вместе
В этой главе рассмотрим взаимодействие описанных механизмов между собой во благо общей цели. Начнем с того, что мы 100% хотим авторизовывать все действия пользователя на наших игровых серверах. Механизм авторизации местного мультиплеера уже известен, а в качестве данных авторизации, как вы наверняка догадались, будет выступать GST.
То есть клиент, подключаясь к игровому серверу, будет обязан передать ему токен. Чтобы получить этот токен, клиенту нужно пройти аутентификацию, то есть передать серверу некоторый секрет, известный только клиенту (например, пару имя пользователя + пароль).
Тут уже прослеживается вопрос-ответ взаимодействие. Клиент передает серверу «Иван Иванов пароль 123» и ждет токен, а сервер проверяет пароль Ивана Иванова и выдает токен, после чего все довольны и соединение мирно завершается. Очевидно, что на сцену самое время выходить GDTPS. Необходимо использовать именно защищенный вариант, так как данные аутентификации являются чувствительными и их мало того что неприлично, даже незаконно передавать открытым текстом.
Вот оно все и пригодилось: у нас в серверной инфраструктуре есть некоторый центр авторизации в виде GDTPS сервиса, который при успешной аутентификации выдает GST токены. Клиент обращается к нему, получает свой токен, и затем с ним идет к центральному игровому серверу и авторизируется в нем при помощи этого токена.
Давайте еще за одно рассмотрим проблему мультилогина, то есть ситуации, при которой активно несколько клиентов, вошедших в систему под одним аккаунтом. Она нам совсем не нужна, так как в таком случае, например, игрок может параллельно фармить сразу несколько локаций или вообще играть сам против себя.
Сейчас в моей схеме ничто не мешает возникновению мультилогина, поэтому в нее нужно что-то добавить. Поступим так: заведем GDTPS сервис сессий, которому каждые условные 5 минут игровой сервер будут сообщать, что игрок с ником Иван Иванов подключен именно к нему и допускать его в другие нельзя.
А центр авторизации при работе с Иванов Ивановым будет обращаться к этому сервису с вопросом, активна ли сейчас сессия Ивана Иванова. Если это так, в выдаче токена будет отказано. Наверняка вы заметили лазейку — уже выданный токен можно передать другим клиентам и войти по нему хоть 100 раз. Будем считать, что подобные махинации слишком дорого обойдутся хакеру и он просто не будет этим заниматься. Не бывает абсолютно безопасных систем, важна лишь стоимость взлома. Такой простой трюк с нашей стороны превращает взлом из бесплатного в дорогой, так как злоумышленнику придется писать отдельный чит.
Итак, получаем такую схему для почти любой онлайн игры:

Бизнес логика тут не затронута совсем, так как она помещается в RPC и логику центра авторизации.
Заключение
Вот, в общем то, и все. Я не затронул еще несколько полезных тем, вроде конфигурации онлайн приложения через аргументы командной строки или управления процессами комнат. Несмотря на это все равно получилось достаточно много материала.
Во время разработки всего этого дела у меня появилось большое количество различных проектов и вариаций описанных механизмов. Одним из проектов делюсь: скачать. Там многие вещи отличаются от того, что описано тут (например GDTPS использует блокирующие функции), и плюс к этому есть многие вообще неописанные. Это как затравочка тем полутора читателям, что дошли до сюда прочитав все и до сих пор желающих изучить тему поглубже 🙂
Напоминаю еще раз: код не тестировался, и использовать его в продакшене нельзя. Более того, многие вещи еще делать и делать и, в общем то, развивать эти идеи можно до бесконечности вплоть до полноценного фреймворка.
Такой вот туториал по неткоду в Godot Engine вышел, на час чтения. Впредь прошу дарить эмоции своими играми не только благодаря красоте и геймплею, но и благодаря возможности товарищества и здравой конкуренции. До скорого!
Источник: habr.com