Как было задано в требованиях к системе, сложность построения должна быть полиномиальной. Без учета сложности правил сложность алгоритма можно оценить в лучшем случае как
, а в худшем — как , где n — размер статистики. В первом случае всегда происходит равномерное деление, и получается уровней по n соединений в каждом, во втором — всегда отделяется ровно одно соединение, и получается n уровней. Из разделов, посвященных конкретным правилам, станет ясно, что сложность метода compute любого из них не может превышать (на самом деле, в большинстве случаев она гораздо меньше и приближается к ). Таким образом, общая сложность алгоритма построения дерева правил оценивается в худшем случае как , что нам кажется вполне приемлемым результатом.Перевод дерева в формат правил iptables.
Допустим, есть вершина N, и это не лист. Тогда одно из правил N является активным. Из-за того, что правила в вершине описывают состояние соединений именно в этой вершине, получить ЭВ, используемое для деления узла N можно так:
Условно обозначим данную операцию PrintSeparation(N). На втором шаге можно было бы использовать и правого потомка, это вопрос соглашения. В генераторе всегда используется левый. Хотя iptables не поддерживает древовидные структуры, есть возможность создавать пользовательские цепочки правил, с помощью чего деревья можно моделировать. Делается это следующим образом (на примере все того же узла N):
Все соединения левого потомка N перейдут в цепочку C(N), а соединения правого потомка останутся в текущей цепочке — что и описывает деление вершины. Имя цепочки строится по шаблону «chain» + порядковый номер.
Все дерево переводится в набор команд iptables обходом в глубину. Выше разобрано, как реализуется перевод обычных вершин (не листьев). В листьях происходит иной процесс. Напомним, что все правила листа помечены как активные. Печатаются именно эти правила, а не правила потомков (т.к. потомков нет). Также производится проверка на избыточность правил. Согласно требованиям к системе, должно обеспечиваться как можно меньшее количество сравнений для определения статуса соединения. В не-листьях избыточных сравнений быть не может, а вот в листьях они могут иметь место. Где-то в ветке от корня до листа может сработать некоторое правило R с пороговым значением а. Если ниже по ветке будут работать другие правила, то вполне вероятна ситуация, когда в конечной вершине ветки R(a) будет выписано вторично. Но это совершенно не нужно, потому что пакеты, не удовлетворяющие R(a), отсеяны раньше и в вершину попасть не могут. Для борьбы с подобными ситуациями при распечатке правил листов происходит обратный поиск по ветке от листа до корня, и избыточные правила не печатаются.
Приведем пример реального дерева правил и того, как оно переводится в набор команд iptables. Данные получены при обработке статистики, собранной на одном из сегментов локальной сети НГУ (подробнее см. главу о тестовых испытаниях системы).
Рисунок 1. Пример дерева.
На рисунке 1 изображено начало дерева. Смысл обозначений таков:
J0: порт назначения меньше или равен 80;
J1: получатель находится в любой из местных подсетей (внутренний адрес);
J2: адрес отправителя равен 193.124.208.85.
А вот как выделенная жирным ветка дерева (J0 → ¬J1 → J2) была переведена в команды iptables:
1: iptables -N chain1
2: iptables -A chain0 -p tcp --dport 1:80 -j chain1
3: iptables -N chain2
4: iptables -A chain1 -m set --set mset0 dst -j chain2
. . .
X: iptables -A chain1 -p tcp --dport 80 -s 193.124.208.85 -j chain_accept
. . .
Номера строк проставлены вручную для удобства, в настоящих командах их нет. Из фрагмента можно увидеть, во что преобразуются J0, J1 и J2. J0 описано в строке 2, J1 — в строке 4. Как видно, все соединения, удовлетворяющие J1, уходят в цепочку chain2 и там проходят дальнейшее деление. Соединения, не удовлетворяющие J1, остаются в цепочке chain1. В строке X описано J2, а также видно следствие того, что правило портов оказалось активным в листе. Было обнаружено, что все соединения в этом листе имеют порт назначения с номером 80, и этот факт отражен в командах iptables (хотя деления по данному параметру не было). О цепочке chain_accept будет рассказано в главе, посвященной структуре генерируемых правил. Сейчас важно лишь то, что в нее попадают все нормальные пакеты.
Далее мы разберем работу двух имеющихся на данный момент в системе правил, которые покрывают четыре свойства соединения: протокол, порт назначения, адреса отправителя и получателя. Станет понятно, почему в примере выше нигде нет проверок на порт отправителя, что скрывается за наименованием mset0, откуда берется адрес 192.124.208.85, и почему в строке X нет проверки на адрес назначения.
8 Правило портов и протоколов.
В некоторых протоколах портов нет, поэтому порты и протоколы в нашей системе реализованы в одном классе PortRule (больше подошло бы название ProtocolAndPortRule, но PortRule сложилось исторически). Проверка порта осуществляется только для порта назначения. В протоколе TCP порт отправления (порт на клиенте) задается случайным образом. Для UDP правила его задания оговариваются в спецификации протоколов прикладного уровня. В некоторых из них он также случаен. В других определяется, что клиент должен ждать ответа на том же порту, что и сервер, либо предлагаются более изощренные решения (например, системный сервис resolver протокола DNS или специальный сервис отображения портов в Sun RPC [10]). Общепринятого соглашения не существует, поэтому в текущей версии нашей системы UDP-порт на клиенте считается случайным (как и TCP-порт).
Случайные порты не должны фигурировать в наборе правил. Поэтому нельзя было реализовывать систему на уровне простых пакетов, а необходим уровень соединений. Для отдельно взятого пакета невозможно определить, какой из его портов случайный, а какой — фиксированный. Если этот пакет шел от клиента к серверу, то случайным будет порт отправителя, а если это был ответ сервера клиенту — то получателя.
Работа метода PortRule::compute заключается в следующем. Устанавливается, скольким различным протоколам принадлежат переданные соединения. Если протоколов несколько, то деление проходит по ним. Для каждого протокола подсчитывается, каков суммарный вес соединений, соответствующих ему. В качестве ЭВ выступает выражение
, а пороговым значением выбирается протокол с наибольшим весом. На этом работа прекращается.Если протокол один, и он поддерживает порты (TCP или UDP), то деление происходит по портам. ЭВ тогда имеет вид
. Это не каноническая форма ЭВ, т.к. есть конъюнкция. Но она необходима, потому что в правиле iptables нельзя задать значение порта, не задав перед этим значение протокола (что логично). Для сокращения перебора в качестве кандидатов на выступают только те номера портов, которые встречаются во множестве. Это дает существенное ускорение работы, ведь пространство значений параметра велико (в худшем случае 216 вариантов), в то же время почти всегда используется гораздо меньше различных портов. Для каждого кандидата запоминается, каков суммарный вес соединений, имеющих порты, меньше или равные ему. Наилучшее деление будет обеспечивать тот порт, у которого этот параметр максимален (иногда это — не единственное возможное решение, но всегда один из равноценных вариантов).Вместе с подсчетом веса портов подсчитывается и так называемый «строгий вес», то есть вес соединений, имеющих порты строго меньше данного. Если после выбора
оказывается, что этот параметр для него равен нулю, то ЭВ преобразуется к форме . Это корректно, т.к. выбирался из используемых во множестве портов, и сам он удовлетворяет условию , а значит, весь его вес состоит из соединений со значением порта, в точности равным .