IPFW: порядок прохождения пакетов, сложные случаи

Автор: | 27/08/2011

Недавно рыскал по просторам интернета в поисках интересных мануалов по настройке IPFW, и наткнулся на весьма любопытную статью Vadim Goncharov (nuclight), которую хочу сохранить и у себя для истории и вдумчевого чтения в ближайшем будущем. Оригинал статьи с комментариями можно почитать тут>>>.

Нарисовал тут давеча в RU.UNIX.BSD схему прохождения пакета через ядро и ipfw, с объяснениями, как это все стыкуется с divert, dummynet, keep-state и т.д., теория и примеры. Народу понравилось, решил опубликовать и здесь, чтоб не потерялось (ибо объяснений, как оно там все внутри, в сети не встречал — только howto-шки на что-то простое или конкретику “вот у меня наконец получилось”, не дающие возможности понять и составить что-то сложное другое самому).

Newsgroups: fido7.ru.unix.bsd
From: Vadim Goncharov <[email protected]>
Subject: ipfw: порядок прохождения пакетов, сложные случаи (was: ipfw fwd и natd)
Date: Tue, 20 May 2008 13:48:53 +0000 (UTC)
Message-ID: <[email protected]>
Organization: Nuclear Lightning @ Tomsk, TPU AVTF Hostel
X-Comment-To: Victor Sudakov
X-FTN-Sender: Vadim Goncharov <[email protected]>
X-FTN-REPLYTO: 2:5020/400 UUCP
X-FTN-Tearline: slrn/0.9.8.1 on FreeBSD 6.2/i386

Hi Victor Sudakov!

On Mon, 12 May 2008 09:48:57 +0000 (UTC); Victor Sudakov wrote about 'Re: ipfw fwd и natd':

 VS>>>>>>> Пакет, отправленный по ipfw fwd, уже больше через ipfw не проходит?
 VS>>>>>>> Как бы мне ухитриться сделать policy routing в некий интерфейс и
 VS>>>>>>> одновременно на этом интерфейсе поднять static NAT ?
 VS>>>>>>      См. на тему ipfw one pass. Я сначала кладу пакет в divert, затем все
 VS>>>>>> что попало на следующее правило отправляю по fwd.
 VS>>>>> А я никак не могу грокнуть такие конфигурации ipfw, в которых правила
 VS>>>>> не привязаны к конкретным интерфейсам на in и out. Также как
 VS>>>>> конфигурации со skipto.
 VG>>>> А в чем конкретно затруднение?
 VS>>> Грокнуть не получается.
 VG>> Что в них конкретно вкурить не получается?
 VS> Пожалуй, порядок прохождения пакетов через правила и моменты, когда
 VS> пакет попадает на повторную обработку (divert etc).

Хм... ну попробую ниже.

 VS> Hа иную конфигурацию смотришь, как на программу на бейсике с
 VS> бесконечными goto, пытаясь распутать клубок.

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

 VG>> А то так в общем объяснять — скорее
 VG>> всего снова не вкурится.
 VS> Объяснять на пальцах бесполезно — на уровне мана оно и так понятно.
 VS> А вот именно интуитивно понять, почувствовать красоту этого дела и с
 VS> удовольствием начать пользоваться новым знанием — не выходит.
 VS> Просветление надобно.

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

Поскольку ipfw — довольно низкоуровневая штука, надобно объяснять
с привлечением сведений для программиста, о вызовах конкретных функций.
Пусть у нас ядро (и часть юзерленда) будет некоей местностью (аналогия на
географию), по которой перемещается пакет. Пешком ли, поезд на сортировочной
станции — неважно... Когда пакет прилетает в какую-нибудь функцию — он попадает
в определенное место. Потом он по ней двигается (у нее есть протяженность)
и уходит в какую-нибудь другую.

                                                  ____
                                             .-<-|natd|-<-.
                                            /     ----
                                            |              |
                                            v              ^
                                             ->------.    |
                                                      |   /
out xmit int0         ________________                v  /
--<--4---3---2---1-<-|                |---<-----4---3---2---1---<---
                     |   роутер с     |                 in recv ext0
внутренняя           |    FreeBSD     | внешняя
сетевуха int0        |                | сетевуха ext0
                     |                |                out xmit ext0
-->--1--2--3--4--->--|________________|--->-----1---2---3---4--->---
in recv int0                                       /  ^
                                                  /   |
                                            .--<-'     `-<-.
                                           /                |
                                           |      ____      |
                                            .->-|natd|->-./
                                                  ----

Это классическая картинка, тут вся машина в целом, видны оба прохода по ipfw.
Допустим, набор правил у нас такой:

ipfw add 1        deny  tcp from any to any 135,445
ipfw add 2 divert 8668  all from any to any via ext0
ipfw add 3       count icmp from any to any
ipfw add 4       allow  all from any to any
# в 65535 по умолчанию deny

Цифрами на рисунке обозначены правила, через которые оно и проходит,
в соответствующем порядке.

Когда пакет проходит через машину, к нему системой прикрепляется дополнительная
информация, помимо собственно его содержимого, видного в tcpdump. Например, на
каком интерфейсе он был получен, через какой отправляется, и т.п. Их можно
проверять соответствующими опциями в правилах ipfw, на рисунке показаны места,
где будут срабатывать соответствующие указания in/out и recv/xmit для сетевух.

