Как это всё работает: iptables с нуля, максимально подробно

0 comments0 reviews

iptables — это не файрвол сам по себе. Файрвол встроен в ядро Linux и называется netfilter. Это несколько «крючков» (hooks) — точек на пути каждого сетевого пакета, где ядро останавливает пакет и спрашивает: «что с ним делать?». iptables — merely утилита командной строки, которая загружает в ядро правила: «в такой точке, для таких пакетов — делай то-то».

Два следствия, которые надо понять сразу:

  • Правила живут в памяти ядра и начинают действовать мгновенно, с первого же пакета.
  • При перезагрузке они пропадают — поэтому в конце нужен netfilter-persistent save, который записывает их на диск, и netfilter-persistent при старте системы загружает обратно.

Каждый пакет, приходящий на сетевую карту, проходит через машину по фиксированному маршруту. Вот он целиком:

пакет пришёл на сетевую карту
        │
        ▼
[1] PREROUTING  (таблица nat)      ← DNAT работает ЗДЕСЬ: подмена адреса НАЗНАЧЕНИЯ
        │
        ▼
   РЕШЕНИЕ О МАРШРУТИЗАЦИИ
   «этот пакет адресован мне или его надо передать дальше?»
        │
   ┌────┴─────────────┐
   │ мне              │ мимо (на другой хост)
   ▼                  ▼
[2] INPUT          [3] FORWARD      (таблица filter)
   │                  │
   ▼                  ▼
локальная         [4] POSTROUTING   (таблица nat)  ← MASQUERADE работает ЗДЕСЬ:
программа              │                               подмена адреса ИСТОЧНИКА
(ssh, nginx...)        ▼
                    уходит с сетевой карты

И отдельно для пакетов, рождённых самим сервером (исходящих):

локальная программа → OUTPUT (filter) → POSTROUTING (nat) → сетевая карта

Что важно понять из этой схемы:

  • Цепочка (chain) — это упорядоченный чек-лист правил. Пакет идёт по нему сверху вниз, и каждое правило — это вопрос «подходит ли пакет под условия?». Первое подошедшее правило выполняет своё действие, и пакет дальше по списку не идёт.
  • Пакет, который сервер просто передаёт дальше (форвардит), никогда не попадает в INPUT — он идёт через PREROUTING → FORWARD → POSTROUTING. Это ключевой момент: на джампхосте проброшенный трафик фильтруется цепочкой FORWARD, а не INPUT.
  • DNAT обязан стоять в PREROUTING, потому что подмена адреса назначения должна произойти до решения о маршрутизации — именно по (уже подменённому) адресу назначения ядро решает, куда слать пакет.
  • MASQUERADE обязан стоять в POSTROUTING — последней точке перед выходом, потому что подменять источник надо на IP того интерфейса, через который пакет реально уйдёт (а это известно только после маршрутизации).

У netfilter несколько таблиц, нам нужны две:

ТаблицаНазначениеЦепочки, которые в ней есть
filterПропустить / выбросить пакет (собственно файрвол)INPUT, FORWARD, OUTPUT
natПереписать IP-адреса в заголовке пакетаPREROUTING, OUTPUT, POSTROUTING

(Есть ещё mangle и raw — для экзотики вроде маркировки пакетов, мы их не трогаем.)

Если в команде не указано -t <таблица>, iptables считает, что вы имеете в виду filter. Поэтому iptables -A INPUT ... — это правило в filter, а iptables -t nat -A PREROUTING ... — в nat.

Возьмём правило из скрипта джампхоста и разберём каждое слово:

iptables -t nat -A PREROUTING -p tcp --dport 443 -j DNAT --to-destination 3.3.3.3
ФрагментЧто означает
iptablesзапустить утилиту настройки netfilter
-t natработать с таблицей nat (переписывание адресов)
-A PREROUTINGAppend — добавить правило в конец цепочки PREROUTING
-p tcpусловие: протокол TCP
--dport 443условие: порт назначения (destination port) = 443 — то, куда стучатся
-j DNATjump: если всё выше совпало — прыгнуть в обработчик DNAT (Destination NAT)
--to-destination 3.3.3.3параметр DNAT: на какой адрес заменить назначение

