The Jerusalem PostFormer hostage Romi Gonen reveals footage from moments she was taken to GazaPunchArmy confirms killing of ISWAP leader in OndoESPN'If we bunt, we win': How the Rays developed a secret weaponBollywood HungamaSanjay Leela Bhansali and Alia Bhatt planning to revive Inshallah after Love And War: REPORTUN NewsOne day behind bars: Students and incarcerated women break prison stigma in ThailandInquirerEl Niño to bring drought to all provinces by March 2027 – DOSTSky TG24Baby Gang resta in carcere, gip respinge domiciliari: "È in rete criminale internazionale"Observador DesportoMoedas: "As lutas ideológicas nunca deram casa a ninguém"Il Fatto Quotidiano“Dal ‘ti amo’ all’addio in una settimana, questo è lui”: Elon Musk lascia Shivon Zilis all’improvviso e lei pubblica le loro chat privateRTL BoulevardKeizer Naruhito woont openingsceremonie Japans parlement bijOnetDonald Tusk zapytany o list żelazny dla posła PiS. Premier wskazał, co było błędem7sur7“Malgré la maladie, il a continué à créer”: le rappeur français Prince Waly est mort à 34 ans
The Daily Newsstand · Free, Always
Monday, October 5, 2026

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

Translate

Привет, Хабр! Я DevOps-инженер в компании hex.team. Расскажу, как мы прятали сервер за цепочкой прокси: скрывали его реальный IP, строили из прокси-узлов цепочку для гео-распределения трафика — и при этом умудрялись сохранять IP клиента.

В hex.team мы среди прочего занимаемся кибербезопасностью АСУ ТП и анализом защищённости сетей и информационных систем. Поэтому такие задачи возникают у нас регулярно — инфраструктуру приходится проектировать с расчётом, что её будут целенаправленно ломать, а не просто случайно просканируют вместе со всем интернетом. Этим опытом и делимся.

Задача пришла из одного из клиентских проектов, детали которого я раскрывать не буду, но сама инженерная постановка вопроса универсальна и наверняка знакома многим, кто занимается инфраструктурой.

Легенда

Есть сервер, который принимает HTTPS-трафик на 443 порту и ещё произвольный TCP/UDP-трафик на других портах — например, игровой сервер или кастомный бинарный протокол поверх TCP/UDP. Сервер живёт в интернете и напрямую отвечает на запросы клиентов.

Проблема в том, что реальный IP такого сервера постоянно светится наружу — виден в DNS, в трейсроутах, да и просто любой желающий может его просканировать и понять, что там крутится и на каких портах. Причём речь не о теоретической угрозе: для клиента это означало реальный риск целевых атак на инфраструктуру в обход любых периметровых защит — достаточно узнать IP один раз, и все дальнейшие меры на уровне DNS/CDN/WAF теряют смысл, потому что до сервера можно достучаться напрямую.

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

При этом задача сразу усложнялась парой требований:

  1. Нужна была возможность выстроить цепочку из нескольких таких прокси-узлов — например, чтобы клиент подключался к ближайшему географически узлу, а тот уже проксировал трафик дальше, до конечного сервера. Это давало сразу два эффекта: гео-распределение (ниже задержка для клиентов в разных регионах) и дополнительный слой изоляции реального сервера — между клиентом и бэкендом может быть не один, а несколько узлов.

  2. Реальный 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

iptables Process Flow — схема прохождения пакета через таблицы и цепочки netfilter

iptables Process Flow — схема прохождения пакета через таблицы и цепочки 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 для всего остального — и с какими новыми сложностями это столкнуло нас в свою очередь.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.