Рассмотрим одну сторону роутера с рисунка выше более подробно, с точки зрения
вызовов функций в ядре (чуть более подробная картинка из мана). На самом деле,
с точки зрения функций, на рисунке выше нет двух сторон, она только одна,
и различается параметром — обрабатываемой сетевухой.

                приложения нашей машины                  natd
                                                        |    |
             ^                          V (5)           ^    v  вместо divert
             |   (4)  ____________      |               |    |    сюда можно
             |   .->-|ip_forward()|->---+     (8)     .--------.  подставить
         (3) |   |   `------------'     |        .->--| DIVERT |  netgraph и
             |   |                      |        |    `--------'  dummynet,
             |   |      .->--------------------+-'       (9) |    механизм и
             |   |      |               |      |             v  пути пакетов
             |   |      |               |      |             |  точно такие же
             |   |      |               +--<---|-----------<-+
             |   |      |               |      |             |
  ip_input() |  не нам  |  ip_output()  v (6)  ^             v
 .-----------|---|-.   /  .-------------|----. |             |
 |          нам /  |  |   | определить шлюз  | |             |
 |           | /   |  ^   | и сетевуху  |    | |             |
 |   кому пакет?   |  |   |             |    | |             |
 |           |     | /    |      ipfw_chk()->--'             |
 |      ipfw_chk()--'     |             |    |               |
 |___________|_____|      |_____________|____|               |
             |                          |                    |
         (2) ^                          V (7)                |
             |                          |                    |
             +---<---------------------------------------<---'
             |                          |
         (1) ^                          V
             |                          |
             |                          |
        ether_demux()           ether_output_frame() это для ipfw layer2, тут
             |                          |            оно нас не интересует
             ^                          V
             |   сетевые интерфейсы     |
             (железки), уровень драйверов            BPF работает здесь

То есть, пакет на входе передается драйвером в ether_demux(), затем он
попадает в ip_input(), в точку (2), где выполняются базовые проверки на
корректность пакета, после чего пакет прилетает в ipfw — функция ipfw_chk().
Допустим, правила были простые, без задействования других подсистем. Тогда,
вернувшись из ipfw, пакет продолжает движение по ip_input(), которая смотрит,
предназначен ли пакет нашей машине ("to me" в терминах ipfw), либо кому-то
другому. Если нам, то пакет уходит в точку (3), где решится, в какой сокет
какой юзерлэндной программе его отправить.

Если же пакет был предназначен не нам, пакет из ip_input() направится в
точку (4), где ip_forward() проверит, установлен ли sysctl, разрешающий
форвардинг, произведет декремент TTL и т.п. действия, после чего пакет
придет в точку (6), функцию ip_output(). Туда же он попадет напрямую, когда
какая-нибудь программа решит что-то отправить в сеть и передаст данные ядру.

Функция ip_output() первым делом смотрит в таблицу маршрутизации, определяя,
каков шлюз и на каком интерфейсе он находится. С этой информацией пакет вновь
передается в ipfw, в котором опять пробегается по всем правилам. После выхода
из ipfw_chk() в ip_output(), если ядро было скомпилировано с соответствующей
опцией, проверяется, не был ли применен ipfw fwd — если да, то просмотр
таблицы маршрутизации выполняется заново с целью получить MAC-адрес нового
шлюза. Затем пакет в точке (7) покидает ip_output() и передается дальше, на
L2 и потом к драйверам интерфейсов.

Это всё было для случая простых правил файрвола. Теперь, предположим, там
появляется divert, рассмотрим на примере правил выше. Пакет из внутренней
сети куда-то в Интернет на порт 80 войдет на внутреннем интерфейсе в точку
(1), пройдет начальные проверки ip_input() в (2), будет передан в ipfw_chk()
и начнет проходить по правилам. Под правило 1 он не подпадает, под 2 тоже,
так как имя интерфеса сейчас int0, правило 3 опять-таки не срабатывает, но
под правило 4 подходят все пакеты, и он выходит из ipfw_chk() дальше в
ip_input(). Там выясняется, что предназначен он идти в Интернет, поэтому
пакет попадает в точки (4) и затем (6), где ip_output() определяет адрес
шлюза и то, что интерфейс будет ext0, с чем пакет и попадает опять в
ipfw_chk() и снова идет по правилам. Правило 1 снова не подходит, но условие
правила 2 срабатывает — "via ext0, проходим прямо сейчас через интерфейс
ext0, в любом направлении".

И вот здесь срабатывает divert — пакет из ipfw_chk() передается в точку (8),
в подсистему divert, при этом к нему предварительно прикрепляется
метаинформация с направлением (out), интерфейсом (ext0) и номером правила 2.
Подсистема divert передает этот пакет в указанный в правиле порт (8668), на
котором в нашем случае работает natd. Тот обрабатывает пакет, метаинформацию
же — не трогает, и возвращает подсистеме divert вместе с измененным пакетом
как есть (так поступают большинство divert-демонов, хотя любой их них, в
принципе, может поменять эту информацию, и пакет будет передан в другое
место).

Подсистема divert выводит полученный из natd пакет из точки (9) в точку (6).
Следует обратить внимание — пакет попадает в ip_output() ЕЩЕ РАЗ! Это
необходимо, так как демон мог вернуть пакет с совершенно другими адресами
или вообще создать новый пакет. Но в нашем случае пакет несет с собой
диверт-тег, метаинформацию, самая важная часть которой — номер правила.
При входе в ipfw_chk() пакет первым делом проверяется на наличие этого самого
тега. Он найден, и в нем содержится номер правила — 2. Поэтому ipfw_chk()
пропускает правила номер 1 и 2, и начинает с номера 2+1, то есть 3 (если бы
пакет применялся к указанному номеру, а не следующему, то он снова попал бы
в divert, то есть получился бы бесконечный цикл).

На этом месте пакет продолжит движение с правила номер 3 и дальше, как обычно,
уйдет в точке (7) в сеть. Таким образом, несмотря на то, что пакет попадал
в ip_output() два раза, с точки зрения пользователя это выглядит так, как
если бы он был там один раз и никуда из файрвола не убегал — просто на правиле
2 в нем волшебным образом поменялись адреса и порты.

Аналогичным образом, ответный пакет, возвращающийся из Интернета на интерфейс
ext0, пройдет через машину по пути (1) — (2) — начало ipfw_chk() — правило 1 -
правило 2 — (8) — divert — natd — divert — (9) — (2) — проверка в ipfw_chk() -
правило 3 — правило 4 — (4) — (6) — начало ipfw_chk() — правило 1 — правило 2 -
правило 3 — правило 4 — (7) — отправка через интерфейс int0.

