В первой статье серии я рассказывал, как VOG пережил почти тридцать лет и перезапуск. Эта статья — первая из технических, и начинается она с вполне бытовой задачи: игровые серверы нужно было перенести на площадку подешевле. Переезд прошёл гладко, кроме одного: серверы перестали видеть, с каких адресов к ним подключаются игроки.
В статье
- Зачем серверу адрес клиента
- Почему HAProxy в режиме TCP его теряет
- Какие были варианты
- Как устроен PROXY protocol
- Доработка сервера: парсер
- Доработка сервера: второй слушающий сокет
- Настройка HAProxy
- Что в этой доработке стоит исправить
- Тот же приём по всему стеку
- Итог
Зачем серверу адрес клиента
Старый игровой сервер VOG написан на C++ и принимает от Windows-клиента долгоживущие TCP-соединения со своим бинарным протоколом. Адрес на другом конце соединения нужен для администрирования. Другим игрокам он не показывается: флаг рядом с ником — это страна, которую игрок сам указал в профиле, с ней он себя и ассоциирует, а откуда он на самом деле подключается, остальным знать незачем. Администрации же адрес нужен: по нему работают баны и разбор жалоб, он вместе со страной по GeoIP пишется в журнал подключений, без которого нечего анализировать, когда что-то пошло не так.
Пока сервер стоял на машине с публичным адресом, всё это работало само: accept() возвращал адрес клиента, и сервер брал его оттуда.
Почему HAProxy в режиме TCP его теряет
На новой площадке у игровых серверов нет публичного адреса. Снаружи остался публичный вход, на нём HAProxy, и он в режиме TCP пробрасывает соединения игроков дальше, на закрытую площадку, по двум каналам.
адрес игрока"] -->|TCP| H["HAProxy
публичный вход"] H -->|"новое TCP-соединение
канал 1"| S["игровой сервер C++
закрытая площадка"] H -.->|"канал 2"| S
flowchart LR
C["Windows-клиент
адрес игрока"] -->|TCP| H["HAProxy
публичный вход"]
H -->|"новое TCP-соединение
канал 1"| S["игровой сервер C++
закрытая площадка"]
H -.->|"канал 2"| S
В режиме TCP HAProxy не разбирает содержимое: он принимает соединение от игрока, открывает своё соединение до сервера и перекладывает байты в обе стороны. Для сервера на другом конце теперь всегда HAProxy. В журнале все игроки разом оказались с одного адреса — локального адреса HAProxy, с которого приходит последнее перед сервером подключение. Бан по IP превратился бы в бан всех сразу.
Какие были варианты
- Прозрачный прокси (TPROXY). HAProxy подставляет в исходящее соединение адрес клиента. Но ответные пакеты сервер отправит этому адресу напрямую, мимо HAProxy, и для этого нужна особая маршрутизация на стороне сервера. Между разными площадками такую маршрутизацию без отдельных туннелей не построить.
- Заголовок
X-Forwarded-For. Хорошо знаком по HTTP, но он живёт внутри HTTP-запроса. У игрового протокола никаких заголовков нет. - Передавать адрес в своём протоколе. Пришлось бы менять протокол и выпускать новый клиент для всех игроков. Для системы, где клиенты обновляются годами, это отпадает сразу.
- PROXY protocol. HAProxy перед первым байтом данных клиента отправляет серверу короткий заголовок с адресами. Клиент об этом ничего не знает и не меняется, меняется только сервер.
Выбор был очевиден: доработать один серверный бинарник проще, чем все клиенты.
Как устроен PROXY protocol
Протокол придумали в HAProxy, его понимают nginx, Angie, Postfix и большинство балансировщиков. Версий две, обе описаны в спецификации PROXY protocol.
Версия 1 — одна текстовая строка, которая заканчивается \r\n:
PROXY TCP4 203.0.113.7 198.51.100.10 51544 3331\r\nЗдесь протокол, адрес клиента, адрес назначения, порт клиента и порт назначения. Строка не длиннее 107 байт. Если адрес неизвестен, приходит PROXY UNKNOWN.
Версия 2 — бинарный заголовок. Он начинается с 12-байтной сигнатуры, которую не спутаешь с началом никакого текстового протокола:
байты 0–11 сигнатура 0D 0A 0D 0A 00 0D 0A 51 55 49 54 0A
байт 12 версия (старшие 4 бита) и команда (младшие 4 бита): 0x21 = v2, PROXY
байт 13 семейство адресов и протокол: 0x11 = IPv4/TCP, 0x21 = IPv6/TCP
байты 14–15 длина оставшейся части заголовка (адреса и TLV), big-endian
далее адрес клиента, адрес назначения, порт клиента, порт назначенияДля IPv4 адреса занимают 12 байт, для IPv6 — 36. За ними могут идти дополнительные поля TLV, и поле длины учитывает их тоже. Команда LOCAL вместо PROXY означает, что соединение открыл сам HAProxy, например для проверки здоровья, и подставлять адрес не надо.
Различить версии можно по первому байту: у v1 это P, у v2 — 0x0D. Этим и пользуется парсер.
Доработка сервера: парсер
Доработка уложилась в один новый модуль на 246 строк и правку цикла приёма соединений. Ниже фрагменты работающей реализации как есть. У неё есть ограничения, и копировать её без исправлений не стоит: они разобраны в разделе «Что в этой доработке стоит исправить».
Интерфейс модуля минимальный: на вход — только что принятый сокет, на выходе — адрес клиента или текст ошибки.
namespace vog::network {
struct ProxyResult {
bool success{false};
std::string clientIp;
uint16_t clientPort{0};
std::string error;
};
/// Parse PROXY protocol header (v1 text or v2 binary) from a newly accepted socket.
/// Must be called immediately after accept(), before any application data is read.
/// Blocks with timeout waiting for the header.
ProxyResult parseProxyProtocol(SOCKET sock, int timeoutMs = 3000);
} // namespace vog::network
Главное правило записано прямо в комментарии: заголовок читается сразу после accept() и до любых данных приложения. Заголовок и данные клиента идут одним потоком, и если хоть один байт заголовка попадёт в разбор игрового протокола, соединение сломается.
Отсюда вторая особенность. Байты заголовка нельзя отдать игровому парсеру. Можно читать с запасом и сохранять остаток в буфере соединения, но наша реализация выбрала путь проще: читать ровно столько, сколько занимает заголовок. Для этого две вспомогательные функции. recvExact дочитывает ровно n байт, ожидая каждую порцию через select с таймаутом:
static ssize_t recvExact(SOCKET sock, void *buf, size_t n, int timeoutMs) {
size_t total = 0;
auto *p = static_cast<uint8_t *>(buf);
while (total < n) {
fd_set fds;
FD_ZERO(&fds);
FD_SET(sock, &fds);
timeval tv{};
tv.tv_sec = timeoutMs / 1000;
tv.tv_usec = (timeoutMs % 1000) * 1000;
int sel = select(sock + 1, &fds, nullptr, nullptr, &tv);
if (sel <= 0) return -1; // timeout or error
ssize_t r = recv(sock, p + total, n - total, 0);
if (r <= 0) return -1; // closed or error
total += r;
}
return static_cast<ssize_t>(total);
}Вторая, recvLine, нужна для v1: она читает по одному байту до \r\n. По байту — потому что длина строки заранее неизвестна, а читать с запасом значило бы сохранять остаток в буфере соединения. Строку она ограничивает 107 байтами, как требует спецификация.
Точка входа читает первый байт и по нему выбирает версию:
ProxyResult parseProxyProtocol(SOCKET sock, int timeoutMs) {
uint8_t firstByte;
if (recvExact(sock, &firstByte, 1, timeoutMs) < 0) {
return {false, {}, 0, "failed to read first byte"};
}
if (firstByte == 0x0D) {
return parseV2(sock, firstByte, timeoutMs);
}
if (firstByte == 'P') {
return parseV1(sock, firstByte, timeoutMs);
}
return {false, {}, 0, "unrecognized protocol (first byte: 0x" +
std::to_string(firstByte) + ")"};
}Разбор v1 — это проверка префикса PROXY , отказ на UNKNOWN и разбор строки на слова через strtok_r: протокол, адрес клиента, адрес назначения, порты. Разбор v2 интереснее, он работает с байтами:
static ProxyResult parseV2(SOCKET sock, uint8_t firstByte, int timeoutMs) {
uint8_t header[kV2HeaderLen]; // 16 байт: сигнатура + 4 байта
header[0] = firstByte;
if (recvExact(sock, header + 1, kV2HeaderLen - 1, timeoutMs) < 0)
return {false, {}, 0, "v2: failed to read header"};
if (memcmp(header, kV2Signature, kV2SignatureLen) != 0)
return {false, {}, 0, "v2: invalid signature"};
uint8_t verCmd = header[12];
uint8_t famProto = header[13];
uint16_t addrLen = (static_cast<uint16_t>(header[14]) << 8) | header[15];
uint8_t version = (verCmd >> 4) & 0x0F;
uint8_t command = verCmd & 0x0F;
if (version != 2)
return {false, {}, 0, "v2: unsupported version " + std::to_string(version)};
// блок адресов дочитываем всегда — даже если он нам не нужен
std::vector<uint8_t> addrBuf(addrLen);
if (addrLen > 0 && recvExact(sock, addrBuf.data(), addrLen, timeoutMs) < 0)
return {false, {}, 0, "v2: failed to read address block"};
if (command == 0x00) return {false, {}, 0, "v2: LOCAL command (use socket addr)"};
if (command != 0x01) return {false, {}, 0, "v2: unknown command " + std::to_string(command)};
uint8_t family = (famProto >> 4) & 0x0F;
if (family == 0x01) { // IPv4: 4 + 4 + 2 + 2 = 12 байт
if (addrLen < 12)
return {false, {}, 0, "v2: IPv4 address block too short"};
char ipStr[INET_ADDRSTRLEN];
in_addr srcAddr{};
memcpy(&srcAddr, addrBuf.data(), 4);
inet_ntop(AF_INET, &srcAddr, ipStr, sizeof(ipStr));
uint16_t srcPort = (static_cast<uint16_t>(addrBuf[8]) << 8) | addrBuf[9];
return {true, ipStr, srcPort, {}};
}
// IPv6 — так же, адрес 16 байт, порт по смещению 32
...
}Обратите внимание: остаток заголовка дочитывается целиком, по полю длины, вместе с TLV, даже если они не нужны. Это обязательно, если соединение дальше обслуживают: иначе хвост заголовка попадёт в игровой протокол. Наш код при LOCAL соединение закрывает, так что сейчас это скорее задел. Почему закрывать LOCAL неправильно, разобрано ниже.
Доработка сервера: второй слушающий сокет
Вторая часть доработки — в самом сервере. PROXY protocol не включается на основном порту. Сервер не может угадать, будет ли в начале соединения заголовок, а к основному порту приходят и соединения без него, например проверки здоровья. Поэтому для соединений через HAProxy сервер открывает отдельный порт, и включается он переменной окружения:
auto *vgoProxyPort = getenv("VGO_PROXY_PORT");
if (vgoProxyPort != nullptr) {
m_ProxyListenPort = std::stoi(vgoProxyPort);
}Если переменная задана, при старте открывается второй слушающий сокет с теми же опциями, что и основной: SO_REUSEADDR, размеры буферов, listen(SOMAXCONN). Если не задана, сервер работает как раньше. Это удобно и для отката, но откатывать нужно обе стороны: сначала на HAProxy вернуть обычный порт и убрать send-proxy-v2, затем убрать переменную и перезапустить сервер. Если убрать только переменную, HAProxy продолжит слать заголовок в порт, который больше никто не слушает.
В главном цикле сервер ждёт событий на обоих сокетах через один select. Новое соединение на прокси-порту обрабатывается так:
if (m_hProxyListenSocket != -1 && FD_ISSET(m_hProxyListenSocket, &fds)) {
SOCKET cSocket = accept(m_hProxyListenSocket, (sockaddr *) &clientAddr, &clientAddrLength);
if (cSocket != -1) {
auto proxyResult = vog::network::parseProxyProtocol(cSocket);
if (proxyResult.success) {
const char *ip = proxyResult.clientIp.c_str();
const auto country = mGeoIpDb.getCountryCodeByIp(ip);
// Convert string IP back to in_addr for VGOConnection
in_addr proxyAddr{};
inet_pton(AF_INET, ip, &proxyAddr);
logger->info("[CONNECT] [ACCEPT-PROXY] S# {} IP[{}@{}]", cSocket, ip, country);
auto *vgoc = new vog::network::VGOConnection(cSocket, proxyAddr, ip, country);
vgoc->m_vgocID = ++m_lastvgocID;
m_vgold.AppendQueueConnect(vgoc);
} else {
logger->warn("[PROXY] Failed to parse header on S# {}: {}", cSocket, proxyResult.error);
close(cSocket);
}
}
}Дальше соединение живёт обычной жизнью: в VGOConnection уже лежат реальный адрес игрока и страна по GeoIP для журнала, и остальной код сервера не знает, что соединение пришло через посредника. Ради этого всё и делалось: изменения сосредоточены в одном месте, а не размазаны по серверу.
Настройка HAProxy
У игрового сервера два входа:
- 3331 — вход с обязательным PROXY-заголовком, сюда HAProxy пробрасывает игроков;
- 3341 — обычный вход без заголовка, сюда ходят проверки здоровья.
На стороне HAProxy включается одна опция у сервера в бэкенде — send-proxy-v2. Вторая версия бинарная: первые 16 байт всегда одинаковой длины и сразу сообщают, сколько читать дальше, поэтому разбирать её проще, чем строку.
defaults
mode tcp
timeout connect 5s
timeout client 50s
timeout server 50s
frontend vog_shell
bind *:3331
timeout client 1h
default_backend bk_vog_shell
backend bk_vog_shell
balance roundrobin
timeout server 1h
timeout tunnel 2h
default-server inter 2s downinter 5s rise 3 fall 2
server game_ch1 10.0.1.10:3331 check port 3341 send-proxy-v2
server game_ch2 10.0.2.10:3331 check port 3341 send-proxy-v2Две детали здесь важнее самой опции.
Таймауты. Игровое соединение может часами молчать, пока игрок думает над ходом или просто сидит в комнате. Общие таймауты в нашей конфигурации — 50 секунд на сторону клиента и сервера, они рассчитаны на сайты и рвали бы такие соединения. Поэтому игровым портам таймауты подняты. Здесь же признание: в боевом конфиге timeout client 1h стоял в секции backend. HAProxy принимает его только в defaults, frontend и listen, а в бэкенде игнорирует. Долгие соединения при этом не рвались благодаря timeout tunnel 2h: в режиме TCP после установки соединения он заменяет и клиентский, и серверный таймаут. В примере выше всё стоит на своих местах.
Проверка здоровья на другом порту. У HAProxy есть тонкость. Если у сервера включён send-proxy-v2 и проверки идут на тот же адрес и порт, они тоже отправляют PROXY-заголовок. Но как только в check переопределён port или addr, заголовок в проверках пропадает, и чтобы вернуть его, нужна опция check-send-proxy. Мы сделали проще: проверки идут на обычный вход 3341, где заголовок не ждут. Важно понимать, что проверяет такая проверка: только то, что сервер принимает TCP-соединения. Работает ли игровая логика, она не скажет.
Что в этой доработке стоит исправить
Доработка работает в бою с апреля 2026 года. Но при разборе для статьи я нашёл в ней несколько мест, которые стоит показать честно — это типичные ошибки при добавлении PROXY protocol в старый код.
1. Заголовок может читаться значительно дольше заданного таймаута и задерживает приём. parseProxyProtocol вызывается прямо в цикле, который принимает соединения на обоих портах. Уже подключённые игроки этого не заметят: их соединения обслуживает отдельный диспетчер. Но пока одно новое соединение не дочитано, никто другой подключиться не может.
И это ожидание не ограничено тремя секундами, как может показаться по параметру. recvExact выставляет таймаут заново перед каждым select, и каждый вызов recvExact получает его заново. Отправитель, который присылает по байту чуть чаще раза в три секунды, растянет чтение строки v1, а она может занимать до 107 байт, на несколько минут. С v2 хуже: recvLine длину строки v1 ограничивает, а блок адресов v2 читается ровно на столько, сколько указано в поле длины, — до 65 535 байт. Тем же приёмом такое чтение растягивается в теории на часы, так что поле длины тоже стоит ограничить разумным пределом.
Исправлений здесь два, и нужны оба. Неблокирующий разбор, то есть отдать новое соединение дальше в состоянии «ждём заголовок» и разбирать его по мере прихода данных, убирает задержку для остальных подключений. Общий дедлайн на весь заголовок ограничивает жизнь незавершённого подключения. Простое уменьшение таймаута не решает ни то, ни другое. Дедлайн выглядит так:
using Clock = std::chrono::steady_clock;
static ssize_t recvExact(SOCKET sock, void *buf, size_t n, Clock::time_point deadline) {
size_t total = 0;
auto *p = static_cast<uint8_t *>(buf);
while (total < n) {
auto left = std::chrono::duration_cast<std::chrono::milliseconds>(
deadline - Clock::now()).count();
if (left <= 0) return -1; // время на весь заголовок вышло
fd_set fds;
FD_ZERO(&fds);
FD_SET(sock, &fds);
timeval tv{};
tv.tv_sec = left / 1000;
tv.tv_usec = (left % 1000) * 1000;
if (select(sock + 1, &fds, nullptr, nullptr, &tv) <= 0) return -1;
ssize_t r = recv(sock, p + total, n - total, 0);
if (r <= 0) return -1;
total += r;
}
return static_cast<ssize_t>(total);
}
// в parseProxyProtocol — один раз, и дальше передавать во все чтения:
// const auto deadline = Clock::now() + std::chrono::milliseconds(timeoutMs);
2. IPv6 теряется. Парсер умеет IPv6, но дальше адрес переводится в in_addr через inet_pton(AF_INET, ...). Для IPv6 этот вызов вернёт ошибку, которую никто не проверяет, и в VGOConnection попадёт нулевой адрес. Пока публичный вход принимает только IPv4, ошибка спит. Исправление — хранить адрес как sockaddr_storage или строку и проверять результат inet_pton.
3. Прокси-порту доверяют всем. Сервер не проверяет, от кого пришло соединение на прокси-порт. Кто сумеет подключиться к нему напрямую, тот пришлёт любой заголовок и выдаст себя за любой адрес. Сейчас защита держится на сети: порт недоступен снаружи площадки. Надёжнее дублировать её в коде — сверять адрес из accept() со списком адресов HAProxy до разбора заголовка:
SOCKET cSocket = accept(m_hProxyListenSocket, (sockaddr *) &clientAddr, &clientAddrLength);
if (cSocket != -1 && !isTrustedProxy(clientAddr.sin_addr)) {
logger->warn("[PROXY] Rejected S# {} from untrusted peer", cSocket);
close(cSocket);
continue;
}В зрелых реализациях это два разных действия. В Angie доверие к адресу из заголовка задаётся через set_real_ip_from: от остальных отправителей заголовок просто не используется для замены адреса. Доступ к самому порту нужно ограничивать отдельно. Наш набросок делает и то и другое разом: чужое соединение закрывается.
Заодно полезно писать в журнал оба адреса: от кого пришло соединение по accept() и кого назвал заголовок. Сейчас журнал хранит только второй, и разобрать по нему, через какой балансировщик пришёл игрок, нельзя:
char peer[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &clientAddr.sin_addr, peer, sizeof(peer));
logger->info("[CONNECT] [ACCEPT-PROXY] S# {} via {} IP[{}@{}]", cSocket, peer, ip, country);4. LOCAL и UNKNOWN закрывают соединение. По спецификации это допустимые заголовки: отправитель говорит «адреса клиента нет, используй адрес соединения». Парсер в этих случаях возвращает success=false, и вызывающий код закрывает сокет. Текст ошибки «use socket addr» при этом обещает то, чего никто не делает. С нашей схемой проверок это не мешает, но любой отправитель, который честно скажет «адреса нет», получит закрытое соединение. Правильнее различать три исхода, а не два:
enum class ProxyStatus {
Address, // заголовок передал адрес клиента
UseSocketAddress, // LOCAL или UNKNOWN: оставить адрес из accept()
Error, // заголовок битый или не пришёл — закрыть
};5. Транспорт в v2 не проверяется. Байт 13 несёт и семейство адресов, и транспорт, а код смотрит только на старшие четыре бита. Заголовок IPv4/UDP разберётся как IPv4/TCP. Для TCP-сервера стоит принимать ровно два значения байта: 0x11, IPv4/TCP, и 0x21, IPv6/TCP.
6. Мелочь в тексте ошибки. При неизвестном первом байте сообщение печатает его через std::to_string, то есть десятичным числом, но с префиксом 0x. Байт 0x16, начало TLS-рукопожатия, в журнале выглядит как 0x22. На работу не влияет, но однажды запутает того, кто читает журнал.
Тот же приём по всему стеку
PROXY protocol у VOG работает не только в игровых серверах, а везде, где трафик проходит через тот же публичный вход.
- Angie перед сайтом принимает заголовок прямо в директиве
listenи подставляет адрес клиента для журналов и ограничений частоты запросов:
server {
listen 7443 ssl proxy_protocol;
set_real_ip_from 10.0.0.2;
set_real_ip_from 10.0.0.3;
real_ip_header proxy_protocol;
...
}- Вход в Kubernetes тоже принимает PROXY protocol от HAProxy. Здесь была своя ошибка: настройку перенесли на площадку, где HAProxy перед кластером нет, и слушатель стал ждать заголовок, которого никто не присылал. Симптом характерный: TCP соединяется, а TLS-рукопожатие тут же обрывается сбросом,
Connection reset by peer. Если соединение устанавливается и сразу рвётся на первых байтах, первым делом стоит проверить, совпадает ли PROXY protocol на обоих концах.
Правило одно на всех: PROXY protocol включается парой. Отправляет только тот, кто стоит перед сервером, а принимает только тот, кто знает, что перед ним этот отправитель.
Итог
- Режим TCP у HAProxy прячет адрес клиента, и для протокола без заголовков
X-Forwarded-Forне поможет. - PROXY protocol решает задачу без изменений в клиентах: меняется только сервер, а клиенты, которые обновляются годами, ничего не замечают.
- Доработка старого сервера — один модуль и одна точка в цикле приёма. Читать заголовок сразу после
accept()и ровно по длине, дочитывать блок адресов целиком, включать отдельным портом через окружение, а откатывать с обеих сторон, начиная с HAProxy. - Проверки здоровья требуют внимания. В нашей конфигурации проверки идут на обычный порт без заголовка и подтверждают только доступность TCP-входа. Если проверять нужно именно вход с заголовком, его включают явно через
check-send-proxy. - Слабые места типичные: нет общего дедлайна на заголовок, разбор блокирует приём,
LOCALзакрывает соединение, транспорт не проверяется, IPv6 теряется, а прокси-порту доверяют без проверки отправителя.
Дальше в серии — порядок вывода легаси: почему мы переносим игры по одной и почему на каждом шаге меняется только что-то одно.
Комментарии