DNAT переписывает в IP-заголовке пакета поле «адрес получателя». Было «кому: 2.2.2.2, порт 443» → стало «кому: 3.3.3.3, порт 443». Порт сохраняется, потому что в --to-destination мы указали только IP без порта. Сам пакет при этом физически остаётся тем же — меняется только адрес на конверте, и ядро дальше маршрутизирует его уже на 3.3.3.3.

Другие флаги, которые встречаются в скриптах:

  • -s 1.1.1.1 — условие по адресу источника (от кого пакет)
  • -d 3.3.3.3 — условие по адресу назначения
  • -i lo — условие по входному интерфейсу (lo — loopback, внутренний интерфейс 127.0.0.1)
  • --sport — порт источника
  • -p icmp / -p udp — другие протоколы
  • -P INPUT DROP — Policy: действие по умолчанию для пакетов, которые дошли до конца цепочки и ни под одно правило не подошли
  • -F — Flush, очистить цепочку от всех правил
  • -D — удалить правило (та же команда с -D вместо -A)
  • -I — вставить правило в начало цепочки (вместо конца)

Это то, что делает Linux-файрвол «умным» (stateful). Ядро автоматически записывает в таблицу (conntrack table) каждое соединение: кто, кому, по каким портам, каким протоколом. У пакета с точки зрения этой таблицы есть состояние:

  • NEW — первый пакет соединения (например, TCP SYN)
  • ESTABLISHED — пакет уже существующего, разрешённого соединения
  • RELATED — побочный трафик другого соединения (классика: ICMP-ошибки, FTP data-канал)
  • INVALID — мусор, не относящийся ни к чему

Отсюда правило, которое есть в обоих скриптах:

iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT

Читается так: «пропускай без вопросов всё, что является ответом на соединение, которое уже было разрешено». Именно поэтому нам не надо писать зеркальные правила для обратного трафика: клиент подключился к SSH (NEW — проверили, оператор, пропустили), и все дальнейшие пакеты этой сессии в обе стороны летят как ESTABLISHED — моментально, без повторных проверек.

Второе следствие conntrack, критичное для NAT: правила nat применяются только к первому пакету соединения. Когда первый пакет прошёл через DNAT+MASQUERADE, ядро запомнило трансляцию, и все остальные пакеты этого соединения (и в одну, и в другую сторону) переписываются автоматически, по таблице. Правило nat — это не «конвейер для каждого пакета», а «инструкция, что делать с новым потоком».

#sysctl -w net.ipv4.ip_forward=1

sysctl — это переключатель параметров ядра. По умолчанию Linux в режиме «я не роутер»: любой пакет, пришедший не на мои адреса, выбрасывается ещё до цепочки FORWARD. Этот флаг включает форвардинг — разрешение ядру передавать транзитные пакеты дальше. Без него всё остальное бессмысленно.

#Цикл по портам — три правила на порт

iptables -t nat -A PREROUTING -p tcp --dport "$p" -j DNAT --to-destination "$BACKEND"

Пакет пришёл на 2.2.2.2:443. Ещё до решения о маршрутизации ядро переписывает адрес назначения на 3.3.3.3. Маршрутизация смотрит: «3.3.3.3 — не я, надо передать через такой-то интерфейс» → пакет становится транзитным и идёт в FORWARD.

iptables -t nat -A POSTROUTING -p tcp -d "$BACKEND" --dport "$p" -j MASQUERADE

Последняя точка перед выходом. MASQUERADE переписывает источник: было «от: случайный-клиент», стало «от: 2.2.2.2». Зачем — смотрите сценарий B ниже, без этого ответы бэкенда не находят дорогу обратно. (MASQUERADE — это SNAT, который сам берёт IP выходного интерфейса; классический SNAT с зашитым IP быстрее, но MASQUERADE проще и работает при динамическом IP.)