Подобное выведение пакета из обработки ipfw в другую подсистему — не уникально
для divert, это общая схема работы в стеке FreeBSD. Например, действия pipe и
queue в dummynet, передача пакета в netgraph (а также появившийся в 7.0
ipfw nat) действуют по тому же принципу. Отличие, однако, в том, что в этих
подсистемах пакет остается внутри ядра, никакому демону не передается.
Поэтому, во-первых, подсистемы вместо номера правила сохраняют на него полный
указатель, и пакет вернется непосредственно в следующее правило, даже если оно
имеет тот же номер. Во-вторых, для таких подсистем действует настройка one_pass
в соответствующем sysctl — если она включена, то при повторном входе пакета в
ipfw после возврата из подсистемы dummynet (netgraph), ipfw_chk() сразу
вернется без прохода по правилам, как если бы к пакету был применен allow.
Это поведение позволяет упростить правила файрвола, когда известно, что если
пакет попал в трубу, то он уже точно отправляется дальше, и не требуется после
каждого pipe вставлять allow (чтобы пакет не попал в следующие правила и
следующие pipe/queue). Если же конфигурация требует сначала ограничить трафик,
а потом уже разбираться по замысловатым требованиям, что из него разрешить, а
что запретить, то упрощению правил наоборот будет способствовать отключенный
one_pass — поскольку с ним вместо allow, расположенных до pipe, пришлось бы
делать skipto.

Итак, как уже было сказано, пакет проходит по списку правил последовательно,
в порядке возрастания номеров правил. Список правил можно рассматривать как
таблицу с тремя столбцами: номер, действие (и его параметры, например log),
и условия, при которых пакет соответствует правилу (например, от адреса
1.1.1.1 адресу 2.2.2.2). Таблица просматривается сверху вниз, пакет
сравнивается с условиями. На первом совпавшем условии смотрим в столбец
действий, выполняем действие, прекращаем просмотр.

Это же можно, возвращаясь к географическим аналогиям, представить как
беговую дорожку или коридор с инструкциями, типа "Если ваш вес больше
50 кг, поверните направо, иначе следуйте дальше". Соответственно, на
первой же инструкции, которая подойдет, пакет свернет с прямой в
нужную дверь. Можно заметить, что инструкции могут быть и вида "если вы
болели в детстве ветрянкой, идите дальше и читайте следующую инструкцию
на двери номер 150, а все, что встретится до нее — пропустите не читая".
Это, очевидно, полный аналог действия skipto в ipfw.

Таким образом, работу ipfw_chk() можно упрощенно предствить в виде следующей
блок-схемы:

         |
         v  вход в ipfw_chk()
   ======|===================
         |
    на пакете есть divert-тег?
         |
        нет    да-->----перейти по номеру--->-.
         |             за правилом с divert   |
         v                                    v
         |                                    |
   включен one_pass и пакет                   |
   вернулся из другой подсистемы?             |
         |                                   |
        нет    да-->--------------------------|-----.
         |                                    |     |
         v                                    |     v
         |                                    |     |
   правило 1: это tcp с любым отправителем    |     |
   и получателем на порты 135 или 445 ?       |     |      DENY: уничтожить
         |                                   |     |      пакет (если он от
        нет    да-->----------------->--------|-----|-->-- локального прило-
         |                                    |     |      жения, вернуть ему
         v                                    v     v      Permission denied)
         |                                    |     |              ^
   правило 2: пакет проходит через            |     |              '---------.
   интерфейс ext0, в любом направлении ?      |     |                        |
         |                                   |     |      отправить пакет в |
        нет    да-->----------------->--------|-----|-->-- DIVERT с номером  |
         | ___________________________________|     |      правила 2 в теге  |
         |/                                  .      |                        |
         v                                     .    v                        ^
         |                                       .  |                        |
   правило 3: это icmp-пакет ?                     .|                        |
         |                                         |.   следует обратить    |
        нет    да-->- обновить счетчики на правиле  |  . внимание на стрелку |
         |            3 и больше ничего не делать   |    перехода от тега    |
         | ______________________/                  |    divert к правилу 3  |
         |/                                         |    — если бы где-то    |
         v                                          v    в начале было       ^
         |                                          |    правило с действием |
   правило 4: вообще любой пакет?                   |    "skipto 3", его     |
         |                                         |    стрелка на ответ    |
        нет    да-->----------------->--------------+    "да" вела бы в то   |
         |  (конкретно в _нашем_ 4 правиле варианта |    же самое место (в   |
         v   "нет", конечно, быть не может)         v    наших правилах      ^
         |                                          |    такого нет, но у    |
   правило 65535: любой пакет, правило по умолчанию |    кого-нибудь —       |
         |                                          |    вполне может быть)  |
   файрвол (ядро) скомпилирован с опцией            |                        |
   IPFIREWALL_DEFAULT_TO_ACCEPT ?                   |                        |
         |                                         |                        |
         да                                        |                        |
         |        '- нет ----------------------->---|------------>-----------'
         |                                          |
         +--<------------------------<--------------'
         |
   ВЫХОД из ipfw_chk(): действие allow, пакет
   беспрепятственно пропущен через файрвол дальше
         |
         v

Из схемы, пояснений и мана уже должно быть понятно, как это всё работает, и
что skipto полностью аналогичен goto, и как он выглядел бы на схеме. Может
возникнуть вопрос, зачем нужен skipto, если он нередко запутывает правила?
Введен он, как это ни странно может показаться, как раз для возможности
упрощения правил и увеличения производительности файрвола (а также позволяет
делать интересные трюки с динамическими правилами, но об этом ниже). Для
того, чтобы это понять, надо рассмотреть, как устроена часть правила ipfw,
отвечающая за проверку соответствия пакета условию.

Ман говорит, что синтаксис "тела" правила (rule body) в ipfw2 есть
[ протокол from набор_адресов1 to набор_адресов2 ] [опция1 [опция2 ...]]
То есть, привычная часть "tcp from any to me" вообще говоря, необязательна,
а в списке опций допустимы OR-блоки, то есть он, по сути своей, представляет
то, что в математике называется конъюнктивной нормальной формой (КНФ) булева
выражения. А сами опции — это предикаты, они могут быть истинны или ложны.
Список опций в мане — и есть список таких предикатов для пакета. Все, что
можно сделать в привычной части в старом синтаксисе, можно сделать и опциями
(во внутреннем представлении в ядре оно так и есть). Таким образом, следующие
формы записи эквивалентны:

