[Из песочницы] Пишем Policy server на C++ для Unity3d
Зачем нужен policy server?
В Unity, начиная с версии 3.0, для сборок под Web player используются механизмы обеспечения безопасности, похожие на те, что использует Adobe в Flash player. Суть его заключается в том, что при обращении к серверу клиент спрашивает у него «разрешение», и если сервер не «разрешает», то клиент не будет пробовать к нему подключиться. Данные ограничения работают для обращения к удаленным серверам через класс WWW и с помощью сокетов. Если вы хотите сделать какой-либо запрос по rest протоколу из вашего клиента на удаленный сервер, необходимо, чтобы в корне домена лежал специальный xml. Он должен называться crossdomain.xml и иметь следующий формат:
<?xml version="1.0"?>
<cross-domain-policy>
<allow-access-from domain="*"/>
</cross-domain-policy>
Клиент перед запросом скачает файл политики безопасности, проверит его и, увидев, что разрешены все домены, продолжит выполнять сделанный вами запрос.
Если вам потребуется подключиться к удаленному серверу с помощью сокетов (tcp/udp), перед подключением клиент сделает запрос к серверу на 843 порт для получения файла политики безопасности, в котором будет описано к каким портам и с каких доменов можно подключаться:
<?xml version="1.0"?>
<cross-domain-policy>
<allow-access-from domain="*" to-ports="1200-1220"/>
</cross-domain-policy>"
Если данные клиента не удовлетворяют всем параметрам (домен, порт), то клиент сгенерирует исключение SecurityException и не будет пытаться подключиться к серверу.
В данной статье речь пойдет о написании сервера, который будет отдавать файлы политики безопасности, в дальнейшем я буду называть его Policy server.
Как должен работать Policy server?
Схема работы сервера проста:
- Сервер запускается и слушает 843 порт по tcp протоколу. Есть возможность переопределить порт Security.PrefetchSocketPolicy()
- Клиент подключается к серверу по tcp протоколу и отправляет xml c запросом файла политики безопасности:
<policy-file-request/>
- Сервер разбирает запрос и отправляет клиенту xml с политикой безопасности
На практике процесс разбора запроса не имеет никакого смысла. Значение имеет время, которое клиент ожидает до получения файла политики безопасности, так как оно увеличивает задержку до подключения к целевому порту. Мы можем модифицровать процесс работы сервера и отдавать клиенту файл политики безопасности сразу же после подключения.
Что уже есть?
На текущий момент есть сервер, написанный на связке Java + Netty, исходный код с инструкцией и jar. Одним из ключевых его недостатков является зависимость от jre. В целом, развернуть jre на linux сервере не проблема, но часто разработчики игр — это клиентские программисты, которые хотят делать как можно меньше телодвижений, тем более они не хотят устанавливать jre и позже его администрировать. Поэтому было принято решение написать Policy server на C++, который работал бы как нативное приложение на linux машине.
Написанный на C++ Policy server не должен уступать по производительности старому, в идеале должен показывать результат намного лучше. Ключевыми метриками производительности будут: время, которое клиент тратит на ожидание файла политики безопасности, и количество клиентов, которые могут одновременно получать файлы политики безопасности, что, по сути, тоже сводится к времени ожидания файла политики.
Для тестирования я использовал этот скрипт. Работает он следующим образом:
- Вычиcляет средний пинг до сервера
- Запускает несколько потоков (количество указывается в скрипте)
- В каждом потоке запрашивает у Policy server'а файл политики безопасности
- Если файл политики соответствует тому, который ожидается, то для каждого запроса сохраняется время, потраченное на ожидание
- Выводит результаты в консоль. Нас интересуют следующие значения: минимальное время ожидания, максимальное время ожидания, среднее время ожидания и те же параметры без пинга
Скрипт написан на ruby, но так как в стандартном интерпретаторе ruby отсутствует поддержка потоков уровня операционной системы, для работы я использовал jruby. Удобней всего использовать rvm, команда для запуска скрипта будет выглядеть так:
rvm jruby do ruby test.rb
Результаты тестирования Policy server'а, написанного на Java+Netty:
Среднее, мс | 245 |
---|---|
Минимальное, мс | 116 |
Максимальное, мс | 693 |
Что понадобится?
По сути, задача — написать на C++ демон, который мог бы слушать несколько портов, при подключении клиентов создавать сокет, копировать в сокет текстовую информацию и закрывать его. Желательно иметь как можно меньше зависимостей, а если они все-таки будут, то они должны быть в репозиториях наиболее распространенных linux дистрибутивов. Для написания кода будем использовать стандарт c++11. В качестве минимального набора библиотек возьмем:
- libev для работы с циклом событий приложения
- boost program_options для работы с параметрами командной строки
- Easylogging++ для работы с логами
Один порт — один поток
Структура приложения достаточно проста: понадобится функционал для работы с параметрами командной строки, классы для работы с потоками, функционал для работы с сетью, функционал для работы с логами. Это простые вещи, с которыми не должно возникнуть проблем, поэтому я не буду на них подробно останавливаться. Код можно посмотреть здесь. Проблемным местом является организация обработки клиентских запросов. Самое простое решение — после подключения клиентского сокета отправлять все данные и сразу же закрывать сокет. Т.е. код, отвечающий за обработку нового подключения будет выглядеть так:
void Connector::connnect(ev::io& connect_event, int )
{
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
int client_sd;
client_sd = accept(connect_event.fd, (struct sockaddr *)&client_addr, &client_len);
if(client_sd < 0)
return;
const char *data = this->server->get_text()->c_str();
send(client_sd, (void*)data, sizeof(char) * strlen(data), 0);
shutdown(client_sd, 2);
close(client_sd);
}
При попытке протестировать на большом количестве потоков (300, по 10 подключений на каждый), я не смог дождаться окончания работы тестовго скрипта. Из чего можно сделать вывод, что данное решением нам не подходит.
Async
Операция передачи данных по сети является затратной по времени, очевидно, что необходмио разделить процесс создания клиентского сокета и процесс отправки данных. Также неплохо было бы отдавать данные несколькими потоками. Неплохое решение — использовать std::async, который появился в стандарте C++11 async. Код, отвечающий за обработку нового подключения будет выглядеть так:
void Connector::connnect(ev::io& connect_event, int )
{
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
int client_sd;
client_sd = accept(connect_event.fd, (struct sockaddr *)&client_addr, &client_len);
std::async(std::launch::async, [client_addr, this](int client_socket) {
const char * data = this->server->get_text()->c_str();
send(client_socket, (void*)data, sizeof(char) * strlen(data), 0);
shutdown(client_socket, 2);
close(client_socket);
}, client_sd);
}
Минусом такого решения является безконтрольность ресурсов. Минимальным вмешательством в код мы получаем возможность асинхронно выполнять отправку данных клиенту, при этом мы не можем контролировать процесс порождения новых потоков. Процесс создания нового потока является затратным для операционной системы, также большое количество потоков может понизить производительность сервера.
Pub/Sub
Подходящим решением для данной задачи является паттерн издатель-подписчик. Схема работы сервера должна выглядеть следующим образом:
- Несколько издателей, по одному на каждый порт, сохраняют в буфер идентификаторы сокетов клиентов, которым надо отдать файл политики безопасности
- Несколько подписчиков получают из буфера идентификаторы сокетов, копируют в них файл политики безопасности и закрывают сокет.
В качестве буфера подходит очередь, первым подключился к серверу — первым получишь файл политики. В стандартной библиотеке C++ есть готовый контейнер очереди, но он нам не подойдёт, так как требуется потокобезопасная очередь. При этом нам необходимо, чтобы операция добавления нового элемента была не блокирующей, в то время как операция чтения была блокирующей. Т.е при старте сервера будут запущены несколько подписчиков, которые будут ждать пока очередь пуста. Как только там появляются данные, один или несколько обработчиков срабатывают. Издатели же асинхронно записывают в данную очередь идентификаторы сокетов.
Немного погуглив, я нашел несколько готовых реализаций:
- https://github.com/cameron314/concurrentqueue.
В данном случае нас интересует blockingconcurrentqueue, которая просто копируется в проект как заголовочный .h файл. Достаточно удобно, и нет никаких зависимостей. Данное решение обладает следующими минусами:- Нет методов для остановки подписчиков. Единственный способ остановить их — добавлять в очередь данные, которые будут сигнализировать подписчикам о том, что надо остановить работу. Это достаточно неудобно и потенциально может вызвать дедлок
- Поддерживается одним человеком, коммиты в последнее время появляются достаточно редко
- Нет методов для остановки подписчиков. Единственный способ остановить их — добавлять в очередь данные, которые будут сигнализировать подписчикам о том, что надо остановить работу. Это достаточно неудобно и потенциально может вызвать дедлок
- tbb concurrent queue.
Многопоточная очередь из библиотеки tbb (Threading Building Blocks). Библиотека разрабатывается и поддерживается Intel, при этом имеет все что нам необходимо:- Блокирующее чтение из очереди
- Неблокирующая запись в очередь
- Возможность в любой момент остановить заблокированные на ожидании данных потоки
Среди минусов можно отметить, что такое решение увеличивает количество зависимостей, т.е. конечным пользователям придется устанавливать на свой сервер tbb. В наиболее рапспространенных linux-репозиториях tbb можно установить через менеджер пакетов операционной системы, поэтому проблем с зависимостями быть не должно.
Таким образом код создания нового подключения будет выглядеть следующим образом:
void Connector::connnect(ev::io& connect_event, int )
{
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
int client_sd;
client_sd = accept(connect_event.fd, (struct sockaddr *)&client_addr, &client_len);
clients_queue()->push(client_sd);
this->handled_clients++;
}
Код обработки клиентского сокета:
void Handler::run()
{
LOG(INFO) << "Handler with thread id " << this->thread.get_id() << " started";
while(this->is_run)
{
int socket_fd = clients_queue()->pop();
this->handle(socket_fd);
}
LOG(INFO) << "Handler with thread id " << this->thread.get_id() << " stopped";
}
Код для работы с очередью:
void ClientsQueue::push(int client)
{
if(!this->queue.try_push(client))
LOG(WARNING) << "Can't push socket " << client << " to queue";
}
int ClientsQueue::pop()
{
int result;
try
{
this->queue.pop(result);
}
catch(...)
{
result = -1;
}
return result;
}
void ClientsQueue::stop()
{
this->queue.abort();
}
Код всего проекта с инструкцией по установке можно найти здесь. Результат тестового запуска с десятью потоками обработчиками:
Среднее, мс | 151 |
---|---|
Минимальное, мс | 100 |
Максимальное, мс | 1322 |
Итог
Сравнительаня таблица результатов
Java+Netty | C++ Pub/Sub | |
---|---|---|
Среднее, мс | 245 | 151 |
Минимальное, мс | 116 | 100 |
Максимальное, мс | 693 | 1322 |
Ссылки:
PS: На текущий момент Unity Web player переживает тяжелые времена, в связи с закрытием npapi в топовых браузерах. Но если кто либо еще использует его и держит сервера на linux машинах, то может воспользоваться данным сервером, надеюсь он окажется вам полезным. Отдельная благодарность themoonisalwaysspyingonyourfears за иллюстрацию к статье.