iptables -A FORWARD -p tcp -d "$BACKEND" --dport "$p" -j ACCEPT

Пропуск в фильтре для транзитного трафика. На многих системах (особенно с Docker) политика FORWARD = DROP, и без явного ACCEPT проброс молча не работал бы, хотя nat-правила верны.

iptables -A FORWARD -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT

Обратный трафик (бэкенд → клиент) — тоже транзитный, тоже проходит FORWARD. По нему правило выше не матчится (у него destination = клиент, а не бэкенд), поэтому пропускаем его как ESTABLISHED.

#Блок INPUT джампхоста

iptables -F INPUT                       # очистить только цепочку INPUT в filter
iptables -A INPUT -i lo -j ACCEPT       # loopback
iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -A INPUT -p icmp --icmp-type echo-request -j ACCEPT
iptables -A INPUT -s "$OPERATOR" -p tcp --dport "$JH_SSH_PORT" -j ACCEPT
iptables -P INPUT DROP
  • lo — это не кабель, а внутренний интерфейс, через который процессы сервера общаются сами с собой (приложение с локальной базой, мониторинг и т.д.). Закрыть его — сломать половину локальных сервисов, поэтому пропускаем всегда.
  • ICMP echo-request — это и есть «ping». Джампхост — публичное лицо, пинговать его можно всем.
  • SSH только с 1.1.1.1 — любой другой, кто сканирует 2.2.2.2, в порт SSH стукнется и уткнётся в тишину.
  • Policy DROP — всё, что не подошло ни под одно правило, выбрасывается.
iptables -F                 # очистить все цепочки таблицы filter
iptables -P INPUT DROP      # всё входящее по умолчанию — выбросить
iptables -P FORWARD DROP    # этот сервер ничего не форвардит, он не роутер

Дальше — короткий белый список: loopback, ответы на исходящие соединения, пинги только с 2.2.2.2 и 1.1.1.1, SSH на 228 только с них же, рабочие порты только с них же. Заметьте: про цепочку OUTPUT мы ничего не пишем — её политика остаётся ACCEPT, сервер сам может инициировать любые исходящие (DNS, обновления). Входящий к нему доступ закрыт, а не он из интернета.

#Про «невидимость»: DROP против REJECT

У файрвола два способа сказать «нет»:

  • REJECT — вежливо ответить «закрыто»: на TCP придёт RST, на UDP — ICMP port-unreachable. Клиент мгновенно узнаёт: «хост есть, порта нет».
  • DROP — молча выбросить пакет. Ничего не answering. Со стороны сканера это выглядит как «filtered» и бесконечный таймаут: хост как будто не существует или дропает всё.

Для задачи «конечный сервер должен быть невидим» выбран DROP по всей цепочке: посторонний пинг — тишина, постороннее TCP-подключение — тишина, никаких признаков жизни.

#Сценарий A: оператор (1.1.1.1) подключается по SSH к джампхосту

  1. Пакет 1.1.1.1 → 2.2.2.2:22 приходит на сетевую карту.
  2. PREROUTING/nat: ни одно nat-правило не матчится (порт 22 не из списка проброса) — адреса не трогаем.
  3. Маршрутизация: адрес 2.2.2.2 — мой → цепочка INPUT.
  4. INPUT: правило lo не подходит; conntrack-правило — нет (это NEW); icmp — нет; а вот -s 1.1.1.1 --dport 22 — совпадение → ACCEPT.
  5. Пакет уходит в sshd. Все следующие пакеты сессии — ESTABLISHED, пропускаются третьим правилом не глядя.

Посторонний на этом же месте дойдёт до конца INPUT и упадёт в политику DROP — тишина.