ipfw add allow tcp from 1.1.1.1,2.2.2.2 to not me in
ipfw add allow proto tcp { src-ip 1.1.1.1 or src-ip 2.2.2.2 } not dst-ip me in

и соответствуют логическому выражению:

(протокол = tcp ?) И ((src-адрес = 1.1.1.1 ?) ИЛИ (src-адрес = 2.2.2.2 ?)) И
(НЕ (dst-адрес = любой мой адрес ?)) И (пакет проверятся на входном проходе ?)

То есть, здесь для каждого предиката проверяется его истинность, и из них из
всех вычисляется истинность или ложность всего логического выражения. Следует
отметить, что, поскольку это КНФ, "НЕ" (not) может быть применен только к
самому предикату, а не их группе, то есть, "to not me 445" будет означать
"(НЕ (dst-адрес = любой мой адрес ?)) И (порт назначения = 445 ?)", но не
"НЕ ((dst-адрес = любой мой адрес ?) И (порт назначения = 445 ?))", а форма
вида "not { ... or ...}" вообще недопустима. К слову, реально вычисление
OR-блока идет слева направо, и при первом же истинном предикате (или его
отрицании) все остальные внутри OR-блока не вычисляются (может быть полезно
для оптимизации).

Однако вычисление предиката для пакета — операция, занимающая какое-то время,
и при большом числе правил и пакетов оно может стать существенным. Здесь-то
как раз и может придти на помощь skipto. Пример из жизни — в исследовательских
целях было написано несколько сот правил вида:

add 120 count log ip from 1.1.1.1 to any in via int0 ipttl 63,65-127,129-255
add 120 count log ip from 1.1.1.2 to any in via int0 ipttl 63,65-127,129-255
add 120 count log ip from 1.1.1.3 to any in via int0 ipttl 63,65-127,129-255
add 120 count log ip from 1.1.1.4 to any in via int0 ipttl 63,65-127,129-255
...

Видно, что правила указаны вполне точно, направление, адрес, интерфейс — но
получается много повторений, и проверяться будет каждый пакет, в то время как
подпадающих под условие пакетов — не так много. Простое добавление 119 правилом
skipto 121 с условием, не совпадающим с повторящимися частями, привело к
вполне заметному невооруженным глазом снижению нагрузки на процессор процентов
на 5-10 (точные замеры не проводились)! А всю конструкцию можно было таким
манером оптимизировать еще больше, убрав повторяющиеся части:

add 119 skipto 121 { not in or not recv int0 or not ipttl 63,65-127,129-255 }
add 120 count log ip from 1.1.1.1 to any
add 120 count log ip from 1.1.1.2 to any
add 120 count log ip from 1.1.1.3 to any
add 120 count log ip from 1.1.1.4 to any
...

Разумеется, такой частный случай оптимизации по скорости — не единственное
применение skipto. Многие, к примеру, рекомендуют разделять пакет по
направлению и интерфейсу наподобие вот такого:

ipfw add 10 deny tcp from any to any 135,445 // блокируем всегда
ipfw add 20 allow tcp from any to any 22 // доступ к роутеру на всякий случай
ipfw add 100 skipto 1000 all from any to any in recv int0
ipfw add 200 skipto 2000 all from any to any out xmit int0
ipfw add 300 skipto 3000 all from any to any in recv ext0
ipfw add 400 skipto 4000 all from any to any out xmit ext0
ipfw add 1000 ... // все пакеты в этой точке и далее будут для in recv int0
...               // поэтому к правилам здесь это можно не приписывать
ipfw add 1999 allow ip from any to any // дефолтная политика для входящих int0
ipfw add 2000 ... // здесь будут пакеты, уходящие с интерфейса int0
...
и т.д.

Использование такого набора правил позволит всегда четко знать, в какой части
рулесета с какими характеристиками проходит пакет. Ман-страница ipfw, кроме
того, настоятельно рекомендует выполнить такое разделение для пакетов на
layer2 (уровень фреймов Ethernet) — когда включен соответствующий sysctl,
пакет, проходящий через роутер, попадает в ipfw_chk() уже не два раза, а
ЧЕТЫРЕ (два на входе и два на выходе), из соответствующих ether_* функций
на первой схеме. Причем предикаты для второго уровня будут проверяться и на
обычных проходах в ip_input()/ip_output() — просто они всегда будут ложными.
Но вот отрицания их всегда будут истинными, и здесь очень легко ошибиться в
правилах с чем-нибудь вроде not MAC 10:20:30:40:50:60 any — так что проверки
второго уровня лучше выделить в отдельные правила, не смешивая их с проверками
на более высоких уровнях, и завершить allow all from any to any для L2-прохода.
Таким образом увеличится и производительность — пакеты на L2 не будут лишний
раз прогоняться по всем IP-правилам, обычно для L2 пишут совсем небольшое
количество правил, они быстро выполнятся, и пакет продолжит путь дальше.

ДИНАМИЧЕСКИЕ ПРАВИЛА и STATEFUL FIREWALL.

И в заключение следует рассмотреть еще одну сложную тему. Как известно, одним
из принципов при проектировании протоколов Internet являлся "состояние должно
храниться во взаимодействующих машинах, а не в самой сети", что является
гарантией того, что сбои где-то в сети между хостами будут иметь на них
минимально возможное влияние. Разнообразные NATы и файрволы с отслеживанием
состояния нарушают этот принцип. Кроме того, отслеживание соединений ведет к
увеличению нагрузки (в том числе по памяти) на маршрутизатор в зависимости от
количества активных соединений, и его, в отличие от аналогичного без хранения
состояния, нередко можно "зафлудить" запросами на соединение. Однако за все
надо платить, и некоторые вещи невозможно сделать без нормального отслеживания
состояния. Скажем, в ipfw есть опции setup и established для tcp-соединений,
которые просто смотрят на соответствующие флаги в tcp-пакете — просто
и быстро, правило с established в начале списка правил может весьма ускорить
работу файрвола ввиду отсутствия необходимости проверять дальнейшие правила
для основной массы пакетов. Но таким образом нельзя организовать отслеживание
для других протоколов (не tcp), да и хакеру никто не мешает передавать данные
в пакетах без флагов с помощью специальных программ — пакеты беспрепятственно
пройдут через такое правило с established, и до остальных запрещающих просто
не дойдут.

