Прячем сервер за прокси. Часть 1: iptables и его пределы

Привет, Хабр! Я DevOps-инженер в компании hex.team. Расскажу, как мы прятали сервер за цепочкой прокси: скрывали его реальный IP, строили из прокси-узлов цепочку для гео-распределения трафика — и при этом умудрялись сохранять IP клиента.
В hex.team мы среди прочего занимаемся кибербезопасностью АСУ ТП и анализом защищённости сетей и информационных систем. Поэтому такие задачи возникают у нас регулярно — инфраструктуру приходится проектировать с расчётом, что её будут целенаправленно ломать, а не просто случайно просканируют вместе со всем интернетом. Этим опытом и делимся.
Задача пришла из одного из клиентских проектов, детали которого я раскрывать не буду, но сама инженерная постановка вопроса универсальна и наверняка знакома многим, кто занимается инфраструктурой.
Легенда
Есть сервер, который принимает HTTPS-трафик на 443 порту и ещё произвольный TCP/UDP-трафик на других портах — например, игровой сервер или кастомный бинарный протокол поверх TCP/UDP. Сервер живёт в интернете и напрямую отвечает на запросы клиентов.
Проблема в том, что реальный IP такого сервера постоянно светится наружу — виден в DNS, в трейсроутах, да и просто любой желающий может его просканировать и понять, что там крутится и на каких портах. Причём речь не о теоретической угрозе: для клиента это означало реальный риск целевых атак на инфраструктуру в обход любых периметровых защит — достаточно узнать IP один раз, и все дальнейшие меры на уровне DNS/CDN/WAF теряют смысл, потому что до сервера можно достучаться напрямую.
Отсюда возникло желание скрыть реальный IP: поставить перед сервером один или несколько промежуточных узлов, чтобы клиент никогда не видел настоящий адрес бэкенда напрямую.
При этом задача сразу усложнялась парой требований:
Нужна была возможность выстроить цепочку из нескольких таких прокси-узлов — например, чтобы клиент подключался к ближайшему географически узлу, а тот уже проксировал трафик дальше, до конечного сервера. Это давало сразу два эффекта: гео-распределение (ниже задержка для клиентов в разных регионах) и дополнительный слой изоляции реального сервера — между клиентом и бэкендом может быть не один, а несколько узлов.
Реальный IP клиента был важен только для HTTP-трафика — он нужен приложению для логов, антифрода и гео-детекции на уровне бизнес-логики. Для остального TCP/UDP-трафика такого требования не было — там важна была только доставка пакетов, а не то, с какого IP они пришли изначально.
Это первая статья из серии в три части про то, как мы шли от простого проброса портов через iptables к унифицированной схеме на Nginx Stream. В этой части — только про iptables: как он устроен, как с его помощью решить задачу проксирования, и где мы упёрлись в стену.
Почему в первую очередь iptables
iptables — самый очевидный кандидат, когда нужно перенаправить трафик, приходящий на сервер, куда-то ещё. Он есть практически в любом Linux-дистрибутиве из коробки, не требует поднимать отдельный процесс-прокси, и работает на уровне ядра — то есть накладные расходы на обработку пакета минимальны по сравнению с проксированием на уровне приложения.
Чтобы понять, что вообще можно с ним сделать и где будут границы возможностей, стоит сначала разобраться, как он устроен.
Как устроен iptables
Таблицы и цепочки
iptables — это интерфейс к netfilter, подсистеме ядра Linux для обработки и модификации сетевых пакетов. Правила группируются в таблицы (tables) по своему назначению, а внутри таблиц — в цепочки (chains), которые соответствуют точкам на пути пакета через сетевой стек.
Всего таблиц пять:
raw— самая ранняя точка обработки, ещё до подсистемы отслеживания соединений (conntrack). Используется, чтобы пометить пакеты как не подлежащие отслеживанию (NOTRACK) — это полезно для высоконагруженных транзитных потоков, где трекать состояние соединения не нужно и накладно.mangle— модификация служебных полей пакета: TTL, TOS/DSCP (приоритизация трафика), простановка меток (MARK) для policy routing — например, чтобы направить трафик через конкретный интерфейс или таблицу маршрутизации.nat— трансляция адресов: DNAT (подмена адреса назначения) и SNAT/MASQUERADE (подмена адреса источника). Именно эта таблица нам и нужна для проксирования.filter— фильтрация трафика (разрешить/запретить), таблица по умолчанию, если явно не указать другую через-t.security— применяется послеfilter, используется модулями мандатного контроля доступа (например, SELinux) для маркировки пакетов метками безопасности. В большинстве сценариев, включая наш, эта таблица не используется вовсе — но знать о её существовании полезно, чтобы понимать полную картину.
Внутри каждой таблицы — свой набор цепочек (не каждая таблица использует все пять), которые соответствуют точкам, в которых пакет может быть перехвачен на своём пути через сетевой стек ядра:
PREROUTING— пакет только что попал на сервер, решение о маршрутизации ещё не принято;INPUT— пакет предназначен самому серверу (локальному процессу);FORWARD— пакет транзитный, идёт через сервер дальше (это как раз наш случай проксирования);OUTPUT— пакет генерируется локальным процессом на самом сервере;POSTROUTING— пакет уже готов покинуть сервер, маршрут выбран.
На каком уровне OSI это работает
iptables/netfilter работает на сетевом и транспортном уровнях (L3/L4) модели OSI — он оперирует IP-адресами, портами и протоколами (TCP/UDP/ICMP), но не заглядывает внутрь полезной нагрузки пакета (в отличие, например, от HTTP-прокси, который разбирает данные на уровне приложения, L7).
Именно поэтому iptables одинаково легко перенаправляет любой TCP- или UDP-трафик, независимо от того, что там внутри — HTTPS, игровой протокол или что-то кастомное. Но по этой же причине он не может заглянуть внутрь пакета и, например, добавить туда информацию о реальном IP клиента — на его уровне абстракции такого понятия просто не существует.
Полная схема пути пакета через netfilter