#Сценарий B: чужой клиент подключается к 2.2.2.2:443 (проброс)

  1. Приходит клиент → 2.2.2.2:443 (первый пакет — TCP SYN).
  2. PREROUTING/nat: матчится DNAT → заголовок переписан: клиент → 3.3.3.3:443. Conntrack запоминает: «поток клиент↔3.3.3.3:443 на входе выглядит как клиент↔2.2.2.2:443».
  3. Маршрутизация: 3.3.3.3 — не я, передать дальше → цепочка FORWARD.
  4. FORWARD/filter: правило –d 3.3.3.3 --dport 443 ACCEPT — совпадение, пропускаем.
  5. POSTROUTING/nat: MASQUERADE переписывает источник: 2.2.2.2 → 3.3.3.3:443.
  6. Пакет физически уезжает с сетевой карты на бэкенд. Бэкенд видит подключение от 2.2.2.2 — и только поэтому его белый список (два IP) пропускает соединение.
  7. Бэкенд отвечает SYN-ACK: 3.3.3.3 → 2.2.2.2. Этот пакет приходит на джампхост, conntrack узнаёт его как часть уже учтённого потока, автоматически выполняет обратную трансляцию (источник становится 2.2.2.2:443... точнее, назначение — обратно клиенту), пакет идёт в FORWARD, матчится как ESTABLISHED → уезжает клиенту.
  8. Дальше весь поток — только шаги 4 и 7 по таблице conntrack, nat-правила заново не вычисляются.

Что было бы без MASQUERADE (шаг 5): на бэкенд пришёл бы пакет с настоящим источником (клиент), бэкенд ответил бы клиенту напрямую, мимо джампхоста. Клиент ждал ответ от 2.2.2.2, а пришло что-то от неизвестного 3.3.3.3 — ядро клиента такой пакет отбрасывает, handshake не завершается. Это классическая ошибка «DNAT есть, SNAT забыли» — соединение виснет на SYN.

#Сценарий C: сканер пингует 3.3.3.3 напрямую

ICMP echo-request от постороннего IP приходит на бэкенд → INPUT → правила по -s 2.2.2.2 и -s 1.1.1.1 не матчатся (источник чужой) → конец цепочки → политика DROP → пакет выброшен молча. Никакого echo-reply, сканер видит таймаут.

#Сценарий D: сканер стучится напрямую на 3.3.3.3:3389

То же самое, только TCP: SYN не матчится ни с одним правилом → DROP → нет ни RST, ни ответа → порт выглядит «filtered», сказать, жив ли хост, невозможно.

#Сценарий E: оператор напрямую на 3.3.3.3:228

-s 1.1.1.1 -p tcp --dport 228 — явное разрешение. Оператор — исключение из невидимости.

Пакет идёт по цепочке сверху вниз и останавливается на первом совпавшем правиле. Если бы в бэкенд-скрипте первым стояло «принимать всё с 2.2.2.2», а ниже «запретить 2.2.2.2 куда-то» — запрет был бы мёртвым: пакеты до него не доходили бы. -A добавляет в конец (порядок = порядок написания), -I — в начало, если когда-нибудь понадобится правило-исключение поверх общего.

# посмотреть filter-таблицу со счётчиками (сколько пакетов/байт легло на каждое правило)
iptables -L -v -n --line-numbers

# посмотреть nat-таблицу (там наши DNAT и MASQUERADE)
iptables -t nat -L -v -n

# увидеть живую таблицу conntrack (все активные трансляции)
conntrack -L        # пакет conntrack: apt install conntrack

# удалить одно правило — повторить его с -D вместо -A
iptables -t nat -D PREROUTING -p tcp --dport 443 -j DNAT --to-destination 3.3.3.3

# или по номеру строки
iptables -L INPUT --line-numbers
iptables -D INPUT 3

# безопасное применение с автооткатом, если вы не подтвердили за 60 секунд
iptables-apply -t 60 script.sh

Счётчики (-v) — лучший инструмент отладки: если под правилом проброса ноль пакетов, а соединение не работает — трафик до правила не доходит (смотрите интерфейс, адреса, доходит ли вообще до хоста).

Последнее: оба скрипта меняют политики на DROP — применяйте их либо с физической консолью, либо через iptables-apply, либо с фоновым dead-man switch (sh -c 'sleep 120; iptables -P INPUT ACCEPT; iptables -F' &), особенно на 3.3.3.3, где SSH ходит по нестандартному порту 228.