Поэтому в ipfw была добавлена поддержка отслеживания состояний (stateful
firewall), называемая динамическими правилами. В соответствии с озвученным
выше принципом, она была именно добавлена, то есть администратор может
использовать обычную попакетную фильтрацию, и в строго определенных, нужных
ему точках, добавлять проверку состояния (отступление: это называется
сохранением состояния, потому что файрвол сохраняет данные о соединениях и
"помнит" их между пакетами, тогда как в обычном режиме, показанном на схемах
выше, вычисление для каждого пакета начинается заново, вне зависимости от
других пакетов).

Реализуется эта поддержка ключевыми словами check-state, keep-state и limit.
Дальше мы мы будет рассматривать только keep-state, потому что правила с
limit отличаются только тем, что налагают ограничение на число записей в
таблице динамических правил, одновременно подпадающих под указанное
ограничение — текущая реализация в ipfw2 при попытке создать новую запись
просто молча уничтожает пакет (применяет deny).

Отдельно от обычных правил, называемых теперь статическими, в ядре заводится
таблица динамических правил (ее текущее содержимое можно посмотреть по
ipfw -d show), над которой возможны две операции: создание записи
(динамического правило) на основе информации в пакете и проверка пакета на
соответствие таблице — есть ли подходящие ему записи. Запись имеет вид:
"протокол адрес1:порт1 <-> адрес2:порт2 ссылка_на_родительское_правило".
Чтобы пакеты соединения в обоих направлениях подпадали под одно и то же
динамическое правило, направление в нем не учитывается — то есть, должен
совпасть протокол и обе пары адресов и портов, но пары можно менять местами:
udp-пакеты с 1.2.3.4:5678 на 6.7.8.9:1234 и с 6.7.8.9:1234 на 1.2.3.4:5678 -
оба подпадут под одно и то же правило.

Этим двум операциям и соответствуют ключевые слова check-state и keep-state.
Причем, поскольку администратору необязательно указывать в правилах явный
check-state (либо он может быть "перепрыгнут" каким либо правилом skipto до
него), то в _каждое_ правило с keep-state неявно добавляется "невидимый"
check-state — это сделано затем, чтоб избежать попыток добавить в таблицу
динамических правил такое правило, которое там уже есть.

         |
         v  фрагмент ipfw_chk(): начало правила с keep-state
         |                                             ______________________
         |                                            | ТАБЛИЦА ДИНАМИЧЕСКИХ |
    проверить таблицу (неявный check-state): <=======>|        ПРАВИЛ        |
    есть ли соответствующее пакету правило?           | (с таймером удаления |
         |                                 .-------->| для каждого правила) |
        нет    да-->--перейти к действию в /          '----------------------'
         |          найденном "родительском"                     ^
         v          правиле и сбросить таймер                    |
         |                в таблице                             |
         |                                                      |
   правило N: пакет соответствует указанному  |                  |
   условию в статическом правиле номер N ?    |         указано keep-state:
         |                                   |         создать в таблице
        нет    да-->----------------->--------|-------> новое динамическое
         |                                    |         правило  |
         v                                    v                  |
   перейти к проверке следующего       выполнить параметры   <---'
   по списку статического правила      действия статического
         |                             правила N (log, tag, ...)
         v                                    |
         |    выполнить действие,             |
         |    указанное в статическом <-------'
         |    правиле N и обновить на
         |    правиле N счетчики
         v

Как можно видеть из схемы, каждая запись в таблице динамических правил
содержит ссылку на так называемое родительское правило — то, которое его
сгенерировало по keep-state. И при поиске соответствия полей пакета записям
в таблице динамических правил производится переход на часть действия
указанного родительского правила — с этого момента динамические правила
как бы перестают существовать, пакет привычным образом продолжает движение
по статическому набору правил, просто был сделан своеобразный skipto (хоть
и не на само правило, а на его кусок).

Что из этого следует? А то, что если действие в правиле каким-либо образом
предполагает дальнейшую обработку в файрволе, например это divert или pipe,
то пакет продолжит свое движение по правилам! Но наиболее интересен случай,
когда действием является skipto. В этом случае для пакетов в оба направления
(принадлежащих соединению) можно организовывать своего рода "подпрограммы" в
любом месте набора правил, применять к ним несколько действий — например,
отправить в pipe/queue, а потом часть пакетов запретить, другую разрешить (по
критериям, отличным от изначального условия создания динамического правила).

Тот факт, что на самом деле "перепрыгивание" выполняется на параметры
действия, позволяет использовать это для интересных вещей. В частности, с
использованием появившегося во FreeBSD 6.2 параметра tag на каждый пакет можно
навешивать внутриядерный тег, что в применении со skipto позволяет сделать, к
примеру, запоминание, с какого шлюза пришел входящий пакет на машине с каналами
к двум разным провайдерам, и ответные пакеты отправлять в тот канал, откуда они
пришли (допустим, у вашей машины только один IP-адрес, и сделать fwd на базе
внешнего адреса не получится), т.е. реализовать аналог reply-to из pf:

ipfw add 100 skipto 300 tag 1 in recv $ext_if1 keep-state
ipfw add 200 skipto 300 tag 2 in recv $ext_if2 keep-state
ipfw add 300 allow { recv $ext_if1 or recv $ext_if2 }  # входящие снаружи
ipfw add 400 allow in recv $int_if   # разрешить ответы на внутреннем проходе
ipfw add 500 fwd $gw1 tagged 1      # остались ответы на внешнем интерфейсе,
ipfw add 600 fwd $gw2 tagged 2      # зарулим их куда надо