Разберём схему по шагам.
Входящий пакет сначала попадает в цепочку PREROUTING, где последовательно проходит через таблицы raw (можно исключить пакет из отслеживания conntrack) → conntrack (отслеживание состояния соединения) → mangle (модификация служебных полей) → nat (здесь происходит DNAT — подмена адреса назначения).
Дальше ядро решает: этот пакет предназначен самому серверу или должен идти дальше транзитом (“For this host?” на схеме).
Если пакет для самого сервера — он уходит в цепочку
INPUT:mangle→filter, и попадает на локальную обработку (Local Processing) — то есть в приложение, которое работает на этом же сервере.Если пакет транзитный — он уходит в цепочку
FORWARD:mangle→filter, где принимается решение ACCEPT/DROP. Затем пакет попадает вPOSTROUTING:mangle→nat(здесь происходит SNAT/MASQUERADE — подмена адреса источника), и в виде исходящего пакета покидает сервер.
Отдельная ветка на схеме справа — путь для пакетов, которые генерирует сам сервер (Locally-generated Packet), например ответ локального процесса. Такой пакет сразу проходит через решение о маршрутизации, затем цепочку OUTPUT: raw → conntrack → mangle → nat → filter, и уже оттуда попадает в общую для всех пакетов цепочку POSTROUTING.
Наш случай — это транзитный пакет. Клиент подключается к прокси-узлу, а не к самому серверу-бэкенду, поэтому пакет проходит именно по правой ветке схемы:
PREROUTING (raw → nat/DNAT) → «For this host?» → N →
FORWARD (filter: ACCEPT/DROP) → POSTROUTING (nat/SNAT) → исходящий пакет
Именно два шага в таблице nat на этом пути — DNAT в PREROUTING и SNAT в POSTROUTING — и определяют всё поведение, которое нас интересует: DNAT перенаправляет пакет на реальный бэкенд, а SNAT на обратном пути подменяет source IP на IP прокси-узла. Это тот самый механизм, из-за которого бэкенд перестаёт видеть настоящий IP клиента — к этой проблеме мы вернёмся чуть ниже.
Для наглядности ниже — та же самая транзитная ветка, но подробнее и с привязкой к таблицам, которые мы перечислили выше (raw, mangle, nat, filter; таблица security на диаграмме не показана, поскольку в подавляющем большинстве конфигураций, включая наш случай, она не используется):
входящий пакет
│
▼
┌───────────────────────────────┐
│ PREROUTING │
│ raw → mangle → nat │ ← DNAT здесь:
│ (NOTRACK) (TTL/MARK) (DNAT) │ подмена адреса
└────────────────┬────────────────┘ назначения
│
решение о маршрутизации
(пакет "наш" или транзитный?)
│
┌────────────────┴────────────────┐
▼ ▼
┌──────────────────┐ ┌──────────────────────┐
│ INPUT │ │ FORWARD │
│ mangle → filter → │ │ mangle → filter → │ ← наш случай:
│ security │ │ security │ filter решает,
│ (для локальных │ │ (ACCEPT/DROP для │ пропустить ли
│ процессов) │ │ транзитных пакетов) │ транзитный пакет
└──────────────────┘ └───────────┬──────────┘
│
▼
┌───────────────────────┐
│ POSTROUTING │
│ mangle → nat │ ← SNAT/MASQUERADE
│ (TTL/MARK) (SNAT) │ здесь: подмена
└────────────┬────────────┘ source IP
│
▼
исходящий пакет
(уходит на бэкенд)
Разберём, что происходит на каждом этапе применительно к нашей задаче:
raw(PREROUTING) — самая ранняя точка. Если нужно исключить пакет из отслеживания conntrack (например, для высоконагруженного UDP-потока, где состояние соединения не важно), это делается здесь черезNOTRACK. В базовом варианте задачи мы этим не пользовались — conntrack нам был полезен, — но для UDP на большом объёме трафика это осмысленная оптимизация.mangle(PREROUTING) — здесь можно проставить метки на пакет или поменять служебные поля до принятия решения о маршрутизации. В нашем случае не использовалась, но пригодилась бы, если нужно, например, направлять разные порты через разные исходящие интерфейсы.nat(PREROUTING) — здесь происходит DNAT: адрес назначения пакета подменяется с IP прокси-узла на IP реального бэкенда. Именно это правило и делает “проброс порта” в привычном понимании.Решение о маршрутизации — после
PREROUTINGядро решает, предназначен ли пакет самому серверу (тогда он идёт вINPUT) или должен быть передан дальше (тогда — вFORWARD). Поскольку адрес назначения уже подменён на DNAT-этапе на внешний (относительно локальных процессов) IP бэкенда, пакет уходит именно вFORWARD.mangle/filter/security(FORWARD) — вfilterздесь принимается решение ACCEPT/DROP для транзитного трафика. Именно тут легко словить проблему “DNAT есть, а трафик всё равно не идёт” — если политикаFORWARDпо умолчаниюDROP, а явного правилаACCEPTдля нужного порта нет.securityв нашем случае не задействована.mangle(POSTROUTING) — снова возможность подправить служебные поля перед выходом пакета с интерфейса.nat(POSTROUTING) — здесь происходит SNAT/MASQUERADE: адрес источника подменяется на IP прокси-узла. Без этого шага бэкенд не сможет корректно ответить — либо у него нет маршрута до клиента напрямую, либо клиент просто не примет ответ от неизвестного ему IP бэкенда.
Именно на стыке двух шагов в таблице nat — DNAT в PREROUTING и SNAT в POSTROUTING — и находится корень главной проблемы, к которой мы сейчас перейдём: подменяя source IP на этапе POSTROUTING, мы теряем информацию о реальном IP клиента ещё до того, как пакет попадёт на бэкенд.
Примеры настройки
Проброс HTTPS (443/tcp)
iptables -t nat -A PREROUTING -p tcp --dport 443 \
-j DNAT --to-destination 10.0.0.10:443
Проброс произвольного TCP-порта
iptables -t nat -A PREROUTING -p tcp --dport 9000 \
-j DNAT --to-destination 10.0.0.10:9000
Проброс UDP-порта (например, игровой сервер)
iptables -t nat -A PREROUTING -p udp --dport 27015 \
-j DNAT --to-destination 10.0.0.10:27015
Маскарадинг для обратного трафика
iptables -t nat -A POSTROUTING -j MASQUERADE
Разрешение транзитного трафика
По умолчанию политика цепочки FORWARD в таблице filter может быть DROP — тогда транзитные пакеты просто не пройдут дальше DNAT, даже если адрес назначения подменён правильно. Нужно явно разрешить форвардинг для нужных портов:
iptables -A FORWARD -p tcp -d 10.0.0.10 --dport 443 -j ACCEPT
iptables -A FORWARD -p tcp -d 10.0.0.10 --dport 9000 -j ACCEPT
iptables -A FORWARD -p udp -d 10.0.0.10 --dport 27015 -j ACCEPT
И включить форвардинг пакетов между интерфейсами на уровне ядра (без этого сервер просто не будет передавать транзитные пакеты дальше, независимо от правил iptables):
sysctl -w net.ipv4.ip_forward=1
Чтобы это осталось после перезагрузки, стоит закрепить параметр в /etc/sysctl.conf или /etc/sysctl.d/*.conf:
echo 'net.ipv4.ip_forward = 1' >> /etc/sysctl.d/99-forwarding.conf
sysctl --system
Как сохранить правила между перезагрузками
Отдельная деталь, про которую легко забыть: правила iptables, введённые командами выше, живут только в памяти ядра и пропадают при перезагрузке сервера. Чтобы этого не произошло, правила нужно явно сохранить и восстанавливать при старте системы.
На Debian/Ubuntu для этого обычно используется пакет iptables-persistent:
apt install iptables-persistent
netfilter-persistent save
Он сохраняет текущие правила в /etc/iptables/rules.v4 (и rules.v6 для IPv6) и восстанавливает их автоматически при загрузке системы через systemd-юнит.
Вручную то же самое можно сделать через iptables-save/iptables-restore:
# Сохранить текущие правила в файл
iptables-save > /etc/iptables/rules.v4
# Восстановить правила из файла
iptables-restore < /etc/iptables/rules.v4
Второй вариант полезен, если нужно версионировать правила в git или деплоить их через Ansible/другую систему конфигурации — файл с правилами получается декларативным и его удобно просто “накатывать” целиком, а не выполнять последовательность отдельных команд.
А что насчёт nftables
nftables — преемник iptables в ядре Linux, доступный начиная с ядра 3.13 и постепенно ставший стандартом де-факто в современных дистрибутивах (в Debian/Ubuntu команда iptables уже давно на самом деле является обёрткой над nftables-бэкендом через iptables-nft).
Принципиальная механика (таблицы, цепочки, хуки netfilter) у него та же самая, но синтаксис компактнее, а сами правила можно описывать одним блоком вместо последовательности отдельных команд:
nft add table nat
nft add chain nat prerouting { type nat hook prerouting priority 0 \; }
nft add chain nat postrouting { type nat hook postrouting priority 100 \; }
nft add rule nat prerouting tcp dport 443 dnat to 10.0.0.10:443
nft add rule nat prerouting udp dport 27015 dnat to 10.0.0.10:27015
nft add rule nat postrouting masquerade
Или тем же самым декларативным файлом:
table nat {
chain prerouting {
type nat hook prerouting priority 0;
tcp dport 443 dnat to 10.0.0.10:443
udp dport 27015 dnat to 10.0.0.10:27015
}
chain postrouting {
type nat hook postrouting priority 100;
masquerade
}
}
Для задачи, которую мы решаем в этой серии статей, nftables не меняет картину принципиально — все те же ограничения по сохранению IP клиента остаются в силе, потому что это ограничение самого уровня абстракции (L3/L4), а не конкретной реализации инструмента. Но если вы настраиваете подобную схему с нуля сегодня — разумно сразу смотреть в сторону nftables: он активно развивается, тогда как iptables в классическом виде уже официально считается устаревшим.
##Плюсы и минусы такого решения
Плюсы:
Работает на уровне ядра — минимальные накладные расходы на обработку пакета по сравнению с проксированием на уровне приложения.
Не требует поднимать и поддерживать отдельный процесс — используется штатная подсистема ядра.
Единообразно работает с любым протоколом поверх TCP/UDP — не важно, что внутри пакета.
Простая начальная настройка для одного бэкенда и одного узла.
Минусы:
MASQUERADE/SNAT подменяет source IP пакета — реальный IP клиента теряется на бэкенде. Для трафика, где IP клиента важен (в нашем случае — HTTP), это неприемлемо без дополнительных костылей поверх (например,TPROXY, который сам по себе сложнее в настройке и хуже поддерживает UDP и TLS-роутинг по SNI).При выстраивании цепочки из нескольких узлов задача усложняется многократно — на каждом хопе снова теряется/подменяется IP, а собрать цельную картину прохождения одного соединения через несколько узлов по логам iptables практически нереально.
Таблица
conntrack, отслеживающая соединения, может стать узким местом при большом количестве одновременных соединений — лимит нужно поднимать и мониторить отдельно.Отладка на уровне логических соединений неудобна: iptables логирует по правилам и пакетам, а не по сессиям.
Заключение
Чистый iptables (или его современный аналог nftables) — рабочее и лёгкое по накладным расходам решение для простого проброса трафика на один бэкенд, если реальный IP клиента не имеет значения. Как только появляются два требования одновременно — сохранить IP для части трафика (в нашем случае HTTP) и выстроить цепочку из нескольких узлов, — инструмент начинает требовать всё более сложных конструкций поверх себя, которые тяжело поддерживать и отлаживать.
В следующей части разберём первый шаг к решению этой проблемы: как мы точечно закрыли вопрос с IP для HTTP-трафика, добавив HTTP-прокси перед бэкендом и оставив iptables для всего остального — и с какими новыми сложностями это столкнуло нас в свою очередь.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.