Следующий пример, взятый из реального up-script'а mpd, хоть и несколько
запутан, но показывает, каким образом можно организовать на внешнем интерфейсе
одновременно NAT для внутренней сети (выпуская только тех пользователей,
которым это разрешено), ограничение полосы пропускания для каждого, лимит
одновременных соединений для каждого пользвателя (динамические правила),
причем с разными лимитами для HTTP-трафика и всего остального, и всё это — в
минимальном количестве правил:

# mpd up-script args vars
eif=$1
our_ip=$3
fw="/sbin/ipfw -q add"

# first split traffic to "incoming" and "outgoing from allowed hosts"
$fw 161 skipto  166 src-ip table($allowed_int_hosts) out xmit $eif
$fw 161 divert natd all from any to $our_ip in recv $eif
$fw 163 queue     1 ip from any to any in via $eif
$fw 164 allow       ip from any to any in via $eif
# deny world inet by default, both incoming and outgoing
$fw 165 deny        not src-ip $our_ip not dst-ip $our_ip via $eif
# don't allow users to open more than 9 WWW connects and 6 for other protocols
$fw 166 skipto 167 tcp from any to any 80 out xmit $eif recv int0 limit src-addr 9
$fw 166 skipto 167 all from any to any out xmit $eif recv int0 limit src-addr 6
# we are using here (undocumented) that 'limit' will drop overlimit packets,
# not go to next rule
$fw 167 divert natd ip from any to any out xmit $eif
$fw 168 allow       ip from $our_ip to any via $eif

Таким образом, в сложных конфигурациях, где, например, требуется фильтровать
трафик как до трансляции, так и после, становится понятно, почему в ipfw, в
отличие от других файрволов, нет жесткой схемы прохождения пакетов, вида
"сначала NAT, потом фильтрация", почему низкоуровневая обработка позволяет
делать более гибкие вещи. Платить за это приходится, разумеется, повышенной
сложностью написания и понимания таких наборов правил, но в ситуациях, где
конфигурации сложны и без того, это становится не столь существенно, и на
первый план выходит сама возможность сделать задачу — и гибкость ipfw это
позволяет.

UPDATE:
По многочисленным просьбам трудящихся, опишу некоторые туманные вопросы, не затронутые в посте.

1. Отличия предикатов via/xmit/recv.

Newsgroups: fido7.ru.unix.bsd
From: Vadim Goncharov <[email protected]>
Subject: Re: ipfw dummynet на много пользователей.
X-Comment-To: Mike Yurlov
Date: Wed, 11 Mar 2009 17:10:28 +0600

Hi Mike Yurlov!

On Mon, 23 Feb 2009 13:48:25 +0000 (UTC); Mike Yurlov wrote about 'Re: ipfw dummynet на много пользователей.':

 MY> Пользуясь случаем прошу ликбеза по in/out via и recv/xmit. Если я правильно понял, то
 MY> in/out это направление прохождения пакета в данный момент, recv/xmit это
 MY> часть метаинформации пакета пока он путешествует по ядру и модулям. recv — через какой
 MY> интерфейс он был получен,  xmit — через какой  интерфейс будет передан на основе
 MY> таблицы маршрутизации (что может быть переиначено например через fwd).

При получении пакета на него прилепляется m_pkthdr.rcvif, который сохраняется
на всё время жизни пакета, его и проверяет recv, то есть оно действует и
на out-проходе.

Перед запуском файрвола на out-проходе выполняется просмотр таблицы
маршрутизации, интерфейс сообщается файрволу в oif, который проверяет xmit.
В текущей реализации fwd его не меняет, более того, fwd тут же делает allow
(немедленный возврат из файрвола), чтобы обойти проблему изменения xmit для
остальных правил. Причем, fwd на in-проходе его также НЕ изменит, см. мою
переписку с Гросбейном по поводу патча на это самое дело.

Ну а via просто на in работает как recv, а на out как xmit, позволяя записывать
сокращенно одним правилом два разных (одно с in recv, другое с out xmit), что
бывает удобно, например, для divert natd.

        case O_RECV:
                match = iface_match(m->m_pkthdr.rcvif, (ipfw_insn_if *)cmd);
                break;

        case O_XMIT:
                match = iface_match(oif, (ipfw_insn_if *)cmd);
                break;

        case O_VIA:
                match = iface_match(oif ? oif : m->m_pkthdr.rcvif, (ipfw_insn_if *)cmd);
                break;

Еще раз, другими словами: метаинформация о интерфейсе входа в машину записывается драйвером по получению и доступна для проверки recv всё время жизни пакета в ядре, информация же об интерфейсе xmit становится доступна только на выходе. Поэтому out recv int0 — не бессмысленное выражение, в отличие от бессмысленого in xmit ext0.

Для чего это может быть полезно? Например, чтобы запретить нашей машине быть “одноруким роутером”, когда полученный на интерфейсе пакет должен по таблице маршрутизации уйти в ту же сеть (случается обычно из-за ошибки в настройках того, кто прислал нам этот пакет, и по умолчанию ему вместе с таким пакетом будет послан и icmp-редирект, сообщающий об этом):

ipfw add deny ip from any to any out recv fxp0 xmit fxp0

Ну а via просто выбирает наиболее подходящую сейчас проверку, если проход входящий, сработает как recv, если исходящий — то как xmit. Другими словами, если обратиться к самой первой схеме вверху поста: там 4 возможные комбинации in/out и recv/xmit, на каждой из двух сетевух. Если вам нужно точно указать, скажем, левый нижний на схеме проход, вы напишете in recv int0. А если нужно, чтобы правило срабатывало сразу на обоих проходах справа и только на них (или на обоих слева), то есть на одной сетевухе вне зависимости от направления, вы можете написать два одинаковых правила, одно с in recv ext0, другое с out xmit ext0. А можете одно — с via. То есть, можно написать:

ipfw add 2 divert 8668 all from any to any in recv ext0
ipfw add 2 divert 8668 all from any to any out xmit ext0

а можно:

ipfw add 2 divert 8668 all from any to any via ext0

и эти конструкции будут абсолютно эквивалентны по эффекту. Просто в ситуации, когда у вас простой набор правил, и они идут вот так вот подряд, второй вариант короче и насколько-то-долей-процента эффективнее в обработке пакетов.

А еще (это есть в мане, но я всё равно разжую), из серии “знаете ли вы, что…” — проверка функции iface_match() в ядре, которая обрабатывает recv/xmit/via (кусок исходника был выше), принимает не только имя интерфейса, но и IP-адреса, и даже шаблон имени в том же виде, в котором в шелле задаются имена файлов! То есть, можно написать:

ipfw add 2 pipe 3 all from any to any in recv ng[23]?*

и это будет ловить интерфейсы с именами с ng200 по ng299 и c ng300 по 399, а также с ng20 по ng29 и c ng30 по 39 (на самом деле с любыми символами в названии после, но мы же знаем, что mpd сделает названия только с циферками).

2. Динамические правила и ipfw fwd; теги.

Приведенный в посте пример того, как на динамических правилах сделать аналог reply-to в pf, на самом деле, не работает. В исходниках был обнаружен запрет специально именно этого случая в мохнатом 2000 году из-за какой-то паники ядра на тех версиях. Что давно уже не актуально, но это, конечно же, поправить забыли. Патчится одной строчкой — открываете /sys/netinet/ip_fw2.c, находите слова case O_FORWARD_IP: (конкретный патч не приведу, зависит от версии системы). И вот там чуть ниже в строчке кусок

if (!q || dyn_dir == MATCH_FORWARD)

надо заменить на

if (sa->sin_port && (!q || dyn_dir == MATCH_FORWARD))
[UPD: 06.07.11 патч закоммичен в 7.x и 8.x, патч более неактуален, теперь всё работает так, как и описано в посте]

Еще, кстати, не стоит забывать, что все имеющиеся на пакетах теги, будь то метаинформация самой системы (от того же IPSEC и много чего еще) или явно навешенные теги ipfw/pf — существуют только внутри ядра. То есть, если вывести пакет из ядра через divert, они потеряются.

3. Порядок вызова файрволов и IPSEC.

Вообще говоря, то, что нарисовано на второй схеме — это упрощенное изложение вызова ipfw. На самом деле, вызывается не непосредственно ipfw_chk(), а фреймворк запуска файрволов pfil(9), который уже вызовет включенные файрволы, то есть в конечном счете и ipfw_chk(). Но это же может быть и другой файрвол — например, pf, а может быть, и оба сразу, или даже три (ipfilter), а может и четыре (а кто знает, что вы там еще в систему установили?). Происходит это так: при загрузке системы (или модуля) файрвол регистрирует себя в pfil, тот дописывает его в список хуков входного прохода и в список выходного, причем в одном случае в конец, а в другом в начало, дабы всё было симметрично. Ну и потом в работе над каждым пакетом ip_input() вызывает pfil_run_hooks(), который все файрволы по списку и вызывает для пакета. И ip_output() поступает аналогично. Поскольку файрволу может быть приказано свои хуки и убрать из списка обработки (pf это делает по pfctl -e/-d, ipfw в 6.х только при загрузке-выгрузке модуля, но с 7.0 ведет себя как pf, по enable/disable), можно добиться такого порядка прохождения пакетов, какой вам нужен Подробнее об этом процессе можно прочитать на http://paix.org.ua/freebsd/fwpackets.html (или здесь, если не открывается), там же можно прочитать о методике экспериментального определения порядка при двух файрволах. А еще я надеюсь, вы не забыли, что при обработке пакета на layer2, с Ethernet-заголовками, ipfw вызывается уже не два, а ЧЕТЫРЕ раза? То есть, на 6.2, вот так:

in: железо -> bpf(4)/tcpdump -> ng_ether -> ipfw layer2 -> pfil(9)
out: pfil(9) -> ng_ether -> ipfw layer2 -> bpf(4)/tcpdump -> железо

Всё, однако, становится несколько геморройнее при вкомпилированном в ядре IPSEC. Оная вещь вообще доставляет админам хлопоты много в чем и на разных платформах, не обошлось без этого и в ядре FreeBSD — IPSEC нормально файрволы не учитывает. По исходникам 6.2, картинка выглядит так:

         |
         v  вход в ip_input()
   ======|===================
         |
    основные проверки заголовка
         |
#if вкомпилирован IPSEC (или FAST_IPSEC) без IPSEC_FILTERGIF
         |
     есть ли на пакете история ipsec?
         |
        нет    да-->----можно доверять,----->-.
         |             пропустим файрвол      |
#endif
         |                                    |
         v                                    v
         |                                    |
   прогнать пакет через pfil_run_hooks()      |
   был ли пакет разрешен?                     |
         |                                   |         DENY: уничтожить
         да    нет->--------------------------|----->-- пакет молча (некому
         |                                    |         возвращать ошибку)
         | ___________________________________|
         |/
         v
         |
   проверка адреса назначения пакета — нам или не нам?
         |
        нам        не нам, будем готовить к ip_forward()
         |                             |
         v                             v
         |                             |
#if вкомпилирован IPSEC или FAST_IPSEC
         |                             |
   выполнение входной       выполнение входной обработи
    обработки IPSEC            IPSEC для форвардинга
         |                             |
#endif
         v                             v
         |                             |
   передача пакета на L4,     проверка sysctl net.inet.ip.forwarding
   локальным приложениям           и вызов ip_forward()
         |                             |
         v                             v
   =========== ВЫХОД из ip_input() ===============

Тут следует обратить внимание на проверку истории перед вызовом файрволов — это рассчитано на туннельные интерфейсы. То есть, поскольку ip_input() вызывается драйвером интерфейса, для туннельного режима IPSEC реальная последовательность будет выглядеть так: реальный сетевой интерфейс -> ip_input() -> дешифровка IPSEC, пакет передан на туннельный интерфейс, в метаинформации пакета помечено, что он прошел через IPSEC -> ip_input() расшифрованного пакета на туннельном интерфейсе.

         |
         v  вход в ip_output()
   ======|===================
         |
    просмотр таблицы маршрутизации и т.п. подготовка <----.
         |                                                |
#if вкомпилирован IPSEC или FAST_IPSEC                    |
         |                                                |
    применение политик IPSEC                              |
         |                                                |
#endif                                                    |
         |                                           поставить флаг
         |                                           пропуска ipfw
    вызов pfil_run_hooks(), что сказали файрволы?         |
         |              |                                ^
       всё OK      DENY пакета       ipfw fwd             |
         |              |                ________________'
         v         вернуть EPERM
         |         вызвавшему нас верхнему уровню
         |
    ВЫХОД из ip_output(): поставить
    пакет в очередь выходного интерфейса
         |
         v

Что из этого видно? А то, что IPSEC не учитывает схему взаимодействия ipfw с внешними сущностями. Например, ipfw divert выведет пакет из ip_output(), после обработки пакет будет запущен в ip_output() вновь, и попадет в IPSEC до ipfw. В некоторых конфигурациях это людям действительно мешает.

В ip_input() также можно заметить проверку истории IPSEC. Ровно это же самое действие доступно позже и в ipfw, с помощью одноименного предиката — ipsec.

UPD3: По состоянию на 17.09.10 выше только описание порядка IPSEC для 6.x (в других версиях уже другое), да и то, как выяснилось, неточное. Кроме того:

1. В первом апдейте выше упоминались динамические правила и fwd, для работы которых необходим патч. Тов. Dmitriy Demidov сделал баг-репорт kern/147720, в котором приводится этот патч. Делать именно так пока не хотят, мол, когда-нибудь в будущем будет придумано правильное решение на базе setfib, и всё такое. Но официальных комментариев к этому PR на текущий момент нет. Кому нужна эта функциональность — не поленитесь, отметьтесь там, пусть пошевелятся раньше наступления светлого будущего.

2. Порядок обработки внутри одного правила был затронут лишь вскользь, где говорилось про оптимизацию OR-блоков. В общем-то, точно такой же и во всём правиле, но есть нюансы. Каждое правило ipfw представляет аналог BPF-программы, где каждая опция один-в-один транслируется в опкод ipfw2 (подробнее см. пост о BPF). При этом сначала по порядку слева направо записываются опкоды опций из тела правила, затем идут опкоды модификаторов действий, наконец, последним — сам action, и эта программа просто исполняется (в отличие от BPF, в ней нет условных переходов, кроме OR-блоков). Что в этом знании полезного для практики, кроме вылизывания при оптимизации? Предположим, есть правило:

add pipe tablearg log all from table(1) to table(2)

Тогда при проверке сначала будет исполнен опкод O_IP_SRC_LOOKUP для таблицы 1, если адрес подходит, исполнение продолжается и переменная tablearg получается значение из таблицы, затем аналогично выполняется O_IP_DST_LOOKUP для адреса назначения, который ищется в таблице 2, и если нашелся, то переменная tablearg переписывается значением из таблицы 2, после чего O_LOG всегда “совпадает”, отправляя данные о пакете в syslog. Каждый опкод возвращает true/false, если false, то считается, что всё правило несовпало, и машина переходит к следующему правилу (см. схемы ранее); только OR-блок внутри себя проверяет опкоды далее при false. Так что до O_PIPE, непосредственно отправляющего пакет в трубу, очередь дойдет в случае совпадения всех перед ним.

Как можно видеть, здесь tablearg получит значение из той таблицы, которая была позже (правее в правиле), т.е. table(2). Но что, если нужно значение из table(1)? Тогда придется переписать правило так, чтобы table(1) была последней:

add pipe tablearg log dst-ip table(2) src-ip table(1)

Хоть синтаксис без from ... to многим и покажется непривычен, иначе в такой ситуации никак.

UPD4: По состоянию на 07.07.11, ситуация из апдейта №3 выше теперь такая:

1. Ситуация с динамическими правилами и fwd, для работы которых необходим патч, исправлена, PR kern/147720 теперь закрыт. Теперь всё работает так, как описано в посте, исправление закоммичено в 8-STABLE (r223819) и 7-STABLE (r223820). Заодно закрыли несколько других PR с той же причиной (кому-то это и прозрачный сквид ломало). Исправили без всякого светлого будущего, именно в этом месте.

2. Для выстановки tablearg из нужной таблицы начиная с 8.1R можно применять новую опцию ipfw:

lookup {dst-ip | dst-port | src-ip | src-port | uid | jail} N

Она точно так же выполняет просмотр таблицы с номером N и выставляет tablearg, но позволяет делать это не только для IP-адресов, но так же и помещать в таблицу номера портов, uid и jailid, вместо IP-адресов. Однако порядок просмотра правила и выстановки tablearg слева направо, описанный в апдейте №3 выше, сохраняется — что следует учитывать на случай, если таблицы опять-таки используются в правиле несколько раз.

3. В готовящийся релиз 9.0 (статус MFC пока неясен) добавлены новые действия для правил ipfw: call и return. В соответствии с духом ipfw как своеобразного ассемблера, делают они именно это — вызывают процедуру и возвращаются из неё (причем, как и в ассемблере, границы условны, можно в одном месте прыгать в начало процедуры, в другом — в середину). В отличие от skipto, вызывать можно правило с любым номером, т.е. делать и прыжки назад — skipto разрешен только вперед во избежание зацикливания пакета при ошибке пользователя. Использовано это может быть для упрощения организации сложных рулесетов, поскольку “процедуры” являются практически тем же, что и цепочки в iptables, просто не выделены в отдельный объект. Следует иметь в виду подводные камни использования этого дела в сложных случаях, особенно при ошибках пользователя (общность стека для in и out-проходов, вывод пакета из ядра, ошибки выделения памяти и т.д.), подробнее всё это описано в мане.